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.owelign.com — atención por WhatsApp, fichas, asignación.crm.owelign.com — chat + tableros + proformas + caja + asistente IA.dashboard.owelign.com — mismo servicio que la app; aquí vive el webhook de Elena.dev.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:
- Elena: bot de calificación por WhatsApp (guion determinista + Gemini opcional).
- Alertas por inactividad en dos niveles y reasignación automática en cadena.
- Aviso de cierre fuera de horario y recordatorios de conversaciones pendientes.
- App del equipo (
crm.*): una sola página con chat, resumen del día, marketing, proformas, caja, personas y asistente IA. - Proformas y caja: lectura con IA de los PDF de SAP que manda un asesor, flujo de cobro.
- Asistente IA: preguntas en lenguaje natural contestadas con SQL de solo lectura sobre vistas en español.
- Atribución de marketing: origen del lead por código en el texto del enlace
wa.meo por elreferralde Meta.
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
- Una sola fuente de verdad: los datos del CRM viven en la base de Chatwoot (Postgres). Lo propio va en
custom_attributes(jsonb) o en el esquemacrm. Un solo respaldo. - El estado de Elena se deriva, no se guarda: el paso del bot sale de qué atributos le faltan al contacto.
- Sin build step: el sidecar es Node + Express; la app es HTML + JS plano. Dependencias de producción:
expressypg. Nada más. - Lógica pura separada de los efectos:
elena.js,reassign.js,lib.jsno hacen red y son lo que se testea;server.jsejecuta. - Los permisos los pone Chatwoot: la app habla con la API del chat con las credenciales de quien está adentro.
2. Arquitectura
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)
| Servicio | Imagen | Puerto | Notas |
|---|---|---|---|
rails | chatwoot/chatwoot:v4.16.2 | 127.0.0.1:3000 | Web + API + ActionCable. Monta brand/ y overrides/. |
sidekiq | misma | — | Jobs: envío de mensajes, push, reportes. También monta los overrides (los push los manda él). |
postgres | pgvector/pgvector:pg16 | 127.0.0.1:5432 | Base chatwoot_production (no la de POSTGRES_DB: Chatwoot corre con RAILS_ENV=production). |
redis | redis:alpine | 127.0.0.1:6379 | Con --requirepass. |
dashboard | build ./sidecar (node:22-alpine + tzdata) | 127.0.0.1:4000 | TZ=America/Costa_Rica. Monta brand/ (mismo logo que el chat). |
caddy | caddy:2-alpine | 80/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
| Variable | Hoy | Destino |
|---|---|---|
CHATWOOT_DOMAIN | chat.owelign.com | rails :3000 |
CRM_DOMAIN | crm.owelign.com | sidecar :4000 |
DASHBOARD_DOMAIN | dashboard.owelign.com | sidecar :4000 (aquí está registrado el webhook /bot/<secreto>) |
DEV_CHATWOOT_DOMAIN | dev.chat.owelign.com | rutas de la app → :4100, resto → :3100 |
DEV_DASHBOARD_DOMAIN | dev.dashboard.owelign.com | :4100 |
DOCS_DOMAIN | docs.crm.owelign.com (por defecto) | archivos estáticos de docs-site/ |
/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 repo | Se monta en | Para qué |
|---|---|---|
overrides/vueapp.html.erb | app/views/layouts/vueapp.html.erb | Copia 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.yml | config/locales/zzz_marca.es.yml | Tí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.rb | modelo y controlador | Respuestas 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.rb | config/initializers/zzz_contactos_propios.rb | Cada 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.css | public/brand-assets/ | Retematizado negro y dorado (--marca-dorado: #c3a358) y ocultamientos de interfaz (algunas reglas van por posición). |
brand/interfaz.js | idem | Pestañas de Conversaciones según rol: admin y recepción arrancan en Todos; el vendedor no ve pestañas. |
brand/agentes.js | idem | Botón Desactivar / Reactivar en Ajustes → Agentes; llama a /api/agentes/… del sidecar con las cabeceras devise del chat. |
brand/proformas.js | idem | Barra "Proforma … pendiente · Enviar" encima de la caja de respuesta de una conversación. |
brand/manifest.json, logo_thumbnail.png | public/manifest.json, public/favicon.ico | Nombre e ícono al instalarla como app; favicon propio. |
contactos_propios.rb, que sí recorta en el servidor.Configuración de Chatwoot que no está en archivos
DISABLE_ENTERPRISE=true: MIT puro, permite rebrandear sin licencia.InstallationConfig DISPLAY_MANIFEST=false: oculta el banner de "versión nueva" (ver §18).PRESENCE_DURATION=300: un agente cuenta como conectado 5 min desde su último latido (de fábrica eran 20 s, igual al intervalo del latido: casi todos aparecían desconectados).DEFAULT_LOCALE=es; zona de la cuentaAmerica/Costa_Rica.scripts/brand.shescribe nombre, logos, URLs de términos/privacidad y color de marca.
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
- Exige
CHATWOOT_URL,CHATWOOT_TOKEN,CHATWOOT_ACCOUNT_ID,DASH_USER,DASH_PASSWORD(≥12 caracteres). Si falta alguna, no arranca. - Con
INTAKE_ENABLED=trueexige ademásINTAKE_BOT_SECRET(≥32),CHATWOOT_BOT_TOKENeINTAKE_INBOX_IDS. - Recarga los roles de la app desde
crm.rolesal arrancar y cada 60 s. - Loguea si Elena responde con Gemini o con el guion, y el horario de atención.
- Las tablas propias (
crm.*) y las vistas (asistente.*) se crean solas conCREATE … IF NOT EXISTSen la primera consulta. No hay migraciones aparte.
Cómo habla con Chatwoot
| Ayudante | Token | Uso |
|---|---|---|
cw() | CHATWOOT_TOKEN (admin) | Lecturas de tableros, contactos, agentes, conversaciones; ciclo de alertas. |
cw2() | idem, /api/v2 | Reportes de rendimiento por agente. |
cwBot() | CHATWOOT_BOT_TOKEN | Todo 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 persona | La app del equipo; Chatwoot aplica los permisos de esa persona. |
db() | Postgres directo | Lo que la API no expone: activar/desactivar, roles, tokens de contraseña, proformas, asistente. |
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)
- 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 rolpanel(Marketing, sin chat). - Si no, se intenta
POST /auth/sign_inen Chatwoot. Si la persona no pertenece a la cuenta o tiene MFA, se rechaza con un mensaje claro. - El rol:
adminsi es administrador de la cuenta; si no, el decrm.roles, y si no tiene fila, las listasCAJERAS/RECEPCIONISTASdel .env; por defectovendedor.
La cookie
dash_session, cifrada con AES-256-GCM (no solo firmada: lleva el token de sesión del chat). Clave = SHA-256 dedash-session-v2:+DASH_PASSWORD.HttpOnly,SameSite=Strict,SecurecuandoFORCE_SSL=true. Dura 12 h.- Cambiar
DASH_PASSWORDcierra todas las sesiones. - Si Chatwoot responde 401 por el puente, se borra la cookie (evita el bucle login → app → 401).
POST /logoutcierra también la sesión del chat (DELETE /auth/sign_out).
Protecciones
- Límite de intentos: 8 fallos por IP cada 15 min → 429. Con
FORCE_SSLse confía en un proxy (trust proxy 1) para leer la IP real; sin proxy no, para que no se pueda falsear. - CSRF:
/cw/*y/datos/asistenteexigen la cabeceraX-CRM: 1, que un formulario de otro sitio no puede mandar. - CORS de
/api: solo el origenFRONTEND_URL(el chat). Cualquier otro origen → 403. - Poner contraseña:
/login?t=<token>usa el mismoreset_password_tokende Devise (lo generapersonas.jsoagente.sh, vence en 6 h). Exige la complejidad de Chatwoot (6+, mayúscula, minúscula, número, símbolo) y entra directo.
8. Roles y permisos
Chatwoot solo conoce administrador y agente. Los demás roles los define el sidecar.
| Rol (app) | En Chatwoot | Vistas de la app | ¿Recibe leads? |
|---|---|---|---|
admin | administrator | asistente, chat, inicio, equipo, nuevo, proformas, caja, marketing, agentes | Sí |
vendedor | agent | asistente, chat, equipo, nuevo, proformas (solo las suyas) | Sí |
recepcion | agent | asistente, chat (todas), equipo, nuevo, proformas | No (Elena y escalación la saltan) |
caja | agent sin bandejas | caja, proformas | No; 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 ruta | Qué hace |
|---|---|
GET /health | {"ok":true} |
GET /login | Pantalla de login; con ?t=, pantalla de poner contraseña. |
POST /login | Login (o poner contraseña si trae t). |
POST /logout | Cierra la sesión de la app y la del chat. |
GET /brand-assets/* | Logos y favicon (caché 1 h). |
POST /bot/:secreto | Webhook 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 ruta | Quién | Qué hace |
|---|---|---|
GET /api/agentes | admin | Lista de agentes con rol, equipos, bandejas, abiertas, estado. |
POST /api/agentes/:id/desactivar | admin | Desactiva (409 con motivo si no se puede). |
POST /api/agentes/:id/reactivar | admin | Reactiva con el mismo rol, equipos y bandejas. |
GET /api/proformas/espera?conversacion= | miembro | Proformas leídas esperando en esa conversación. |
POST /api/proformas/espera/:id/enviar | miembro | La pasa a caja (crea en crm.proformas) y deja nota privada. |
POST /api/proformas/espera/:id/descartar | miembro | La saca de la espera sin crearla. |
Con sesión de la app
| Método y ruta | Quién | Qué hace |
|---|---|---|
GET /, GET /app.js | todos | La app. |
GET /yo | todos | id, nombre, rol, pubsub, cuenta, origen del chat, minutos de alertas. |
ALL /cw/* | con usuario de chat | Puente genérico a /api/* de Chatwoot (hasta 40 MB, adjuntos incluidos). Exige X-CRM: 1. |
GET /datos/resumen | admin, panel | Leads hoy/ayer, embudo de 7 días, rendimiento del equipo (30 días) con abiertas. |
GET /datos/conversaciones | admin, panel | Abiertas, sin asignar, y las que esperan ordenadas por espera. |
GET /datos/marketing | admin, panel | Últimos 30 días vs 30 previos: leads, cotizados, vendidos, canales (utm_source), campañas. |
GET /datos/clientes?q= | admin, panel | Contactos filtrados (sin acentos ni mayúsculas, en todas las columnas). Tope 300 filas. |
GET /datos/agentes | admin, panel | Agentes con rol de la app. |
POST /datos/agentes/:id/desactivar|reactivar | admin, panel | Igual que /api, desde la app. |
POST /datos/personas | admin (usuario) | Alta: crea el agente en Chatwoot, guarda el rol, devuelve el enlace de contraseña. |
POST /datos/personas/:id/rol | admin (usuario) | Cambia rol (admin/vendedor/recepcion/caja). Nunca deja la cuenta sin admin. |
POST /datos/personas/:id/enlace | admin (usuario) | Nuevo enlace para poner contraseña ("olvidé mi contraseña"). |
GET /datos/proformas?contacto= | con usuario | Lista; el vendedor ve solo las suyas. |
POST /datos/proformas | con usuario | Proforma manual. Los montos se recalculan en el servidor. |
POST /datos/proformas/leer | con usuario | Foto/PDF (≤12 MB: PDF, JPG, PNG, WEBP, HEIC) → datos leídos por Gemini. No guarda. |
POST /datos/proformas/:id/estado | con usuario | Cambio de estado según reglas (§13). Concurrencia optimista: 409 si alguien la cambió. |
POST /datos/asistente | admin, panel, vendedor, recepcion | Pregunta 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.*)
- Una página (
src/app/index.html+app.js), sin librerías ni build. Lo que ve cada quien sale deVISTAS[rol]. - Vistas: Asistente IA · Conversaciones · Resumen del día · Equipo (chat interno, Próximamente) · Nuevo cliente · Proformas · Caja · Marketing · Configuración (agentes y personas). La búsqueda del encabezado (clientes) está para todos.
- Tiempo real: abre el mismo websocket de Chatwoot (
wss://chat…/cable) con elpubsub_tokende la persona; si se cae, reintenta, y de respaldo refresca cada 30 s. - Conversaciones: respuestas predefinidas con
/, notas privadas con Shift+Enter, adjuntos, asignar, resolver, estado del lead. Las plantillas de WhatsApp (fuera de las 24 h) se mandan desde el chat clásico. - Presencia: con acceso al chat, la app mantiene la presencia como el chat; caja no tiene websocket, así que nunca aparece conectada ni recibe asignaciones.
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)
- Solo
message_created. Los salientes públicos se revisan por proformas (§13). Se ignoran privados, otras bandejas y conversaciones que no estén enpending. - Si el cliente contesta una plantilla que mandó un asesor (
quienLoReactivo), la conversación se abre con ese asesor sin pasar por Elena.
El guion (elena.js, 10 nodos)
| Nodo | Pregunta / acción | Guarda |
|---|---|---|
| N1 | Saludo + nombre y cédula en un solo mensaje (con aviso de fuera de horario si aplica) | nombre, cedula (los dos o ninguno) |
| N2 | Motivo, 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 |
| N6 | Queja: traspaso inmediato sin más preguntas | — |
| N7 | Ambiguo sin resolver: cola general | — |
| … | Cierre y traspaso con resumen; intake=listo o incompleto | intake |
- Estado derivado: si falta
nombretoca N1; si faltamotivo_contacto, N2; etc. Un reinicio no pierde el hilo y un cliente que vuelve no repite el cuestionario. Solo las repreguntas van en memoria (flowState). - Una sola repregunta (
MAX_REPREGUNTAS=1); después traspasa con lo que haya.intake=incompletono vuelve a recibir preguntas. - Cédula: física 9 dígitos, jurídica 10, DIMEX 11–12; con o sin guiones. Separa nombre y cédula en cualquier orden y descarta teléfonos de 8 dígitos. No valida dígito verificador.
- Botones reales de WhatsApp (
input_select), máx. 3 y 20 caracteres por título (se recorta por palabra). - Merge obligatorio: el
PUT /contacts/:idreemplaza el hash completo decustom_attributes; se relee el contacto y se mezcla, o se borraría la atribución de marketing.
Gemini (gemini.js)
const step = (GEMINI_API_KEY && await geminiStep(...)) || nextStep(contact, payload.content, opts);
- Mismo contrato que el guion:
{action, message, items, save, cola, resumen}. Gemini decide;server.jsejecuta igual. geminiStepdevuelvenullante cualquier problema (sin clave, cuota, timeout deGEMINI_TIMEOUT_MS=12 s, JSON roto, salida inválida) y contesta el guion. El guion nunca se apaga.- La salida del modelo es entrada no confiable: solo puede escribir 6 campos; enums cerrados; un dato solo se guarda si el cliente lo dijo (se cerró el caso de inventar
tipo_proyecto/zona);parseNombreYCedularellena lo que el modelo omita. Detalle enELENA-GEMINI.md. - Llamada con
fetchnativo agenerativelanguage.googleapis.com/v1beta; sin SDK.
Traspaso (el orden importa)
- Cola:
INTAKE_COLA_UNICA(hoygeneral) o, si está vacía, por motivo (cotizar→ventas, problema→soporte, otra_consulta→general). Los equipos se buscan por nombre exacto, en minúscula. - Candidatos: toda la cuenta (no los miembros del equipo), sin recepción ni caja.
- 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. POST assignments {team_id}y despuésPOST assignments {assignee_id}— dos llamadas: el controlador esif assignee_id … elsif team_id.- Recién entonces
toggle_status → open. Asignar antes de abrir: si se abre primero, el auto-reparto de Chatwoot ("Default Policy") corre en elafter_savey pisa la elección. - Marca
traspasado_enen 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
- Meta Click-to-WhatsApp:
content_attributes.referraldel primer mensaje →utm_source=meta-ads|meta-post,utm_campaign=<id del anuncio>. Gana sobre el código. - Código en el texto: WhatsApp descarta los parámetros de
wa.me, así que el código viaja en el texto pre-escrito:https://wa.me/506XXXXXXXX?text=Hola,%20quiero%20info%20[IG-JUL01]. Regex\[([A-Za-z]{2,6})-([A-Za-z0-9]{2,20})\]; redes: ig, fb, tt, go, wa, web, qr. - Se guarda el primer origen y nunca se sobrescribe. Sin origen → "Directo / sin origen". Guía de códigos:
MANUAL-MARKETING.md.
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
| Perfil | Cuándo | Aviso suave | Escalación |
|---|---|---|---|
| corto | nadie respondió todavía (first_reply_created_at vacío) | ALERT_SOFT_MINUTES (3): nota que menciona al asesor | ALERT_HARD_MINUTES (5): reasigna y prioridad alta |
| largo | un humano ya respondió | ALERT_RESPONDIDA_SOFT_MINUTES (10): menciona al asesor y a los admins | ALERT_RESPONDIDA_HARD_MINUTES (20) |
- Desde cuándo espera (
esperaDesde): el mayor dewaiting_since(cliente escribió y nadie respondió) ytraspasado_en(Elena entregó y nadie saludó: su despedida limpiawaiting_since). Los mensajes del bot no cuentan como respuesta. - Las horas cerradas no cuentan: el reloj arranca a la apertura.
- Escalación en cadena sin tope: pasa al siguiente conectado con menos carga que no la haya tenido en esta vuelta (
createReassignTracker); al agotar la lista, vuelve a empezar; nunca dos veces seguidas al mismo. Si nadie está conectado, no se mueve y deja la nota "no hay ningún asesor conectado". - Asignación manual respetada: si alguien reasigna a mano (cambio de asesor entre dos vueltas que no hizo el ciclo), el elegido recibe su plazo completo y el aviso suave de bienvenida.
- Solo actúa sobre conversaciones con asesor y fuera de Elena. Recepción y caja nunca reciben escalaciones. Todo lo firma el bot.
- Estado en memoria del proceso (tracker, avisados, último asesor visto): un reinicio lo olvida, a lo sumo no detecta un cambio manual de ese mismo minuto.
Aviso de cierre (AVISO_CIERRE)
- Al que escribió y nadie le contestó: "Ya cerramos por hoy 🌙 Tu mensaje quedó anotado…". Al que dejó de responder y la conversación estuvo viva hoy: variante sin "quedó anotado".
- Una sola vez por noche; vuelve a habilitarse cuando el asesor contesta. Da 2 min de gracia. No se manda fuera de la ventana de 24 h de WhatsApp.
Pendientes (PENDIENTE_AVISO)
- Conversaciones que una persona puso en pendiente (no las que está calificando Elena): etiqueta
pendiente+ recordatorio que menciona al asesor cadaPENDIENTE_CADA_MINUTOS(2), hastaPENDIENTE_MAX_AVISOS(10). Al reabrir o resolver, la etiqueta se cae.
13. Proformas y caja
Tres formas de entrar
- 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_esperay 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 encrm.proformasy la ve caja. Descartar la saca sin crearla. - Foto o PDF desde la app:
/datos/proformas/leerdevuelve los datos para el formulario; el vendedor revisa y guarda. - Manual: formulario en la app.
Reglas
- Los montos del navegador no se creen:
totales()recalcula subtotal, IVA (13 %) y total desde las líneas. El precio por línea se guarda neto de descuento. - Si los montos leídos por la IA no cuadran entre sí, la proforma entra en estado
revisar. - Estados:
revisar·pendiente·pagada·anulada. Los cambian caja y admin. El vendedor solo puede reenviar apendientelas suyas que caja devolvió arevisar. - Devolver a
revisarexige motivo.pagadaexige medio (SINPE Móvil, Transferencia, Tarjeta, Efectivo), monto > 0 y referencia (salvo efectivo). - Cada cambio se agrega a
historial(de, a, por, cuándo, pago, nota). ElUPDATEes condicional al estado de origen cuando aplica: si otro lo cambió, 409. numeroes único: no se puede crear dos veces la misma oferta (doble clic, o ya cargada a mano).
14. Asistente IA
- Gemini no ve las tablas de Chatwoot: ve el esquema
asistente, vistas en español ya filtradas a la cuenta, con horas en Costa Rica y sin nada sensible:asesores,conversaciones,contactos,mensajes,proformas,proforma_lineas. - El modelo escribe un
SELECT, el sidecar lo corre y le devuelve las filas; puede encadenar hasta 6 consultas por pregunta (200 filas, 400 caracteres por celda, 40 000 caracteres por resultado). Historial de 12 turnos. TimeoutASISTENTE_TIMEOUT_MS(30 s). - La barrera es Postgres, no el prompt:
- Cada consulta entra con un usuario de login propio:
crm_ia_todo(admin, panel, recepción) ocrm_ia_u<id>(vendedor). Solo tienenSELECTsobre las vistas (vía el rolcrm_ia), contraseña aleatoria por arranque en memoria, máx. 3 conexiones. - Lo que ve el vendedor lo decide
current_userdentro de la vista; ese usuario no es miembro de otro rol, así queSET ROLEno lo saca. - Transacción
READ ONLYystatement_timeoutde 5 s.
- Cada consulta entra con un usuario de login propio:
- Admin ve también el SQL usado, para confiar en el número.
15. Tableros
- Leen con
CHATWOOT_TOKENy caché de 60 s (contactos y rendimiento). Contactos paginados de 15 en 15 hasta 200 páginas (~3000). - Resumen del día: leads hoy/ayer; embudo de 7 días (Leads → Contactados → Cotizados → Vendidos, por
estado_lead); equipo con métricas de 30 días (/api/v2 summary_reports/agent) y abiertas. Si el reporte falla, se dice; no se pinta vacío. - Marketing: 30 días contra los 30 anteriores; canales siempre visibles (instagram, facebook, tiktok, sin origen); campañas con cotizados y vendidos. La conversión se mide sobre el total de leads, no sobre los cerrados.
- Configuración: agentes (activar/desactivar) y personas (alta, rol, enlace).
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
| Tabla | Claves útiles |
|---|---|
contacts | custom_attributes jsonb: nombre, cedula, motivo_contacto, producto_interes, tipo_proyecto, zona, intake, utm_source, utm_campaign, origen_declarado, estado_lead |
conversations | status 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 |
messages | message_type 0 entrante · 1 saliente · 2 actividad · 3 plantilla; private; sender_type Contact/User/AgentBot |
account_users | role 0 agent · 1 administrator |
inboxes / channel_whatsapp | canal polimórfico (channel_type); token y phone_number_id de Meta en provider_config |
contact_inboxes | un contacto por canal; de ahí cuelgan las conversaciones |
agent_bots, agent_bot_inboxes | Elena y en qué bandejas actúa (outgoing_url = webhook) |
custom_attribute_definitions | declaración de atributos (sin ella no se ven en la ficha) |
canned_responses | + columna user_id (agregada por ajustes.sh) |
Esquema crm (propio)
| Tabla | Columnas |
|---|---|
crm.roles | correo PK (minúscula), rol (vendedor|recepcion|caja), por, en |
crm.proformas | id, 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_espera | id, 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.
| Grupo | Variables |
|---|---|
| Chatwoot | FRONTEND_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 |
| Correo | MAILER_SENDER_EMAIL, SMTP_*. Sin SMTP no salen invitaciones ni "olvidé mi contraseña" (alternativa: agente.sh o Personas en la app) |
| Apps móviles | IOS_APP_ID, ANDROID_*, ENABLE_PUSH_RELAY_SERVER |
| Sidecar | CHATWOOT_URL, CHATWOOT_ACCOUNT_ID, CHATWOOT_TOKEN, DASH_USER, DASH_PASSWORD |
| Alertas | REASSIGN_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 |
| Roles | RECEPCIONISTAS, CAJERAS (respaldo de crm.roles; los mantienen los scripts) |
| Horario | HORARIO_ATENCION=08:00-18:00 |
| Dominios | CHATWOOT_DOMAIN, DASHBOARD_DOMAIN, CRM_DOMAIN, DEV_CHATWOOT_DOMAIN, DEV_DASHBOARD_DOMAIN, DOCS_DOMAIN (opcional) |
| Marca | BRAND_NAME (también es el negocio para el que dice trabajar Elena), BRAND_COLOR, BRAND_SITE, BRAND_TERMS_URL, BRAND_PRIVACY_URL |
| Elena | INTAKE_ENABLED, INTAKE_INBOX_IDS, INTAKE_COLA_UNICA, INTAKE_BOT_SECRET, CHATWOOT_BOT_TOKEN, INTAKE_DEBUG (vuelca datos del cliente al log: solo para diagnosticar) |
| Gemini | GEMINI_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
| Prod | Dev | |
|---|---|---|
| Ruta | /opt/crm | /opt/crm-dev |
| Rama | main | develop |
| Puertos | 3000 / 4000 | 3100 / 4100 (y 5532, 6479) |
| Caddy | sí (perfil prod), atiende a los dos | no |
| Datos | reales, WhatsApp | base propia casi vacía, sin WhatsApp (se prueba con el widget web) |
| Quién despliega | solo el administrador del servidor | desarrollo: 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.yaml | up -d (recrea solo lo que cambió de config) |
sidecar/ | up -d --build --no-deps dashboard |
overrides/ | recrea rails y sidekiq |
Caddyfile | restart 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
- 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. - Docker oficial +
ufwcon solo 22, 80, 443. - Clonar en
/opt/crm,cp .env.example .env, generar secretos (openssl rand). 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../scripts/brand.sh; crear el admin; zona horaria de la cuenta;./scripts/ajustes.sh.- WhatsApp Cloud (bandeja manual, webhook en Meta suscrito a
messages) y el Agent Bot de Elena. Paso a paso enDEPLOY.md. - Respaldo diario con
BACKUP_REMOTEfuera 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:
- Comparar
overrides/*.rbyvueapp.html.erbcon los de la imagen nueva (fuera de lo nuestro, deben ser iguales). - Ajustes → Agentes muestra Desactivar; admin entra con Todos; vendedor sin pestañas; panel de conversación con solo dos secciones; barra de proforma visible.
rel="manifest"presente yDISPLAY_MANIFESTen false.- Los títulos de
notificaciones.es.ymlsiguen aplicando (I18n.t(...)).
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
| Script | Qué hace |
|---|---|
deploy.sh | Pull y reinicio selectivo (§18.2). |
crear-dev.sh | Crea 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.csv | Alta masiva desde CSV (correo,nombre,cedula,rol[,equipos]). |
recepcionista.sh / cajera.sh | Alta + 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.sh | Logos y configuración de marca de Chatwoot. |
backup.sh | pg_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
| Trampa | Qué pasa | Cómo se evita |
|---|---|---|
Falta X-Forwarded-Proto: https | Redirección a https://rails:3000 y fetch failed | Usar cw(); todo fetch suelto la lleva |
PUT /contacts sin merge | Se borran utm y demás atributos | Releer el contacto y mezclar |
| Abrir antes de asignar | El reparto de Chatwoot pisa la elección de Elena | Equipo → asesor → open |
team_id y assignee_id juntos | Se descarta el equipo en silencio | Dos llamadas |
pending → open no reparte | Chatwoot solo reparte al crear | El sidecar asigna a mano |
| Webhook con hostname privado | Rechazado por SSRF; la conversación se abre sola con error | URL pública + secreto |
| Migraciones tras actualizar | Chatwoot levanta "bien" con el esquema viejo | db:chatwoot_prepare siempre |
| Bandeja creada por consola | Sin miembros: todo queda sin asignar | Agregar agentes a la bandeja |
| Atributos no declarados | El dato existe pero no se ve en la ficha | ajustes.sh |
| Cambiar nombre de equipo o atributo | Elena deja de enrutar; el tablero deja de contar | Nombres 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 tzdata | Fechas corridas un día | apk add tzdata en el Dockerfile |
| Cookie Secure sobre HTTP | Bucle de login en local | Atada a FORCE_SSL |
22. Límites conocidos
- El horario no sabe de fines de semana ni feriados: el aviso de cierre de un viernes dice "mañana".
- Sin SMTP: no hay invitaciones por correo ni recuperación de contraseña por correo.
- La clave compartida del panel no deja auditoría de quién entró.
- Ocultar menús es interfaz, no permiso (salvo Contactos).
- Estado de alertas en memoria: un reinicio olvida el historial de la cadena.
- Topes "ponytail": ~3000 contactos en tableros, 300 filas en Clientes, 250 conversaciones abiertas en el resumen. Paginar si el negocio crece.
- Las proformas se leen solo si hay
GEMINI_API_KEY.
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).