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,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 |
|
||||
Reference in New Issue
Block a user