Files
AgendaPro/platform/README.md
AgendaPro DevandClaude Opus 5 6d67b23e55 feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.

## Multi-tenancy

El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.

- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
  fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
  acabaríamos mandando como credencial al CRM. La clave maestra vive en
  `CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
  `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
  parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
  nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
  el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
  defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
  ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
  100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
  POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.

Lo único de la credencial que sale del servidor es la huella de 6 caracteres.

## Consola de superadministración

`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».

Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.

## Sincronización por identificador

`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.

- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
  002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
  escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
  habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.

## Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.

47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.

110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.

## Deuda conocida, dicha sin rodeos

- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
  rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
  de esa base: no debe quedar expuesta a internet hasta endurecerla.

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

260 lines
14 KiB
Markdown

# `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`](../docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md).
## Arranque
```bash
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
```bash
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:
```bash
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`](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`](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
```bash
# 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:
```bash
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](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](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](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
```bash
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
```bash
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.