Files
AgendaPro/platform
AgendaPro DevandClaude Opus 5 2437012c46 feat(deploy): empaquetar platform/ y desplegarlo en el server E3
Prepara el backend Postgres para producción y lo despliega como stack de Swarm
en el servidor de Consultoría E3, detrás de Traefik.

## Lo que le faltaba al proyecto para poder empaquetarse

- **`platform/` importaba de `server/`.** `routes/dayClose.ts` traía los helpers
  de zona horaria de `../../server/lib/time.ts`, contradiciendo lo que el propio
  README declara. El typecheck pasaba limpio porque en el equipo de desarrollo
  el archivo existe; solo se vio al contenerizar, cuando el proceso murió al
  arrancar con ERR_MODULE_NOT_FOUND. Ahora hay `platform/lib/time.ts`, copia
  deliberada —misma decisión que `businessDefaults.ts`— y una prueba que impide
  que vuelva a colarse una importación cruzada.
- **No había endpoint de salud.** `/api/health` consulta la base: uno que solo
  responde `{ok:true}` sigue en verde con Postgres caído, justo cuando el
  orquestador debería reiniciar.
- **No servía el frontend.** Ahora sirve `dist/` con fallback de SPA que excluye
  `/api/`, para que una ruta de API inexistente devuelva 404 y no el index.
- **No había apagado ordenado.** Sin él, cada redespliegue corta las peticiones
  en vuelo. Verificado: la tarea vieja registra el SIGTERM y cierra.

## Imagen

`platform/Dockerfile`, multi-etapa. Corre como `USER node`, fija `TZ=UTC` —la
agenda saca la zona de `businesses.timezone`, y dejar la del proceso en México
haría que una recaída pasara desapercibida— y **no** ejecuta las migraciones al
arrancar: se lanzan como paso explícito del despliegue, porque hacerlo al inicio
deja el esquema a medias si el arranque falla.

`tsx` pasa de devDependencies a dependencies: este backend corre TypeScript
directo y arrancar con `npx tsx` lo bajaría de npm en cada arranque, sin versión
fijada y sobre todo el código del servidor.

## Estado en el servidor

- Base `agendapro` con rol `agendapro_app` de mínimo privilegio, `PUBLIC`
  revocado. Verificado: el rol no ve ninguna tabla de las otras bases.
- Clave maestra de cifrado **generada en el servidor**, nunca copiada de
  desarrollo.
- Stack `agendapro` desplegado, 1/1 réplicas, 5 migraciones aplicadas, 17 tablas.
- Salud, SPA y API de administración verificadas desde dentro de la red overlay.

## Pendiente y bloqueante para el acceso externo

El A-record `agendapro.consultoriae3.com` -> 157.173.205.217 hay que crearlo a
mano en SiteGround. Sin él no hay certificado, porque Let's Encrypt valida por
HTTP-01.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 15:33:28 -06:00
..

platform/ — backend Postgres de Yola Franco Spa

Backend nuevo sobre PostgreSQL 16 para la plataforma del spa. Habla los mismos contratos /api que el frontend de este repo, así que la SPA de src/ funciona contra él sin cambios de stack.

El server/ de SQLite sigue en pie y sin tocar: es la demo de AgendaPro y el punto de comparación. Los dos backends no se hablan ni comparten base.

Plan e historia de las decisiones: docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md.

Arranque

npm run pg:up          # Postgres 16 en Docker, puerto 5434
npm run pg:migrate     # aplica las migraciones pendientes
node scripts/run-tsx.mjs platform/scripts/seed.ts   # siembra el spa y citas de hoy
npm run platform       # API en :3100

# el frontend contra este backend:
API_URL=http://127.0.0.1:3100 npx vite --port 5175

Cuenta de la dueña: [email protected] / demo1234. El personal entra con karla@, brenda@ y [email protected], misma contraseña.

Puerto 5434 y no 5432/5433: los dos están ocupados por contenedores de otros proyectos en esta máquina.

Pruebas

npm run test:platform                                  # 38 pruebas
node --import tsx --test platform/lib/phone.test.ts     # solo las puras

Corren contra la base yola_test, que se crea una vez:

docker exec yola-postgres psql -U yola -d postgres -c "CREATE DATABASE yola_test OWNER yola"

--test-concurrency=1 en el script no es cosmético: cada archivo de prueba hace DROP SCHEMA public y en paralelo se pisan entre sí. resetDb() se niega a correr si DATABASE_URL no apunta a yola_test.

Las tres decisiones que sostienen el diseño

  1. La doble reserva la impide el motor, no un if. appointments lleva una restricción EXCLUDE USING gist (employee_id WITH =, during WITH &&) sobre un tstzrange generado. Postgres rechaza la fila con 23P01 y el router lo traduce a un 409 en español. Requiere la extensión btree_gist, que aplica 000_bootstrap.sql.
  2. visits está separada de appointments. Una cita es una intención; una visita es un hecho con dinero. Un solo registro que sirve para planear y para cerrar termina sin cerrarse nunca — es exactamente lo que dejó 3 002 oportunidades congeladas en el CRM del spa. Por eso el "no vino" es un estado de la cita y no crea una visita vacía.
  3. clients.phone_e164 es la clave de identidad. Índice único parcial por (business_id, phone_e164), parcial porque el 40.8 % del histórico medido no tiene teléfono y esas clientas tienen que poder existir: quedan marcadas contactable = false.

