Files
AgendaPro/platform/crm/HALLAZGOS.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

18 KiB
Raw Permalink Blame History

Hallazgos medidos contra Bucéfalo CRM

Subcuenta Yola Franco Spa (Pk89Wa23QaxvkOfKgwjZ) · 2026-08-29 · token PIT de subcuenta.

Todo lo de aquí se ejerció contra el CRM real y se verificó releyendo, nunca aceptando un 200 como prueba. Los spikes que lo produjeron están en platform/scripts/crm-spike*.ts y se pueden volver a correr.


Lo que quedó confirmado

# Hallazgo Consecuencia
1 La subcuenta responde: Yola Franco Spa, tz America/Mexico_City, país MX El token y el locationId son correctos
2 3 209 contactos y 3 210 conversaciones Hay material real que sincronizar
3 attributionSource viene poblado con datos reales (sessionSource, medium, campaign, campaignId, adId, utmMedium, utmContent) La atribución UTM que pide el proyecto existe y se puede traer
4 Pipeline único: Standar Mrclt4VzRZV1DI4Vbt5c, 9 etapas, con Ganado (b91c1653-…) y Perdido (04b28d7f-…) Hay dónde aterrizar won y lost sin inventar nada
5 SÍ existen 7 calendarios, uno de ellos Servicio Spa (LXZIuRPYa3uCPlUlsqY7) Resuelve la pregunta que el análisis previo marcaba como bloqueante
6 GET /calendars/services/catalog devuelve services: [] El catálogo de servicios del CRM está vacío: la duración y el precio tienen que vivir en la plataforma. Confirma la sospecha previa
7 POST /contacts/ con attributionSource → persiste íntegro (verificado releyendo) Se puede dar de alta con UTM completo
8 POST /contacts/ duplicado → 400 con meta.contactId y meta.matchingField Idempotencia real y gratuita. Es mejor que upsert, cuya rama actualizar descarta la atribución
9 POST /opportunities/ → crea y el importe persiste El valor del servicio llega al CRM
10 POST /conversations/messages con type: "Email" → 200 Email queued successfully con conversationId, messageId, threadId Hay canal de vuelta para probar mensajes

Lo que NO funciona como uno esperaría

# Hallazgo Cómo se sortea
11 PUT /opportunities/{id}/status rechaza pipelineStageId con 422 property pipelineStageId should not exist El estado y la etapa se cambian en dos llamadas: /status con solo status, y PUT /opportunities/{id} con pipelineId + pipelineStageId
12 POST /opportunities/ rechaza una segunda oportunidad del mismo contacto aunque la primera esté en won (400 OPPORTUNITY_NO_DUPLICATE con meta.existingId) Ver «La decisión de las oportunidades» abajo
13 La causa es el ajuste settings.allowDuplicateOpportunity: false de la subcuenta Es un ajuste, no un límite duro. El cliente puede activarlo
14 GET /users/?locationId → 401 fuera de scope OBSOLETO — ver hallazgo 35: hoy responde 200 El token PIT no listaba personal. Volvió a medirse el 2026-08-29 y sí lo lista
15 POST /opportunities/pipelines → 401 The token is not authorized for this scope pipelines.create no está en el token. Rediseñar el pipeline a etapas de spa es trabajo de UI, no de código
16 GET /contacts/{id}/opportunities → 404, la ruta no existe Se usa GET /opportunities/search?location_id=&contact_id=, que sí funciona

Lo que el CRM usa para deduplicar, y coincide con lo pedido

GET /locations/{id} devuelve:

"settings": {
  "allowDuplicateContact": false,
  "allowDuplicateOpportunity": false,
  "contactUniqueIdentifiers": ["email", "phone"]
}

La cadena de identidad pedida para el proyecto —id de contacto → teléfono → correo— es exactamente la que el CRM aplica. Con allowDuplicateContact: false, el propio CRM devuelve el contactId existente en el 400: la deduplicación no hay que construirla, hay que leerla del rechazo.

La decisión de las oportunidades

El encargo es «una cita = una oportunidad», con el nombre SERVICIO + NOMBRE CONTACTO, el importe del servicio, y open / won / lost según el estado. El hallazgo 12 lo impide hoy: con allowDuplicateOpportunity: false, una clienta que vuelve por segunda vez no puede estrenar oportunidad, y una clienta de spa vuelve muchas veces.

