# Sincronización por id de las cinco entidades — Plan de implementación > **Para trabajadores agénticos:** SUB-SKILL OBLIGATORIA: usa `superpowers:subagent-driven-development` > (recomendado) o `superpowers:executing-plans`. **Tacha las casillas al completarlas.** **Objetivo:** poder traer o empujar una entidad concreta de Bucéfalo CRM **por su identificador** — contactos, conversaciones, mensajes, citas y servicios— en vez de depender solo del arrastre masivo de contactos que existe hoy. **Arquitectura:** hoy la única sincronización es `POST /api/crm/sync/contacts`, que trae los 3 210 contactos enteros (~22 s). Este plan añade un punto de entrada único `POST /api/crm/sync/:entidad/:id` que resuelve una sola entidad, y el espejo persistido de conversaciones y mensajes —cuyas tablas existen desde `002_crm.sql` y **nadie escribe**, de modo que hoy la bandeja consulta el CRM en vivo en cada carga. Para citas y servicios la dirección útil es la contraria: **empujar**, porque el calendario del CRM está prácticamente vacío y su catálogo de servicios lo está del todo. **Stack:** TypeScript + Express 4 + `pg` sobre PostgreSQL 16; `node:test`; React 18 + React Query. **Depende de:** `docs/superpowers/plans/2026-08-29-multitenant-credenciales-crm.md`. **No empieces este plan sin aquel terminado**: todas las funciones de aquí reciben `CrmCtx`, que produce la Tarea 3 de aquel plan. **Especificación de origen:** `platform/crm/HALLAZGOS.md`, hallazgos **1-37**. Los del 24 al 37 se midieron específicamente para este plan, con dos sondeos de solo lectura (`crm-spike-lectura-id.ts`, `crm-spike-calendarios.ts`) y uno de permisos que no crea nada (`crm-spike-permisos.ts`). --- ## Restricciones globales - **Lo medido gana a lo documentado.** Es la regla que produjo los 37 hallazgos. Donde la documentación oficial y la observación chocan, manda la observación, y el choque se anota. - **Nunca se acepta un `200` como prueba.** Toda escritura se verifica releyendo. - **Cabecera `Version`:** `2021-07-28` para contactos, conversaciones, mensajes, oportunidades y subcuentas; **`v3` para todo `/calendars/`**. Equivocarla es `400`. Ya lo resuelve `client.ts` automáticamente por el prefijo de la ruta; no lo cambies «para alinearlo con la documentación», que dice otra cosa y está desactualizada. - **Tres convenciones de paginación distintas en la misma API**, y confundirlas devuelve resultados incompletos sin error: contactos usan `searchAfter` (tomado del **último elemento** de la página, no de la raíz); conversaciones usan `startAfterDate`; mensajes usan `lastMessageId` + `nextPage`. - **Toda consulta filtra por `business_id`.** - **Texto visible y errores de la API en español.** Al CRM se le llama «Bucéfalo CRM». - **No hacer commits salvo que se pidan.** Verificación por tarea: `npm run typecheck` + `npm run test:platform`. --- ## Estructura de archivos | Archivo | Responsabilidad | |---|---| | `platform/crm/client.ts` | **Modificar.** Leer las cabeceras `X-RateLimit-*` y ajustar el estrangulador con dato real. | | `platform/crm/conversations.ts` | **Crear.** Leer conversaciones y mensajes por id. Sale de `messages.ts`, que se queda con el envío. | | `platform/crm/calendars.ts` | **Crear.** Calendarios, y citas por id y por rango. | | `platform/crm/services.ts` | **Crear.** Catálogo de servicios y personal de la subcuenta. | | `platform/crm/syncConversations.ts` | **Crear.** Espejo persistido de conversaciones y mensajes. | | `platform/crm/syncOne.ts` | **Crear.** El despachador: entidad + id → función correspondiente. | | `platform/routes/crm.ts` | **Modificar.** `POST /sync/:entidad/:id` y `POST /sync/conversations`. | | `platform/db/migrations/004_sync_por_id.sql` | **Crear.** Índices y columnas que faltan para el espejo. | | `platform/test/syncOne.test.ts` | **Crear.** | | `platform/test/syncConversations.test.ts` | **Crear.** | | `src/pages/MessagesPage.tsx` | **Modificar.** Leer del espejo. | | `src/components/CrmSyncPanel.tsx` | **Modificar.** Buscar por id. | --- ## Tarea 1: Estrangulador con dato real, no con estimación **Archivos:** - Modificar: `platform/crm/client.ts` - Modificar: `platform/crm/client.test.ts` **Interfaces:** - Consume: `esperaDeToken`, `registrarPeticion` (Tarea 4 del plan de multi-tenancy). - Produce: `limitesDe(token: string): Limites | null` con `{ max, ventanaMs, restantes, diarioRestante }`. **Por qué:** el comentario de `client.ts` dice «~1 petición cada 0.65 s por token» y fija `MIN_INTERVAL_MS = 650`. **Medido (hallazgo 37)**, las cabeceras reales dicen `x-ratelimit-max: 100` en `x-ratelimit-interval-milliseconds: 10000`, o sea **1 cada 100 ms**. El cliente va **6,5 veces por debajo** de lo permitido. Con la sincronización de contactos en primer plano, eso son 22 s que podrían ser ~4. Y hasta ahora nadie leía esas cabeceras: el 650 era una estimación observada, no una cuota conocida. **Decisión deliberada:** no se baja a 100 ms de golpe. Se baja a **150 ms** —un 50 % de margen sobre la cuota— y se **aprende de las cabeceras**: si el CRM dice otra cosa, gana el CRM. Fijar el valor teórico exacto deja el sistema sin colchón para las peticiones que el propio worker hace en paralelo. - [ ] **Paso 1: Escribir la prueba que falla** ```ts // añadir a platform/crm/client.test.ts import { anotarLimites, limitesDe, intervaloDe } from "./client.ts"; test("las cabeceras del CRM mandan sobre el valor por defecto", () => { anotarLimites("tok-1", new Headers({ "x-ratelimit-max": "100", "x-ratelimit-interval-milliseconds": "10000", "x-ratelimit-remaining": "94", "x-ratelimit-daily-remaining": "199970", })); const l = limitesDe("tok-1"); assert.equal(l?.max, 100); assert.equal(l?.ventanaMs, 10000); // 10000/100 = 100 ms teóricos, +50 % de margen = 150 assert.equal(intervaloDe("tok-1"), 150); }); test("sin cabeceras se usa el intervalo conservador por defecto", () => { assert.equal(intervaloDe("tok-sin-datos"), 650); }); test("cuando quedan pocas peticiones en la ventana, se frena", () => { anotarLimites("tok-2", new Headers({ "x-ratelimit-max": "100", "x-ratelimit-interval-milliseconds": "10000", "x-ratelimit-remaining": "3", })); assert.ok(intervaloDe("tok-2") > 150, "con la ventana casi agotada hay que espaciar más"); }); ``` - [ ] **Paso 2: Correr y verificar que falla** Ejecuta: `node --import tsx --test platform/crm/client.test.ts` Esperado: FALLA — esos tres símbolos no existen. - [ ] **Paso 3: Implementar** ```ts // en platform/crm/client.ts export interface Limites { max: number; ventanaMs: number; restantes: number; diarioRestante: number | null; } const limitesPorToken = new Map(); /** Intervalo conservador mientras el CRM no diga la cuota real. */ export const MIN_INTERVAL_MS = 650; /** Margen sobre la cuota: no se corre al límite exacto. */ const MARGEN = 1.5; 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. * * MEDIDO (hallazgo 37): la cuota real es 100 peticiones por 10 s, o sea 1 cada * 100 ms — 6,5× más de lo que el cliente asumía. Se toma esa cuota con un 50 % * de margen, no al límite: el worker y una sincronización manual pueden coincidir. * Si la ventana está casi agotada se espacia hasta que se renueve, que es 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; } ``` Cambia `esperaDeToken` para que use `intervaloDe(token)` en lugar de la constante, y en `crmRequest`, después de recibir la respuesta, añade `anotarLimites(token, res.headers);` **antes** de leer el cuerpo. - [ ] **Paso 4: Correr y verificar que pasa** Ejecuta: `node --import tsx --test platform/crm/client.test.ts` Esperado: las 3 pruebas nuevas pasan y las 2 anteriores siguen pasando. - [ ] **Paso 5: Medir la mejora contra el CRM real** Ejecuta: `node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts` Esperado: sigue imprimiendo las cabeceras. Después, con el servidor arriba, lanza la sincronización de contactos y compara: **antes ~22 s**. Anota el nuevo tiempo en `HALLAZGOS.md`. Si no baja de forma apreciable, el cuello de botella no era el estrangulador y hay que decirlo. --- ## Tarea 2: Lectura por id de conversaciones y mensajes **Archivos:** - Crear: `platform/crm/conversations.ts` - Modificar: `platform/crm/messages.ts` (se queda solo con el envío) - Crear: `platform/crm/conversations.test.ts` **Interfaces:** - Consume: `CrmCtx`, `crmRequest`. - Produce: - `obtenerConversacion(ctx: CrmCtx, id: string): Promise` - `conversacionesDeContacto(ctx: CrmCtx, contactId: string): Promise` - `buscarConversaciones(ctx: CrmCtx, opts?: { limit?: number; startAfterDate?: number }): Promise<{ conversations: CrmConversation[]; total: number }>` - `mensajesDeConversacion(ctx: CrmCtx, conversationId: string, opts?: { limit?: number; lastMessageId?: string }): Promise<{ mensajes: CrmMessage[]; lastMessageId: string | null; hayMas: boolean }>` - `obtenerMensaje(ctx: CrmCtx, id: string): Promise` **Medido, y hay que respetarlo:** - `GET /conversations/{id}` devuelve los campos **en la raíz**, sin envoltorio (hallazgo 24). - `GET /conversations/search` sí admite `contactId` **y** `id` como filtros (hallazgo 25). - `GET /conversations/{id}/messages` devuelve **`{ messages: { messages: [...], lastMessageId, nextPage } }`** — anidado dos niveles (hallazgo 26). - `GET /conversations/messages/{id}` funciona y devuelve el mensaje en la raíz (hallazgo 27). - **`type` es numérico en `/conversations/{id}` y una cadena `TYPE_SMS` en el buscador.** Es la misma información con dos formas según el endpoint. Normalízalo al leer. - [ ] **Paso 1: Escribir la prueba que falla** ```ts // platform/crm/conversations.test.ts 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", () => { 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"); }); 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); }); ``` - [ ] **Paso 2: Correr y verificar que falla** Ejecuta: `npm run test:platform` Esperado: FALLA — no existe `./conversations.ts`. - [ ] **Paso 3: Implementar** ```ts // platform/crm/conversations.ts import { crmRequest } 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 viene como cadena (`TYPE_SMS`) desde el buscador y como número desde * `GET /conversations/{id}`. Es la misma información con dos formas, y cruzarlas * produce una bandeja que etiqueta mal los hilos. */ const POR_NUMERO: Record = { 1: "Phone", 2: "Email", 3: "FB", 4: "Review", 5: "SMS", }; export function normalizarTipo(t: string | number | undefined): string { if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido"; if (typeof t === "string" && t) return t.replace(/^TYPE_/, "").replace(/_/g, " "); return "Desconocido"; } /** MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...] } }`. */ 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 }; } export async function obtenerConversacion( ctx: CrmCtx, id: string ): Promise { try { // MEDIDO (hallazgo 24): los campos vienen en la raíz, sin envoltorio. return await crmRequest("GET", `/conversations/${id}`, { token: ctx.token }); } catch (e: any) { if (e?.status === 404) return null; throw e; } } export async function conversacionesDeContacto( ctx: CrmCtx, contactId: string ): Promise { const r = await crmRequest("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("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("GET", `/conversations/${conversationId}/messages`, { token: ctx.token, query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId }, }); return formaDeMensajes(r); } export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise { try { const r = await crmRequest("GET", `/conversations/messages/${id}`, { token: ctx.token }); return (r?.message ?? r) as CrmMessage; } catch (e: any) { if (e?.status === 404) return null; throw e; } } ``` Deja en `platform/crm/messages.ts` **solo** `enviarCorreo`, y actualiza los imports de `platform/routes/messages.ts`. - [ ] **Paso 4: Correr y verificar que pasa** Ejecuta: `npm run typecheck && npm run test:platform` - [ ] **Paso 5: Comprobar contra el CRM real** Ejecuta: `node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts` Esperado: sigue dando 10 rutas en verde. Ese sondeo ejerce exactamente estas rutas. --- ## Tarea 3: Espejo persistido de conversaciones y mensajes **Archivos:** - Crear: `platform/db/migrations/004_sync_por_id.sql` - Crear: `platform/crm/syncConversations.ts` - Crear: `platform/test/syncConversations.test.ts` **Interfaces:** - Consume: `obtenerConversacion`, `buscarConversaciones`, `mensajesDeConversacion` (Tarea 2). - Produce: - `sincronizarConversaciones(businessId: number, opts?: { limit?: number; userId?: number }): Promise` - `sincronizarConversacion(businessId: number, crmConversationId: string): Promise<{ conversacion: number; mensajes: number }>` **Por qué esto existe:** las tablas `conversations` y `messages` se declararon en `002_crm.sql` y **nadie escribe en ellas**. La bandeja consulta el CRM en vivo en cada carga, lo que significa que sin conexión no hay bandeja, que cada visita gasta cuota, y que no se puede buscar ni cruzar un hilo con una clienta sin volver a salir a la red. El espejo lo arregla, y las tablas ya estaban pensadas para él. - [ ] **Paso 1: Escribir la migración** ```sql -- platform/db/migrations/004_sync_por_id.sql -- El espejo de conversaciones ya tenía tablas (002_crm.sql) pero nada que las -- escribiera. Aquí se añade lo que faltaba para poder llenarlas y consultarlas. -- De qué contacto del CRM es cada mensaje, para poder cruzarlo con la clienta -- sin pasar por la conversación. ALTER TABLE messages ADD COLUMN crm_contact_id text, -- Cuerpo normalizado del canal: la API lo devuelve como número o como cadena -- según el endpoint (hallazgo 24 vs buscador). ADD COLUMN channel_raw text; CREATE INDEX messages_crm_contact ON messages (business_id, crm_contact_id) WHERE crm_contact_id IS NOT NULL; -- Cursor de la última sincronización de conversaciones, para poder continuar -- donde se quedó en vez de releer las 3 213 cada vez. ALTER TABLE crm_connections ADD COLUMN conv_cursor_date bigint; -- `crm_sync_runs.kind` gana dos valores; la columna es text sin CHECK, así que -- no hace falta migrar nada: se documenta y ya. COMMENT ON COLUMN crm_sync_runs.kind IS 'contacts | appointments | conversations | one — "one" es la sincronización de una sola entidad por id'; ``` - [ ] **Paso 2: Escribir la prueba que falla** ```ts // platform/test/syncConversations.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { pool } from "../db/pool.ts"; import { resetDb, crearNegocio } from "./helpers.ts"; import { upsertConversacion, upsertMensaje } from "../crm/syncConversations.ts"; test("upsertConversacion es idempotente: dos veces no duplica", async () => { await resetDb(); const b = await crearNegocio(); const conv = { id: "conv-1", contactId: "c-1", fullName: "Ana", lastMessageBody: "hola", lastMessageType: "TYPE_SMS", lastMessageDate: 1756000000000, unreadCount: 2, }; const id1 = await upsertConversacion(b.id, conv as any); const id2 = await upsertConversacion(b.id, conv as any); assert.equal(id1, id2); const { rows } = await pool.query( `SELECT count(*)::int AS n FROM conversations WHERE business_id = $1`, [b.id] ); assert.equal(rows[0].n, 1); }); test("upsertConversacion enlaza con la clienta local por crm_contact_id", async () => { await resetDb(); const b = await crearNegocio(); const { rows: cl } = await pool.query( `INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-9') RETURNING id`, [b.id] ); await upsertConversacion(b.id, { id: "conv-9", contactId: "c-9", fullName: "Ana" } as any); const { rows } = await pool.query( `SELECT client_id FROM conversations WHERE business_id = $1 AND crm_conversation_id = 'conv-9'`, [b.id] ); assert.equal(rows[0].client_id, cl[0].id); }); test("upsertMensaje no duplica el mismo crm_message_id", async () => { await resetDb(); const b = await crearNegocio(); const convId = await upsertConversacion(b.id, { id: "conv-2", contactId: "c-2" } as any); const m = { id: "msg-1", body: "hola", direction: "inbound", messageType: "TYPE_SMS" }; await upsertMensaje(b.id, convId, m as any); await upsertMensaje(b.id, convId, m as any); const { rows } = await pool.query( `SELECT count(*)::int AS n FROM messages WHERE business_id = $1`, [b.id] ); assert.equal(rows[0].n, 1); }); ``` - [ ] **Paso 3: Correr y verificar que falla** Ejecuta: `npm run pg:migrate && npm run test:platform` Esperado: la migración se aplica; las pruebas fallan por falta de `syncConversations.ts`. - [ ] **Paso 4: Implementar** ```ts // platform/crm/syncConversations.ts 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 → timestamptz. Acepta ISO y epoch en ms, que la API mezcla. */ function fecha(v: string | number | undefined | null): Date | null { if (v == null) return null; const d = typeof v === "number" ? new Date(v) : new Date(v); return isNaN(d.getTime()) ? null : d; } export async function upsertConversacion( businessId: number, c: CrmConversation ): Promise { 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 = EXCLUDED.crm_contact_id, contact_name = EXCLUDED.contact_name, last_message_type = EXCLUDED.last_message_type, last_message_body = EXCLUDED.last_message_body, last_message_at = EXCLUDED.last_message_at, unread_count = EXCLUDED.unread_count, -- El enlace con la clienta solo se rellena, nunca se borra: si la -- sincronización de contactos aún no ha corrido, `client_id` es NULL y -- pisarlo con NULL más tarde perdería 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 { 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 dueño del histórico: aquí se reescribe, nunca se edita. 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 ResumenSync { conversaciones: number; mensajes: number; runId: number; } /** Trae una conversación concreta con todos sus mensajes. */ 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" }; const convId = await upsertConversacion(businessId, { ...c, id: crmConversationId }); 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) break; cursor = lastMessageId; } return { conversacion: convId, mensajes: total }; } /** Trae las conversaciones más recientes y sus mensajes. */ export async function sincronizarConversaciones( businessId: number, opts: { limit?: number; userId?: number } = {} ): Promise { 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?.message ?? e).slice(0, 500)] ); throw e; } } ``` - [ ] **Paso 5: Correr y verificar que pasa** Ejecuta: `npm run typecheck && npm run test:platform` Esperado: las 3 pruebas nuevas pasan. --- ## Tarea 4: El despachador «sincroniza esto por su id» **Archivos:** - Crear: `platform/crm/syncOne.ts` - Crear: `platform/test/syncOne.test.ts` - Modificar: `platform/routes/crm.ts` **Interfaces:** - Consume: `obtenerContacto` (contacts.ts), `sincronizarConversacion` (Tarea 3), `obtenerMensaje` (Tarea 2), `upsertClienteDesdeCrm` (syncContacts.ts). - Produce: `sincronizarPorId(businessId, entidad: Entidad, id: string)` con `type Entidad = "contacto" | "conversacion" | "mensaje" | "cita" | "servicio"`. - Produce: `POST /api/crm/sync/:entidad/:id`. - [ ] **Paso 1: Escribir la prueba que falla** ```ts // platform/test/syncOne.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { esEntidad, ENTIDADES } from "../crm/syncOne.ts"; test("esEntidad acepta solo las cinco entidades del encargo", () => { for (const e of ENTIDADES) assert.ok(esEntidad(e)); assert.equal(esEntidad("cliente"), false); assert.equal(esEntidad(""), false); assert.equal(esEntidad("../../etc/passwd"), false); }); test("ENTIDADES son exactamente las cinco, ni una más", () => { assert.deepEqual([...ENTIDADES].sort(), ["cita", "contacto", "conversacion", "mensaje", "servicio"]); }); ``` - [ ] **Paso 2: Correr y verificar que falla** Ejecuta: `npm run test:platform` - [ ] **Paso 3: Implementar el despachador** ```ts // platform/crm/syncOne.ts 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; } /** * Sincroniza UNA entidad por su identificador. * * La dirección no es la misma para todas, y no es un capricho: * - contacto, conversación y mensaje se TRAEN: el CRM es su dueño. * - cita y servicio se EMPUJAN. MEDIDO (hallazgos 29 y 32): el calendario del * CRM tiene una sola cita en dos años y el catálogo de servicios está vacío, * así que no hay nada que arrastrar. La agenda y el catálogo nacen aquí. */ export async function sincronizarPorId( businessId: number, entidad: Entidad, id: string ): Promise { const ctx = await ctxDe(businessId); switch (entidad) { case "contacto": { 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.creado ? "creado" : "actualizado", detalle: { clientId: r.clientId } }; } case "conversacion": { const r = await sincronizarConversacion(businessId, id); return { entidad, id, accion: "espejada", detalle: r }; } case "mensaje": { 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" }; } // Se sincroniza el hilo entero: un mensaje suelto sin su conversación no // se puede guardar, porque `messages.conversation_id` es obligatorio. const r = await sincronizarConversacion(businessId, m.conversationId); return { entidad, id, accion: "espejado con su hilo", detalle: r }; } case "cita": { const r = await proyectarCita(businessId, Number(id)); return { entidad, id, accion: "empujada", detalle: r as Record }; } case "servicio": { const r = await publicarServicio(businessId, Number(id)); return { entidad, id, accion: "publicado", detalle: r as Record }; } } } ``` - [ ] **Paso 4: Añadir la ruta** En `platform/routes/crm.ts`: ```ts import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts"; import { sincronizarConversaciones } from "../crm/syncConversations.ts"; /** Sincroniza UNA entidad por su id. */ crmRouter.post( "/sync/:entidad/:id", h(async (req: AuthedRequest, res) => { const { entidad, id } = req.params; if (!esEntidad(entidad)) { err(res, 400, `Entidad no reconocida. Las válidas son: ${ENTIDADES.join(", ")}`); return; } if (!id || id.length > 64) { err(res, 400, "Identificador ausente o demasiado largo"); return; } try { res.json(await sincronizarPorId(req.user!.business_id!, entidad, id)); } catch (e: any) { if (e?.status) { err(res, e.status, e.error ?? e.message); return; } err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`); } }) ); /** Espejo de las conversaciones recientes. */ crmRouter.post( "/sync/conversations", h(async (req: AuthedRequest, res) => { try { res.json(await sincronizarConversaciones(req.user!.business_id!, { limit: Number(req.body?.limite) || 50, userId: req.user!.id, })); } catch (e: any) { if (e?.status) { err(res, e.status, e.error ?? e.message); return; } err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`); } }) ); ``` > **Ojo con el orden de las rutas.** `POST /sync/:entidad/:id` tiene dos segmentos y > `POST /sync/conversations` tiene uno, así que no chocan. Pero `POST /sync/contacts` (el masivo, ya > existente) también tiene uno: **déjalo declarado antes** que el genérico para que no haya > ambigüedad si alguien añade mañana un `/sync/:algo` de un solo segmento. - [ ] **Paso 5: Verificar** Ejecuta: `npm run typecheck && npm run test:platform` --- ## Tarea 5: Empujar citas al calendario del CRM **Archivos:** - Crear: `platform/crm/calendars.ts` - Crear: `platform/crm/calendars.test.ts` - Modificar: `platform/crm/syncAppointments.ts` **Interfaces:** - Consume: `CrmCtx`; `crm_connections.calendar_id` (plan de multi-tenancy, Tarea 2). - Produce: - `listarCalendarios(ctx: CrmCtx): Promise` - `listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]>` - `obtenerCita(ctx: CrmCtx, eventId: string): Promise` - `citasEnRango(ctx: CrmCtx, calendarId: string, desdeMs: number, hastaMs: number): Promise` - `crearCita(ctx: CrmCtx, args: AltaCita): Promise<{ id: string }>` - `isoConDesplazamiento(d: Date, tz: string): string` **Medido, y cada punto es una trampa distinta:** - **`calendars/events.write` sí está** (hallazgo 33). Esto ya no es una incógnita. - `GET /calendars/events` **exige** uno de `calendarId`, `userId` o `groupId`; sin ellos es `422` (hallazgo 28). - La **entrada** del rango va en **milisegundos epoch** y la **salida** viene en **ISO con desplazamiento** (`2026-09-03T11:00:00-06:00`). Es asimétrico. - **`POST` acepta `locationId`; el `PUT` lo rechaza con `422`.** Es la misma asimetría ya medida en contactos (hallazgo de cabeceras y trampas). No recicles el cuerpo del alta para actualizar. - El evento devuelve `appointmentStatus` **y** `appoinmentStatus` —con la errata, del lado del CRM— con el mismo valor (hallazgo 30). - [ ] **Paso 1: Escribir la prueba que falla** ```ts // platform/crm/calendars.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { isoConDesplazamiento, estadoCitaCrm } from "./calendars.ts"; 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"); }); 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"); }); ``` - [ ] **Paso 2: Correr y verificar que falla** Ejecuta: `node --import tsx --test platform/crm/calendars.test.ts` - [ ] **Paso 3: Implementar** ```ts // platform/crm/calendars.ts import { crmRequest, 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; startTime: string; // ISO con desplazamiento endTime: string; title: string; assignedUserId?: string; appointmentStatus?: string; } /** * ISO con el desplazamiento de la zona del NEGOCIO. * * El CRM acepta `2026-09-03T11:00:00-06:00` y NO milisegundos, al revés que el * filtro de rango de `/calendars/events`. Y no vale `toISOString()`: eso da 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 no 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 p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, 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: tz, 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"; return `${g("year")}-${g("month")}-${g("day")}T${g("hour")}:${g("minute")}:${g("second")}${desp}`; } /** MEDIDO: en la petición el enum es new|confirmed|cancelled|showed|noshow|invalid. */ 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 { const r = await crmRequest("GET", "/calendars/", { token: ctx.token, query: { locationId: ctx.locationId }, version: VERSION_CALENDARS, }); return r?.calendars ?? []; } /** MEDIDO (hallazgo 35): esta ruta volvió a estar disponible. Da los ids que * `staff[]` exige al crear servicios y `assignedUserId` al crear citas. */ export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> { const r = await crmRequest("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 { try { const r = await crmRequest("GET", `/calendars/events/appointments/${eventId}`, { token: ctx.token, version: VERSION_CALENDARS, }); return (r?.event ?? r?.appointment ?? r) as CrmEvent; } catch (e: any) { if (e?.status === 404) return null; throw e; } } /** MEDIDO (hallazgo 28): sin `calendarId` esto es 422. Y el rango va en ms epoch. */ export async function citasEnRango( ctx: CrmCtx, calendarId: string, desdeMs: number, hastaMs: number ): Promise { const r = await crmRequest("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("POST", "/calendars/events/appointments", { token: ctx.token, version: VERSION_CALENDARS, body: { locationId: ctx.locationId, // va en el POST y ROMPE el PUT: no reciclar 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 sus // automatizaciones encima 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. Que el CRM no rechace por su propia idea de disponibilidad. ignoreFreeSlotValidation: true, }, }); const id = r?.id ?? r?.event?.id; if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador"); return { id }; } ``` - [ ] **Paso 4: Correr y verificar que pasa** Ejecuta: `node --import tsx --test platform/crm/calendars.test.ts` - [ ] **Paso 5: Ejercerlo contra el CRM real, y limpiar** Escribe `platform/scripts/crm-spike-cita.ts` que: cree una cita en el calendario `Servicio Spa` para el contacto de prueba `WzBTBaHkNnpmjMb1Avx3`, **la relea** con `obtenerCita` para confirmar que existe y que la hora de pared coincide, y la borre con `DELETE /calendars/events/{eventId}`. Ejecuta: `node scripts/run-tsx.mjs platform/scripts/crm-spike-cita.ts` Esperado: crea, relee con la misma hora, borra. **Anota el resultado en `HALLAZGOS.md` como hallazgo 38** — es la primera escritura de calendario del proyecto. --- ## Tarea 6: Publicar servicios al catálogo del CRM **Archivos:** - Crear: `platform/crm/services.ts` - Modificar: `platform/routes/crm.ts` **Interfaces:** - Consume: `listarPersonal` (Tarea 5), `CrmCtx`. - Produce: - `catalogoDelCrm(ctx: CrmCtx): Promise` - `publicarServicio(businessId: number, serviceId: number): Promise<{ crmServiceId: string }>` **La decisión que hay que entender antes de escribir código:** «sincronizar servicios» **no puede significar traerlos**. Medido dos veces (hallazgos 6 y 32), `GET /calendars/services/catalog` devuelve `services: []`: el catálogo del CRM está vacío, y la duración y el precio los define el negocio en la plataforma. Lo único con sentido es **publicar** hacia allá. Y eso ahora es posible: `calendars.write` está (hallazgo 34) y `staff[]` —que era el impedimento— se puede rellenar desde que `GET /users/` volvió a responder (hallazgo 35). - [ ] **Paso 1: Añadir la columna de anclaje** Añade a `platform/db/migrations/004_sync_por_id.sql`: ```sql -- El servicio de la plataforma, una vez publicado en el catálogo del CRM. ALTER TABLE services ADD COLUMN crm_service_id text, ADD COLUMN crm_synced_at timestamptz; CREATE INDEX services_crm ON services (crm_service_id) WHERE crm_service_id IS NOT NULL; ``` - [ ] **Paso 2: Implementar** ```ts // platform/crm/services.ts import { pool } from "../db/pool.ts"; import { crmRequest, VERSION_CALENDARS } from "./client.ts"; import { ctxDe } from "./ctx.ts"; import { listarPersonal } from "./calendars.ts"; import type { CrmCtx } from "./ctx.ts"; export interface CrmService { id: string; name: string; slug: string; serviceDuration?: number; serviceDurationUnit?: string; } export async function catalogoDelCrm(ctx: CrmCtx): Promise { const r = await crmRequest("GET", "/calendars/services/catalog", { token: ctx.token, query: { locationId: ctx.locationId }, version: VERSION_CALENDARS, }); return r?.services ?? []; } function slugify(s: string): string { return (s || "servicio").toLowerCase().normalize("NFD") .replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "").slice(0, 60) || "servicio"; } /** * Publica un servicio de la plataforma en el catálogo del CRM. * * MEDIDO (hallazgo 34): `staff[]` con al menos un miembro es obligatorio, y el * `422` lo dice explícitamente. Se toma el primer usuario de la subcuenta si no * hay uno mejor: el catálogo no admite un servicio sin quien lo preste. */ export async function publicarServicio( businessId: number, serviceId: number ): Promise<{ crmServiceId: string }> { 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" }; const personal = await listarPersonal(ctx); 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 r = await crmRequest("POST", "/calendars/services/catalog", { token: ctx.token, version: VERSION_CALENDARS, body: { locationId: ctx.locationId, name: s.name, slug: slugify(s.name), description: s.description ?? undefined, eventColor: s.color ?? undefined, serviceDuration: Number(s.duration_min), serviceDurationUnit: "mins", staff: personal.slice(0, 1).map((p) => ({ id: p.id })), variations: [], }, }); 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"); } await pool.query( `UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`, [serviceId, crmServiceId] ); return { crmServiceId }; } ``` - [ ] **Paso 3: Verificar contra el CRM real** Con el servidor arriba: `POST /api/crm/sync/servicio/`. Esperado: `201`/`200` con el `crmServiceId`, y `GET /calendars/services/catalog` deja de devolver vacío. **Anótalo como hallazgo 39** — sería la primera escritura al catálogo. Si falla con `422` sobre `staff`, comprueba con `listarPersonal` que los ids se están mandando como `[{ id: "..." }]` y no como `["..."]`: el `422` medido dice *«staff must be an array»* sin precisar la forma de sus elementos, y esa ambigüedad es exactamente donde se pierde una tarde. - [ ] **Paso 4: Verificar el conjunto** Ejecuta: `npm run typecheck && npm run test:platform` --- ## Tarea 7: La interfaz **Archivos:** - Modificar: `src/lib/api.ts`, `src/components/CrmSyncPanel.tsx`, `src/pages/MessagesPage.tsx` - [ ] **Paso 1: Métodos de API** ```ts syncOne: (entidad: string, id: string) => request<{ entidad: string; id: string; accion: string; detalle: Record }>( `/crm/sync/${entidad}/${encodeURIComponent(id)}`, { method: "POST" } ), syncConversations: (limite = 50) => request<{ conversaciones: number; mensajes: number }>("/crm/sync/conversations", { method: "POST", body: JSON.stringify({ limite }), }), ``` - [ ] **Paso 2: Buscar por id en el panel** En `CrmSyncPanel.tsx`, añade un bloque «Traer una ficha concreta» con un `