CRM Owelign — documentación técnica

De punta a punta: qué piezas hay, cómo se hablan, qué hace cada archivo, cómo se configura, cómo se despliega y qué trampas ya costaron tiempo.

Chat (Chatwoot)chat.owelign.com — atención por WhatsApp, fichas, asignación.
App del equipocrm.owelign.com — chat + tableros + proformas + caja + asistente IA.
Panel (legado)dashboard.owelign.com — mismo servicio que la app; aquí vive el webhook de Elena.
Devdev.chat.owelign.com · dev.dashboard.owelign.com — rama develop.

1. Visión general

El CRM es Chatwoot (licencia MIT, imagen oficial fijada en docker-compose.yaml) más un servicio propio en Node, el sidecar, que agrega todo lo que Chatwoot no trae:

El código fuente de Chatwoot no vive en el repo: no hay fork. Las personalizaciones de Chatwoot son archivos montados como volumen (overrides/, brand/). Actualizar Chatwoot es cambiar un tag y re-verificar esos archivos (§18.5).

Principios del diseño

2. Arquitectura

Clientes WhatsAppMeta Cloud API Equipo (navegador)chat.* · crm.* · PWA Google GeminiElena · proformas · IA Caddy :80/:443TLS automático · zstd/gzipred del hostperfil "prod" rails (Chatwoot) :3000API, web, /cable, webhooksoverrides/ + brand/ montados dashboard (sidecar) :4000Node 22 · Express · pgapp, Elena, alertas, IA sidekiqjobs, push, envíos rediscolas y caché postgres (pgvector pg16)chatwoot_production+ esquemas crm, asistente chat.* crm.* · dashboard.* webhook API REST SQL directo
Todo corre en un VPS con Docker Compose. Solo Caddy publica puertos al exterior; el resto escucha en 127.0.0.1.

Flujo de un mensaje entrante de WhatsApp

Cliente escribe por WhatsApp
  → Meta llama a https://chat.owelign.com/webhooks/whatsapp/+<numero>
  → Chatwoot crea/actualiza contacto y conversación (nace en "pending" porque hay un Agent Bot)
  → Chatwoot llama al webhook del Agent Bot: https://dashboard.owelign.com/bot/<secreto>
  → sidecar responde 200 al instante y procesa en segundo plano (handleBotEvent)
      → lee contacto + hilo; decide el paso (Gemini o guion)
      → guarda atributos (merge del hash completo), manda el mensaje con el token del bot
      → al terminar: asigna equipo, asigna asesor, pasa a "open", marca traspasado_en, deja el resumen
  → desde ahí la atiende un humano; el ciclo de alertas vigila cada 60 s

Flujo de la app del equipo

Navegador → crm.owelign.com (Caddy) → sidecar :4000
  /login           correo + contraseña del chat → POST /auth/sign_in de Chatwoot → cookie cifrada
  /                index.html + app.js (sin build)
  /cw/…            puente a /api/… de Chatwoot con los tokens devise de esa persona
  /datos/…         tableros, proformas, personas, asistente (lógica del sidecar)
  wss://chat.…/cable  el mismo websocket del chat, con el pubsub_token de la persona

3. Repositorio

GitHub: Owelign-Porceramica/CRM. Ramas: main = producción, develop = dev.

docker-compose.yaml     rails, sidekiq, postgres, redis, dashboard (sidecar), caddy (perfil prod)
Caddyfile               dominios y reverse proxy; dominios salen del .env
.env.example            plantilla de configuración (el .env real nunca se commitea)
brand/                  logos, manifest, custom.css y 3 scripts inyectados en el frontend de Chatwoot
overrides/              archivos de Chatwoot pisados por volumen (layout, locales, modelos, controladores)
sidecar/                servicio propio: Node 22 + Express + pg
  src/server.js         rutas HTTP, webhook de Elena, ciclo de alertas, efectos sobre Chatwoot
  src/elena.js          guion determinista de Elena (lógica pura)
  src/gemini.js         Elena con Gemini, mismo contrato que el guion
  src/reassign.js       alertas, ventanas horarias, reparto, cierre, pendientes (lógica pura)
  src/agentes.js        activar/desactivar agentes por SQL; pool de Postgres compartido
  src/personas.js       alta de personas, roles de la app (crm.roles), enlace de contraseña
  src/proformas.js      proformas: tabla, validación, lectura con Gemini, estados, espera
  src/asistente.js      asistente IA: vistas, usuarios de Postgres de solo lectura, bucle con Gemini
  src/auth.js           sesión cifrada AES-GCM, comparación en tiempo constante, límite de intentos
  src/lib.js            agregaciones de tableros (lógica pura)
  src/views.js          páginas de login y de "poner contraseña"
  src/app/index.html    la app del equipo (una página)
  src/app/app.js        la app del equipo (JS plano)
  test.js               tests con node:test (lógica pura)
  probar-gemini.js      conversación real contra Gemini, sin desplegar
  probar-e2e.js         server.js real contra un Chatwoot falso que graba las llamadas