Se implementan los dos caminos y la plataforma elige solo, sin configuración:

  1. Intenta crear. Si el CRM la acepta, una cita = una oportunidad, tal como se pidió.
  2. Si responde 400 OPPORTUNITY_NO_DUPLICATE, toma el meta.existingId y recicla esa oportunidad: le pone el nombre de la cita nueva, su importe y su estado. Verificado que se puede renombrar, cambiar el importe, cerrar y reabrir una ya cerrada.

Con el ajuste desactivado, la oportunidad representa la cita vigente de la clienta y el histórico completo vive en AgendaMax. Con el ajuste activado, el modelo pasa a ser el pedido sin tocar una línea de código.

Para que sea «una cita = una oportunidad» hace falta que alguien active Allow Duplicate Opportunity en los ajustes de la subcuenta. Es un interruptor de la UI del CRM; el token no puede cambiarlo. Mientras tanto el MVP funciona reciclando.

Cabeceras y trampas

  • Version: 2021-07-28 para contactos, oportunidades y conversaciones; Version: v3 para todo /calendars/. Equivocarla es 400.
  • locationId va en el cuerpo del POST /contacts/ y rompe el PUT (422 property locationId should not exist). Es una asimetría fácil de cruzar reciclando código.
  • Hay lista blanca de propiedades: cualquier clave desconocida es 422 y no crea nada. Es un fallo seguro y sirve de herramienta de descubrimiento.
  • La atribución es de una sola oportunidad: se escribe en el alta y un PUT posterior devuelve 200 sin guardar nada.
  • campaign hay que mandarlo además de utmCampaign: el buscador de contactos descarta utmCampaign y conserva campaign.

Hallazgos posteriores, ya con la integración escrita

# Hallazgo Consecuencia
17 PUT .../status mueve la etapa por su cuenta. Con la etapa escrita primero y el estado después, el CRM la devolvió de «Ganado» a «Cotización Aceptada» El orden correcto es estado primero, etapa después
18 Ese movimiento es asíncrono y gana igualmente en won: se reescribió y releyó tres veces y el CRM la volvió a mover después de que la relectura ya confirmaba la nuestra. En lost sí respeta «Perdido» Hay una regla del lado del CRM que gobierna la etapa en won. No se pelea con ella: lo que el negocio pidió mapear es el status, y ese sí queda estable
19 El buscador de contactos pagina con searchAfter, tomado del último contacto de la página anterior Con page se topa un techo de profundidad mucho antes de los 3 200
20 Sincronización completa medida: 3 210 contactos en 22 s, idempotente (segunda corrida: 0 creados, 3 210 actualizados) El botón puede correr en primer plano sin tarea de fondo
21 Calidad real de los contactos traídos: 59,4 % con teléfono normalizable, 8 con correo de 3 215, 801 con campaña, 824 con anuncio Coincide con la auditoría independiente previa (59,8 % y 0,2 %). Cuatro de cada diez clientas no son contactables
22 El prefijo 521 heredado de mensajería aparece en los teléfonos reales (+5215656592254) y se colapsa bien a +525656592254. Cero duplicados por teléfono tras sincronizar 3 210 La deduplicación por E.164 funciona sobre datos reales
23 El mensaje enviado aparece al releer el hilo, pero su status viene null El CRM no expone el estado de entrega ahí: sigue sin poder afirmarse que llegó

Lo que sigue sin verificarse

  • Que el correo se entregue. Email queued successfully es acuse de encolado, no de entrega. Exige mirar una bandeja real.
  • WhatsApp y SMS: no están conectados en la subcuenta. Fuera del MVP.
  • Escritura de citas al calendario del CRM (POST /calendars/events/appointments): no se ha ejercido. El MVP proyecta las citas como oportunidades, no como eventos de calendario.
  • Webhooks: exigen OAuth, que este token no es. La sincronización de entrada es por sondeo.

Datos de prueba creados en la subcuenta real

Contacto WzBTBaHkNnpmjMb1Avx3 ([email protected], tag agendamax:prueba) y su oportunidad IMkYdAkBowggN9aKVbfc. Se limpian con node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts.


Sondeo de lectura por id — 2026-08-29

Medido con crm-spike-lectura-id.ts y crm-spike-calendarios.ts, ambos solo lectura. Cubre las cinco entidades que pide la sincronización por id: contactos, conversaciones, mensajes, citas y servicios.

