Files
AgendaPro/docs/superpowers/plans/2026-08-29-sincronizacion-por-id.md
T
AgendaPro DevandClaude Opus 5 6d67b23e55 feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.

## Multi-tenancy

El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.

- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
  fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
  acabaríamos mandando como credencial al CRM. La clave maestra vive en
  `CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
  `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
  parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
  nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
  el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
  defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
  ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
  100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
  POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.

Lo único de la credencial que sale del servidor es la huella de 6 caracteres.

## Consola de superadministración

`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».

Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.

## Sincronización por identificador

`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.

- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
  002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
  escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
  habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.

## Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.

47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.

110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.

## Deuda conocida, dicha sin rodeos

- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
  rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
  de esa base: no debe quedar expuesta a internet hasta endurecerla.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 15:07:20 -06:00

48 KiB
Raw Blame History

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
// 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
// en platform/crm/client.ts
export interface Limites {
  max: number;
  ventanaMs: number;
  restantes: number;
  diarioRestante: number | null;
}

const limitesPorToken = new Map<string, Limites>();

/** 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<CrmConversation | null>
    • conversacionesDeContacto(ctx: CrmCtx, contactId: string): Promise<CrmConversation[]>
    • 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<CrmMessage | null>

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

// 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
// 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<number, string> = {
  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<CrmConversation | null> {
  try {
    // MEDIDO (hallazgo 24): los campos vienen en la raíz, sin envoltorio.
    return await crmRequest<CrmConversation>("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<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);
}

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: 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<ResumenSync>
    • 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
-- 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
// 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
// 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<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    = 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<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 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<ResumenSync> {
  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

// 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
// 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<string, unknown>;
}

/**
 * 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<ResultadoUno> {
  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<string, unknown> };
    }
    case "servicio": {
      const r = await publicarServicio(businessId, Number(id));
      return { entidad, id, accion: "publicado", detalle: r as Record<string, unknown> };
    }
  }
}
  • Paso 4: Añadir la ruta

En platform/routes/crm.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<CrmCalendar[]>
    • listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]>
    • obtenerCita(ctx: CrmCtx, eventId: string): Promise<CrmEvent | null>
    • citasEnRango(ctx: CrmCtx, calendarId: string, desdeMs: number, hastaMs: number): Promise<CrmEvent[]>
    • 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

// 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
// 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<CrmCalendar[]> {
  const r = await crmRequest<any>("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<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: 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<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: 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<CrmService[]>
    • 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:

-- 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
// 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<CrmService[]> {
  const r = await crmRequest<any>("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<any>("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/<id de un servicio local>. 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

    syncOne: (entidad: string, id: string) =>
      request<{ entidad: string; id: string; accion: string; detalle: Record<string, unknown> }>(
        `/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 <select> de las cinco entidades y un campo de texto para el id. Al enviar, llama api.crm.syncOne(...) y muestra el accion que devuelve el servidor —«creado», «actualizado», «espejada»— en vez de un genérico «listo»: es la diferencia entre saber que trajo algo y suponerlo. En error, muestra el mensaje del servidor, que distingue «no existe en el CRM» de «el negocio no está vinculado».

Etiqueta ambos campos con htmlFor e id.

  • Paso 3: La bandeja lee del espejo

MessagesPage.tsx consulta hoy el CRM en vivo. Cámbiala para que lea de conversations y messages, con un botón «Actualizar» que llame a syncConversations. Deja claro en la interfaz cuándo se sincronizó por última vez: una bandeja espejada que no dice su antigüedad se lee como si fuera tiempo real, y no lo es.

  • Paso 4: Verificar

Ejecuta: npm run typecheck && npm run build


Autorrevisión

Cobertura del objetivo. «Sincronizar datos haciendo búsqueda por ID de contactos, conversaciones y mensajes; también las citas y servicios» → Tarea 4 es el punto de entrada único, y se apoya en la Tarea 2 (conversaciones y mensajes por id), la Tarea 3 (el espejo, sin el cual un mensaje traído no tiene dónde guardarse), la Tarea 5 (citas) y la Tarea 6 (servicios). Los contactos por id ya existían (obtenerContacto) y solo se enchufan al despachador.

Placeholders. Ninguno: todos los cuerpos, enums y mensajes de error están escritos.

Consistencia de tipos. CrmCtx viene del plan hermano y se usa igual en las siete tareas. CrmConversation y CrmMessage se definen en la Tarea 2 y se consumen en la 3 y la 4. Entidad y ENTIDADES se definen en la Tarea 4 y se consumen en la 7. publicarServicio y proyectarCita tienen la misma firma en la Tarea 4 (donde se llaman) y en las Tareas 5 y 6 (donde se definen).

Dos cosas que dejo dichas, no escondidas:

  1. Las Tareas 5 y 6 escriben en el CRM de un cliente real por primera vez. Los permisos están medidos, pero ninguna de las dos escrituras se ha ejercido nunca. Los pasos de verificación crean y borran, y eso hay que hacerlo con el cliente avisado, no un viernes por la tarde.
  2. La dirección de la sincronización de citas y servicios es empuje, y eso es una decisión de producto, no técnica. Se apoya en que hoy el calendario del CRM tenga una sola cita y el catálogo esté vacío. Si el spa empieza a agendar dentro del CRM, la premisa se cae y hará falta arrastre, con la pregunta de cuál de los dos manda cuando difieren. Conviene decidirlo con el negocio antes de que ocurra, no después.