Deuda conocida, dicha sin rodeos

  • La autenticación no se endureció. El token es el id del usuario en texto plano y la contraseña se compara sin hashear, portado tal cual del backend de demo. Arreglarlo es un entregable propio: bcrypt/Argon2id + sesión real + src/lib/api.ts + el AuthProvider + las pruebas, todo a la vez. A medias rompe el login.
  • Sin rate limiting y con cors() abierto. Igual que el backend de demo.
  • La validación de entrada es manual. zod está en dependencies y sigue sin importarse en ningún archivo.
  • De Bucéfalo CRM falta lo de entrada. Lo que hay está en la segunda mitad de este documento; lo que no: webhooks (exigen OAuth y este token es un PIT), el espejo persistido de conversaciones —las tablas existen y nadie las escribe—, y el arrastre de citas.
  • El catálogo sembrado no es el del negocio. Los nombres salen del vocabulario medido en los hilos del CRM; las duraciones y los precios son marcadores de posición y hay que sustituirlos por los reales antes de enseñar esto como catálogo del spa.
  • Falta parte de la superficie de /api. Hoy están auth, business, clients, appointments, attendance, day-close, crm y messages. Servicios, empleados, tablero, caja, tickets y recordatorios siguen solo en el backend de SQLite.

Integración con Bucéfalo CRM

Subcuenta Yola Franco Spa. Todo el diseño sale de hallazgos medidos contra el CRM real, no de la especificación. Dos documentos, y conviene no confundirlos:

  • crm/HALLAZGOS.md — los 47 hallazgos empíricos: qué se ejerció, contra qué y con qué resultado. Es la fuente de verdad y manda sobre la documentación oficial.
  • crm/API.md — la referencia de endpoints: rutas, parámetros, scopes, límites de tasa, y la lista explícita de lo que la documentación oficial dice mal o no dice.

Los spikes que produjeron los hallazgos se pueden volver a correr.

Puesta en marcha

# 1. Clave maestra del cifrado de credenciales (una vez por instalación):
cp platform/.env.example platform/.env
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
#    → pégala en CRM_MASTER_KEY

# 2. Vincula la subcuenta desde la consola de administración:
#    PUT /api/admin/businesses/:id/crm  { location_id, token, label }
#    Las credenciales se COMPRUEBAN contra el CRM antes de guardarse.

# 3. Trae los contactos (o pulsa el botón en la pantalla de Clientes)

Para migrar un negocio que ya estaba vinculado por variables de entorno:

node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>

Qué hace hoy

Pieza Estado
Traer contactos con su atribución UTM ✅ 3 210 en ~22 s, idempotente, con botón en Clientes
Deduplicar por id → teléfono → correo ✅ Misma cadena que el CRM aplica. Cero duplicados sobre datos reales
Proyectar citas como oportunidades ✅ SERVICIO — CLIENTA, importe del servicio, open/won/lost
Bandeja de salida con reintentos ✅ Encolada en la misma transacción del cambio, despachada cada minuto
Leer conversaciones y responder ✅ Solo correo: WhatsApp y SMS no están conectados en la subcuenta
Ver la atribución en la ficha ✅ Fuente, medio, campaña, UTM, anuncio y fecha de sincronización

Multi-tenancy: una credencial por negocio

Cada negocio guarda su propio locationId y su token privado, cifrado en la base. Antes el locationId era por negocio pero el token era una variable de entorno global: con dos cuentas, el servidor usaba el token de la primera contra la subcuenta de la segunda —401 en el mejor caso, escritura en la subcuenta equivocada en el peor—. Tres piezas lo sostienen y hay que tocarlas juntas:

  • CrmOptions.token es obligatorio (crm/client.ts). No tiene valor por defecto a propósito: olvidarlo es un error de compilación, no una petición con la credencial de otro cliente.
  • CrmCtx { businessId, locationId, token } (crm/ctx.ts) sustituye al locationId: string suelto que antes viajaba por once firmas. Es un objeto y no dos parámetros porque dos string seguidos se cruzan sin que el compilador diga nada. ctxDe(businessId) es el único sitio donde el token existe descifrado, y solo en memoria.
  • El token se cifra con AES-256-GCM (lib/crypto.ts), autenticado a propósito: una fila manipulada hace que el descifrado falle, en vez de devolver basura que acabaríamos mandando como credencial. La clave maestra vive en CRM_MASTER_KEY, fuera de la base.

Lo único de la credencial que sale del servidor es token_fingerprint, los 6 últimos caracteres. Ni la API, ni los registros, ni audit_log ven el token.

El estrangulador también es por token y aprende la cuota de las cabeceras x-ratelimit-* que el CRM devuelve: son 100 peticiones por 10 s, no la estimación de 1 cada 650 ms con la que se escribió el cliente.

La consola de administración de plataforma

/api/admin, solo para el rol admin (cuyo business_id es NULL):

