Files
AgendaPro/platform/crm/API.md
T
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

289 lines
13 KiB
Markdown

# Referencia de la API de Bucéfalo CRM
Host base: `https://services.leadconnectorhq.com` · Autenticación: `Authorization: Bearer <token privado de subcuenta>`.
Este documento es la **referencia de endpoints**. Los hallazgos empíricos —lo que se ha ejercido
contra la subcuenta real y con qué resultado— viven en [`HALLAZGOS.md`](HALLAZGOS.md), y **mandan
sobre lo que diga aquí**: la documentación oficial de esta API está incompleta o desactualizada en
varios puntos concretos, todos marcados abajo.
Cada entrada lleva su origen:
**[DOC]** de la especificación OpenAPI pública del proveedor · **[MEDIDO]** ejercido contra la
subcuenta real de este proyecto · **[INFERENCIA]** deducción no verificada, que **no debe usarse como
base para escribir código sin comprobarla antes**.
---
## La cabecera `Version`: donde más chocan la documentación y la realidad
| Familia | Según la documentación | Lo que funciona [MEDIDO] |
|---|---|---|
| `/contacts/` | `2021-07-28` | `2021-07-28` ✅ |
| `/locations/` | `2021-07-28` | `2021-07-28` ✅ |
| `/conversations/` | `2021-04-15` | **`2021-07-28`** ⚠ |
| `/calendars/` | `2021-04-15` | **`v3`** ⚠ |
| `/opportunities/` | `2021-07-28` | `2021-07-28` ✅ |
`client.ts` la elige sola por el prefijo de la ruta. **No la cambies «para alinearla con la
documentación»**: con estos valores se ejercieron los 3 210 contactos, los 3 213 hilos y los 7
calendarios. Equivocarla es un `400`.
---
## Tres convenciones de paginación distintas en la misma API
Confundirlas devuelve listas **incompletas sin ningún error**, que es la peor forma de fallar.
| Qué se pagina | Cómo | Detalle |
|---|---|---|
| Contactos | `searchAfter` | El cursor sale del **último elemento** del array, no de la raíz de la respuesta. Con `page` se topa un techo de profundidad antes de los 3 200 |
| Conversaciones | `startAfterDate` | El valor de ordenación del último hilo de la página |
| Mensajes | `lastMessageId` + `nextPage` | El id del último mensaje, y un booleano que dice si hay más |
---
## A · Contactos
### `GET /contacts/{contactId}`
Scope `contacts.readonly`. Devuelve el contacto **envuelto en `contact`**.
**[MEDIDO]** El `404` existe aunque la documentación no lo liste.
**[MEDIDO]** `attributionSource` trae `sessionSource` y `adId` poblados, que el esquema oficial **no
declara**. `mapAtribucion()` es más fiel que el esquema.
### `POST /contacts/search`
Scope `contacts.readonly`. **El esquema del cuerpo NO está documentado**: el `$ref` del OpenAPI
apunta a un objeto vacío. Todo lo que sabemos es medido:
```json
{ "locationId": "…", "pageLimit": 100, "searchAfter": [1724900000000, "seD4Pf…"] }
```
**[MEDIDO]** 3 210 contactos en ~22 s, idempotente. **[INFERENCIA]** 100 es probablemente el máximo
de `pageLimit`; no está escrito en ningún sitio.
**[MEDIDO]** El buscador descarta `utmCampaign` y conserva `campaign`. Al dar de alta hay que mandar
**los dos**.
**Filtrar por id, teléfono o correo dentro de este buscador no está documentado en ninguna parte.**
Lo que funciona: por id → `GET /contacts/{id}`; por teléfono o correo → `GET /contacts/?query=`.
### `GET /contacts/`
**Marcado DEPRECADO** por la propia documentación. `limit` máximo 100, default 20. Útil solo para
búsquedas puntuales de 1-5 resultados, que es como lo usa `buscarPorIdentificador`.
**Trampa de forma:** aquí la atribución se llama **`attributions` (array)**; en `GET /contacts/{id}`
se llama **`attributionSource` (objeto)**. Misma información, dos formas.
### `POST /contacts/`
Scope `contacts.write`.
**[MEDIDO]** `locationId` va en el cuerpo del POST y **rompe el PUT** con `422 property locationId
should not exist`.
**[MEDIDO]** Un duplicado responde `400` **con `meta.contactId`**: es idempotencia regalada por el
servidor, y mejor que un `upsert`, cuya rama de actualización descarta la atribución.
**[MEDIDO]** La atribución **solo se escribe en el alta**; un PUT posterior devuelve `200` sin
guardar nada.
---
## B · Conversaciones y mensajes
### `GET /conversations/search`
Scope `conversations.readonly`. Filtros documentados: `locationId` (obligatorio), **`contactId`**,
**`id`**, `assignedTo`, `followers`, `mentions`, `query`, `sort`, `sortBy`, `status`,
`lastMessageType` (43 valores), `lastMessageDirection`, `lastMessageAction`, `startDate`/`endDate`
(epoch ms), `startAfterDate`, `limit`.
### `GET /conversations/{conversationId}`
**[MEDIDO]** Devuelve los campos **en la raíz**, sin envoltorio.
**[MEDIDO, hallazgo 38] NO devuelve el nombre del contacto ni un canal reconocible** — eso solo
viene del buscador. Sincronizar un hilo por su id degradaba el nombre a «Sin nombre»; ver
`syncConversations.ts`, que ya no deja que un dato pobre pise a uno bueno.
**Trampa de forma:** aquí `type` es un **número**; en el buscador es la cadena `TYPE_PHONE`.
### `GET /conversations/{conversationId}/messages`
Scope **`conversations/message.readonly`** — distinto de `conversations.readonly`. Tener uno no da
el otro.
**[MEDIDO]** La respuesta viene **anidada dos niveles**: `{ messages: { messages: [...],
lastMessageId, nextPage } }`.
### `GET /conversations/messages/{id}`
**[MEDIDO]** Funciona y devuelve el mensaje en la raíz. El parámetro de ruta no está declarado en la
especificación — es un fallo de la documentación, no de la API.
### `POST /conversations/messages`
Scope `conversations/message.write`. Tipos: `SMS`, `RCS`, `Email`, `WhatsApp`, `IG`, `FB`, `Custom`,
`Live_Chat`, `TIKTOK`.
**[CONTRADICCIÓN]** La documentación marca `subType` y `status` como obligatorios. `enviarCorreo()`
**no manda ninguno** y responde `200`. Manda lo medido; no los añadas «por cumplir el esquema» sin
un sondeo, porque cualquier clave inesperada es `422`.
**[MEDIDO]** La respuesta es `Email queued successfully`: acuse de **encolado**, no de entrega. Y al
releer el hilo, el `status` del mensaje propio viene `null` (hallazgo 23). **No se puede afirmar que
llegó.**
---
## C · Calendarios, citas y servicios
### `GET /calendars/`
Scope `calendars.readonly`, `Version: v3`. **[MEDIDO]** 7 calendarios en la subcuenta, uno
`Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`).
### `GET /calendars/events`
Scope `calendars/events.readonly`.
**[MEDIDO, hallazgo 28]** Exige uno de `calendarId`, `userId` o `groupId`; sin ellos es
`422 Either of userId, calendarId or groupId is required`. **No existe «todas las citas de la
subcuenta»**: hay que iterar los calendarios.
**Asimetría de formato:** la **entrada** (`startTime`/`endTime` del query) va en **milisegundos
epoch**; la **salida** viene en **ISO con desplazamiento** (`2026-12-28T14:00:00-06:00`).
### `GET /calendars/events/appointments/{eventId}`
**[MEDIDO, hallazgo 43] Sigue devolviendo la cita después de borrarla** — es un borrado lógico.
Para comprobar si una cita existe, **lista el rango del calendario**; por id da un falso positivo.
### `POST /calendars/events/appointments`
Scope `calendars/events.write` — **[MEDIDO, hallazgo 33] el token lo tiene**.
Obligatorios: `calendarId`, `locationId`, `contactId`, `startTime`.
`startTime`/`endTime` en **ISO con desplazamiento**, no en epoch — al revés que el filtro de arriba.
Palancas que este proyecto usa a propósito:
- `toNotify: false` — la plataforma ya avisó a la clienta; que el CRM no lo haga otra vez.
- `ignoreFreeSlotValidation: true` — AgendaMax es la fuente de verdad del horario y su base ya
impide el solape.
**[MEDIDO, hallazgo 42]** Verificado de punta a punta: creada, releída con la **hora de pared
idéntica** a la escrita, y borrada.
### `PUT /calendars/events/appointments/{eventId}`
**No admite `locationId` ni `contactId`.** Misma trampa que en contactos: no recicles el cuerpo del
alta.
### `DELETE /calendars/events/{eventId}`
**[MEDIDO]** Existe y el token lo tiene. Borrado lógico (ver hallazgo 43).
### Servicios: `GET`/`POST /calendars/services/catalog`
Scopes `calendars.readonly` / **`calendars.write`** — **[MEDIDO, hallazgo 34] el token lo tiene**.
Un «servicio» es una prestación vendible con duración, precio, categoría, personal y variaciones:
literalmente el modelo de un spa.
**[MEDIDO, hallazgos 6 y 32] El catálogo de esta subcuenta está VACÍO.** Por eso la duración y el
precio viven en la plataforma y «sincronizar servicios» solo puede significar **publicar**.
Obligatorios: `locationId`, `name`, `slug`, **`staff[]` con al menos un miembro** (como
`[{ id: "…" }]`, no como `["…"]`).
**[MEDIDO, hallazgos 44-45] La primera publicación en una subcuenta sin catálogo falla** con
`400 No default service category found for this location` — y **ese mismo intento hace que el CRM
cree la categoría por defecto**. El reintento entra sin cambiar nada. `publicarServicio` reintenta
una vez y **solo** ante ese mensaje.
### `GET /calendars/service-categories`
**[MEDIDO, hallazgo 46]** Esta es la ruta correcta. `/calendars/services/categories` cae en el
comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID`, que se lee como «no
existe el recurso» cuando en realidad significa «no existe la ruta».
### `DELETE /calendars/services/catalog/{serviceId}`
**[MEDIDO]** Existe y el token lo tiene.
---
## D · Subcuentas
### `GET /locations/{locationId}`
Scope `locations.readonly`. **Un token privado de subcuenta basta**; no hace falta token de agencia.
**[MEDIDO] `settings` trae una quinta clave que la documentación no declara:**
```json
"contactUniqueIdentifiers": ["email", "phone"]
```
No es un detalle: es la cadena de identidad que el CRM aplica para deduplicar, y la justificación de
que la del proyecto (id → teléfono → correo) **coincida con la suya** en vez de pelearse con ella.
`allowDuplicateOpportunity: false` es la causa del `400 OPPORTUNITY_NO_DUPLICATE`, y es **un
interruptor de la interfaz**, no un límite duro.
### Cómo validar que un token corresponde a una subcuenta
**No hay endpoint de introspección de token.** El método correcto, y el que usa
`PUT /api/admin/businesses/:id/crm`, es leer `GET /locations/{locationId}` con ese token y **comparar
`location.id` con el esperado** — identidad contra identidad, no un `200` genérico.
**[MEDIDO] El `401` de esta API es ambiguo**: significa a la vez «token caducado», «al token le falta
el scope» y (probablemente) «el token es de otra subcuenta». Por eso el mensaje de error de la
consola nombra las tres posibilidades en vez de afirmar una.
---
## E · Límites de tasa
**[MEDIDO, hallazgo 37]** Cabeceras reales de la subcuenta:
| Cabecera | Valor observado |
|---|---|
| `x-ratelimit-max` | `100` |
| `x-ratelimit-interval-milliseconds` | `10000` |
| `x-ratelimit-limit-daily` | `200000` |
| `x-ratelimit-remaining` | va bajando en la ventana |
| `x-ratelimit-daily-remaining` | `199970` |
O sea **1 petición cada 100 ms**, no cada 650 como asumía el cliente originalmente. `client.ts` lee
esas cabeceras en cada respuesta y ajusta el intervalo con un 50 % de margen; el 650 ms queda solo
como respaldo para la primera petición de un token, antes de haber visto ninguna cabecera.
La cuota es **por aplicación y por subcuenta**: añadir cuentas no reparte el límite, cada una tiene
el suyo. Por eso el estrangulador es **por token** y no global.
---
## F · Scopes
```
contacts.readonly contacts.write
conversations.readonly conversations.write
conversations/message.readonly conversations/message.write
calendars.readonly calendars.write
calendars/events.readonly calendars/events.write
calendars/groups.readonly calendars/groups.write
locations.readonly locations.write
locations/customFields.* locations/customValues.* locations/tags.*
```
**Dos parejas que se confunden fácil y dan `401` donde no se espera:**
`conversations.readonly` **no** incluye `conversations/message.readonly`, y `calendars.readonly`
**no** incluye `calendars/events.readonly`.
**[MEDIDO] Estado del token de esta subcuenta**, al 2026-08-29:
| Scope | Estado |
|---|---|
| contactos, conversaciones, mensajes, subcuenta | ✔ |
| `calendars/events.write` | ✔ (hallazgo 33) |
| `calendars.write` | ✔ (hallazgo 34) |
| usuarios | ✔ **hoy** — daba `401` cuando se midió por primera vez (hallazgos 14 → 35) |
| crear pipelines | ✖ `401 The token is not authorized for this scope` |
**Un permiso medido una vez no queda medido para siempre.** El de usuarios cambió entre dos
mediciones porque alguien tocó el token. Conviene comprobar los permisos al vincular una subcuenta y
volver a hacerlo cuando algo falle con `401`, en vez de fiarse de una tabla escrita en el pasado.
---
## Lo que NO está documentado y no debe inventarse
1. El esquema del cuerpo de `POST /contacts/search`: filtros, operadores y campos filtrables. El
OpenAPI lo declara como objeto vacío.
2. El máximo real de `pageLimit`.
3. El valor del techo de profundidad al paginar por número de página. Que existe está medido; cuánto
es, no.
4. El cuerpo de la respuesta `429` y la política de reintento recomendada.
5. Si los tokens privados tienen cuota distinta de las aplicaciones de marketplace.
6. Un endpoint de introspección de token.
7. El código exacto que devuelve un token de **otra** subcuenta. Se infiere `401`; comprobarlo exige
un segundo token y este proyecto solo tiene uno.
---
## Webhooks
**Exigen OAuth.** Un token privado de subcuenta no puede suscribirse, así que **la entrada de datos
es por sondeo**: los botones de sincronización y `POST /api/crm/sync/:entidad/:id`. Si algún día se
quiere tiempo real, hay que pasar por OAuth, y eso es un entregable propio.