scripts/                operación: deploy, altas, bajas, roles, ajustes, marca, respaldos, dev
docs-site/              este sitio (docs.crm.owelign.com)
*.md                    manuales: MANUAL-SISTEMAS, GUIA-CHATS, DEPLOY, BD-LOCAL, ELENA-GEMINI, MANUAL-MARKETING, INFORME-FASE-1

4. Servicios (Docker Compose)

ServicioImagenPuertoNotas
railschatwoot/chatwoot:v4.16.2127.0.0.1:3000Web + API + ActionCable. Monta brand/ y overrides/.
sidekiqmisma—Jobs: envío de mensajes, push, reportes. También monta los overrides (los push los manda él).
postgrespgvector/pgvector:pg16127.0.0.1:5432Base chatwoot_production (no la de POSTGRES_DB: Chatwoot corre con RAILS_ENV=production).
redisredis:alpine127.0.0.1:6379Con --requirepass.
dashboardbuild ./sidecar (node:22-alpine + tzdata)127.0.0.1:4000TZ=America/Costa_Rica. Monta brand/ (mismo logo que el chat).
caddycaddy:2-alpine80/443 (host)Solo con --profile prod. network_mode: host para alcanzar prod (3000/4000) y dev (3100/4100). Sirve también docs-site/.

Volúmenes: storage_data (adjuntos de Chatwoot), postgres_data, redis_data, caddy_data/caddy_config (certificados).

Dominios en el Caddyfile

