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:
co-authored by
Claude Opus 5
parent
dcbf750c09
commit
6d67b23e55
@@ -0,0 +1,288 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,206 @@
|
||||
# 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:
|
||||
|
||||
```json
|
||||
"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` (`urieljareth@grupo-e3.com`, 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 |
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isoConDesplazamiento, estadoCitaCrm } from "./calendars.ts";
|
||||
|
||||
// La suite corre con la zona del proceso FIJADA a otra distinta de la del
|
||||
// negocio, para que un cálculo que se ancle a la del proceso falle aquí.
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
test("isoConDesplazamiento escribe la hora de pared del negocio con su desplazamiento", () => {
|
||||
// 2026-09-03 11:00 en México = 17:00Z
|
||||
const d = new Date("2026-09-03T17:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T11:00:00-06:00");
|
||||
});
|
||||
|
||||
test("isoConDesplazamiento no usa la zona del proceso", () => {
|
||||
const d = new Date("2026-09-03T17:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "UTC"), "2026-09-03T17:00:00+00:00");
|
||||
// Misma entrada, dos zonas, dos horas de pared distintas.
|
||||
assert.notEqual(
|
||||
isoConDesplazamiento(d, "UTC"),
|
||||
isoConDesplazamiento(d, "America/Mexico_City")
|
||||
);
|
||||
});
|
||||
|
||||
test("isoConDesplazamiento: la medianoche se escribe 00, no 24", () => {
|
||||
// 00:00 del 4 de septiembre en México = 06:00Z
|
||||
const d = new Date("2026-09-04T06:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-04T00:00:00-06:00");
|
||||
});
|
||||
|
||||
test("isoConDesplazamiento: una cita de la tarde cae en el día correcto", () => {
|
||||
// 19:00 de México del día 3 = 01:00Z del día 4. El día de pared es el 3.
|
||||
const d = new Date("2026-09-04T01:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T19:00:00-06:00");
|
||||
});
|
||||
|
||||
test("estadoCitaCrm traduce los estados de la plataforma a los del CRM", () => {
|
||||
assert.equal(estadoCitaCrm("scheduled"), "confirmed");
|
||||
assert.equal(estadoCitaCrm("completed"), "showed");
|
||||
assert.equal(estadoCitaCrm("no_show"), "noshow");
|
||||
assert.equal(estadoCitaCrm("cancelled"), "cancelled");
|
||||
});
|
||||
|
||||
test("estadoCitaCrm: un estado que no conocemos no inventa, cae en confirmed", () => {
|
||||
assert.equal(estadoCitaCrm("lo-que-sea"), "confirmed");
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { crmRequest, CrmError, VERSION_CALENDARS } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
export interface CrmCalendar {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
calendarType?: string;
|
||||
}
|
||||
|
||||
export interface CrmEvent {
|
||||
id: string;
|
||||
calendarId: string;
|
||||
contactId?: string;
|
||||
title?: string;
|
||||
appointmentStatus?: string;
|
||||
assignedUserId?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface AltaCita {
|
||||
calendarId: string;
|
||||
contactId: string;
|
||||
/** ISO con desplazamiento, no epoch. Ver `isoConDesplazamiento`. */
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
title: string;
|
||||
assignedUserId?: string;
|
||||
appointmentStatus?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO con el desplazamiento horario del NEGOCIO.
|
||||
*
|
||||
* El CRM acepta `2026-09-03T11:00:00-06:00` en el alta de citas, y **no**
|
||||
* milisegundos — al revés que el filtro de rango de `/calendars/events`, que sí
|
||||
* los exige. Esa asimetría es de la API, no nuestra.
|
||||
*
|
||||
* Y no vale `toISOString()`: devuelve UTC con `Z`, y aunque el instante sea el
|
||||
* mismo, la hora de pared que el CRM enseña en su interfaz sale de lo que se
|
||||
* escribe aquí. Se construye con `Intl` y nunca con `new Date(y, m, d, …)`, que
|
||||
* resuelve el reloj en la zona del proceso — el error que ya costó un fallo de
|
||||
* producción en este repo (ver la sección de zonas horarias de CLAUDE.md).
|
||||
*/
|
||||
export function isoConDesplazamiento(d: Date, tz: string): string {
|
||||
const zona = tz || "America/Mexico_City";
|
||||
const p = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: zona,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const g = (t: string) => p.find((x) => x.type === t)!.value;
|
||||
|
||||
const off = new Intl.DateTimeFormat("en-US", { timeZone: zona, timeZoneName: "longOffset" })
|
||||
.formatToParts(d)
|
||||
.find((x) => x.type === "timeZoneName")!.value;
|
||||
const m = off.match(/GMT([+-])(\d{2}):(\d{2})/);
|
||||
const desp = m ? `${m[1]}${m[2]}:${m[3]}` : "+00:00";
|
||||
|
||||
// `en-CA` con hour12:false puede rendir la medianoche como 24; el CRM espera 00.
|
||||
const hora = g("hour") === "24" ? "00" : g("hour");
|
||||
return `${g("year")}-${g("month")}-${g("day")}T${hora}:${g("minute")}:${g("second")}${desp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de la cita de la plataforma → estado del CRM.
|
||||
*
|
||||
* En la PETICIÓN el enum admite `new|confirmed|cancelled|showed|noshow|invalid`.
|
||||
* En la respuesta hay dos más (`active`, `completed`) que el CRM asigna por su
|
||||
* cuenta y no se pueden escribir.
|
||||
*/
|
||||
export function estadoCitaCrm(estado: string): string {
|
||||
switch (estado) {
|
||||
case "completed":
|
||||
return "showed";
|
||||
case "no_show":
|
||||
return "noshow";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
default:
|
||||
return "confirmed";
|
||||
}
|
||||
}
|
||||
|
||||
export async function listarCalendarios(ctx: CrmCtx): Promise<CrmCalendar[]> {
|
||||
const r = await crmRequest<any>("GET", "/calendars/", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return r?.calendars ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* El personal de la subcuenta.
|
||||
*
|
||||
* MEDIDO (hallazgo 35): esta ruta devolvía `401` cuando se midió por primera vez
|
||||
* y hoy responde `200` con 6 usuarios. Da los ids que `staff[]` exige al crear
|
||||
* servicios y `assignedUserId` al crear citas. Si vuelve a dar 401, quien llame
|
||||
* debe poder seguir sin ella, no romperse.
|
||||
*/
|
||||
export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> {
|
||||
const r = await crmRequest<any>("GET", "/users/", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
});
|
||||
return (r?.users ?? []).map((u: any) => ({ id: u.id, name: u.name ?? "" }));
|
||||
}
|
||||
|
||||
export async function obtenerCita(ctx: CrmCtx, eventId: string): Promise<CrmEvent | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${eventId}`, {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return (r?.event ?? r?.appointment ?? r) as CrmEvent;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Citas de un calendario en un rango.
|
||||
*
|
||||
* MEDIDO (hallazgo 28): sin `calendarId`, `userId` o `groupId` la API responde
|
||||
* `422 Either of userId, calendarId or groupId is required`. No existe «dame
|
||||
* todas las citas de la subcuenta»: hay que iterar los calendarios.
|
||||
*
|
||||
* El rango va en **milisegundos epoch**, al revés que el alta.
|
||||
*/
|
||||
export async function citasEnRango(
|
||||
ctx: CrmCtx,
|
||||
calendarId: string,
|
||||
desdeMs: number,
|
||||
hastaMs: number
|
||||
): Promise<CrmEvent[]> {
|
||||
const r = await crmRequest<any>("GET", "/calendars/events", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
query: {
|
||||
locationId: ctx.locationId,
|
||||
calendarId,
|
||||
startTime: String(desdeMs),
|
||||
endTime: String(hastaMs),
|
||||
},
|
||||
});
|
||||
return r?.events ?? [];
|
||||
}
|
||||
|
||||
export async function crearCita(ctx: CrmCtx, a: AltaCita): Promise<{ id: string }> {
|
||||
const r = await crmRequest<any>("POST", "/calendars/events/appointments", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: {
|
||||
// `locationId` va en el POST y ROMPE el PUT con 422. No reciclar el cuerpo
|
||||
// del alta para actualizar: es la misma trampa ya medida en contactos.
|
||||
locationId: ctx.locationId,
|
||||
calendarId: a.calendarId,
|
||||
contactId: a.contactId,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
title: a.title,
|
||||
appointmentStatus: a.appointmentStatus ?? "confirmed",
|
||||
...(a.assignedUserId ? { assignedUserId: a.assignedUserId } : {}),
|
||||
// La plataforma ya avisó a la clienta: que el CRM no dispare además sus
|
||||
// automatizaciones y le llegue el mismo aviso dos veces.
|
||||
toNotify: false,
|
||||
// AgendaMax es la fuente de verdad del horario, y su base ya impide el
|
||||
// solape con una restricción de exclusión. Que el CRM no rechace por su
|
||||
// propia idea de disponibilidad, que no conoce la agenda real.
|
||||
ignoreFreeSlotValidation: true,
|
||||
},
|
||||
});
|
||||
const id = r?.id ?? r?.event?.id ?? r?.appointment?.id;
|
||||
if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador");
|
||||
return { id };
|
||||
}
|
||||
|
||||
/** Actualiza una cita ya escrita. Sin `locationId` ni `contactId`: el PUT los rechaza. */
|
||||
export async function actualizarCita(
|
||||
ctx: CrmCtx,
|
||||
eventId: string,
|
||||
cambios: Partial<Omit<AltaCita, "contactId">>
|
||||
): Promise<void> {
|
||||
await crmRequest("PUT", `/calendars/events/appointments/${eventId}`, {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: {
|
||||
...(cambios.calendarId ? { calendarId: cambios.calendarId } : {}),
|
||||
...(cambios.startTime ? { startTime: cambios.startTime } : {}),
|
||||
...(cambios.endTime ? { endTime: cambios.endTime } : {}),
|
||||
...(cambios.title ? { title: cambios.title } : {}),
|
||||
...(cambios.appointmentStatus
|
||||
? { appointmentStatus: cambios.appointmentStatus }
|
||||
: {}),
|
||||
toNotify: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
esperaDeToken,
|
||||
registrarPeticion,
|
||||
MIN_INTERVAL_MS,
|
||||
anotarLimites,
|
||||
limitesDe,
|
||||
intervaloDe,
|
||||
} from "./client.ts";
|
||||
|
||||
test("el estrangulador cuenta por token, no globalmente", () => {
|
||||
const ahora = 1_000_000;
|
||||
registrarPeticion("token-A", ahora);
|
||||
// El mismo token tiene que esperar…
|
||||
assert.ok(esperaDeToken("token-A", ahora + 10) > 0);
|
||||
// …pero otro token no espera nada: su límite es independiente.
|
||||
assert.equal(esperaDeToken("token-B", ahora + 10), 0);
|
||||
});
|
||||
|
||||
test("pasado el intervalo, el mismo token deja de esperar", () => {
|
||||
const ahora = 2_000_000;
|
||||
registrarPeticion("token-C", ahora);
|
||||
assert.equal(esperaDeToken("token-C", ahora + MIN_INTERVAL_MS + 1), 0);
|
||||
});
|
||||
|
||||
test("sin cabeceras se usa el intervalo conservador por defecto", () => {
|
||||
assert.equal(intervaloDe("token-sin-datos"), MIN_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("las cabeceras del CRM mandan sobre el valor por defecto", () => {
|
||||
// MEDIDO (hallazgo 37): la cuota real de la subcuenta.
|
||||
anotarLimites(
|
||||
"token-D",
|
||||
new Headers({
|
||||
"x-ratelimit-max": "100",
|
||||
"x-ratelimit-interval-milliseconds": "10000",
|
||||
"x-ratelimit-remaining": "94",
|
||||
"x-ratelimit-daily-remaining": "199970",
|
||||
})
|
||||
);
|
||||
const l = limitesDe("token-D");
|
||||
assert.equal(l?.max, 100);
|
||||
assert.equal(l?.ventanaMs, 10000);
|
||||
assert.equal(l?.diarioRestante, 199970);
|
||||
// 10000/100 = 100 ms teóricos, con 50 % de margen = 150
|
||||
assert.equal(intervaloDe("token-D"), 150);
|
||||
});
|
||||
|
||||
test("con la ventana casi agotada se espacia más, para no comerse un 429", () => {
|
||||
anotarLimites(
|
||||
"token-E",
|
||||
new Headers({
|
||||
"x-ratelimit-max": "100",
|
||||
"x-ratelimit-interval-milliseconds": "10000",
|
||||
"x-ratelimit-remaining": "3",
|
||||
})
|
||||
);
|
||||
assert.ok(intervaloDe("token-E") > 150);
|
||||
});
|
||||
|
||||
test("una respuesta sin cabeceras de límite no borra lo que ya se sabía", () => {
|
||||
anotarLimites(
|
||||
"token-F",
|
||||
new Headers({ "x-ratelimit-max": "100", "x-ratelimit-interval-milliseconds": "10000" })
|
||||
);
|
||||
anotarLimites("token-F", new Headers({}));
|
||||
assert.equal(limitesDe("token-F")?.max, 100);
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
|
||||
const BASE_URL_DEFAULT = "https://services.leadconnectorhq.com";
|
||||
|
||||
/**
|
||||
* La cabecera `Version` no es opcional y no es una sola: la familia de
|
||||
* calendarios exige `v3` y el resto `2021-07-28`. Omitirla o equivocarla es un
|
||||
* 400, y es el error más fácil de cometer al reciclar código entre dominios.
|
||||
*/
|
||||
export const VERSION_DEFAULT = "2021-07-28";
|
||||
export const VERSION_CALENDARS = "v3";
|
||||
|
||||
/**
|
||||
* Intervalo conservador mientras el CRM no diga su cuota real.
|
||||
*
|
||||
* MEDIDO (hallazgo 37): las cabeceras `x-ratelimit-*` declaran 100 peticiones
|
||||
* por 10 s, o sea 1 cada 100 ms — 6,5 veces más de lo que este valor asume. Se
|
||||
* mantiene como respaldo para la primera petición de un token, antes de haber
|
||||
* visto ninguna cabecera; a partir de ahí manda `intervaloDe()`.
|
||||
*/
|
||||
export const MIN_INTERVAL_MS = 650;
|
||||
/** Margen sobre la cuota declarada: no se corre al límite exacto. */
|
||||
const MARGEN = 1.5;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export interface Limites {
|
||||
max: number;
|
||||
ventanaMs: number;
|
||||
restantes: number;
|
||||
diarioRestante: number | null;
|
||||
}
|
||||
|
||||
const limitesPorToken = new Map<string, Limites>();
|
||||
|
||||
/** Registra lo que el CRM dice de su propia cuota. Una respuesta sin cabeceras
|
||||
* no borra lo ya sabido: no todas las rutas las devuelven. */
|
||||
export function anotarLimites(token: string, h: Headers): void {
|
||||
const max = Number(h.get("x-ratelimit-max"));
|
||||
const ventanaMs = Number(h.get("x-ratelimit-interval-milliseconds"));
|
||||
if (!max || !ventanaMs) return;
|
||||
limitesPorToken.set(token, {
|
||||
max,
|
||||
ventanaMs,
|
||||
restantes: Number(h.get("x-ratelimit-remaining") ?? max),
|
||||
diarioRestante: h.get("x-ratelimit-daily-remaining")
|
||||
? Number(h.get("x-ratelimit-daily-remaining"))
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function limitesDe(token: string): Limites | null {
|
||||
return limitesPorToken.get(token) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuánto esperar entre peticiones de ESTE token.
|
||||
*
|
||||
* Se toma la cuota que el CRM declara, con un 50 % de margen y no al límite
|
||||
* exacto: el worker de la bandeja y una sincronización manual pueden coincidir.
|
||||
* Si la ventana está casi agotada se espacia hasta que se renueve, que sale más
|
||||
* barato que comerse un 429 y su espera lineal de 5, 10 y 15 s.
|
||||
*/
|
||||
export function intervaloDe(token: string): number {
|
||||
const l = limitesPorToken.get(token);
|
||||
if (!l) return MIN_INTERVAL_MS;
|
||||
const base = Math.ceil((l.ventanaMs / l.max) * MARGEN);
|
||||
if (l.restantes <= 5) {
|
||||
return Math.max(base, Math.ceil(l.ventanaMs / Math.max(1, l.restantes)));
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export class CrmError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
readonly body?: unknown
|
||||
) {
|
||||
super(`CRM ${status}: ${message}`);
|
||||
this.name = "CrmError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un fallo de transporte no es una respuesta: el servidor no habló, así que no
|
||||
* se sabe si la escritura entró. Reenviarlo es fabricar la doble creación. Se
|
||||
* marca aparte para que la bandeja de salida lo deje en `indeterminado` y lo
|
||||
* resuelva **leyendo**, nunca reintentando.
|
||||
*/
|
||||
export class CrmTransportError extends Error {
|
||||
readonly indeterminate = true;
|
||||
constructor(message: string) {
|
||||
super(`CRM sin respuesta: ${message}`);
|
||||
this.name = "CrmTransportError";
|
||||
}
|
||||
}
|
||||
|
||||
// Un reloj por token, no uno global: el límite del CRM es por credencial, así
|
||||
// que un semáforo único serializaría negocios que pueden ir en paralelo. Con
|
||||
// diez cuentas, la décima esperaría a las nueve anteriores sin ninguna razón.
|
||||
const ultimaPeticionPorToken = new Map<string, number>();
|
||||
|
||||
/** Milisegundos que este token debe esperar antes de su próxima petición. */
|
||||
export function esperaDeToken(token: string, ahora = Date.now()): number {
|
||||
const ultima = ultimaPeticionPorToken.get(token) ?? 0;
|
||||
return Math.max(0, intervaloDe(token) - (ahora - ultima));
|
||||
}
|
||||
|
||||
export function registrarPeticion(token: string, ahora = Date.now()): void {
|
||||
ultimaPeticionPorToken.set(token, ahora);
|
||||
}
|
||||
|
||||
async function throttle(token: string) {
|
||||
const espera = esperaDeToken(token);
|
||||
if (espera > 0) await new Promise((r) => setTimeout(r, espera));
|
||||
registrarPeticion(token);
|
||||
}
|
||||
|
||||
export interface CrmOptions {
|
||||
/**
|
||||
* Token privado de la subcuenta. **Obligatorio y sin valor por defecto.**
|
||||
*
|
||||
* Antes caía a `requireEnv("CRM_TOKEN")`, una variable global del proceso: con
|
||||
* dos negocios, olvidar el token no daba error — usaba el del primero contra
|
||||
* la subcuenta del segundo. Al hacerlo obligatorio, ese olvido pasa a ser un
|
||||
* error de compilación, que es el gate real de calidad de este repo.
|
||||
*
|
||||
* Sale siempre de `CrmCtx.token` (ver platform/crm/ctx.ts).
|
||||
*/
|
||||
token: string;
|
||||
body?: unknown;
|
||||
version?: string;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
export async function crmRequest<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: CrmOptions
|
||||
): Promise<T> {
|
||||
loadEnv();
|
||||
const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT;
|
||||
const token = opts.token;
|
||||
|
||||
let url = `${base}${path}`;
|
||||
if (opts.query) {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query)) {
|
||||
if (v !== undefined) q.set(k, String(v));
|
||||
}
|
||||
const s = q.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
|
||||
const version =
|
||||
opts.version ?? (path.startsWith("/calendars/") ? VERSION_CALENDARS : VERSION_DEFAULT);
|
||||
|
||||
let intento = 0;
|
||||
for (;;) {
|
||||
await throttle(token);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
version,
|
||||
accept: "application/json",
|
||||
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Timeout / conexión caída: no hubo respuesta. Se reintenta el transporte
|
||||
// solo en GET, que es idempotente por naturaleza; en escrituras se
|
||||
// propaga para que arriba se resuelva leyendo.
|
||||
if (method === "GET" && intento < MAX_RETRIES) {
|
||||
await new Promise((r) => setTimeout(r, 2000 * 2 ** intento));
|
||||
intento++;
|
||||
continue;
|
||||
}
|
||||
throw new CrmTransportError(`${e?.name ?? "Error"}: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
// El CRM declara su propia cuota en cada respuesta. Leerla es la única
|
||||
// forma de no ir a ciegas: el intervalo por defecto era una estimación.
|
||||
anotarLimites(token, res.headers);
|
||||
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
if (intento < MAX_RETRIES) {
|
||||
// 429 lineal (5/10/15 s), 5xx exponencial: es la política ya medida en
|
||||
// el proyecto hermano.
|
||||
const espera = res.status === 429 ? 5000 * (intento + 1) : 2000 * 2 ** intento;
|
||||
await new Promise((r) => setTimeout(r, espera));
|
||||
intento++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const texto = await res.text();
|
||||
let cuerpo: any = null;
|
||||
try {
|
||||
cuerpo = texto ? JSON.parse(texto) : null;
|
||||
} catch {
|
||||
cuerpo = texto;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// Rotar el token es trabajo humano: no se reintenta y se dice claro.
|
||||
throw new CrmError(401, "Token rechazado — hay que regenerarlo en el CRM", cuerpo);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(Array.isArray(cuerpo?.message) ? cuerpo.message.join("; ") : cuerpo?.message) ||
|
||||
cuerpo?.error ||
|
||||
res.statusText;
|
||||
throw new CrmError(res.status, String(msg), cuerpo);
|
||||
}
|
||||
return cuerpo as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { crmRequest } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
import type { EtapasPipeline } from "./opportunities.ts";
|
||||
|
||||
export interface CrmConnection {
|
||||
id: number;
|
||||
business_id: number;
|
||||
location_id: string;
|
||||
pipeline_id: string | null;
|
||||
stage_open_id: string | null;
|
||||
stage_won_id: string | null;
|
||||
stage_lost_id: string | null;
|
||||
allow_duplicate_opp: boolean;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string | null;
|
||||
}
|
||||
|
||||
export async function obtenerConexion(businessId: number): Promise<CrmConnection | null> {
|
||||
const { rows } = await pool.query<CrmConnection>(
|
||||
`SELECT * FROM crm_connections WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export function etapasDe(c: CrmConnection): EtapasPipeline {
|
||||
if (!c.pipeline_id) throw new Error("La conexión con el CRM no tiene pipeline configurado");
|
||||
return {
|
||||
pipelineId: c.pipeline_id,
|
||||
open: c.stage_open_id,
|
||||
won: c.stage_won_id,
|
||||
lost: c.stage_lost_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta el pipeline y las etapas de la subcuenta y las guarda.
|
||||
*
|
||||
* Las etapas se eligen por nombre porque sus identificadores son opacos y
|
||||
* distintos en cada subcuenta. Se busca «ganado» y «perdido»; si no aparecen,
|
||||
* se cae a la primera y la última por posición, que es lo que un embudo suele
|
||||
* significar. Ese respaldo se registra en `last_sync_status` para que no pase
|
||||
* inadvertido.
|
||||
*/
|
||||
export async function autoconfigurar(ctx: CrmCtx): Promise<CrmConnection> {
|
||||
const r = await crmRequest<any>("GET", "/opportunities/pipelines", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
});
|
||||
const pipelines: any[] = r?.pipelines ?? [];
|
||||
if (!pipelines.length) {
|
||||
throw new Error("La subcuenta del CRM no tiene ningún pipeline");
|
||||
}
|
||||
const pipe = pipelines[0];
|
||||
const stages: any[] = [...(pipe.stages ?? [])].sort(
|
||||
(a, b) => (a.position ?? 0) - (b.position ?? 0)
|
||||
);
|
||||
|
||||
const porNombre = (...palabras: string[]) =>
|
||||
stages.find((s) => {
|
||||
const n = String(s.name ?? "").toLowerCase();
|
||||
return palabras.some((p) => n.includes(p));
|
||||
})?.id ?? null;
|
||||
|
||||
const won = porNombre("ganado", "won", "asisti", "complet");
|
||||
const lost = porNombre("perdido", "lost", "cancel", "no asis");
|
||||
const open = stages[0]?.id ?? null;
|
||||
|
||||
// MEDIDO: la subcuenta expone el ajuste que decide si una cita puede estrenar
|
||||
// su propia oportunidad o hay que reciclar la de la clienta.
|
||||
let permiteDuplicados = false;
|
||||
try {
|
||||
const loc = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, {
|
||||
token: ctx.token,
|
||||
});
|
||||
permiteDuplicados = Boolean(loc?.location?.settings?.allowDuplicateOpportunity);
|
||||
} catch {
|
||||
// Si no se puede leer, se asume el caso restrictivo: reciclar nunca rompe,
|
||||
// crear a ciegas sí.
|
||||
}
|
||||
|
||||
const nota =
|
||||
won && lost
|
||||
? `pipeline «${pipe.name}»; etapas detectadas por nombre`
|
||||
: `pipeline «${pipe.name}»; OJO: no se hallaron etapas de ganado/perdido por nombre`;
|
||||
|
||||
// `location_id` NO se escribe aquí: lo puso `guardarCredencial` junto al token,
|
||||
// y son la misma decisión. Escribirlo desde dos sitios permite que se separen.
|
||||
const { rows } = await pool.query<CrmConnection>(
|
||||
`INSERT INTO crm_connections
|
||||
(business_id, location_id, pipeline_id, stage_open_id, stage_won_id, stage_lost_id,
|
||||
allow_duplicate_opp, last_sync_status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (business_id) DO UPDATE SET
|
||||
pipeline_id = EXCLUDED.pipeline_id,
|
||||
stage_open_id = EXCLUDED.stage_open_id,
|
||||
stage_won_id = EXCLUDED.stage_won_id,
|
||||
stage_lost_id = EXCLUDED.stage_lost_id,
|
||||
allow_duplicate_opp = EXCLUDED.allow_duplicate_opp,
|
||||
last_sync_status = EXCLUDED.last_sync_status
|
||||
RETURNING *`,
|
||||
[ctx.businessId, ctx.locationId, pipe.id, open, won, lost, permiteDuplicados, nota]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapAtribucion, nombreDe } from "./contacts.ts";
|
||||
|
||||
test("aplana la atribución real que devuelve el CRM", () => {
|
||||
// Este objeto es una captura literal de un contacto real de la subcuenta.
|
||||
const a = mapAtribucion({
|
||||
id: "x",
|
||||
source: "WhatsApp",
|
||||
attributionSource: {
|
||||
sessionSource: "Paid Social",
|
||||
medium: "instagram",
|
||||
mediumId: "28379457221686971",
|
||||
campaign: "Servicios-Interaccion-WhatsApp",
|
||||
utmMedium: "Uñas-Interaccion-WA",
|
||||
utmContent: "Post - Uñas - WA",
|
||||
campaignId: "120249003827580681",
|
||||
adId: "120249003827530681",
|
||||
},
|
||||
});
|
||||
assert.equal(a.crm_source, "WhatsApp");
|
||||
assert.equal(a.attr_session_source, "Paid Social");
|
||||
assert.equal(a.attr_medium, "instagram");
|
||||
assert.equal(a.attr_campaign, "Servicios-Interaccion-WhatsApp");
|
||||
assert.equal(a.attr_campaign_id, "120249003827580681");
|
||||
assert.equal(a.attr_ad_id, "120249003827530681");
|
||||
});
|
||||
|
||||
test("«campaign» gana a «utmCampaign»", () => {
|
||||
// No es un capricho de orden: el buscador de contactos del CRM descarta
|
||||
// `utmCampaign` y conserva `campaign`. Leerlos al revés deja la campaña
|
||||
// vacía en la mitad de los contactos.
|
||||
const a = mapAtribucion({
|
||||
id: "x",
|
||||
attributionSource: { campaign: "la_buena", utmCampaign: "la_descartada" },
|
||||
});
|
||||
assert.equal(a.attr_campaign, "la_buena");
|
||||
});
|
||||
|
||||
test("cae a utmCampaign cuando campaign no viene", () => {
|
||||
const a = mapAtribucion({ id: "x", attributionSource: { utmCampaign: "solo_esta" } });
|
||||
assert.equal(a.attr_campaign, "solo_esta");
|
||||
});
|
||||
|
||||
test("una atribución vacía no inventa valores", () => {
|
||||
const a = mapAtribucion({ id: "x" });
|
||||
assert.equal(a.attr_campaign, null);
|
||||
assert.equal(a.attr_session_source, null);
|
||||
assert.equal(a.crm_source, null);
|
||||
});
|
||||
|
||||
test("las cadenas vacías cuentan como ausencia, no como valor", () => {
|
||||
const a = mapAtribucion({ id: "x", attributionSource: { campaign: "", utmCampaign: "buena" } });
|
||||
assert.equal(a.attr_campaign, "buena");
|
||||
});
|
||||
|
||||
test("el nombre sale de los tres orígenes que trae el CRM, en orden", () => {
|
||||
assert.equal(nombreDe({ id: "1", firstName: "Ana", lastName: "Ruiz" }), "Ana Ruiz");
|
||||
assert.equal(nombreDe({ id: "2", firstName: "Ana" }), "Ana");
|
||||
assert.equal(nombreDe({ id: "3", contactName: "Ana R." }), "Ana R.");
|
||||
assert.equal(nombreDe({ id: "4", email: "[email protected]" }), "[email protected]");
|
||||
assert.equal(nombreDe({ id: "5" }), "Sin nombre");
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
|
||||
/** Lo que el CRM devuelve de un contacto, en la forma que nos interesa. */
|
||||
export interface CrmContact {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
contactName?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
source?: string | null;
|
||||
tags?: string[] | null;
|
||||
dateAdded?: string | null;
|
||||
dateOfBirth?: string | null;
|
||||
attributionSource?: Record<string, string | null> | null;
|
||||
customFields?: { id: string; value: unknown }[] | null;
|
||||
}
|
||||
|
||||
/** La atribución, aplanada a las columnas de `clients`. */
|
||||
export interface Atribucion {
|
||||
crm_source: string | null;
|
||||
attr_session_source: string | null;
|
||||
attr_medium: string | null;
|
||||
attr_campaign: string | null;
|
||||
attr_campaign_id: string | null;
|
||||
attr_utm_source: string | null;
|
||||
attr_utm_medium: string | null;
|
||||
attr_utm_content: string | null;
|
||||
attr_ad_id: string | null;
|
||||
attr_referrer: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplana `attributionSource`.
|
||||
*
|
||||
* `campaign` se lee ANTES que `utmCampaign` a propósito: el buscador de
|
||||
* contactos del CRM descarta `utmCampaign` y conserva `campaign`, así que
|
||||
* leerlos al revés deja la campaña vacía en la mitad de los contactos.
|
||||
*/
|
||||
export function mapAtribucion(c: CrmContact): Atribucion {
|
||||
const a = c.attributionSource ?? {};
|
||||
const g = (...claves: string[]) => {
|
||||
for (const k of claves) {
|
||||
const v = (a as any)[k];
|
||||
if (v !== undefined && v !== null && v !== "") return String(v);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
crm_source: c.source ?? null,
|
||||
attr_session_source: g("sessionSource"),
|
||||
attr_medium: g("medium"),
|
||||
attr_campaign: g("campaign", "utmCampaign"),
|
||||
attr_campaign_id: g("campaignId"),
|
||||
attr_utm_source: g("utmSource"),
|
||||
attr_utm_medium: g("utmMedium"),
|
||||
attr_utm_content: g("utmContent"),
|
||||
attr_ad_id: g("adId"),
|
||||
attr_referrer: g("referrer", "url"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Nombre presentable, con los tres orígenes que trae el CRM. */
|
||||
export function nombreDe(c: CrmContact): string {
|
||||
const compuesto = [c.firstName, c.lastName].filter(Boolean).join(" ").trim();
|
||||
return compuesto || (c.contactName ?? "").trim() || (c.email ?? "").trim() || "Sin nombre";
|
||||
}
|
||||
|
||||
/** Una página del buscador de contactos. */
|
||||
export interface PaginaContactos {
|
||||
contacts: CrmContact[];
|
||||
total: number;
|
||||
searchAfter?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorre los contactos de la subcuenta.
|
||||
*
|
||||
* Pagina con `searchAfter` y no con `page`: el buscador tiene un techo de
|
||||
* profundidad por número de página, y con 3 209 contactos se alcanza. El cursor
|
||||
* sale del ÚLTIMO contacto de la página anterior.
|
||||
*/
|
||||
export async function buscarContactos(
|
||||
ctx: CrmCtx,
|
||||
opts: { pageLimit?: number; searchAfter?: unknown } = {}
|
||||
): Promise<PaginaContactos> {
|
||||
const body: Record<string, unknown> = {
|
||||
locationId: ctx.locationId,
|
||||
pageLimit: opts.pageLimit ?? 100,
|
||||
};
|
||||
if (opts.searchAfter) body.searchAfter = opts.searchAfter;
|
||||
|
||||
const r = await crmRequest<any>("POST", "/contacts/search", { token: ctx.token, body });
|
||||
return {
|
||||
contacts: r?.contacts ?? [],
|
||||
total: r?.total ?? 0,
|
||||
searchAfter: r?.contacts?.length ? r.contacts[r.contacts.length - 1]?.searchAfter : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function obtenerContacto(ctx: CrmCtx, id: string): Promise<CrmContact | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: ctx.token });
|
||||
return r?.contact ?? null;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Busca por un identificador natural. El CRM deduplica por email y teléfono. */
|
||||
export async function buscarPorIdentificador(
|
||||
ctx: CrmCtx,
|
||||
q: string
|
||||
): Promise<CrmContact | null> {
|
||||
const r = await crmRequest<any>("GET", "/contacts/", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId, query: q, limit: 5 },
|
||||
});
|
||||
return r?.contacts?.[0] ?? null;
|
||||
}
|
||||
|
||||
export interface AltaContacto {
|
||||
locationId: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
name?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
source?: string;
|
||||
tags?: string[];
|
||||
attribution?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ResultadoResolucion {
|
||||
contact: CrmContact;
|
||||
/** Cómo se llegó a él: importa para la auditoría y para depurar duplicados. */
|
||||
via: "crm_id" | "telefono" | "correo" | "creado" | "duplicado_400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve el contacto en el CRM siguiendo la cadena de identidad acordada:
|
||||
* **id de contacto → teléfono → correo**, y si no existe, lo crea.
|
||||
*
|
||||
* Es la misma cadena que el CRM aplica por su cuenta
|
||||
* (`contactUniqueIdentifiers: ["email","phone"]`), así que las dos coinciden y
|
||||
* no se pelean.
|
||||
*
|
||||
* El caso interesante es el último: si el alta choca con un duplicado, el CRM
|
||||
* responde `400` **con el `contactId` existente en `meta`**. Eso es idempotencia
|
||||
* de verdad, regalada por el servidor, y es mejor que `upsert` — cuya rama
|
||||
* *actualizar* descarta la atribución en silencio.
|
||||
*/
|
||||
export async function resolverContacto(
|
||||
ctx: CrmCtx,
|
||||
datos: {
|
||||
crmContactId?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
name: string;
|
||||
source?: string;
|
||||
tags?: string[];
|
||||
attribution?: Record<string, string | undefined>;
|
||||
}
|
||||
): Promise<ResultadoResolucion> {
|
||||
// 1. Por id del CRM, si ya lo teníamos anclado.
|
||||
if (datos.crmContactId) {
|
||||
const c = await obtenerContacto(ctx, datos.crmContactId);
|
||||
if (c) return { contact: c, via: "crm_id" };
|
||||
// El id guardado ya no resuelve: el contacto se borró en el CRM. Se sigue
|
||||
// por los fallbacks en vez de fallar.
|
||||
}
|
||||
|
||||
// 2. Por teléfono normalizado.
|
||||
const tel = normalizePhone(datos.phone);
|
||||
if (tel) {
|
||||
const c = await buscarPorIdentificador(ctx, tel);
|
||||
if (c) return { contact: c, via: "telefono" };
|
||||
}
|
||||
|
||||
// 3. Por correo.
|
||||
if (datos.email) {
|
||||
const c = await buscarPorIdentificador(ctx, datos.email);
|
||||
if (c) return { contact: c, via: "correo" };
|
||||
}
|
||||
|
||||
// 4. Crear. La atribución solo entra AQUÍ: después es inmutable.
|
||||
const partes = datos.name.trim().split(/\s+/);
|
||||
const body: Record<string, unknown> = {
|
||||
// MEDIDO: `locationId` va en el POST de alta y ROMPE el PUT con
|
||||
// `422 property locationId should not exist`. No reciclar este cuerpo.
|
||||
locationId: ctx.locationId,
|
||||
firstName: partes[0] || datos.name,
|
||||
lastName: partes.slice(1).join(" ") || undefined,
|
||||
country: "MX",
|
||||
source: datos.source ?? "AgendaMax",
|
||||
};
|
||||
if (tel) body.phone = tel;
|
||||
if (datos.email) body.email = datos.email;
|
||||
if (datos.tags?.length) body.tags = datos.tags;
|
||||
if (datos.attribution && Object.keys(datos.attribution).length) {
|
||||
body.attributionSource = datos.attribution;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await crmRequest<any>("POST", "/contacts/", { token: ctx.token, body });
|
||||
return { contact: r.contact, via: "creado" };
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 400) {
|
||||
const existente = (e.body as any)?.meta?.contactId;
|
||||
if (existente) {
|
||||
const c = await obtenerContacto(ctx, existente);
|
||||
if (c) return { contact: c, via: "duplicado_400" };
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizarTipo, formaDeMensajes } from "./conversations.ts";
|
||||
|
||||
test("normalizarTipo: la API devuelve número o cadena según el endpoint", () => {
|
||||
// MEDIDO (hallazgo 24): `/conversations/{id}` da un número y el buscador una
|
||||
// cadena `TYPE_SMS`. Es la misma información con dos formas.
|
||||
assert.equal(normalizarTipo("TYPE_SMS"), "SMS");
|
||||
assert.equal(normalizarTipo("TYPE_EMAIL"), "Email");
|
||||
assert.equal(normalizarTipo(1), "Phone");
|
||||
assert.equal(normalizarTipo(2), "Email");
|
||||
assert.equal(normalizarTipo(3), "FB");
|
||||
assert.equal(normalizarTipo(undefined), "Desconocido");
|
||||
assert.equal(normalizarTipo(""), "Desconocido");
|
||||
});
|
||||
|
||||
test("normalizarTipo: un tipo desconocido no se traga, se ve", () => {
|
||||
assert.equal(normalizarTipo("TYPE_TIKTOK"), "TIKTOK");
|
||||
assert.equal(normalizarTipo(99), "Desconocido");
|
||||
});
|
||||
|
||||
test("formaDeMensajes: desanida la respuesta real, que trae messages.messages", () => {
|
||||
const r = formaDeMensajes({
|
||||
messages: { messages: [{ id: "m1" }], lastMessageId: "m1", nextPage: true },
|
||||
});
|
||||
assert.equal(r.mensajes.length, 1);
|
||||
assert.equal(r.lastMessageId, "m1");
|
||||
assert.equal(r.hayMas, true);
|
||||
});
|
||||
|
||||
test("formaDeMensajes: tolera la forma plana por si la API cambia", () => {
|
||||
const r = formaDeMensajes({ messages: [{ id: "m1" }] });
|
||||
assert.equal(r.mensajes.length, 1);
|
||||
assert.equal(r.hayMas, false);
|
||||
assert.equal(r.lastMessageId, null);
|
||||
});
|
||||
|
||||
test("formaDeMensajes: una respuesta vacía no revienta", () => {
|
||||
const r = formaDeMensajes({});
|
||||
assert.deepEqual(r.mensajes, []);
|
||||
assert.equal(r.hayMas, false);
|
||||
});
|
||||
|
||||
test("normalizarTipo reconoce los canales reales de la subcuenta", () => {
|
||||
// MEDIDO: los hilos reales traen TYPE_INSTAGRAM y TYPE_ACTIVITY_OPPORTUNITY.
|
||||
assert.equal(normalizarTipo("TYPE_INSTAGRAM"), "Instagram");
|
||||
assert.equal(normalizarTipo("TYPE_ACTIVITY_OPPORTUNITY"), "Actividad");
|
||||
assert.equal(normalizarTipo("TYPE_WHATSAPP"), "WhatsApp");
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
export interface CrmConversation {
|
||||
id: string;
|
||||
contactId?: string;
|
||||
fullName?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
lastMessageBody?: string;
|
||||
lastMessageType?: string;
|
||||
lastMessageDate?: string | number;
|
||||
unreadCount?: number;
|
||||
type?: string | number;
|
||||
}
|
||||
|
||||
export interface CrmMessage {
|
||||
id: string;
|
||||
body?: string;
|
||||
direction?: "inbound" | "outbound";
|
||||
messageType?: string;
|
||||
status?: string | null;
|
||||
dateAdded?: string;
|
||||
contactId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* El canal llega como cadena (`TYPE_SMS`) desde el buscador y como número desde
|
||||
* `GET /conversations/{id}`.
|
||||
*
|
||||
* MEDIDO (hallazgo 24). Es la misma información con dos formas, y mezclarlas
|
||||
* produce una bandeja que etiqueta mal los hilos. Un tipo que no se reconozca se
|
||||
* deja pasar tal cual en vez de esconderlo: si el CRM añade un canal, se verá.
|
||||
*/
|
||||
const POR_NUMERO: Record<number, string> = {
|
||||
1: "Phone",
|
||||
2: "Email",
|
||||
3: "FB",
|
||||
4: "Review",
|
||||
5: "SMS",
|
||||
};
|
||||
|
||||
/** Los canales conocidos se rinden con su nombre propio; el resto pasa tal cual. */
|
||||
const POR_NOMBRE: Record<string, string> = {
|
||||
SMS: "SMS",
|
||||
EMAIL: "Email",
|
||||
CALL: "Llamada",
|
||||
VOICEMAIL: "Buzón de voz",
|
||||
WHATSAPP: "WhatsApp",
|
||||
FB: "Facebook",
|
||||
IG: "Instagram",
|
||||
INSTAGRAM: "Instagram",
|
||||
FACEBOOK: "Facebook",
|
||||
GMB: "Google Business",
|
||||
WEBCHAT: "Chat web",
|
||||
// Los TYPE_ACTIVITY_* no son mensajes de la clienta: son notas que el propio
|
||||
// CRM escribe en el hilo cuando pasa algo (se creó una oportunidad, se agendó
|
||||
// una cita). Se etiquetan como actividad para poder distinguirlos en la
|
||||
// bandeja en vez de mostrarlos como si alguien los hubiera escrito.
|
||||
ACTIVITY_OPPORTUNITY: "Actividad",
|
||||
ACTIVITY_APPOINTMENT: "Actividad",
|
||||
ACTIVITY_CONTACT: "Actividad",
|
||||
ACTIVITY: "Actividad",
|
||||
REVIEW: "Reseña",
|
||||
LIVE_CHAT: "Chat en vivo",
|
||||
CUSTOM: "Otro",
|
||||
};
|
||||
|
||||
export function normalizarTipo(t: string | number | undefined | null): string {
|
||||
if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido";
|
||||
if (typeof t === "string" && t) {
|
||||
const crudo = t.replace(/^TYPE_/, "");
|
||||
return POR_NOMBRE[crudo] ?? crudo.replace(/_/g, " ");
|
||||
}
|
||||
return "Desconocido";
|
||||
}
|
||||
|
||||
/**
|
||||
* MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...],
|
||||
* lastMessageId, nextPage } }` — anidada dos niveles.
|
||||
*
|
||||
* Esta es la TERCERA convención de paginación de la misma API: contactos usan
|
||||
* `searchAfter`, conversaciones `startAfterDate`, y los mensajes `lastMessageId`
|
||||
* con un booleano `nextPage`. Reciclar una por otra devuelve listas incompletas
|
||||
* sin dar ningún error.
|
||||
*/
|
||||
export function formaDeMensajes(r: any): {
|
||||
mensajes: CrmMessage[];
|
||||
lastMessageId: string | null;
|
||||
hayMas: boolean;
|
||||
} {
|
||||
const anidado = r?.messages?.messages;
|
||||
if (Array.isArray(anidado)) {
|
||||
return {
|
||||
mensajes: anidado,
|
||||
lastMessageId: r.messages.lastMessageId ?? null,
|
||||
hayMas: Boolean(r.messages.nextPage),
|
||||
};
|
||||
}
|
||||
const plano = Array.isArray(r?.messages) ? r.messages : [];
|
||||
return { mensajes: plano, lastMessageId: null, hayMas: false };
|
||||
}
|
||||
|
||||
/** Una conversación por su id. MEDIDO: los campos vienen en la raíz, sin envoltorio. */
|
||||
export async function obtenerConversacion(
|
||||
ctx: CrmCtx,
|
||||
id: string
|
||||
): Promise<CrmConversation | null> {
|
||||
try {
|
||||
return await crmRequest<CrmConversation>("GET", `/conversations/${id}`, {
|
||||
token: ctx.token,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Las conversaciones de un contacto. MEDIDO (hallazgo 25): `contactId` es filtro. */
|
||||
export async function conversacionesDeContacto(
|
||||
ctx: CrmCtx,
|
||||
contactId: string
|
||||
): Promise<CrmConversation[]> {
|
||||
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId, contactId, limit: 50 },
|
||||
});
|
||||
return r?.conversations ?? [];
|
||||
}
|
||||
|
||||
export async function buscarConversaciones(
|
||||
ctx: CrmCtx,
|
||||
opts: { limit?: number; startAfterDate?: number } = {}
|
||||
): Promise<{ conversations: CrmConversation[]; total: number }> {
|
||||
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||
token: ctx.token,
|
||||
query: {
|
||||
locationId: ctx.locationId,
|
||||
limit: opts.limit ?? 20,
|
||||
sortBy: "last_message_date",
|
||||
sort: "desc",
|
||||
startAfterDate: opts.startAfterDate,
|
||||
},
|
||||
});
|
||||
return { conversations: r?.conversations ?? [], total: r?.total ?? 0 };
|
||||
}
|
||||
|
||||
export async function mensajesDeConversacion(
|
||||
ctx: CrmCtx,
|
||||
conversationId: string,
|
||||
opts: { limit?: number; lastMessageId?: string } = {}
|
||||
) {
|
||||
const r = await crmRequest<any>("GET", `/conversations/${conversationId}/messages`, {
|
||||
token: ctx.token,
|
||||
query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId },
|
||||
});
|
||||
return formaDeMensajes(r);
|
||||
}
|
||||
|
||||
/** Un mensaje suelto por su id. MEDIDO (hallazgo 27): funciona y viene en la raíz. */
|
||||
export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise<CrmMessage | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/conversations/messages/${id}`, {
|
||||
token: ctx.token,
|
||||
});
|
||||
return (r?.message ?? r) as CrmMessage;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { cifrar, descifrar, huella } from "../lib/crypto.ts";
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
|
||||
/**
|
||||
* Todo lo que hace falta para hablar con la subcuenta de UN negocio.
|
||||
*
|
||||
* Sustituye al `locationId: string` suelto que antes viajaba por once firmas.
|
||||
* Es un objeto y no dos parámetros a propósito: dos `string` seguidos se pueden
|
||||
* cruzar sin que el compilador diga nada, y cruzarlos aquí significa mandar el
|
||||
* token de un cliente a la subcuenta de otro.
|
||||
*
|
||||
* Es el ÚNICO sitio del código donde el token existe descifrado, y solo en
|
||||
* memoria. Ni se registra, ni se audita, ni sale por la API.
|
||||
*/
|
||||
export interface CrmCtx {
|
||||
businessId: number;
|
||||
locationId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface FilaCredencial {
|
||||
location_id: string;
|
||||
token_cipher: Buffer | null;
|
||||
token_nonce: Buffer | null;
|
||||
token_tag: Buffer | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga la credencial del negocio y la descifra.
|
||||
*
|
||||
* Distingue «no vinculado» de «vinculado sin token» a propósito: son dos
|
||||
* situaciones con dos arreglos distintos, y un solo mensaje para las dos manda
|
||||
* a quien lo lea a mirar donde no es.
|
||||
*/
|
||||
export async function ctxDe(businessId: number): Promise<CrmCtx> {
|
||||
const { rows } = await pool.query<FilaCredencial>(
|
||||
`SELECT location_id, token_cipher, token_nonce, token_tag
|
||||
FROM crm_connections WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
const c = rows[0];
|
||||
if (!c) {
|
||||
throw { status: 409, error: "Este negocio no está vinculado a Bucéfalo CRM" };
|
||||
}
|
||||
if (!c.token_cipher || !c.token_nonce || !c.token_tag) {
|
||||
throw {
|
||||
status: 409,
|
||||
error:
|
||||
"Este negocio no tiene token de Bucéfalo CRM. Vincúlalo desde la consola de administración.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
businessId,
|
||||
locationId: c.location_id,
|
||||
token: descifrar({ cipher: c.token_cipher, nonce: c.token_nonce, tag: c.token_tag }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda o rota la credencial de un negocio. Idempotente.
|
||||
*
|
||||
* La etiqueta se conserva si no se manda otra: al rotar un token caducado nadie
|
||||
* quiere volver a teclear el nombre de la subcuenta, y perderlo en silencio
|
||||
* dejaría la consola llena de cuentas sin identificar.
|
||||
*/
|
||||
export async function guardarCredencial(
|
||||
businessId: number,
|
||||
locationId: string,
|
||||
token: string,
|
||||
label?: string
|
||||
): Promise<void> {
|
||||
const c = cifrar(token);
|
||||
await pool.query(
|
||||
`INSERT INTO crm_connections
|
||||
(business_id, location_id, token_cipher, token_nonce, token_tag,
|
||||
token_fingerprint, token_updated_at, label)
|
||||
VALUES ($1,$2,$3,$4,$5,$6, now(), $7)
|
||||
ON CONFLICT (business_id) DO UPDATE SET
|
||||
location_id = EXCLUDED.location_id,
|
||||
token_cipher = EXCLUDED.token_cipher,
|
||||
token_nonce = EXCLUDED.token_nonce,
|
||||
token_tag = EXCLUDED.token_tag,
|
||||
token_fingerprint = EXCLUDED.token_fingerprint,
|
||||
token_updated_at = now(),
|
||||
label = COALESCE(EXCLUDED.label, crm_connections.label)`,
|
||||
[businessId, locationId, c.cipher, c.nonce, c.tag, huella(token), label ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desvincula: borra la credencial y **conserva** la conexión y todo lo ya
|
||||
* sincronizado. Quitar el token no es motivo para tirar 3 200 contactos, sus
|
||||
* conversaciones y la atribución que costó traer.
|
||||
*/
|
||||
export async function olvidarCredencial(businessId: number): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE crm_connections
|
||||
SET token_cipher = NULL, token_nonce = NULL, token_tag = NULL,
|
||||
token_fingerprint = NULL, token_updated_at = NULL
|
||||
WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexto construido desde el entorno, **solo para los scripts de sondeo**.
|
||||
*
|
||||
* El servidor nunca debe usar esto: sus credenciales salen de la base, por
|
||||
* negocio, vía `ctxDe`. Aquí existe porque los spikes se lanzan a mano contra
|
||||
* la subcuenta que esté configurada en `platform/.env`, antes incluso de que
|
||||
* exista una fila en `crm_connections`.
|
||||
*/
|
||||
export function ctxDesdeEnv(businessId = 0): CrmCtx {
|
||||
// Carga el .env explícitamente: depender de que otro import lo haya hecho
|
||||
// antes funciona por casualidad y se rompe al reordenar los imports.
|
||||
loadEnv();
|
||||
const locationId = process.env.CRM_LOCATION_ID;
|
||||
const token = process.env.CRM_TOKEN;
|
||||
if (!locationId || !token) {
|
||||
throw new Error(
|
||||
"Faltan CRM_LOCATION_ID y/o CRM_TOKEN en platform/.env — este script los necesita para hablar con la subcuenta."
|
||||
);
|
||||
}
|
||||
return { businessId, locationId, token };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { crmRequest } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
/**
|
||||
* Envío de mensajes hacia Bucéfalo CRM.
|
||||
*
|
||||
* La LECTURA de conversaciones y mensajes vive en `conversations.ts`: son dos
|
||||
* responsabilidades distintas y la de lectura creció con la sincronización por
|
||||
* id. Aquí queda solo lo que escribe.
|
||||
*/
|
||||
|
||||
export interface EnvioCorreo {
|
||||
contactId: string;
|
||||
emailTo: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface ResultadoEnvio {
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
emailMessageId?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un correo por el CRM.
|
||||
*
|
||||
* **Un `200` aquí es acuse de encolado, no de entrega** — la respuesta literal
|
||||
* es `Email queued successfully`. No se puede afirmar que el mensaje llegó sin
|
||||
* mirar una bandeja real, y la interfaz no debe decir «enviado» como si fuera
|
||||
* un hecho confirmado.
|
||||
*
|
||||
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||
* canal ejercible hoy.
|
||||
*/
|
||||
export async function enviarCorreo(ctx: CrmCtx, e: EnvioCorreo): Promise<ResultadoEnvio> {
|
||||
return crmRequest<ResultadoEnvio>("POST", "/conversations/messages", {
|
||||
token: ctx.token,
|
||||
body: {
|
||||
type: "Email",
|
||||
contactId: e.contactId,
|
||||
subject: e.subject,
|
||||
html: e.html,
|
||||
emailTo: e.emailTo,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { estadoOportunidad, nombreOportunidad } from "./opportunities.ts";
|
||||
|
||||
test("el estado de la cita se mapea al de la oportunidad", () => {
|
||||
assert.equal(estadoOportunidad("scheduled"), "open", "en espera → open");
|
||||
assert.equal(estadoOportunidad("completed"), "won", "completada → won");
|
||||
assert.equal(estadoOportunidad("cancelled"), "lost", "cancelada → lost");
|
||||
});
|
||||
|
||||
test("no vino también es una pérdida para el embudo", () => {
|
||||
// El matiz de POR QUÉ se perdió (no vino / canceló la clienta / canceló el
|
||||
// spa) vive en AgendaMax: el CRM aplana los tres en `lost`.
|
||||
assert.equal(estadoOportunidad("no_show"), "lost");
|
||||
});
|
||||
|
||||
test("un estado desconocido no cierra la oportunidad", () => {
|
||||
// Cerrar por error es peor que dejar abierto: `won` mete ingreso inventado en
|
||||
// los reportes del CRM y `lost` mata una cita viva.
|
||||
assert.equal(estadoOportunidad("cualquier_cosa"), "open");
|
||||
});
|
||||
|
||||
test("el nombre de la oportunidad es SERVICIO + CLIENTA", () => {
|
||||
assert.equal(
|
||||
nombreOportunidad("Extensiones de pestañas", "Mariana López"),
|
||||
"Extensiones de pestañas — Mariana López"
|
||||
);
|
||||
});
|
||||
|
||||
test("el nombre se recorta para no romper el límite del CRM", () => {
|
||||
const n = nombreOportunidad("S".repeat(200), "C".repeat(200));
|
||||
assert.equal(n.length, 255);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
/** El enum de la API. La cita en espera es `open`, completada `won`, cancelada `lost`. */
|
||||
export type CrmOppStatus = "open" | "won" | "lost" | "abandoned";
|
||||
|
||||
export interface CrmOpportunity {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: CrmOppStatus;
|
||||
monetaryValue?: number;
|
||||
pipelineId?: string;
|
||||
pipelineStageId?: string;
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
/** Estado de la cita en AgendaMax → estado de la oportunidad en el CRM. */
|
||||
export function estadoOportunidad(estadoCita: string): CrmOppStatus {
|
||||
switch (estadoCita) {
|
||||
case "completed":
|
||||
return "won";
|
||||
case "cancelled":
|
||||
case "no_show":
|
||||
// Una cita a la que no vino la clienta tampoco produjo ingreso: para el
|
||||
// embudo del CRM es una pérdida. El matiz de POR QUÉ se perdió (no vino,
|
||||
// canceló ella, canceló el spa) vive en AgendaMax, que sí lo distingue.
|
||||
return "lost";
|
||||
default:
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* El nombre de la oportunidad, con el formato acordado: SERVICIO + NOMBRE.
|
||||
* Se recorta a 255 porque el CRM no documenta el límite y un nombre largo
|
||||
* es la clase de cosa que falla en producción y no en pruebas.
|
||||
*/
|
||||
export function nombreOportunidad(servicio: string, cliente: string): string {
|
||||
return `${servicio} — ${cliente}`.slice(0, 255);
|
||||
}
|
||||
|
||||
export interface EtapasPipeline {
|
||||
pipelineId: string;
|
||||
open?: string | null;
|
||||
won?: string | null;
|
||||
lost?: string | null;
|
||||
}
|
||||
|
||||
function etapaPara(estado: CrmOppStatus, etapas: EtapasPipeline): string | null {
|
||||
if (estado === "won") return etapas.won ?? null;
|
||||
if (estado === "lost") return etapas.lost ?? null;
|
||||
return etapas.open ?? null;
|
||||
}
|
||||
|
||||
export async function obtenerOportunidad(
|
||||
ctx: CrmCtx,
|
||||
id: string
|
||||
): Promise<CrmOpportunity | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/opportunities/${id}`, { token: ctx.token });
|
||||
return r?.opportunity ?? null;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Las oportunidades de un contacto. `GET /contacts/{id}/opportunities` NO existe (404). */
|
||||
export async function oportunidadesDeContacto(
|
||||
ctx: CrmCtx,
|
||||
contactId: string
|
||||
): Promise<CrmOpportunity[]> {
|
||||
const r = await crmRequest<any>("GET", "/opportunities/search", {
|
||||
token: ctx.token,
|
||||
query: { location_id: ctx.locationId, contact_id: contactId, limit: 20 },
|
||||
});
|
||||
return r?.opportunities ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambia el estado y la etapa.
|
||||
*
|
||||
* Tres cosas medidas gobiernan esta función, y las tres son contraintuitivas:
|
||||
*
|
||||
* 1. Son **dos llamadas**, no una: `PUT /opportunities/{id}/status` rechaza
|
||||
* `pipelineStageId` con `422 property pipelineStageId should not exist`.
|
||||
* 2. **El orden importa**: el `/status` mueve la etapa por su cuenta, así que
|
||||
* va primero y la etapa deseada se escribe después. Al revés, el `/status`
|
||||
* pisa la etapa recién puesta (medido: de «Ganado» a «Cotización Aceptada»).
|
||||
* 3. En `won`, la subcuenta acaba imponiendo **su** etapa igualmente: se probó
|
||||
* reescribir y releer tres veces y el CRM la devuelve a «Cotización
|
||||
* Aceptada» de forma asíncrona, después de que la relectura ya confirmó la
|
||||
* nuestra. Hay una regla del lado del CRM que gobierna eso, y pelearse con
|
||||
* ella sería un bucle que nunca gana. En `lost` sí respeta «Perdido».
|
||||
*
|
||||
* Se escribe la etapa una vez y no se insiste. Lo que el negocio pidió mapear
|
||||
* es el **estado** —`open` / `won` / `lost`—, y ese sí queda estable y
|
||||
* verificado; la etapa es presentación y la manda el CRM.
|
||||
*/
|
||||
async function aplicarEstado(
|
||||
ctx: CrmCtx,
|
||||
id: string,
|
||||
estado: CrmOppStatus,
|
||||
etapas: EtapasPipeline
|
||||
): Promise<void> {
|
||||
await crmRequest("PUT", `/opportunities/${id}/status`, {
|
||||
token: ctx.token,
|
||||
body: { status: estado },
|
||||
});
|
||||
|
||||
const etapa = etapaPara(estado, etapas);
|
||||
if (!etapa) return;
|
||||
|
||||
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, pipelineStageId: etapa },
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResultadoOportunidad {
|
||||
opportunity: CrmOpportunity;
|
||||
via: "creada" | "reciclada" | "actualizada";
|
||||
}
|
||||
|
||||
/**
|
||||
* Proyecta una cita al CRM como oportunidad.
|
||||
*
|
||||
* Dos caminos, y la plataforma elige sola sin configuración:
|
||||
*
|
||||
* 1. **Crear.** Si la subcuenta permite duplicados, cada cita estrena su
|
||||
* oportunidad — que es el modelo pedido.
|
||||
* 2. **Reciclar.** Si responde `400 OPPORTUNITY_NO_DUPLICATE`, el CRM entrega
|
||||
* en `meta.existingId` la que ya existe, y se le pone el nombre, el importe
|
||||
* y el estado de esta cita.
|
||||
*
|
||||
* El camino 2 es el que corre hoy en Yola: la subcuenta tiene
|
||||
* `allowDuplicateOpportunity: false`. Ahí la oportunidad representa *la cita
|
||||
* vigente de la clienta*, y el histórico completo vive en AgendaMax. Si alguien
|
||||
* activa el ajuste en el CRM, esta misma función pasa al camino 1 sin cambios.
|
||||
*/
|
||||
export async function upsertOportunidad(
|
||||
ctx: CrmCtx,
|
||||
args: {
|
||||
contactId: string;
|
||||
nombre: string;
|
||||
importe: number;
|
||||
estado: CrmOppStatus;
|
||||
etapas: EtapasPipeline;
|
||||
/** Si ya la teníamos anclada, se actualiza directamente. */
|
||||
oportunidadId?: string | null;
|
||||
}
|
||||
): Promise<ResultadoOportunidad> {
|
||||
const { contactId, nombre, importe, estado, etapas } = args;
|
||||
|
||||
// Ya anclada: actualizar en sitio.
|
||||
if (args.oportunidadId) {
|
||||
const existente = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||
if (existente) {
|
||||
await crmRequest("PUT", `/opportunities/${args.oportunidadId}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||
});
|
||||
await aplicarEstado(ctx, args.oportunidadId, estado, etapas);
|
||||
const releida = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||
return { opportunity: releida ?? existente, via: "actualizada" };
|
||||
}
|
||||
// El id guardado ya no resuelve; se sigue por el camino normal.
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
pipelineId: etapas.pipelineId,
|
||||
locationId: ctx.locationId,
|
||||
name: nombre,
|
||||
status: estado,
|
||||
contactId,
|
||||
monetaryValue: importe,
|
||||
};
|
||||
const etapa = etapaPara(estado, etapas);
|
||||
if (etapa) body.pipelineStageId = etapa;
|
||||
|
||||
try {
|
||||
const r = await crmRequest<any>("POST", "/opportunities/", { token: ctx.token, body });
|
||||
const creada = r?.opportunity;
|
||||
// Se RELEE antes de dar el id por bueno: la respuesta de creación de esta
|
||||
// API refleja lo que mandaste, no necesariamente lo que persistió.
|
||||
const releida = creada?.id ? await obtenerOportunidad(ctx, creada.id) : null;
|
||||
return { opportunity: releida ?? creada, via: "creada" };
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 400) {
|
||||
const cuerpo = e.body as any;
|
||||
const existenteId =
|
||||
cuerpo?.meta?.existingId ??
|
||||
(cuerpo?.code === "OPPORTUNITY_NO_DUPLICATE" ? cuerpo?.meta?.id : null);
|
||||
|
||||
let id: string | null = existenteId ?? null;
|
||||
if (!id) {
|
||||
// El 400 no trajo el id: se busca. Es el tercer mecanismo, el más débil,
|
||||
// pero aquí la llave natural (contacto + subcuenta) es exacta.
|
||||
const previas = await oportunidadesDeContacto(ctx, contactId);
|
||||
id = previas[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||
});
|
||||
await aplicarEstado(ctx, id, estado, etapas);
|
||||
const releida = await obtenerOportunidad(ctx, id);
|
||||
if (releida) return { opportunity: releida, via: "reciclada" };
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { CrmTransportError } from "./client.ts";
|
||||
import { proyectarCita } from "./syncAppointments.ts";
|
||||
|
||||
export type EntidadOutbox = "appointment" | "client" | "message";
|
||||
|
||||
/**
|
||||
* Clave de deduplicación **propia y estable**. Nunca se deriva del contenido:
|
||||
* dos ediciones que dejan el mismo valor son dos intenciones distintas y las
|
||||
* dos tienen que salir.
|
||||
*/
|
||||
export function claveDedup(
|
||||
businessId: number,
|
||||
entidad: EntidadOutbox,
|
||||
entidadId: number,
|
||||
operacion: string,
|
||||
secuencia: number | string
|
||||
): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update([businessId, entidad, entidadId, operacion, secuencia].join("|"))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encola un cambio para el CRM **dentro de la transacción que lo produjo**.
|
||||
*
|
||||
* Recibe el `PoolClient` a propósito: el cambio local y su fila de bandeja se
|
||||
* escriben juntos o no se escriben. Sin eso aparece la escritura perdida — el
|
||||
* usuario ve «guardado», el proceso muere antes de encolar, y nadie lo reclama
|
||||
* nunca.
|
||||
*/
|
||||
export async function encolar(
|
||||
tx: PoolClient,
|
||||
args: {
|
||||
businessId: number;
|
||||
entidad: EntidadOutbox;
|
||||
entidadId: number;
|
||||
operacion: string;
|
||||
payload: unknown;
|
||||
secuencia?: number | string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const secuencia = args.secuencia ?? Date.now();
|
||||
const dedup = claveDedup(
|
||||
args.businessId,
|
||||
args.entidad,
|
||||
args.entidadId,
|
||||
args.operacion,
|
||||
secuencia
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO crm_outbox (business_id, entity, entity_id, operation, payload, dedup_key)
|
||||
VALUES ($1,$2,$3,$4,$5::jsonb,$6)
|
||||
ON CONFLICT (dedup_key) DO NOTHING`,
|
||||
[
|
||||
args.businessId,
|
||||
args.entidad,
|
||||
args.entidadId,
|
||||
args.operacion,
|
||||
JSON.stringify(args.payload ?? {}),
|
||||
dedup,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResumenDespacho {
|
||||
tomadas: number;
|
||||
confirmadas: number;
|
||||
fallidas: number;
|
||||
indeterminadas: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Despacha la bandeja de salida de un negocio.
|
||||
*
|
||||
* FIFO estricto y **una sola escritura en vuelo por registro**: el CRM
|
||||
* estrangula por token y dos escrituras concurrentes sobre la misma cita
|
||||
* corren contra una base que ya cambió.
|
||||
*/
|
||||
export async function despachar(
|
||||
businessId: number,
|
||||
limite = 25
|
||||
): Promise<ResumenDespacho> {
|
||||
const resumen: ResumenDespacho = {
|
||||
tomadas: 0,
|
||||
confirmadas: 0,
|
||||
fallidas: 0,
|
||||
indeterminadas: 0,
|
||||
};
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, entity, entity_id, operation, attempts
|
||||
FROM crm_outbox
|
||||
WHERE business_id = $1 AND status IN ('pendiente','indeterminado')
|
||||
ORDER BY id
|
||||
LIMIT $2`,
|
||||
[businessId, limite]
|
||||
);
|
||||
resumen.tomadas = rows.length;
|
||||
|
||||
for (const fila of rows) {
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox SET status = 'enviando', attempts = attempts + 1 WHERE id = $1`,
|
||||
[fila.id]
|
||||
);
|
||||
try {
|
||||
let crmId: string | null = null;
|
||||
|
||||
if (fila.entity === "appointment") {
|
||||
const r = await proyectarCita(businessId, fila.entity_id);
|
||||
crmId = r.crmOpportunityId;
|
||||
} else {
|
||||
// Todavía no hay más entidades salientes; se descarta explícitamente
|
||||
// en vez de dejarla girando en la cola para siempre.
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox
|
||||
SET status = 'fallido', last_error = 'entidad no soportada todavía'
|
||||
WHERE id = $1`,
|
||||
[fila.id]
|
||||
);
|
||||
resumen.fallidas++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox
|
||||
SET status = 'confirmado', crm_id = $2, evidence = 'relectura', sent_at = now(),
|
||||
last_error = NULL
|
||||
WHERE id = $1`,
|
||||
[fila.id, crmId]
|
||||
);
|
||||
resumen.confirmadas++;
|
||||
} catch (e: any) {
|
||||
// Un fallo de transporte NO se reintenta: el servidor no habló, así que
|
||||
// no se sabe si la escritura entró, y reenviar es fabricar el duplicado.
|
||||
// Queda en `indeterminado` para resolverlo LEYENDO.
|
||||
const indeterminado = e instanceof CrmTransportError || e?.indeterminate === true;
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox SET status = $2, last_error = $3 WHERE id = $1`,
|
||||
[
|
||||
fila.id,
|
||||
indeterminado ? "indeterminado" : fila.attempts >= 4 ? "fallido" : "pendiente",
|
||||
String(e?.message ?? e).slice(0, 500),
|
||||
]
|
||||
);
|
||||
if (indeterminado) resumen.indeterminadas++;
|
||||
else resumen.fallidas++;
|
||||
}
|
||||
}
|
||||
|
||||
return resumen;
|
||||
}
|
||||
|
||||
export interface EstadoOutbox {
|
||||
pendiente: number;
|
||||
enviando: number;
|
||||
confirmado: number;
|
||||
fallido: number;
|
||||
indeterminado: number;
|
||||
}
|
||||
|
||||
export async function estadoOutbox(businessId: number): Promise<EstadoOutbox> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT status, count(*)::int AS c FROM crm_outbox WHERE business_id = $1 GROUP BY status`,
|
||||
[businessId]
|
||||
);
|
||||
const base: EstadoOutbox = {
|
||||
pendiente: 0,
|
||||
enviando: 0,
|
||||
confirmado: 0,
|
||||
fallido: 0,
|
||||
indeterminado: 0,
|
||||
};
|
||||
for (const r of rows) (base as any)[r.status] = r.c;
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { crmRequest, VERSION_CALENDARS } from "./client.ts";
|
||||
import { ctxDe, type CrmCtx } from "./ctx.ts";
|
||||
import { listarPersonal } from "./calendars.ts";
|
||||
import { slugify } from "../lib/businessDefaults.ts";
|
||||
|
||||
export interface CrmService {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
serviceDuration?: number;
|
||||
serviceDurationUnit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* El catálogo de servicios de la subcuenta.
|
||||
*
|
||||
* MEDIDO dos veces (hallazgos 6 y 32): devuelve `services: []`. El catálogo del
|
||||
* CRM está VACÍO, no ausente — el modelo existe y admite duración, precio,
|
||||
* categoría y variaciones. Simplemente nadie lo ha poblado.
|
||||
*
|
||||
* De ahí la dirección: «sincronizar servicios» no puede significar traerlos. La
|
||||
* duración y el precio los define el negocio en la plataforma, y lo único con
|
||||
* sentido es publicarlos hacia allá.
|
||||
*/
|
||||
export async function catalogoDelCrm(ctx: CrmCtx): Promise<CrmService[]> {
|
||||
const r = await crmRequest<any>("GET", "/calendars/services/catalog", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return r?.services ?? [];
|
||||
}
|
||||
|
||||
export interface ResultadoPublicacion {
|
||||
crmServiceId: string;
|
||||
nombre: string;
|
||||
yaEstaba: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publica un servicio de la plataforma en el catálogo del CRM.
|
||||
*
|
||||
* MEDIDO (hallazgo 34): `calendars.write` está y `staff[]` con al menos un
|
||||
* miembro es obligatorio — el `422` lo dice literalmente. Los ids de personal
|
||||
* salen de `GET /users/`, que volvió a estar disponible (hallazgo 35).
|
||||
*/
|
||||
export async function publicarServicio(
|
||||
businessId: number,
|
||||
serviceId: number
|
||||
): Promise<ResultadoPublicacion> {
|
||||
const ctx = await ctxDe(businessId);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, description, duration_min, price, color, crm_service_id
|
||||
FROM services WHERE id = $1 AND business_id = $2 AND active = true`,
|
||||
[serviceId, businessId]
|
||||
);
|
||||
const s = rows[0];
|
||||
if (!s) throw { status: 404, error: "Ese servicio no existe en este negocio, o está inactivo" };
|
||||
|
||||
if (s.crm_service_id) {
|
||||
// Ya publicado. Se comprueba que siga existiendo antes de darlo por bueno:
|
||||
// alguien pudo borrarlo desde la interfaz del CRM.
|
||||
const catalogo = await catalogoDelCrm(ctx);
|
||||
if (catalogo.some((x) => x.id === s.crm_service_id)) {
|
||||
return { crmServiceId: s.crm_service_id, nombre: s.name, yaEstaba: true };
|
||||
}
|
||||
}
|
||||
|
||||
let personal: { id: string; name: string }[] = [];
|
||||
try {
|
||||
personal = await listarPersonal(ctx);
|
||||
} catch (e: any) {
|
||||
// El permiso de personal se ha visto ir y venir (hallazgo 14 → 35). Si no
|
||||
// está, se dice qué falta en vez de fallar con el 422 del catálogo.
|
||||
throw {
|
||||
status: 409,
|
||||
error:
|
||||
"No se pudo leer el personal de la subcuenta, y el catálogo exige al menos una persona por servicio. Revisa que el token tenga permiso de usuarios.",
|
||||
};
|
||||
}
|
||||
if (!personal.length) {
|
||||
throw {
|
||||
status: 409,
|
||||
error:
|
||||
"La subcuenta de Bucéfalo CRM no tiene personal, y el catálogo exige al menos una persona por servicio",
|
||||
};
|
||||
}
|
||||
|
||||
const cuerpo = {
|
||||
locationId: ctx.locationId,
|
||||
name: s.name,
|
||||
slug: slugify(s.name),
|
||||
...(s.description ? { description: s.description } : {}),
|
||||
...(s.color ? { eventColor: s.color } : {}),
|
||||
serviceDuration: Number(s.duration_min),
|
||||
serviceDurationUnit: "mins",
|
||||
staff: personal.slice(0, 1).map((p) => ({ id: p.id })),
|
||||
variations: [],
|
||||
};
|
||||
|
||||
let r: any;
|
||||
try {
|
||||
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: cuerpo,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// MEDIDO: la PRIMERA publicación en una subcuenta que nunca ha tenido
|
||||
// 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 (`isSystemGenerated: true`). El reintento sí entra.
|
||||
//
|
||||
// Se reintenta UNA vez y solo ante ese mensaje concreto: reintentar a ciegas
|
||||
// un POST es fabricar duplicados.
|
||||
const msg = String(e?.message ?? "");
|
||||
if (e?.status === 400 && /default service category/i.test(msg)) {
|
||||
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: cuerpo,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const crmServiceId = r?.service?.id ?? r?.id;
|
||||
if (!crmServiceId) {
|
||||
throw new Error("El CRM aceptó el servicio pero no devolvió su identificador");
|
||||
}
|
||||
|
||||
// No se acepta el 200 como prueba: se relee el catálogo y se busca.
|
||||
const catalogo = await catalogoDelCrm(ctx);
|
||||
if (!catalogo.some((x) => x.id === crmServiceId)) {
|
||||
throw new Error(
|
||||
"El servicio no aparece al releer el catálogo del CRM: la escritura no persistió"
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||
[serviceId, crmServiceId]
|
||||
);
|
||||
return { crmServiceId, nombre: s.name, yaEstaba: false };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import { obtenerConexion, etapasDe } from "./connection.ts";
|
||||
import { resolverContacto } from "./contacts.ts";
|
||||
import {
|
||||
upsertOportunidad,
|
||||
estadoOportunidad,
|
||||
nombreOportunidad,
|
||||
} from "./opportunities.ts";
|
||||
|
||||
export interface ResultadoProyeccion {
|
||||
appointmentId: number;
|
||||
crmContactId: string;
|
||||
crmOpportunityId: string;
|
||||
status: string;
|
||||
via: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proyecta UNA cita al CRM como oportunidad.
|
||||
*
|
||||
* Nombre `SERVICIO — CLIENTA`, importe el del servicio, y estado según la cita:
|
||||
* en espera → `open`, completada → `won`, cancelada o no asistió → `lost`.
|
||||
*
|
||||
* Resuelve primero el contacto: `POST /calendars/...` y `POST /opportunities/`
|
||||
* exigen `contactId`, así que una clienta nacida en la plataforma tiene que
|
||||
* existir en el CRM antes de que su cita pueda salir.
|
||||
*/
|
||||
export async function proyectarCita(
|
||||
businessId: number,
|
||||
appointmentId: number
|
||||
): Promise<ResultadoProyeccion> {
|
||||
const conexion = await obtenerConexion(businessId);
|
||||
if (!conexion) {
|
||||
throw Object.assign(new Error("Este negocio no tiene conexión con Bucéfalo CRM"), {
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
// La conexión da pipeline y etapas; el contexto da la credencial. Son dos
|
||||
// cosas distintas y por eso se piden por separado.
|
||||
const ctx = await ctxDe(businessId);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, a.status, a.price, a.crm_opportunity_id,
|
||||
c.id AS client_id, c.name AS client_name, c.phone, c.email,
|
||||
c.crm_contact_id, s.name AS service_name
|
||||
FROM appointments a
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
JOIN services s ON s.id = a.service_id
|
||||
WHERE a.id = $1 AND a.business_id = $2`,
|
||||
[appointmentId, businessId]
|
||||
);
|
||||
const cita = rows[0];
|
||||
if (!cita) {
|
||||
throw Object.assign(new Error("Cita no encontrada"), { status: 404 });
|
||||
}
|
||||
|
||||
const contacto = await resolverContacto(ctx, {
|
||||
crmContactId: cita.crm_contact_id,
|
||||
phone: cita.phone,
|
||||
email: cita.email,
|
||||
name: cita.client_name,
|
||||
source: "AgendaMax",
|
||||
tags: ["agendamax"],
|
||||
});
|
||||
|
||||
// Se ancla el contacto en cuanto se conoce: si la oportunidad falla después,
|
||||
// al menos la clienta ya no se volverá a crear duplicada.
|
||||
if (contacto.contact.id !== cita.crm_contact_id) {
|
||||
await pool.query(
|
||||
`UPDATE clients SET crm_contact_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||
[cita.client_id, contacto.contact.id]
|
||||
);
|
||||
}
|
||||
|
||||
const estado = estadoOportunidad(cita.status);
|
||||
const oportunidad = await upsertOportunidad(ctx, {
|
||||
contactId: contacto.contact.id,
|
||||
nombre: nombreOportunidad(cita.service_name, cita.client_name),
|
||||
importe: Number(cita.price) || 0,
|
||||
estado,
|
||||
etapas: etapasDe(conexion),
|
||||
oportunidadId: cita.crm_opportunity_id,
|
||||
});
|
||||
|
||||
await pool.query(
|
||||
`UPDATE appointments
|
||||
SET crm_opportunity_id = $2, crm_status = $3, crm_synced_at = now()
|
||||
WHERE id = $1`,
|
||||
[appointmentId, oportunidad.opportunity.id, estado]
|
||||
);
|
||||
|
||||
return {
|
||||
appointmentId,
|
||||
crmContactId: contacto.contact.id,
|
||||
crmOpportunityId: oportunidad.opportunity.id,
|
||||
status: estado,
|
||||
via: `contacto:${contacto.via} · oportunidad:${oportunidad.via}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
import { buscarContactos, mapAtribucion, nombreDe, type CrmContact } from "./contacts.ts";
|
||||
|
||||
export interface ResumenSync {
|
||||
runId: number;
|
||||
fetched: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total_crm: number;
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae los contactos del CRM a la plataforma, con su atribución.
|
||||
*
|
||||
* Dirección: **una sola, 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.
|
||||
*
|
||||
* Reconciliación, en el mismo orden que la cadena de identidad acordada:
|
||||
* 1. `crm_contact_id` — si ya está anclado, es él y no se busca más.
|
||||
* 2. teléfono normalizado a E.164.
|
||||
* 3. correo.
|
||||
* Si ninguno encaja, se crea la clienta.
|
||||
*
|
||||
* La atribución se sobrescribe siempre desde el CRM: es dato del CRM y él es su
|
||||
* único dueño. El nombre, en cambio, **no pisa** uno editado en la plataforma
|
||||
* si el CRM no trae nada mejor.
|
||||
*/
|
||||
export async function sincronizarContactos(
|
||||
businessId: number,
|
||||
opts: { userId?: number | null; maxPaginas?: number } = {}
|
||||
): Promise<ResumenSync> {
|
||||
// El contexto se pide UNA vez, al principio: descifra el token y ya no se
|
||||
// vuelve a tocar la base para eso en toda la corrida.
|
||||
const ctx = await ctxDe(businessId);
|
||||
|
||||
const run = await pool.query<{ id: number }>(
|
||||
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||
VALUES ($1,'contacts','pull',$2) RETURNING id`,
|
||||
[businessId, opts.userId ?? null]
|
||||
);
|
||||
const runId = run.rows[0].id;
|
||||
|
||||
let fetched = 0;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let total = 0;
|
||||
|
||||
try {
|
||||
let cursor: unknown = undefined;
|
||||
const maxPaginas = opts.maxPaginas ?? 60; // 60 × 100 = 6 000 contactos por corrida
|
||||
|
||||
for (let pagina = 0; pagina < maxPaginas; pagina++) {
|
||||
const p = await buscarContactos(ctx, {
|
||||
pageLimit: 100,
|
||||
searchAfter: cursor,
|
||||
});
|
||||
total = p.total;
|
||||
if (!p.contacts.length) break;
|
||||
fetched += p.contacts.length;
|
||||
|
||||
for (const c of p.contacts) {
|
||||
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||
if (r === "created") created++;
|
||||
else if (r === "updated") updated++;
|
||||
else skipped++;
|
||||
}
|
||||
|
||||
if (!p.searchAfter) break;
|
||||
cursor = p.searchAfter;
|
||||
if (fetched >= total) break;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs
|
||||
SET finished_at = now(), status = 'ok',
|
||||
fetched = $2, created = $3, updated = $4, skipped = $5
|
||||
WHERE id = $1`,
|
||||
[runId, fetched, created, updated, skipped]
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE crm_connections
|
||||
SET last_sync_at = now(),
|
||||
last_sync_status = $2
|
||||
WHERE business_id = $1`,
|
||||
[businessId, `${created} nuevas, ${updated} actualizadas de ${fetched} leídas`]
|
||||
);
|
||||
|
||||
return { runId, fetched, created, updated, skipped, total_crm: total, status: "ok" };
|
||||
} catch (e: any) {
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs
|
||||
SET finished_at = now(), status = 'error', error = $2,
|
||||
fetched = $3, created = $4, updated = $5, skipped = $6
|
||||
WHERE id = $1`,
|
||||
[runId, String(e?.message ?? e).slice(0, 500), fetched, created, updated, skipped]
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE crm_connections SET last_sync_status = $2 WHERE business_id = $1`,
|
||||
[businessId, `error: ${String(e?.message ?? e).slice(0, 200)}`]
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
type Resultado = "created" | "updated" | "skipped";
|
||||
|
||||
export async function upsertClienteDesdeCrm(
|
||||
businessId: number,
|
||||
c: CrmContact
|
||||
): Promise<Resultado> {
|
||||
const tel = normalizePhone(c.phone);
|
||||
const email = (c.email ?? "").trim().toLowerCase() || null;
|
||||
const nombre = nombreDe(c);
|
||||
const attr = mapAtribucion(c);
|
||||
const tags = Array.isArray(c.tags) ? c.tags.join(",") : null;
|
||||
|
||||
return withTx(async (tx) => {
|
||||
// Cadena de identidad: id anclado → teléfono → correo.
|
||||
let existente: { id: number; name: string } | null = null;
|
||||
|
||||
const porId = await tx.query(
|
||||
`SELECT id, name FROM clients WHERE business_id = $1 AND crm_contact_id = $2`,
|
||||
[businessId, c.id]
|
||||
);
|
||||
existente = porId.rows[0] ?? null;
|
||||
|
||||
if (!existente && tel) {
|
||||
const porTel = await tx.query(
|
||||
`SELECT id, name FROM clients
|
||||
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||
[businessId, tel]
|
||||
);
|
||||
existente = porTel.rows[0] ?? null;
|
||||
}
|
||||
if (!existente && email) {
|
||||
const porMail = await tx.query(
|
||||
`SELECT id, name FROM clients
|
||||
WHERE business_id = $1 AND lower(email) = $2 AND deleted_at IS NULL`,
|
||||
[businessId, email]
|
||||
);
|
||||
existente = porMail.rows[0] ?? null;
|
||||
}
|
||||
|
||||
const cols = [
|
||||
attr.crm_source,
|
||||
attr.attr_session_source,
|
||||
attr.attr_medium,
|
||||
attr.attr_campaign,
|
||||
attr.attr_campaign_id,
|
||||
attr.attr_utm_source,
|
||||
attr.attr_utm_medium,
|
||||
attr.attr_utm_content,
|
||||
attr.attr_ad_id,
|
||||
attr.attr_referrer,
|
||||
tags,
|
||||
c.dateAdded ?? null,
|
||||
];
|
||||
|
||||
if (existente) {
|
||||
await tx.query(
|
||||
`UPDATE clients SET
|
||||
crm_contact_id = $2,
|
||||
crm_synced_at = now(),
|
||||
-- El teléfono y el correo solo se rellenan si aquí faltaban: son
|
||||
-- las llaves de identidad y pisarlas puede fusionar dos personas.
|
||||
phone = COALESCE(phone, $3),
|
||||
phone_e164 = COALESCE(phone_e164, $4),
|
||||
email = COALESCE(email, $5),
|
||||
-- El nombre solo se completa si el de aquí está vacío: alguien pudo
|
||||
-- corregirlo en la plataforma y el CRM trae 56 % de apellidos.
|
||||
name = CASE WHEN btrim(name) = '' THEN $6 ELSE name END,
|
||||
crm_source = $7, attr_session_source = $8, attr_medium = $9,
|
||||
attr_campaign = $10, attr_campaign_id = $11, attr_utm_source = $12,
|
||||
attr_utm_medium = $13, attr_utm_content = $14, attr_ad_id = $15,
|
||||
attr_referrer = $16, crm_tags = $17, crm_date_added = $18::timestamptz
|
||||
WHERE id = $1`,
|
||||
[existente.id, c.id, c.phone ?? null, tel, email, nombre, ...cols]
|
||||
);
|
||||
return "updated";
|
||||
}
|
||||
|
||||
try {
|
||||
await tx.query(
|
||||
`INSERT INTO clients
|
||||
(business_id, name, email, phone, phone_e164, crm_contact_id, crm_synced_at,
|
||||
crm_source, attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id, attr_referrer,
|
||||
crm_tags, crm_date_added, birth_date)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,now(),
|
||||
$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::timestamptz,$19::date)`,
|
||||
[
|
||||
businessId,
|
||||
nombre,
|
||||
email,
|
||||
c.phone ?? null,
|
||||
tel,
|
||||
c.id,
|
||||
...cols,
|
||||
c.dateOfBirth ?? null,
|
||||
]
|
||||
);
|
||||
return "created";
|
||||
} catch (e: any) {
|
||||
// El índice único de teléfono ganó una carrera: otra clienta con el mismo
|
||||
// número entró entre la consulta y este INSERT. Se ancla al existente en
|
||||
// vez de perder el contacto.
|
||||
if (e.code === "23505" && tel) {
|
||||
await tx.query(
|
||||
`UPDATE clients SET crm_contact_id = $3, crm_synced_at = now()
|
||||
WHERE business_id = $1 AND phone_e164 = $2 AND crm_contact_id IS NULL`,
|
||||
[businessId, tel, c.id]
|
||||
);
|
||||
return "updated";
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import {
|
||||
buscarConversaciones,
|
||||
mensajesDeConversacion,
|
||||
obtenerConversacion,
|
||||
normalizarTipo,
|
||||
type CrmConversation,
|
||||
type CrmMessage,
|
||||
} from "./conversations.ts";
|
||||
|
||||
/**
|
||||
* Fecha del CRM → `Date`.
|
||||
*
|
||||
* La API mezcla formatos: `lastMessageDate` llega como epoch en milisegundos y
|
||||
* `dateAdded` como ISO. Aceptar los dos aquí evita repartir esa comprobación por
|
||||
* todos los sitios que guardan una fecha.
|
||||
*/
|
||||
function fecha(v: string | number | undefined | null): Date | null {
|
||||
if (v == null) return null;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function upsertConversacion(
|
||||
businessId: number,
|
||||
c: CrmConversation
|
||||
): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO conversations
|
||||
(business_id, crm_conversation_id, crm_contact_id, contact_name,
|
||||
last_message_type, last_message_body, last_message_at, unread_count,
|
||||
client_id, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||||
(SELECT id FROM clients
|
||||
WHERE business_id = $1 AND crm_contact_id = $3 AND deleted_at IS NULL
|
||||
LIMIT 1),
|
||||
now())
|
||||
ON CONFLICT (business_id, crm_conversation_id) DO UPDATE SET
|
||||
crm_contact_id = COALESCE(EXCLUDED.crm_contact_id, conversations.crm_contact_id),
|
||||
-- Ni el nombre ni el canal se degradan.
|
||||
--
|
||||
-- MEDIDO: GET /conversations/{id} NO devuelve el nombre del contacto ni
|
||||
-- un canal reconocible; eso solo viene del buscador. Sincronizar un hilo
|
||||
-- por su id sobrescribia un nombre bueno con "Sin nombre" y el canal con
|
||||
-- "Desconocido". Un dato pobre no puede pisar a uno que ya se tenia.
|
||||
contact_name = CASE
|
||||
WHEN EXCLUDED.contact_name = 'Sin nombre'
|
||||
THEN COALESCE(conversations.contact_name, EXCLUDED.contact_name)
|
||||
ELSE EXCLUDED.contact_name
|
||||
END,
|
||||
last_message_type = CASE
|
||||
WHEN EXCLUDED.last_message_type = 'Desconocido'
|
||||
THEN COALESCE(conversations.last_message_type, EXCLUDED.last_message_type)
|
||||
ELSE EXCLUDED.last_message_type
|
||||
END,
|
||||
last_message_body = COALESCE(EXCLUDED.last_message_body, conversations.last_message_body),
|
||||
last_message_at = COALESCE(EXCLUDED.last_message_at, conversations.last_message_at),
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
-- El enlace con la clienta solo se RELLENA, nunca se borra: si la
|
||||
-- sincronizacion de contactos todavia no ha corrido, client_id es NULL,
|
||||
-- y pisarlo con NULL mas tarde perderia un enlace ya resuelto.
|
||||
client_id = COALESCE(conversations.client_id, EXCLUDED.client_id),
|
||||
synced_at = now()
|
||||
RETURNING id`,
|
||||
[
|
||||
businessId,
|
||||
c.id,
|
||||
c.contactId ?? null,
|
||||
c.fullName || c.contactName || "Sin nombre",
|
||||
normalizarTipo(c.lastMessageType),
|
||||
c.lastMessageBody ?? null,
|
||||
fecha(c.lastMessageDate),
|
||||
c.unreadCount ?? 0,
|
||||
]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
export async function upsertMensaje(
|
||||
businessId: number,
|
||||
conversationId: number,
|
||||
m: CrmMessage
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO messages
|
||||
(business_id, conversation_id, crm_message_id, crm_contact_id,
|
||||
direction, channel, channel_raw, body, status, sent_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (business_id, crm_message_id) DO UPDATE SET
|
||||
-- El CRM es el dueno del historico: aqui se reescribe desde el, nunca se
|
||||
-- edita. Solo cambian cuerpo y estado; el resto es inmutable.
|
||||
body = EXCLUDED.body,
|
||||
status = EXCLUDED.status`,
|
||||
[
|
||||
businessId,
|
||||
conversationId,
|
||||
m.id,
|
||||
m.contactId ?? null,
|
||||
m.direction === "outbound" ? "outbound" : "inbound",
|
||||
normalizarTipo(m.messageType),
|
||||
m.messageType ?? null,
|
||||
m.body ?? null,
|
||||
m.status ?? null,
|
||||
fecha(m.dateAdded),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResumenSyncConv {
|
||||
conversaciones: number;
|
||||
mensajes: number;
|
||||
runId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Espeja UNA conversación con todos sus mensajes.
|
||||
*
|
||||
* Pagina hasta 20 vueltas de 100: son 2 000 mensajes por hilo, muy por encima de
|
||||
* cualquier conversación real, y el tope existe para que un `nextPage` que nunca
|
||||
* deje de ser `true` no cuelgue la petición para siempre.
|
||||
*/
|
||||
export async function sincronizarConversacion(
|
||||
businessId: number,
|
||||
crmConversationId: string
|
||||
): Promise<{ conversacion: number; mensajes: number }> {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const c = await obtenerConversacion(ctx, crmConversationId);
|
||||
if (!c) throw { status: 404, error: "Esa conversación no existe en Bucéfalo CRM" };
|
||||
|
||||
// El endpoint de una conversación suelta no trae el nombre del contacto. Si
|
||||
// la clienta ya está en la plataforma, se usa el suyo: es mejor dato que el
|
||||
// relleno, y evita que la bandeja muestre "Sin nombre" para alguien conocido.
|
||||
let nombre = c.fullName || c.contactName;
|
||||
if (!nombre && c.contactId) {
|
||||
const { rows } = await pool.query<{ name: string }>(
|
||||
`SELECT name FROM clients
|
||||
WHERE business_id = $1 AND crm_contact_id = $2 AND deleted_at IS NULL LIMIT 1`,
|
||||
[businessId, c.contactId]
|
||||
);
|
||||
nombre = rows[0]?.name;
|
||||
}
|
||||
|
||||
const convId = await upsertConversacion(businessId, {
|
||||
...c,
|
||||
id: crmConversationId,
|
||||
fullName: nombre,
|
||||
});
|
||||
|
||||
let cursor: string | undefined;
|
||||
let total = 0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { mensajes, lastMessageId, hayMas } = await mensajesDeConversacion(
|
||||
ctx,
|
||||
crmConversationId,
|
||||
{ limit: 100, lastMessageId: cursor }
|
||||
);
|
||||
for (const m of mensajes) {
|
||||
await upsertMensaje(businessId, convId, m);
|
||||
total++;
|
||||
}
|
||||
if (!hayMas || !lastMessageId || !mensajes.length) break;
|
||||
cursor = lastMessageId;
|
||||
}
|
||||
|
||||
// El canal del hilo se deduce de su ultimo mensaje real.
|
||||
//
|
||||
// GET /conversations/{id} no devuelve un canal reconocible, pero los mensajes
|
||||
// que acabamos de traer si lo traen. Deducirlo de ahi es mejor que dejar
|
||||
// "Desconocido" en la bandeja, y no cuesta ni una peticion mas.
|
||||
// Se excluyen las actividades: son notas que el propio CRM escribe en el hilo,
|
||||
// no un canal por el que hablar con la clienta.
|
||||
await pool.query(
|
||||
`UPDATE conversations c
|
||||
SET last_message_type = COALESCE(
|
||||
(SELECT m.channel FROM messages m
|
||||
WHERE m.conversation_id = c.id AND m.channel <> 'Actividad'
|
||||
ORDER BY m.sent_at DESC NULLS LAST, m.id DESC LIMIT 1),
|
||||
c.last_message_type)
|
||||
WHERE c.id = $1 AND c.last_message_type = 'Desconocido'`,
|
||||
[convId]
|
||||
);
|
||||
|
||||
return { conversacion: convId, mensajes: total };
|
||||
}
|
||||
|
||||
/** Espeja las conversaciones más recientes con sus últimos mensajes. */
|
||||
export async function sincronizarConversaciones(
|
||||
businessId: number,
|
||||
opts: { limit?: number; userId?: number | null } = {}
|
||||
): Promise<ResumenSyncConv> {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const { rows: run } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||
VALUES ($1,'conversations','pull',$2) RETURNING id`,
|
||||
[businessId, opts.userId ?? null]
|
||||
);
|
||||
const runId = run[0].id;
|
||||
|
||||
try {
|
||||
const { conversations } = await buscarConversaciones(ctx, { limit: opts.limit ?? 50 });
|
||||
let mensajes = 0;
|
||||
for (const c of conversations) {
|
||||
const convId = await upsertConversacion(businessId, c);
|
||||
const { mensajes: ms } = await mensajesDeConversacion(ctx, c.id, { limit: 50 });
|
||||
for (const m of ms) {
|
||||
await upsertMensaje(businessId, convId, m);
|
||||
mensajes++;
|
||||
}
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs
|
||||
SET status='ok', finished_at=now(), fetched=$2, created=$3
|
||||
WHERE id = $1`,
|
||||
[runId, conversations.length, mensajes]
|
||||
);
|
||||
return { conversaciones: conversations.length, mensajes, runId };
|
||||
} catch (e: any) {
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs SET status='error', finished_at=now(), error=$2 WHERE id=$1`,
|
||||
[runId, String(e?.error ?? e?.message ?? e).slice(0, 500)]
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import { obtenerContacto } from "./contacts.ts";
|
||||
import { upsertClienteDesdeCrm } from "./syncContacts.ts";
|
||||
import { sincronizarConversacion } from "./syncConversations.ts";
|
||||
import { obtenerMensaje } from "./conversations.ts";
|
||||
import { proyectarCita } from "./syncAppointments.ts";
|
||||
import { publicarServicio } from "./services.ts";
|
||||
|
||||
export const ENTIDADES = ["contacto", "conversacion", "mensaje", "cita", "servicio"] as const;
|
||||
export type Entidad = (typeof ENTIDADES)[number];
|
||||
|
||||
export function esEntidad(v: string): v is Entidad {
|
||||
return (ENTIDADES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export interface ResultadoUno {
|
||||
entidad: Entidad;
|
||||
id: string;
|
||||
accion: string;
|
||||
detalle: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza UNA entidad por su identificador.
|
||||
*
|
||||
* La dirección no es la misma para las cinco, y no es un capricho:
|
||||
*
|
||||
* - **contacto, conversación y mensaje se TRAEN**: el CRM es su dueño. Es donde
|
||||
* viven la deduplicación y las automatizaciones.
|
||||
* - **cita y servicio se EMPUJAN.** MEDIDO (hallazgos 29 y 32): el calendario
|
||||
* del CRM tiene UNA cita en dos años y su catálogo de servicios está vacío.
|
||||
* No hay nada que arrastrar; la agenda y el catálogo nacen en la plataforma.
|
||||
*
|
||||
* Si algún día el spa empieza a agendar dentro del CRM, esa premisa se cae y
|
||||
* habrá que decidir cuál de los dos manda cuando difieran. Conviene decidirlo
|
||||
* antes de que ocurra.
|
||||
*/
|
||||
export async function sincronizarPorId(
|
||||
businessId: number,
|
||||
entidad: Entidad,
|
||||
id: string
|
||||
): Promise<ResultadoUno> {
|
||||
switch (entidad) {
|
||||
case "contacto": {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const c = await obtenerContacto(ctx, id);
|
||||
if (!c) throw { status: 404, error: "Ese contacto no existe en Bucéfalo CRM" };
|
||||
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||
return {
|
||||
entidad,
|
||||
id,
|
||||
accion: r === "created" ? "creado" : r === "updated" ? "actualizado" : "sin cambios",
|
||||
detalle: { resultado: r },
|
||||
};
|
||||
}
|
||||
|
||||
case "conversacion": {
|
||||
const r = await sincronizarConversacion(businessId, id);
|
||||
return { entidad, id, accion: "espejada", detalle: r };
|
||||
}
|
||||
|
||||
case "mensaje": {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const m = await obtenerMensaje(ctx, id);
|
||||
if (!m) throw { status: 404, error: "Ese mensaje no existe en Bucéfalo CRM" };
|
||||
if (!m.conversationId) {
|
||||
throw {
|
||||
status: 409,
|
||||
error: "El mensaje no dice a qué conversación pertenece, y sin ella no se puede guardar",
|
||||
};
|
||||
}
|
||||
// Se sincroniza el hilo entero: `messages.conversation_id` es obligatorio,
|
||||
// así que un mensaje suelto sin su conversación no tiene dónde ir.
|
||||
const r = await sincronizarConversacion(businessId, m.conversationId);
|
||||
return {
|
||||
entidad,
|
||||
id,
|
||||
accion: "espejado con su hilo",
|
||||
detalle: { ...r, conversacion_crm: m.conversationId },
|
||||
};
|
||||
}
|
||||
|
||||
case "cita": {
|
||||
const n = Number(id);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw { status: 400, error: "El identificador de la cita es el numérico de la plataforma" };
|
||||
}
|
||||
const r = await proyectarCita(businessId, n);
|
||||
return { entidad, id, accion: "empujada", detalle: r as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
case "servicio": {
|
||||
const n = Number(id);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw {
|
||||
status: 400,
|
||||
error: "El identificador del servicio es el numérico de la plataforma",
|
||||
};
|
||||
}
|
||||
const r = await publicarServicio(businessId, n);
|
||||
return {
|
||||
entidad,
|
||||
id,
|
||||
accion: r.yaEstaba ? "ya estaba publicado" : "publicado",
|
||||
detalle: r as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { despachar } from "./outbox.ts";
|
||||
|
||||
let corriendo = false;
|
||||
|
||||
/**
|
||||
* Vacía la bandeja de salida cada cierto tiempo.
|
||||
*
|
||||
* Es un intervalo y no una cola de verdad a propósito: hay un solo negocio, el
|
||||
* CRM estrangula a ~1 petición cada 0.65 s, y el volumen real son unas pocas
|
||||
* citas al día. Redis y un worker aparte serían infraestructura sin problema
|
||||
* que resolver. Cuando haya varios negocios habrá que revisarlo, porque el
|
||||
* estrangulamiento es **por token** y estos despachos serían secuenciales.
|
||||
*
|
||||
* La guarda `corriendo` evita que dos vueltas se solapen: dos escrituras
|
||||
* concurrentes sobre la misma cita corren contra una base que ya cambió.
|
||||
*/
|
||||
export function arrancarWorker(intervaloMs = 60_000): NodeJS.Timeout {
|
||||
const tick = async () => {
|
||||
if (corriendo) return;
|
||||
corriendo = true;
|
||||
try {
|
||||
const { rows } = await pool.query<{ business_id: number }>(
|
||||
`SELECT DISTINCT business_id FROM crm_outbox WHERE status = 'pendiente'`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const res = await despachar(r.business_id, 25);
|
||||
if (res.tomadas) {
|
||||
console.log(
|
||||
`[crm-worker] negocio ${r.business_id}: ${res.confirmadas} confirmadas, ` +
|
||||
`${res.fallidas} fallidas, ${res.indeterminadas} indeterminadas`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Un fallo aquí no debe tumbar el servidor: la bandeja seguirá llena y el
|
||||
// panel de clientes lo enseña, que es justo para lo que existe.
|
||||
console.error("[crm-worker]", (e as Error).message);
|
||||
} finally {
|
||||
corriendo = false;
|
||||
}
|
||||
};
|
||||
|
||||
const t = setInterval(tick, intervaloMs);
|
||||
// No mantiene vivo el proceso: si el servidor se cierra, no hay que esperarlo.
|
||||
t.unref?.();
|
||||
return t;
|
||||
}
|
||||
Reference in New Issue
Block a user