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]>
This commit is contained in:
AgendaPro Dev
2026-08-30 15:07:20 -06:00
co-authored by Claude Opus 5
parent dcbf750c09
commit 6d67b23e55
95 changed files with 16132 additions and 41 deletions
+157
View File
@@ -0,0 +1,157 @@
-- ---------------------------------------------------------------------------
-- Integración con Bucéfalo CRM.
--
-- Todo lo de aquí está diseñado contra hallazgos MEDIDOS contra la subcuenta
-- real de Yola Franco Spa (Pk89Wa23QaxvkOfKgwjZ) el 2026-08-29, no contra la
-- especificación. Ver platform/crm/HALLAZGOS.md.
-- ---------------------------------------------------------------------------
-- La conexión con la subcuenta. Una fila por negocio.
-- El token NO vive aquí: vive en el entorno del servidor. Esta tabla guarda
-- qué subcuenta, qué pipeline y qué etapas usa cada negocio.
CREATE TABLE crm_connections (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL UNIQUE REFERENCES businesses(id) ON DELETE CASCADE,
location_id text NOT NULL,
pipeline_id text,
stage_open_id text,
stage_won_id text,
stage_lost_id text,
-- MEDIDO: la subcuenta trae `allowDuplicateOpportunity: false`, así que el
-- CRM rechaza una segunda oportunidad por contacto AUNQUE la anterior esté
-- cerrada. Mientras esté en false, la plataforma recicla la oportunidad
-- existente en vez de crear una por cita. Si el cliente activa el ajuste,
-- esta bandera pasa a true y cada cita estrena la suya.
allow_duplicate_opp boolean NOT NULL DEFAULT false,
last_sync_at timestamptz,
last_sync_status text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Atribución de la clienta. Se separa de `clients` porque son 10+ columnas que
-- solo existen si el contacto vino del CRM, y porque el CRM las declara
-- inmutables: se escriben en el alta y un PUT posterior devuelve 200 sin
-- guardar nada. Aquí son espejo de lectura.
ALTER TABLE clients
ADD COLUMN crm_source text,
ADD COLUMN attr_session_source text,
ADD COLUMN attr_medium text,
ADD COLUMN attr_campaign text,
ADD COLUMN attr_campaign_id text,
ADD COLUMN attr_utm_source text,
ADD COLUMN attr_utm_medium text,
ADD COLUMN attr_utm_content text,
ADD COLUMN attr_ad_id text,
ADD COLUMN attr_referrer text,
ADD COLUMN crm_tags text,
ADD COLUMN crm_date_added timestamptz;
CREATE INDEX clients_crm_contact ON clients (crm_contact_id)
WHERE crm_contact_id IS NOT NULL;
-- La cita se proyecta al CRM como oportunidad.
ALTER TABLE appointments
ADD COLUMN crm_opportunity_id text,
ADD COLUMN crm_synced_at timestamptz,
ADD COLUMN crm_status text; -- lo que el CRM cree: open|won|lost
CREATE INDEX appointments_crm_opp ON appointments (crm_opportunity_id)
WHERE crm_opportunity_id IS NOT NULL;
-- ---------------------------------------------------------------------------
-- Bandeja de salida. Existe desde el día uno a propósito: la API del CRM falla,
-- y sin cola un fallo se traga la cita de una clienta sin que nadie lo sepa.
-- El cambio local y su fila de bandeja se escriben en la MISMA transacción; sin
-- eso aparece la escritura perdida (el usuario ve "guardado", el proceso muere
-- antes de encolar, y nadie lo reclama nunca).
-- ---------------------------------------------------------------------------
CREATE TABLE crm_outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
entity text NOT NULL, -- client | appointment | message
entity_id bigint NOT NULL,
operation text NOT NULL, -- create | update | status | send
payload jsonb NOT NULL,
-- pendiente → enviando → confirmado | fallido | indeterminado
--
-- `indeterminado` no es un adorno: es donde cae un fallo de TRANSPORTE
-- (timeout, conexión caída). Un 5xx es una respuesta —el servidor habló—;
-- un timeout no dice nada sobre si la escritura entró. Reenviarlo es
-- fabricar la doble creación, así que se resuelve leyendo, nunca reenviando.
status text NOT NULL DEFAULT 'pendiente'
CHECK (status IN ('pendiente','enviando','confirmado','fallido','indeterminado')),
attempts integer NOT NULL DEFAULT 0,
last_error text,
-- Clave de deduplicación propia y estable. NUNCA se deriva del contenido:
-- dos ediciones que dejan el mismo valor son dos intenciones distintas.
dedup_key text NOT NULL,
crm_id text, -- se llena tras RELEER, no tras el 200
evidence text, -- relectura | 400_meta | busqueda
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz
);
CREATE INDEX crm_outbox_pendientes ON crm_outbox (business_id, status, id)
WHERE status IN ('pendiente','indeterminado');
CREATE INDEX crm_outbox_entidad ON crm_outbox (entity, entity_id);
CREATE UNIQUE INDEX crm_outbox_dedup ON crm_outbox (dedup_key);
-- Historial de cada corrida del botón de sincronización.
CREATE TABLE crm_sync_runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
kind text NOT NULL, -- contacts | appointments
direction text NOT NULL, -- pull | push
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
status text NOT NULL DEFAULT 'corriendo'
CHECK (status IN ('corriendo','ok','error')),
fetched integer NOT NULL DEFAULT 0,
created integer NOT NULL DEFAULT 0,
updated integer NOT NULL DEFAULT 0,
skipped integer NOT NULL DEFAULT 0,
error text,
started_by_user_id bigint REFERENCES users(id)
);
CREATE INDEX crm_sync_runs_business ON crm_sync_runs (business_id, started_at DESC);
-- ---------------------------------------------------------------------------
-- Espejo de conversaciones y mensajes. El CRM es el dueño: aquí solo se
-- guardan metadatos y referencias, y nunca se editan — se reescriben desde el
-- CRM. La plataforma solo CREA mensajes salientes.
-- ---------------------------------------------------------------------------
CREATE TABLE conversations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
crm_conversation_id text NOT NULL,
client_id bigint REFERENCES clients(id),
crm_contact_id text,
contact_name text,
last_message_type text,
last_message_body text,
last_message_at timestamptz,
unread_count integer NOT NULL DEFAULT 0,
synced_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (business_id, crm_conversation_id)
);
CREATE INDEX conversations_reciente ON conversations (business_id, last_message_at DESC);
CREATE TABLE messages (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
conversation_id bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
crm_message_id text,
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
channel text NOT NULL, -- Email | SMS | WhatsApp | FB | IG…
body text,
subject text,
status text, -- del CRM: queued|sent|delivered|failed
sent_by_user_id bigint REFERENCES users(id),
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (business_id, crm_message_id)
);
CREATE INDEX messages_conversacion ON messages (conversation_id, sent_at);