Endpoint Qué hace
GET /api/admin/businesses Las cuentas, con el estado de su vínculo. Nunca devuelve el token
POST /api/admin/businesses Alta de cuenta y su dueña, en una transacción. El negocio nace con slug y horario
PUT /api/admin/businesses/:id/crm Vincula la subcuenta. Comprueba las credenciales contra el CRM antes de guardarlas
DELETE /api/admin/businesses/:id/crm Desvincula. Borra la credencial y conserva lo sincronizado
PATCH /api/admin/businesses/:id Suspender o reactivar, renombrar, cambiar zona horaria

Sincronización por identificador

POST /api/crm/sync/:entidad/:id resuelve una entidad. Las cinco están ejercidas contra la subcuenta real. La dirección la decide la entidad, no quien llama:

Entidad Dirección Por qué
contacto ← del CRM Es su dueño: ahí viven la deduplicación y las automatizaciones
conversacion ← del CRM Se espeja con todos sus mensajes
mensaje ← del CRM Se espeja con su hilo: messages.conversation_id es obligatorio
cita → al CRM MEDIDO: el calendario del CRM tiene una cita en dos años
servicio → al CRM MEDIDO: su catálogo está vacío

POST /api/crm/sync/conversations espeja las conversaciones recientes con sus mensajes.

Si el spa empieza a agendar dentro del CRM, la premisa de las dos últimas se cae y habrá que decidir cuál de los dos manda cuando difieran. Conviene decidirlo antes de que pase.

Las tres decisiones que no son obvias

1. La sincronización de contactos va en una sola dirección: del CRM hacia aquí. El contacto es del CRM —es su llave de deduplicación y donde viven las automatizaciones—, así que esta sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar pasa por la bandeja de salida, que es otra cosa y tiene otras garantías.

2. La oportunidad se recicla, no se duplica. La subcuenta tiene allowDuplicateOpportunity: false, y eso hace que el CRM rechace una segunda oportunidad por contacto aunque la primera esté cerrada. La plataforma intenta crear y, si recibe ese rechazo, reutiliza la existente con el nombre, el importe y el estado de la cita nueva. Si alguien activa ese ajuste en el CRM, pasa a «una cita = una oportunidad» sin tocar código.

3. Un fallo de transporte no se reintenta. Un 5xx es una respuesta: el servidor habló. Un timeout no dice nada sobre si la escritura entró, y reenviarlo es fabricar la doble creación. Esas filas quedan en indeterminado y se resuelven leyendo.

Modo prueba de mensajes

Mientras CRM_TEST_EMAIL esté definido, el servidor solo envía a esa dirección, sin importar a quién apunte la interfaz. La subcuenta es la de un cliente real con 3 200 contactos: un bucle mal escrito escribiría a personas de verdad. Se levanta con CRM_ALLOW_REAL_SENDS=1, y esa es una decisión deliberada, no un descuido de configuración.

Lo que NO hace, y conviene tener presente

  • La bandeja de mensajes todavía lee en vivo del CRM, no del espejo. Las tablas conversations y messages ya se llenan (POST /api/crm/sync/conversations), pero MessagesPage sigue sin apuntar a ellas.
  • No recibe webhooks. Exigen OAuth y este token es un PIT. La entrada es por sondeo: el botón.
  • No confirma entrega de correo. El CRM acusa «encolado». La interfaz dice «en camino» a propósito, y no «entregado».
  • No trae el catálogo de servicios, porque el del CRM está vacío. Duración y precio viven aquí, y lo que sí se puede es publicarlos hacia el CRM.
  • 4 de cada 10 clientas no tienen teléfono. El panel lo enseña en ámbar. Es el techo de utilidad de cualquier recordatorio, y se arregla pidiendo el teléfono al agendar, no con código.

Scripts

node scripts/run-tsx.mjs platform/scripts/crm-spike.ts            # sondeo de lectura
node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts      # escrituras, con relectura
node scripts/run-tsx.mjs platform/scripts/crm-spike-opps.ts       # regla de duplicados
node scripts/run-tsx.mjs platform/scripts/crm-spike-dup.ts        # ajustes de la subcuenta
node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts         # conectar y autodetectar
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts  # listar basura de pruebas
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar

Los spikes escriben en la subcuenta real del cliente. Todo lo que crean lleva el tag agendamax:prueba y el correo autorizado, y crm-limpiar-pruebas.ts los borra.

Sondeos contra el CRM

node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts       # lectura por id (solo lectura)
node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts      # los 7 calendarios (solo lectura)
node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts         # qué permisos tiene el token, sin crear nada
node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts          # ¿se puede deshacer?, sin crear nada
node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts  # ESCRIBE: crea, relee y borra
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <id> # mueve la credencial del .env a la base, cifrada
node platform/scripts/admin-ui-check.mjs                                # la consola de cuentas, en navegador

Los tres primeros no escriben nada. El de escritura crea, relee y borra en un finally, así que no deja rastro aunque falle a mitad — y antes de escribir se comprobó con crm-spike-borrado.ts que el borrado existe. Preguntar si se puede deshacer antes de tocar el CRM de un cliente, no después.