Portal Digincrease — Documentación técnica
Referencia completa del sistema: arquitectura, base de datos, APIs, webhooks y automatización.
Visión general
El Portal de Digincrease es una plataforma SaaS multi-tenant para agencias de marketing. Permite gestionar clientes, contenido, facturación, inbox omnicanal y automatizaciones de respuesta con IA.
Framework
Next.js 14.2 (App Router)
Base de datos
PostgreSQL + Prisma v5
Infraestructura
VPS Ubuntu + Docker + Caddy
Auth
NextAuth.js v4 (JWT)
Mensajería
Meta Cloud API v25.0
Automatización
n8n self-hosted
Arquitectura
El sistema corre en un único VPS con Docker Compose. Caddy actúa como reverse proxy con SSL automático.
┌─────────────────────────────────────────────────────┐
│ VPS (tu-servidor-ip) │
│ │
│ Caddy (443/80) │
│ ├── portal.tudominio.com → :3005 (Next.js) │
│ ├── docs.tudominio.com → :3005/docs │
│ └── automation.tudominio.com → :5678 (n8n) │
│ │
│ Docker Compose │
│ ├── web (node:20 / Next.js) → puerto 3005 │
│ └── postgres (PostgreSQL 15) → interno │
└─────────────────────────────────────────────────────┘
Flujo de mensaje entrante (WhatsApp):
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Usuario │───▶│ Meta Cloud │───▶│ Portal │
│ WhatsApp │ │ API Webhook │ │/api/ │
└──────────┘ └──────────────┘ │webhooks/ │
│meta │
└────┬─────┘
│ guarda en DB
│ reenvía a n8n
┌────▼─────┐
│ n8n │
│ AI Agent │
└────┬─────┘
│ POST /api/v1/...
┌────▼─────┐
│ Portal │
│ API send │
└────┬─────┘
│
┌────▼──────┐
│ Meta API │
│ /messages │
└───────────┘
Base de datos
PostgreSQL gestionado con Prisma ORM. El schema completo está en prisma/schema.prisma. Los comandos de migración se ejecutan con prisma db push dentro de un container efímero node:20-alpine.
Agency
Tabla raíz del sistema. Cada agencia tiene sus propios clientes, configuración de canales y datos de facturación.
| Campo | Tipo | Descripción |
|---|---|---|
| id | String (cuid) | PK — identificador único |
| name | String | Nombre de la agencia |
| String? | Email de contacto | |
| channelConfig | Json? | Config de canales: { whatsapp: { phoneNumberId, businessAccountId } } |
| createdAt | DateTime | Fecha de creación |
Client
Clientes que pertenecen a una agencia. Cada cliente tiene su propio portal de acceso.
| Campo | Tipo | Descripción |
|---|---|---|
| id | String (cuid) | PK |
| agencyId | String | FK → Agency |
| companyName | String | Nombre de la empresa |
| String? | Email del cliente | |
| planType | String | basic | pro | enterprise |
| planStatus | String | active | overdue | cancelled |
| nextBillingDate | DateTime? | Próximo vencimiento |
| amountDue | Float? | Monto a cobrar |
User
| Campo | Tipo | Descripción |
|---|---|---|
| id | String | PK |
| String (único) | Email para login | |
| role | String | admin | client |
| agencyId | String? | FK → Agency (solo admins) |
| clientId | String? | FK → Client (solo clientes) |
| passwordHash | String | bcrypt hash |
InboxConversation
| Campo | Tipo | Descripción |
|---|---|---|
| id | String (cuid) | PK — también usado como conversation_id en n8n |
| agencyId | String | FK → Agency |
| clientId | String? | FK → Client (opcional, para inbox por cliente) |
| channel | String | whatsapp | instagram | messenger | email |
| contactId | String | FK → InboxContact |
| status | String | open | closed |
| lastMessageAt | DateTime? | Timestamp del último mensaje |
InboxContact
| Campo | Tipo | Descripción |
|---|---|---|
| id | String | PK |
| agencyId | String | FK → Agency |
| channel | String | Canal del contacto |
| externalId | String | WA ID, PSID, IG ID según canal |
| name | String? | Nombre del contacto |
| phone | String? | Teléfono con + (ej: +54911XXXXXXXX) |
| avatarUrl | String? | URL de foto de perfil |
InboxMessage
| Campo | Tipo | Descripción |
|---|---|---|
| id | String | PK — puede ser el mid de Meta para evitar duplicados |
| conversationId | String | FK → InboxConversation |
| direction | String | inbound | outbound |
| body | String | Texto del mensaje |
| mediaUrl | String? | URL de imagen/video/audio |
| mediaType | String? | image | audio | video | document |
| externalId | String? | ID original de Meta |
| readAt | DateTime? | Null = no leído (solo inbound) |
| createdAt | DateTime | Timestamp del mensaje |
SocialAccount
| Campo | Tipo | Descripción |
|---|---|---|
| id | String | PK |
| clientId | String | FK → Client |
| platform | String | facebook | instagram | whatsapp |
| accountId | String | Page ID / IG User ID / Phone Number ID |
| pageId | String? | Page ID de Facebook (para Messenger) |
| igUserId | String? | IG User ID (para Instagram) |
| accessToken | String? | Token de acceso (page token o user token) |
| isActive | Boolean | Si la cuenta está activa |
API Reference
Todas las rutas bajo /api/admin/* requieren sesión NextAuth con role: admin. Las rutas /api/portal/* requieren role: client.
Inbox — Admin
/api/admin/inbox?channel=whatsappLista conversaciones de la agencia filtradas por canal (all | whatsapp | instagram | messenger | email)
/api/admin/inbox?conversationId=...Mensajes de una conversación. Marca mensajes inbound como leídos.
/api/admin/inboxEnvía un mensaje outbound. Body: { conversationId, body }. Envía por Meta API y guarda en DB.
/api/admin/inboxAbre o cierra una conversación. Body: { conversationId, status: 'open' | 'closed' }
Clientes — Admin
/api/admin/clientesLista todos los clientes de la agencia (id, companyName, email, planType, planStatus)
/api/admin/clientesCrea un nuevo cliente con usuario asociado. Body: { companyName, email, password, planType, ... }
Configuración — Admin
/api/admin/configuracionDevuelve channelStatus (connected/disconnected por canal) y channelConfig
/api/admin/configuracionActualiza channelConfig. Body: { channelConfig: { whatsapp: { phoneNumberId, businessAccountId } } }
Webhooks — Públicos
/api/webhooks/metaVerificación del webhook de Meta. Params: hub.mode, hub.verify_token, hub.challenge
/api/webhooks/metaRecibe mensajes de Meta (WhatsApp, Messenger, Instagram). Guarda en DB y reenvía a n8n.
Automatización — API compatible con Chatwoot
/api/v1/accounts/[agencyId]/conversations/[conversationId]/messagesEndpoint que usa n8n para enviar respuestas. Header: api_access_token. Body: { content, message_type, content_type }
Portal — Client
/api/portal/inboxConversaciones del cliente autenticado (ownership verificada por clientId en sesión)
Meta Webhooks
Configuración en Meta Developer
La misma URL y token se usa para todos los canales (WhatsApp, Messenger, Instagram).
| Campo | Valor |
|---|---|
| URL del Callback | https://portal.tudominio.com/api/webhooks/meta |
| Verify Token | tu_verify_token_secreto (definido en META_WEBHOOK_VERIFY_TOKEN) |
| Campos suscriptos | messages (WhatsApp), messages (Messenger), messages (Instagram) |
Routing por objeto
POST /api/webhooks/meta body.object === 'page' → Messenger handler body.object === 'instagram' → Instagram handler body.object === 'whatsapp_business_account' → WhatsApp handler
Lookup de agencia para WhatsApp
WhatsApp no tiene OAuth como Instagram/Facebook, por lo que no existe SocialAccount. El handler busca la agencia por el phoneNumberId guardado en Agency.channelConfig:
// 1. Buscar en SocialAccount (por si hay cuenta vinculada) // 2. Fallback: buscar agencia donde channelConfig.whatsapp.phoneNumberId === phoneNumberId const match = agencies.find(a => a.channelConfig?.whatsapp?.phoneNumberId === phoneNumberId)
Automatización n8n
Flujo completo
1. WhatsApp → Meta Cloud → POST /api/webhooks/meta
2. Portal guarda mensaje en InboxMessage (direction: inbound)
3. Portal hace POST a AUTOMATION_WEBHOOK_URL con formato Chatwoot-compatible:
{
body: {
message_type: "incoming",
content: "texto del mensaje",
content_type: "text",
conversation: {
id: "<conversationId>", ← UUID del portal, no número
meta: { sender: { phone_number: "+54911XXXXXXXX", name: "Nombre del contacto" } },
labels: [],
messages: [{ sender: { phone_number: "+54911XXXXXXXX" } }]
},
account: { id: "<agencyId>" }
}
}
4. n8n AI Agent procesa y llama al portal:
POST /api/v1/accounts/{agencyId}/conversations/{conversationId}/messages
Header: api_access_token: <PORTAL_API_TOKEN>
Body: { content: "respuesta del bot", message_type: "outgoing" }
5. Portal envía via WhatsApp Cloud API y guarda en InboxMessage (direction: outbound)Cambios en el nodo "Enviar a Chatwoot1"
| Campo | Valor anterior (Chatwoot) | Valor nuevo (Portal) |
|---|---|---|
| URL | igual — no cambia (mismo dominio) | igual — no cambia (mismo dominio) |
| api_access_token | <token_chatwoot_anterior> | <PORTAL_API_TOKEN> definido en .env |
El resto del flujo n8n (Redis buffer, AI Agent, Airtable, Google Calendar) no requiere cambios.
Variables relevantes en n8n
| Expresión n8n | Valor que recibe del portal |
|---|---|
| $json.body.conversation.id | UUID de InboxConversation (ej: cm9xxxxxxxxxxxxx) |
| $json.body.account.id | UUID de Agency (ej: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) |
| $json.body.conversation.meta.sender.phone_number | +54911XXXXXXXX (con prefijo +) |
| $json.body.conversation.meta.sender.name | Nombre del contacto de WhatsApp |
| $json.body.content | Texto del mensaje entrante |
| $json.body.conversation.labels | [] (array vacío — bot siempre activo) |
Sistema de Inbox
Arquitectura del inbox
El inbox es multi-canal y multi-tenant. Cada conversación pertenece a una agencia y opcionalmente a un cliente específico.
InboxContact (1) ──── (N) InboxConversation (1) ──── (N) InboxMessage
│
├── agencyId (siempre presente)
└── clientId (opcional, para inbox por cliente)Unread count
Los mensajes inbound tienen readAt: null hasta que el admin abre la conversación. El endpoint GET con conversationId los marca como leídos automáticamente.
Auto-refresh
El frontend hace polling cada 3 segundos mientras hay una conversación activa. El scroll automático solo se activa si el usuario está en el fondo del chat.
Envío optimista
Al enviar, el mensaje aparece inmediatamente con opacidad reducida (estado temporal) mientras se procesa el POST. Luego se reemplaza con el dato real del servidor.
Variables de entorno
Archivo .env en /home/ubuntu/marketing-portal/ en el VPS.
| Variable | Ejemplo / Descripción |
|---|---|
| DATABASE_URL | postgresql://USER:PASSWORD@postgres:5432/DB_NAME |
| NEXTAUTH_SECRET | string aleatorio largo (ej: generado con openssl rand -base64 32) |
| NEXTAUTH_URL | https://portal.tudominio.com |
| META_WEBHOOK_VERIFY_TOKEN | token que elegís vos y configurás en Meta Developer |
| META_WHATSAPP_PHONE_NUMBER_ID | ID del número de WhatsApp Business (Meta Developer → WhatsApp → API Setup) |
| META_WHATSAPP_BUSINESS_ACCOUNT_ID | ID de la cuenta WABA (Meta Developer → WhatsApp → API Setup) |
| META_WHATSAPP_ACCESS_TOKEN | Token de acceso de WhatsApp Cloud API (Meta Developer → WhatsApp → API Setup) |
| META_FACEBOOK_ACCESS_TOKEN | Token de página de Facebook (Meta Developer → Messenger → Settings) |
| META_INSTAGRAM_ACCESS_TOKEN | Token de acceso de Instagram (Meta Developer → Instagram → API Setup) |
| PORTAL_API_TOKEN | token secreto que compartís con n8n para autenticar respuestas del bot |
| AUTOMATION_WEBHOOK_URL | https://automation.tudominio.com/webhook/tu-path |
Comandos útiles en VPS
# Ver logs en tiempo real ssh -i ~/tu-ssh-key.pem ubuntu@TU_VPS_IP \ "docker logs -f nombre-del-container-web" # Rebuild y deploy cd /home/ubuntu/tu-proyecto docker compose build --no-cache && docker compose up -d # Aplicar cambios de schema Prisma APP=/home/ubuntu/tu-proyecto docker run --rm --network tu-proyecto_net \ -v $APP/prisma:/app/prisma \ -e DATABASE_URL='postgresql://USER:PASSWORD@postgres:5432/DB_NAME' \ -w /app node:20-alpine sh -c \ 'apk add -q openssl && npm install -g prisma@5 && prisma db push --skip-generate' # Consultar DB directamente docker exec nombre-del-container-postgres psql -U postgres nombre_db -c "SELECT ..."
Portal Digincrease — Documentación técnica interna. Actualizado en Junio 2026.