VariableHoyDestino
CHATWOOT_DOMAINchat.owelign.comrails :3000
CRM_DOMAINcrm.owelign.comsidecar :4000
DASHBOARD_DOMAINdashboard.owelign.comsidecar :4000 (aquí está registrado el webhook /bot/<secreto>)
DEV_CHATWOOT_DOMAINdev.chat.owelign.comrutas de la app → :4100, resto → :3100
DEV_DASHBOARD_DOMAINdev.dashboard.owelign.com:4100
DOCS_DOMAINdocs.crm.owelign.com (por defecto)archivos estáticos de docs-site/
El webhook /bot/<secreto> tiene que ser público: Chatwoot valida la URL contra SSRF y rechaza hostnames privados (http://dashboard:4000 falla con "has no public ip addresses"). La barrera es el secreto de ≥32 caracteres, comparado en tiempo constante.

5. Personalizaciones de Chatwoot

Chatwoot no tiene puntos de extensión para su frontend ni para sus modelos. Todo se hace pisando archivos de la imagen por volumen. Cada actualización de Chatwoot obliga a re-comparar estos archivos.

Archivo del repoSe monta enPara qué
overrides/vueapp.html.erbapp/views/layouts/vueapp.html.erbCopia del layout con el <link> a brand/custom.css, los scripts de brand/, window.CRM_RECEPCIONISTAS, y el <link rel="manifest"> fuera del bloque DISPLAY_MANIFEST (así se esconde el banner de versión sin perder la PWA ni el push en iPhone).
overrides/notificaciones.es.ymlconfig/locales/zzz_marca.es.ymlTítulos de las notificaciones (campana y push). Se llama zzz_ porque Rails carga locales en orden alfabético y gana el último.
overrides/canned_response.rb + canned_responses_controller.rbmodelo y controladorRespuestas predefinidas personales: filtro por user_id (columna creada por ajustes.sh). Las viejas con user_id vacío siguen siendo de todos.
overrides/contactos_propios.rbconfig/initializers/zzz_contactos_propios.rbCada vendedor ve en Contactos solo a sus clientes (con alguna conversación asignada a él). Admin y recepción ven todo. Recorta listas y búsqueda; abrir /contacts/:id directo sigue andando.
brand/custom.csspublic/brand-assets/Retematizado negro y dorado (--marca-dorado: #c3a358) y ocultamientos de interfaz (algunas reglas van por posición).
brand/interfaz.jsidemPestañas de Conversaciones según rol: admin y recepción arrancan en Todos; el vendedor no ve pestañas.
brand/agentes.jsidemBotón Desactivar / Reactivar en Ajustes → Agentes; llama a /api/agentes/… del sidecar con las cabeceras devise del chat.
brand/proformas.jsidemBarra "Proforma … pendiente · Enviar" encima de la caja de respuesta de una conversación.
brand/manifest.json, logo_thumbnail.pngpublic/manifest.json, public/favicon.icoNombre e ícono al instalarla como app; favicon propio.
Ocultar con CSS/JS es interfaz, no permiso. Un agente con las herramientas del navegador puede pedir a la API conversaciones ajenas. Los permisos reales (roles personalizados) son de la edición de pago. La excepción es contactos_propios.rb, que sí recorta en el servidor.

Configuración de Chatwoot que no está en archivos

6. El sidecar

Un proceso Node (node src/server.js) que es a la vez servidor HTTP, receptor del webhook de Elena y un ciclo periódico (setInterval) de alertas. Arranca en :4000 y loguea Dashboard escuchando en :4000.

Al arrancar

Cómo habla con Chatwoot

AyudanteTokenUso
cw()CHATWOOT_TOKEN (admin)Lecturas de tableros, contactos, agentes, conversaciones; ciclo de alertas.
cw2()idem, /api/v2Reportes de rendimiento por agente.
cwBot()CHATWOOT_BOT_TOKENTodo lo que "firma" Elena: mensajes, asignaciones, notas, prioridad. Así el agente ve "por Elena" y no el nombre del admin.
/cw/*tokens devise de la personaLa app del equipo; Chatwoot aplica los permisos de esa persona.
db()Postgres directoLo que la API no expone: activar/desactivar, roles, tokens de contraseña, proformas, asistente.
Toda llamada a Chatwoot lleva X-Forwarded-Proto: https. Con FORCE_SSL, sin esa cabecera Rails redirige a https://rails:3000 (que habla HTTP) y el fetch muere con un error de TLS que el log muestra como fetch failed. Y el token va como api-access-token con guiones: los proxies descartan cabeceras con guion bajo.

7. Autenticación y sesiones

Login de la app (POST /login)

  1. Si usuario y contraseña coinciden con DASH_USER/DASH_PASSWORD (comparación en tiempo constante, sin revelar cuál de los dos falló) → sesión rol panel (Marketing, sin chat).
  2. Si no, se intenta POST /auth/sign_in en Chatwoot. Si la persona no pertenece a la cuenta o tiene MFA, se rechaza con un mensaje claro.
  3. El rol: admin si es administrador de la cuenta; si no, el de crm.roles, y si no tiene fila, las listas CAJERAS/RECEPCIONISTAS del .env; por defecto vendedor.

La cookie

Protecciones

8. Roles y permisos

Chatwoot solo conoce administrador y agente. Los demás roles los define el sidecar.

Rol (app)En ChatwootVistas de la app¿Recibe leads?
adminadministratorasistente, chat, inicio, equipo, nuevo, proformas, caja, marketing, agentesSí
vendedoragentasistente, chat, equipo, nuevo, proformas (solo las suyas)Sí
recepcionagentasistente, chat (todas), equipo, nuevo, proformasNo (Elena y escalación la saltan)
cajaagent sin bandejascaja, proformasNo; sin websocket ni presencia
panel— (clave compartida)asistente, inicio, marketing, agentes—

Fuente del rol, en orden: tabla crm.roles (lo que se elige en Configuración → Personas) → lista CAJERAS → lista RECEPCIONISTAS → vendedor. El servidor revalida el rol en cada endpoint; el ocultar vistas en app.js es solo presentación.

9. API HTTP del sidecar

Públicas (sin sesión)

Método y rutaQué hace
GET /health{"ok":true}
GET /loginPantalla de login; con ?t=, pantalla de poner contraseña.
POST /loginLogin (o poner contraseña si trae t).
POST /logoutCierra la sesión de la app y la del chat.
GET /brand-assets/*Logos y favicon (caché 1 h).
POST /bot/:secretoWebhook del Agent Bot (solo con INTAKE_ENABLED). Secreto incorrecto → 404. Responde 200 antes de procesar (un 500 hace que Chatwoot reintente y el cliente reciba la pregunta dos veces).

Desde el frontend del chat (/api, cabeceras devise access-token, client, uid)

Método y rutaQuiénQué hace
GET /api/agentesadminLista de agentes con rol, equipos, bandejas, abiertas, estado.
POST /api/agentes/:id/desactivaradminDesactiva (409 con motivo si no se puede).
POST /api/agentes/:id/reactivaradminReactiva con el mismo rol, equipos y bandejas.
GET /api/proformas/espera?conversacion=miembroProformas leídas esperando en esa conversación.
POST /api/proformas/espera/:id/enviarmiembroLa pasa a caja (crea en crm.proformas) y deja nota privada.
POST /api/proformas/espera/:id/descartarmiembroLa saca de la espera sin crearla.

Con sesión de la app

Método y rutaQuiénQué hace
GET /, GET /app.jstodosLa app.
GET /yotodosid, nombre, rol, pubsub, cuenta, origen del chat, minutos de alertas.
ALL /cw/*con usuario de chatPuente genérico a /api/* de Chatwoot (hasta 40 MB, adjuntos incluidos). Exige X-CRM: 1.
GET /datos/resumenadmin, panelLeads hoy/ayer, embudo de 7 días, rendimiento del equipo (30 días) con abiertas.
GET /datos/conversacionesadmin, panelAbiertas, sin asignar, y las que esperan ordenadas por espera.
GET /datos/marketingadmin, panelÚltimos 30 días vs 30 previos: leads, cotizados, vendidos, canales (utm_source), campañas.
GET /datos/clientes?q=admin, panelContactos filtrados (sin acentos ni mayúsculas, en todas las columnas). Tope 300 filas.
GET /datos/agentesadmin, panelAgentes con rol de la app.
POST /datos/agentes/:id/desactivar|reactivaradmin, panelIgual que /api, desde la app.
POST /datos/personasadmin (usuario)Alta: crea el agente en Chatwoot, guarda el rol, devuelve el enlace de contraseña.
POST /datos/personas/:id/roladmin (usuario)Cambia rol (admin/vendedor/recepcion/caja). Nunca deja la cuenta sin admin.
POST /datos/personas/:id/enlaceadmin (usuario)Nuevo enlace para poner contraseña ("olvidé mi contraseña").
GET /datos/proformas?contacto=con usuarioLista; el vendedor ve solo las suyas.
POST /datos/proformascon usuarioProforma manual. Los montos se recalculan en el servidor.
POST /datos/proformas/leercon usuarioFoto/PDF (≤12 MB: PDF, JPG, PNG, WEBP, HEIC) → datos leídos por Gemini. No guarda.
POST /datos/proformas/:id/estadocon usuarioCambio de estado según reglas (§13). Concurrencia optimista: 409 si alguien la cambió.
POST /datos/asistenteadmin, panel, vendedor, recepcionPregunta al asistente IA. Admin recibe también el SQL usado.

Errores: una caída del chat devuelve 502 con {error, detalle}; la app lo muestra como error, nunca como "no hay datos". Tamaño excedido → 413.

10. La app del equipo (crm.*)

11. Elena — bot de calificación

Mecanismo

Elena es un Agent Bot de Chatwoot (tipo webhook) asignado a las bandejas de INTAKE_INBOX_IDS. Con un bot asignado, la conversación nace en pending; mientras siga así, el sidecar contesta. En cuanto pasa a open, Elena no vuelve a meterse.

Eventos que procesa (handleBotEvent)

El guion (elena.js, 10 nodos)

NodoPregunta / acciónGuarda
N1Saludo + nombre y cédula en un solo mensaje (con aviso de fuera de horario si aplica)nombre, cedula (los dos o ninguno)
N2Motivo, con botones: Quiero cotizar · Tengo un problema · Otra consulta. También clasifica texto libre (quejas antes que ventas).motivo_contacto
N3¿Qué estás buscando? (texto libre, sin muletillas)producto_interes
N4¿Casa o proyecto en construcción? (botones)tipo_proyecto
N5¿Desde qué zona?zona
N6Queja: traspaso inmediato sin más preguntas—
N7Ambiguo sin resolver: cola general—
…Cierre y traspaso con resumen; intake=listo o incompletointake

Gemini (gemini.js)

const step = (GEMINI_API_KEY && await geminiStep(...)) || nextStep(contact, payload.content, opts);

Traspaso (el orden importa)

  1. Cola: INTAKE_COLA_UNICA (hoy general) o, si está vacía, por motivo (cotizar→ventas, problema→soporte, otra_consulta→general). Los equipos se buscan por nombre exacto, en minúscula.
  2. Candidatos: toda la cuenta (no los miembros del equipo), sin recepción ni caja.
  3. Asesor: el que atendió la última conversación del contacto si está conectado (asesorPrevio); si no, el conectado con menos abiertas (pickLeastLoaded). Si nadie está conectado, igual se asigna al de menos carga: un lead sin dueño no lo ve nadie.
  4. POST assignments {team_id} y después POST assignments {assignee_id} — dos llamadas: el controlador es if assignee_id … elsif team_id.
  5. Recién entonces toggle_status → open. Asignar antes de abrir: si se abre primero, el auto-reparto de Chatwoot ("Default Policy") corre en el after_save y pisa la elección.
  6. Marca traspasado_en en la conversación (arranca el reloj de alertas) y deja el resumen como nota privada: "📋 David busca porcelanato tipo mármol para un proyecto en construcción desde Guanacaste. Cédula …"

Origen del lead

Fuera de horario

HORARIO_ATENCION (hoy 08:00-18:00, zona del servidor). Elena avisa el horario completo en el saludo y en el cierre, pero califica igual: el asesor encuentra el lead listo al abrir. No distingue fines de semana ni feriados; no admite franjas que crucen medianoche.

12. Alertas, reparto y ciclo periódico

Con REASSIGN_ENABLED=true, cada REASSIGN_EVERY_SECONDS (60) corre revisarAlertas(), en este orden: aviso de cierre → recordatorios de pendientes → detección de asignación manual → aviso suave → escalación.

Dos relojes

PerfilCuándoAviso suaveEscalación
cortonadie respondió todavía (first_reply_created_at vacío)ALERT_SOFT_MINUTES (3): nota que menciona al asesorALERT_HARD_MINUTES (5): reasigna y prioridad alta
largoun humano ya respondióALERT_RESPONDIDA_SOFT_MINUTES (10): menciona al asesor y a los adminsALERT_RESPONDIDA_HARD_MINUTES (20)

Aviso de cierre (AVISO_CIERRE)

Pendientes (PENDIENTE_AVISO)

13. Proformas y caja

Tres formas de entrar

  1. Por WhatsApp: cuando un asesor manda un PDF al cliente, el webhook (evento saliente) lo baja y Gemini lo lee con un esquema JSON estricto. Si es una proforma, queda en crm.proformas_espera y se deja nota privada. En la conversación aparece la barra "Proforma … pendiente · Enviar" (brand/proformas.js). Cuando el cliente llega a la tienda, el asesor toca Enviar y recién ahí se crea en crm.proformas y la ve caja. Descartar la saca sin crearla.
  2. Foto o PDF desde la app: /datos/proformas/leer devuelve los datos para el formulario; el vendedor revisa y guarda.
  3. Manual: formulario en la app.

Reglas

14. Asistente IA

15. Tableros

Activar / desactivar (agentes.js)

Chatwoot solo sabe borrar (y el borrado se lleva el nombre del historial). Desactivar hace por SQL: quita account_users, quita equipos y bandejas, pone confirmed_at=NULL y tokens='{}' (cierra sesiones). Todo lo quitado queda en users.custom_attributes->'desactivado' para que Reactivar lo reponga igual. No deja desactivar al último admin, a uno mismo ni a un super admin.

16. Base de datos

Un Postgres, base chatwoot_production. ~99 tablas de Chatwoot (casi sin foreign keys declaradas: las sostiene Rails) + dos esquemas propios.

Tablas de Chatwoot que importan

TablaClaves útiles
contactscustom_attributes jsonb: nombre, cedula, motivo_contacto, producto_interes, tipo_proyecto, zona, intake, utm_source, utm_campaign, origen_declarado, estado_lead
conversationsstatus 0 open · 1 resolved · 2 pending · 3 snoozed; priority 0–3; assignee_id, team_id, waiting_since, first_reply_created_at, custom_attributes.traspasado_en
messagesmessage_type 0 entrante · 1 saliente · 2 actividad · 3 plantilla; private; sender_type Contact/User/AgentBot
account_usersrole 0 agent · 1 administrator
inboxes / channel_whatsappcanal polimórfico (channel_type); token y phone_number_id de Meta en provider_config
contact_inboxesun contacto por canal; de ahí cuelgan las conversaciones
agent_bots, agent_bot_inboxesElena y en qué bandejas actúa (outgoing_url = webhook)
custom_attribute_definitionsdeclaración de atributos (sin ella no se ven en la ficha)
canned_responses+ columna user_id (agregada por ajustes.sh)

Esquema crm (propio)

TablaColumnas
crm.rolescorreo PK (minúscula), rol (vendedor|recepcion|caja), por, en
crm.proformasid, numero único, fecha, cliente jsonb, rep, lineas jsonb, descuento, gastos, subtotal, iva, total, kg, estado, origen (chat|manual), vendedor_id, vendedor, contacto_id, conversacion_id, archivo_url, creada_en, pago jsonb, historial jsonb
crm.proformas_esperaid, numero único, conversacion_id, datos jsonb, creada_en

Esquema asistente: solo vistas (§14), recreadas con CREATE OR REPLACE. Las migraciones de Chatwoot no tocan ninguno de los dos, y el respaldo de la base los lleva.

17. Configuración (.env)

El .env se lee al arrancar: tras editarlo hay que recrear el servicio (docker compose --profile prod up -d --no-deps --force-recreate <servicio>); deploy.sh no lo hace porque no viene por git.

GrupoVariables
ChatwootFRONTEND_URL, FORCE_SSL, SECRET_KEY_BASE, ENABLE_ACCOUNT_SIGNUP=false (nunca true: crea CRMs vacíos para cualquiera), DISABLE_ENTERPRISE=true, DEFAULT_LOCALE=es, PRESENCE_DURATION
Base y cachéPOSTGRES_HOST, POSTGRES_USERNAME, POSTGRES_PASSWORD, POSTGRES_DATABASE (opcional), REDIS_URL, REDIS_PASSWORD
CorreoMAILER_SENDER_EMAIL, SMTP_*. Sin SMTP no salen invitaciones ni "olvidé mi contraseña" (alternativa: agente.sh o Personas en la app)
Apps móvilesIOS_APP_ID, ANDROID_*, ENABLE_PUSH_RELAY_SERVER
SidecarCHATWOOT_URL, CHATWOOT_ACCOUNT_ID, CHATWOOT_TOKEN, DASH_USER, DASH_PASSWORD
AlertasREASSIGN_ENABLED, ALERT_SOFT_MINUTES, ALERT_HARD_MINUTES, ALERT_RESPONDIDA_SOFT_MINUTES, ALERT_RESPONDIDA_HARD_MINUTES, REASSIGN_WINDOW (vacío = siempre; sin definir hereda el horario), REASSIGN_EVERY_SECONDS, AVISO_CIERRE, PENDIENTE_AVISO, PENDIENTE_CADA_MINUTOS, PENDIENTE_MAX_AVISOS
RolesRECEPCIONISTAS, CAJERAS (respaldo de crm.roles; los mantienen los scripts)
HorarioHORARIO_ATENCION=08:00-18:00
DominiosCHATWOOT_DOMAIN, DASHBOARD_DOMAIN, CRM_DOMAIN, DEV_CHATWOOT_DOMAIN, DEV_DASHBOARD_DOMAIN, DOCS_DOMAIN (opcional)
MarcaBRAND_NAME (también es el negocio para el que dice trabajar Elena), BRAND_COLOR, BRAND_SITE, BRAND_TERMS_URL, BRAND_PRIVACY_URL
ElenaINTAKE_ENABLED, INTAKE_INBOX_IDS, INTAKE_COLA_UNICA, INTAKE_BOT_SECRET, CHATWOOT_BOT_TOKEN, INTAKE_DEBUG (vuelca datos del cliente al log: solo para diagnosticar)
GeminiGEMINI_API_KEY (sin ella: guion, sin lectura de proformas, sin asistente), GEMINI_MODEL (gemini-3.7-flash), GEMINI_TIMEOUT_MS, ASISTENTE_TIMEOUT_MS

18. Despliegue

18.1 Ambientes

ProdDev
Ruta/opt/crm/opt/crm-dev
Ramamaindevelop
Puertos3000 / 40003100 / 4100 (y 5532, 6479)
Caddysí (perfil prod), atiende a los dosno
Datosreales, WhatsAppbase propia casi vacía, sin WhatsApp (se prueba con el widget web)
Quién despliegasolo el administrador del servidordesarrollo: sudo -u deploy /opt/crm-dev/scripts/deploy.sh (único sudo permitido)

Los clones son del usuario deploy con llave de solo lectura del repo. Nunca se copian archivos a mano: se despliega lo que está en la rama.

18.2 scripts/deploy.sh

git pull --ff-only y reinicia solo lo que cambió:

Si cambióHace
docker-compose.yamlup -d (recrea solo lo que cambió de config)
sidecar/up -d --build --no-deps dashboard
overrides/recrea rails y sidekiq
Caddyfilerestart caddy
brand/, docs-site/nada: son volúmenes, llegan con el pull

Todo el script va entre { }: bash lee el archivo por partes mientras corre y el pull lo puede reescribir (tumbó prod un minuto el 2026-10-09). Si el propio script cambió, se relanza la versión nueva.

18.3 Flujo de un cambio

PR → develop → (deploy dev) → probar en dev.chat.owelign.com
   → merge develop → main → el admin corre en el VPS:  cd /opt/crm && ./scripts/deploy.sh

Verificar: docker ps; docker logs --tail 20 crm-dashboard-1 debe decir Dashboard escuchando en :4000; curl https://chat.owelign.com/health → {"status":"woot"}; curl https://dashboard.owelign.com/health → {"ok":true}.

18.4 Instalación desde cero

  1. DNS: registros A de cada dominio a la IP del VPS (Spaceship, zona owelign.com). Antes de levantar Caddy: si no resuelven, Let's Encrypt bloquea reintentos por horas.
  2. Docker oficial + ufw con solo 22, 80, 443.
  3. Clonar en /opt/crm, cp .env.example .env, generar secretos (openssl rand).
  4. docker compose up -d postgres redis → docker compose run --rm rails bundle exec rails db:chatwoot_prepare (obligatorio la primera vez) → docker compose --profile prod up -d.
  5. ./scripts/brand.sh; crear el admin; zona horaria de la cuenta; ./scripts/ajustes.sh.
  6. WhatsApp Cloud (bandeja manual, webhook en Meta suscrito a messages) y el Agent Bot de Elena. Paso a paso en DEPLOY.md.
  7. Respaldo diario con BACKUP_REMOTE fuera del servidor.

18.5 Actualizar Chatwoot

./scripts/backup.sh
sed -i 's|chatwoot/chatwoot:vX.Y.Z|chatwoot/chatwoot:vA.B.C|' docker-compose.yaml
docker compose --profile prod pull && docker compose --profile prod up -d
docker compose exec -T rails bundle exec rails db:chatwoot_prepare   # las migraciones NO corren solas
docker compose restart rails sidekiq

Checklist después de actualizar:

18.6 Este sitio de documentación

Vive en docs-site/ y lo sirve el mismo Caddy de prod como archivos estáticos (bloque {$DOCS_DOMAIN:docs.crm.owelign.com}, volumen ./docs-site:/srv/docs:ro). Requiere el registro DNS A docs.crm → IP del VPS. Editar el HTML y desplegar main lo actualiza; no hace falta reiniciar nada.

19. Scripts de operación

ScriptQué hace
deploy.shPull y reinicio selectivo (§18.2).
crear-dev.shCrea el ambiente dev una vez: clona develop, arma .env con secretos nuevos, crea la BD.
agente.sh correo "Nombre" [administrator]Alta (o "olvidé mi contraseña"): enlace de 6 h para que la persona ponga su contraseña, todas las bandejas, push encendido.
altas.sh gente.csvAlta masiva desde CSV (correo,nombre,cedula,rol[,equipos]).
recepcionista.sh / cajera.shAlta + agrega el correo a RECEPCIONISTAS/CAJERAS (caja sin bandejas). Recrear el dashboard después.
rol.sh [--aplicar] rol correos…Admin ↔ agente. Por defecto solo muestra. Nunca deja la cuenta sin admin ni toca super admins.
baja.sh [--borrar] correos…Borrado definitivo con respaldo. No borra admins sin --incluir-admins.
ajustes.sh [--revisar]Idempotente: declara atributos y listas, filtro "todos", etiqueta pendiente, columna user_id de respuestas predefinidas, enciende push, diagnostica push (VAPID, relay, suscripciones). Correr con cada agente nuevo.
brand.shLogos y configuración de marca de Chatwoot.
backup.shpg_dump comprimido y verificado; con BACKUP_REMOTE lo copia fuera del servidor.
limpiar-pruebas.sh [--borrar]Borra contactos, conversaciones y mensajes (con respaldo). No toca usuarios ni configuración.

20. Pruebas

cd sidecar
npm test                      # node --test, lógica pura, TZ=America/Costa_Rica
node probar-gemini.js         # conversación de 5 turnos contra Gemini real
node probar-e2e.js            # server.js real contra un Chatwoot falso que graba las llamadas
node probar-e2e.js --guion    # lo mismo forzando el guion

Sin frameworks ni dependencias de desarrollo. Varios tests documentan fallos reales de producción (un saludo guardado como nombre, un origen sobrescrito, el eje del gráfico mintiendo). El E2E comprueba lo que los unitarios no pueden: el merge de atributos, el token del bot, el orden equipo → asesor → open, y el resumen como nota privada. Antes de mergear a main, los tests en verde.

Base local

BD-LOCAL.md: bajar una copia fresca de prod, restaurarla en local y levantar el CRM contra ella. Los respaldos restaurados se prueban: un respaldo que nunca se restauró no es un respaldo.

21. Trampas conocidas

TrampaQué pasaCómo se evita
Falta X-Forwarded-Proto: httpsRedirección a https://rails:3000 y fetch failedUsar cw(); todo fetch suelto la lleva
PUT /contacts sin mergeSe borran utm y demás atributosReleer el contacto y mezclar
Abrir antes de asignarEl reparto de Chatwoot pisa la elección de ElenaEquipo → asesor → open
team_id y assignee_id juntosSe descarta el equipo en silencioDos llamadas
pending → open no reparteChatwoot solo reparte al crearEl sidecar asigna a mano
Webhook con hostname privadoRechazado por SSRF; la conversación se abre sola con errorURL pública + secreto
Migraciones tras actualizarChatwoot levanta "bien" con el esquema viejodb:chatwoot_prepare siempre
Bandeja creada por consolaSin miembros: todo queda sin asignarAgregar agentes a la bandeja
Atributos no declaradosEl dato existe pero no se ve en la fichaajustes.sh
Cambiar nombre de equipo o atributoElena deja de enrutar; el tablero deja de contarNombres exactos, en minúscula
Token de WhatsApp de 24 h"El bot no contesta" (los entrantes sí llegan)Token permanente de usuario del sistema en Meta
Alpine sin tzdataFechas corridas un díaapk add tzdata en el Dockerfile
Cookie Secure sobre HTTPBucle de login en localAtada a FORCE_SSL

22. Límites conocidos

Fuente: el código y los manuales del repositorio Owelign-Porceramica/CRM. Si algo de aquí contradice al código, manda el código: corregir este archivo (docs-site/index.html).