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 { // 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 { 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; } }); }