# Hallazgo Consecuencia
24 GET /conversations/{id} funciona y trae contactId, messageTypes, unreadCount, firstUnreadInboundMessageId Se puede anclar una conversación por su id sin recorrer la lista
25 GET /conversations/search?contactId=… filtra por contacto Es el camino para «las conversaciones de esta clienta» sin traerse las 3 213
26 GET /conversations/{id}/messages pagina con lastMessageId + nextPage, no con page ni searchAfter Tercera convención de paginación distinta en la misma API. No reciclar la de contactos
27 GET /conversations/messages/{id} funciona: trae un mensaje suelto por su id, con from, messageType, contentType, meta Permite reconciliar un mensaje concreto sin releer el hilo entero
28 GET /calendars/events exige uno de userId, calendarId o groupId; sin ellos es 422. Con un userId inexistente es 400 User with id … not found No hay forma de pedir «todas las citas de la subcuenta» en una llamada: hay que iterar los calendarios
29 La subcuenta tiene 1 sola cita en total en los 7 calendarios, en una ventana de 2 años atrás y 1 adelante, y está en Servicio Spa (LXZIuRPYa3uCPlUlsqY7) El calendario del CRM está prácticamente sin usar. La agenda real no vive ahí: nace en la plataforma. Confirma que la sincronización de citas es empuje, no arrastre
30 Forma del evento: appointmentStatus, assignedUserId, calendarId, contactId, startTime, endTime, dateAdded, address. Devuelve además appoinmentStatus — con la errata — con el mismo valor Si se lee el estado, leer appointmentStatus y tolerar la errata: es del CRM, no nuestra
31 GET /calendars/groups → 0 grupos No hay agrupación que aprovechar
32 GET /calendars/services/catalog → services: [] reconfirmado El catálogo de servicios del CRM sigue vacío. Duración y precio viven en la plataforma, y «sincronizar servicios» no puede significar traerlos de allá

Lo que esto decide

  • Contactos, conversaciones y mensajes: los tres se pueden traer por id concreto. La sincronización selectiva que pide el encargo es viable tal cual, sin rodeos.
  • Citas: no hay nada que arrastrar. La dirección útil es empujar la cita de la plataforma al calendario del CRM (POST /calendars/events/appointments, aún sin ejercer) además de la oportunidad que ya se proyecta.
  • Servicios: no hay catálogo en el CRM que sincronizar. Lo único con sentido es publicar hacia allá los de la plataforma, y eso exige comprobar antes si el token tiene permiso de escritura sobre /calendars/services — no se ha probado.

Sondeo de permisos y de límites — 2026-08-29

Medido con crm-spike-permisos.ts. La técnica no crea nada: se manda un POST deliberadamente incompleto y se mira qué error vuelve. Un 401 not authorized for this scope significa que falta el permiso; un 422 sobre los campos significa que el permiso está y lo que falla es el cuerpo. Distinguir esas dos cosas era lo único que faltaba para saber si se puede planificar escritura, y no costó un solo registro basura en la subcuenta del cliente.

# Hallazgo Consecuencia
33 calendars/events.write SÍ está. POST /calendars/events/appointments responde 422 calendarId should not be empty · startTime must be a valid ISO 8601 date string, no 401 Se pueden escribir citas al calendario del CRM. Deja de ser una incógnita: el MVP puede proyectar la cita como evento además de como oportunidad
34 calendars.write SÍ está. POST /calendars/services/catalog responde 422 name should not be empty · At least one staff member is required Se puede poblar el catálogo de servicios del CRM. Que esté vacío (hallazgo 6) no es un límite de la API: es que nadie lo llenó
35 GET /users/?locationId responde 200 con 6 usuarios. Contradice el hallazgo 14, medido semanas antes El token tiene ahora permiso de personal. Esto desbloquea el staff[] obligatorio del alta de servicios, que era el impedimento práctico del hallazgo 34. Ids disponibles, entre ellos 6HCOVjDvvdUlv1bjbR7W (Yola Spa Recepción), que es el assignedUserId de la única cita real
36 GET /contacts/{id}/appointments responde 200 con {"events":[]} Hay una ruta directa para «las citas de esta clienta» sin recorrer calendarios. Devuelve vacío porque la subcuenta casi no tiene citas (hallazgo 29)
37 Cabeceras de límite reales: x-ratelimit-max: 100, x-ratelimit-interval-milliseconds: 10000, x-ratelimit-limit-daily: 200000 La cuota es 1 petición cada 100 ms, no cada 650. El cliente estrangula 6,5× por debajo de lo permitido. Bajar MIN_INTERVAL_MS acortaría la sincronización de 22 s a ~4 s. Hasta hoy nadie leía esas cabeceras: el 650 ms era una estimación observada, no una cuota conocida

