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

1250 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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<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**
```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<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**
```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<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`:
```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**
```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<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`:
```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<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**
```ts
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.