Files
AgendaPro/platform/crm/syncContacts.ts
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

227 lines
7.6 KiB
TypeScript
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.
import { pool, withTx } from "../db/pool.ts";
import { ctxDe } from "./ctx.ts";
import { normalizePhone } from "../lib/phone.ts";
import { buscarContactos, mapAtribucion, nombreDe, type CrmContact } from "./contacts.ts";
export interface ResumenSync {
runId: number;
fetched: number;
created: number;
updated: number;
skipped: number;
total_crm: number;
status: "ok" | "error";
error?: string;
}
/**
* Trae los contactos del CRM a la plataforma, con su atribución.
*
* Dirección: **una sola, del CRM hacia aquí.** El contacto es del CRM —es su
* llave de deduplicación y donde viven las automatizaciones—, así que esta
* sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar
* pasa por la bandeja de salida, que es otra cosa.
*
* Reconciliación, en el mismo orden que la cadena de identidad acordada:
* 1. `crm_contact_id` — si ya está anclado, es él y no se busca más.
* 2. teléfono normalizado a E.164.
* 3. correo.
* Si ninguno encaja, se crea la clienta.
*
* La atribución se sobrescribe siempre desde el CRM: es dato del CRM y él es su
* único dueño. El nombre, en cambio, **no pisa** uno editado en la plataforma
* si el CRM no trae nada mejor.
*/
export async function sincronizarContactos(
businessId: number,
opts: { userId?: number | null; maxPaginas?: number } = {}
): Promise<ResumenSync> {
// El contexto se pide UNA vez, al principio: descifra el token y ya no se
// vuelve a tocar la base para eso en toda la corrida.
const ctx = await ctxDe(businessId);
const run = await pool.query<{ id: number }>(
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
VALUES ($1,'contacts','pull',$2) RETURNING id`,
[businessId, opts.userId ?? null]
);
const runId = run.rows[0].id;
let fetched = 0;
let created = 0;
let updated = 0;
let skipped = 0;
let total = 0;
try {
let cursor: unknown = undefined;
const maxPaginas = opts.maxPaginas ?? 60; // 60 × 100 = 6 000 contactos por corrida
for (let pagina = 0; pagina < maxPaginas; pagina++) {
const p = await buscarContactos(ctx, {
pageLimit: 100,
searchAfter: cursor,
});
total = p.total;
if (!p.contacts.length) break;
fetched += p.contacts.length;
for (const c of p.contacts) {
const r = await upsertClienteDesdeCrm(businessId, c);
if (r === "created") created++;
else if (r === "updated") updated++;
else skipped++;
}
if (!p.searchAfter) break;
cursor = p.searchAfter;
if (fetched >= total) break;
}
await pool.query(
`UPDATE crm_sync_runs
SET finished_at = now(), status = 'ok',
fetched = $2, created = $3, updated = $4, skipped = $5
WHERE id = $1`,
[runId, fetched, created, updated, skipped]
);
await pool.query(
`UPDATE crm_connections
SET last_sync_at = now(),
last_sync_status = $2
WHERE business_id = $1`,
[businessId, `${created} nuevas, ${updated} actualizadas de ${fetched} leídas`]
);
return { runId, fetched, created, updated, skipped, total_crm: total, status: "ok" };
} catch (e: any) {
await pool.query(
`UPDATE crm_sync_runs
SET finished_at = now(), status = 'error', error = $2,
fetched = $3, created = $4, updated = $5, skipped = $6
WHERE id = $1`,
[runId, String(e?.message ?? e).slice(0, 500), fetched, created, updated, skipped]
);
await pool.query(
`UPDATE crm_connections SET last_sync_status = $2 WHERE business_id = $1`,
[businessId, `error: ${String(e?.message ?? e).slice(0, 200)}`]
);
throw e;
}
}
type Resultado = "created" | "updated" | "skipped";
export async function upsertClienteDesdeCrm(
businessId: number,
c: CrmContact
): Promise<Resultado> {
const tel = normalizePhone(c.phone);
const email = (c.email ?? "").trim().toLowerCase() || null;
const nombre = nombreDe(c);
const attr = mapAtribucion(c);
const tags = Array.isArray(c.tags) ? c.tags.join(",") : null;
return withTx(async (tx) => {
// Cadena de identidad: id anclado → teléfono → correo.
let existente: { id: number; name: string } | null = null;
const porId = await tx.query(
`SELECT id, name FROM clients WHERE business_id = $1 AND crm_contact_id = $2`,
[businessId, c.id]
);
existente = porId.rows[0] ?? null;
if (!existente && tel) {
const porTel = await tx.query(
`SELECT id, name FROM clients
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
[businessId, tel]
);
existente = porTel.rows[0] ?? null;
}
if (!existente && email) {
const porMail = await tx.query(
`SELECT id, name FROM clients
WHERE business_id = $1 AND lower(email) = $2 AND deleted_at IS NULL`,
[businessId, email]
);
existente = porMail.rows[0] ?? null;
}
const cols = [
attr.crm_source,
attr.attr_session_source,
attr.attr_medium,
attr.attr_campaign,
attr.attr_campaign_id,
attr.attr_utm_source,
attr.attr_utm_medium,
attr.attr_utm_content,
attr.attr_ad_id,
attr.attr_referrer,
tags,
c.dateAdded ?? null,
];
if (existente) {
await tx.query(
`UPDATE clients SET
crm_contact_id = $2,
crm_synced_at = now(),
-- El teléfono y el correo solo se rellenan si aquí faltaban: son
-- las llaves de identidad y pisarlas puede fusionar dos personas.
phone = COALESCE(phone, $3),
phone_e164 = COALESCE(phone_e164, $4),
email = COALESCE(email, $5),
-- El nombre solo se completa si el de aquí está vacío: alguien pudo
-- corregirlo en la plataforma y el CRM trae 56 % de apellidos.
name = CASE WHEN btrim(name) = '' THEN $6 ELSE name END,
crm_source = $7, attr_session_source = $8, attr_medium = $9,
attr_campaign = $10, attr_campaign_id = $11, attr_utm_source = $12,
attr_utm_medium = $13, attr_utm_content = $14, attr_ad_id = $15,
attr_referrer = $16, crm_tags = $17, crm_date_added = $18::timestamptz
WHERE id = $1`,
[existente.id, c.id, c.phone ?? null, tel, email, nombre, ...cols]
);
return "updated";
}
try {
await tx.query(
`INSERT INTO clients
(business_id, name, email, phone, phone_e164, crm_contact_id, crm_synced_at,
crm_source, attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id, attr_referrer,
crm_tags, crm_date_added, birth_date)
VALUES ($1,$2,$3,$4,$5,$6,now(),
$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::timestamptz,$19::date)`,
[
businessId,
nombre,
email,
c.phone ?? null,
tel,
c.id,
...cols,
c.dateOfBirth ?? null,
]
);
return "created";
} catch (e: any) {
// El índice único de teléfono ganó una carrera: otra clienta con el mismo
// número entró entre la consulta y este INSERT. Se ancla al existente en
// vez de perder el contacto.
if (e.code === "23505" && tel) {
await tx.query(
`UPDATE clients SET crm_contact_id = $3, crm_synced_at = now()
WHERE business_id = $1 AND phone_e164 = $2 AND crm_contact_id IS NULL`,
[businessId, tel, c.id]
);
return "updated";
}
throw e;
}
});
}