Lo que esto cambia

  • Las cinco entidades del encargo son viables. Contactos, conversaciones y mensajes se leen por id (24-27); las citas se pueden escribir al calendario (33) además de proyectarse como oportunidad; y los servicios se pueden publicar al catálogo (34) ahora que hay ids de personal (35). Ninguna queda bloqueada por permisos.
  • «Sincronizar servicios» solo puede significar empujar, nunca traer: el catálogo del CRM está vacío y la duración y el precio los define el negocio en la plataforma.
  • Un permiso medido una vez no queda medido para siempre. El hallazgo 14 era cierto cuando se midió y hoy es falso, porque alguien cambió el token o sus permisos. Conviene que la plataforma compruebe los permisos al vincular una subcuenta y lo vuelva a hacer cuando algo falle con 401, en vez de fiarse de una tabla escrita en el pasado.

Ejercido con la sincronización por id escrita — 2026-08-29

# Hallazgo Consecuencia
38 GET /conversations/{id} no devuelve el nombre del contacto ni un canal reconocible. Solo el buscador los trae Sincronizar un hilo por su id degradaba un nombre bueno a «Sin nombre» y el canal a «Desconocido». El upsert ya no deja que un dato pobre pise a uno que ya se tenía, y el nombre se toma de la clienta enlazada
39 El canal del hilo se puede deducir de su último mensaje, que sí lo trae Evita el «Desconocido» sin gastar una petición más. Se excluyen los TYPE_ACTIVITY_*, que son notas del propio CRM y no un canal por el que hablar con la clienta
40 Los hilos reales traen TYPE_INSTAGRAM y TYPE_ACTIVITY_OPPORTUNITY, que no estaban en ningún mapa Instagram es un canal de verdad de este negocio; las actividades no lo son y conviene distinguirlas en la bandeja
41 Sincronización por id verificada de punta a punta contra la subcuenta real: contacto (creado), conversación (espejada, 3 mensajes) y mensaje suelto (espejado con su hilo) Las tres entidades que el CRM posee se pueden traer una a una. La conversación quedó enlazada con la clienta local por crm_contact_id

Primera escritura de citas y servicios al CRM — 2026-08-29

Ejercida con crm-spike-escritura-cita-servicio.ts, que crea, relee, borra y confirma el borrado en la misma corrida. Nada quedó en la subcuenta: la limpieza va en un finally, así que se ejecuta aunque el sondeo falle a mitad. Antes se comprobó con crm-spike-borrado.ts que el borrado existe — preguntar si se puede deshacer antes de escribir en el CRM de un cliente real, no después.

# Hallazgo Consecuencia
42 Escribir una cita al calendario funciona. POST /calendars/events/appointments la crea, se relee con el contacto correcto, el estado confirmed, y la hora de pared idéntica a la escrita: se mandó 2026-12-28T14:00:00-06:00 y se releyó igual La proyección de citas al calendario deja de ser una incógnita. Y confirma que isoConDesplazamiento acierta: escribir con toISOString() habría movido la hora que el CRM enseña
43 GET /calendars/events/appointments/{id} SIGUE devolviendo la cita después de borrarla. El listado del rango sí deja de incluirla Es un borrado lógico. Comprobar existencia por id da un falso positivo: la comprobación fiable es listar el calendario
44 La primera publicación de un servicio falla con 400 No default service category found for this location, aunque staff, name y slug sean correctos La documentación marca serviceCategoryId como opcional. No lo es cuando la subcuenta no tiene ninguna categoría
45 Ese mismo intento fallido hace que el CRM cree la categoría por defecto (GET /calendars/service-categories la devuelve con isSystemGenerated: true y fecha del segundo del fallo). El reintento entra sin cambiar nada publicarServicio reintenta una vez y solo ante ese mensaje. Reintentar un POST a ciegas fabrica duplicados
46 La ruta de categorías es /calendars/service-categories, no /calendars/services/categories — esta última cae en el comodín /{serviceId} y devuelve 404 Please provide a valid service ID Un 404 con ese texto significa «la ruta no existe», no «el recurso no existe». Es fácil de leer al revés
47 Publicar servicio verificado de punta a punta: catálogo 0 → 1, con la duración correcta, y borrado después dejándolo en 0 Las cinco entidades del encargo quedan ejercidas contra el CRM real