import { crmRequest, CrmError } from "./client.ts"; import type { CrmCtx } from "./ctx.ts"; import { normalizePhone } from "../lib/phone.ts"; /** Lo que el CRM devuelve de un contacto, en la forma que nos interesa. */ export interface CrmContact { id: string; firstName?: string | null; lastName?: string | null; contactName?: string | null; email?: string | null; phone?: string | null; source?: string | null; tags?: string[] | null; dateAdded?: string | null; dateOfBirth?: string | null; attributionSource?: Record | null; customFields?: { id: string; value: unknown }[] | null; } /** La atribución, aplanada a las columnas de `clients`. */ export interface Atribucion { crm_source: string | null; attr_session_source: string | null; attr_medium: string | null; attr_campaign: string | null; attr_campaign_id: string | null; attr_utm_source: string | null; attr_utm_medium: string | null; attr_utm_content: string | null; attr_ad_id: string | null; attr_referrer: string | null; } /** * Aplana `attributionSource`. * * `campaign` se lee ANTES que `utmCampaign` a propósito: el buscador de * contactos del CRM descarta `utmCampaign` y conserva `campaign`, así que * leerlos al revés deja la campaña vacía en la mitad de los contactos. */ export function mapAtribucion(c: CrmContact): Atribucion { const a = c.attributionSource ?? {}; const g = (...claves: string[]) => { for (const k of claves) { const v = (a as any)[k]; if (v !== undefined && v !== null && v !== "") return String(v); } return null; }; return { crm_source: c.source ?? null, attr_session_source: g("sessionSource"), attr_medium: g("medium"), attr_campaign: g("campaign", "utmCampaign"), attr_campaign_id: g("campaignId"), attr_utm_source: g("utmSource"), attr_utm_medium: g("utmMedium"), attr_utm_content: g("utmContent"), attr_ad_id: g("adId"), attr_referrer: g("referrer", "url"), }; } /** Nombre presentable, con los tres orígenes que trae el CRM. */ export function nombreDe(c: CrmContact): string { const compuesto = [c.firstName, c.lastName].filter(Boolean).join(" ").trim(); return compuesto || (c.contactName ?? "").trim() || (c.email ?? "").trim() || "Sin nombre"; } /** Una página del buscador de contactos. */ export interface PaginaContactos { contacts: CrmContact[]; total: number; searchAfter?: unknown; } /** * Recorre los contactos de la subcuenta. * * Pagina con `searchAfter` y no con `page`: el buscador tiene un techo de * profundidad por número de página, y con 3 209 contactos se alcanza. El cursor * sale del ÚLTIMO contacto de la página anterior. */ export async function buscarContactos( ctx: CrmCtx, opts: { pageLimit?: number; searchAfter?: unknown } = {} ): Promise { const body: Record = { locationId: ctx.locationId, pageLimit: opts.pageLimit ?? 100, }; if (opts.searchAfter) body.searchAfter = opts.searchAfter; const r = await crmRequest("POST", "/contacts/search", { token: ctx.token, body }); return { contacts: r?.contacts ?? [], total: r?.total ?? 0, searchAfter: r?.contacts?.length ? r.contacts[r.contacts.length - 1]?.searchAfter : undefined, }; } export async function obtenerContacto(ctx: CrmCtx, id: string): Promise { try { const r = await crmRequest("GET", `/contacts/${id}`, { token: ctx.token }); return r?.contact ?? null; } catch (e) { if (e instanceof CrmError && e.status === 404) return null; throw e; } } /** Busca por un identificador natural. El CRM deduplica por email y teléfono. */ export async function buscarPorIdentificador( ctx: CrmCtx, q: string ): Promise { const r = await crmRequest("GET", "/contacts/", { token: ctx.token, query: { locationId: ctx.locationId, query: q, limit: 5 }, }); return r?.contacts?.[0] ?? null; } export interface AltaContacto { locationId: string; firstName?: string; lastName?: string; name?: string; email?: string | null; phone?: string | null; source?: string; tags?: string[]; attribution?: Record; } export interface ResultadoResolucion { contact: CrmContact; /** Cómo se llegó a él: importa para la auditoría y para depurar duplicados. */ via: "crm_id" | "telefono" | "correo" | "creado" | "duplicado_400"; } /** * Resuelve el contacto en el CRM siguiendo la cadena de identidad acordada: * **id de contacto → teléfono → correo**, y si no existe, lo crea. * * Es la misma cadena que el CRM aplica por su cuenta * (`contactUniqueIdentifiers: ["email","phone"]`), así que las dos coinciden y * no se pelean. * * El caso interesante es el último: si el alta choca con un duplicado, el CRM * responde `400` **con el `contactId` existente en `meta`**. Eso es idempotencia * de verdad, regalada por el servidor, y es mejor que `upsert` — cuya rama * *actualizar* descarta la atribución en silencio. */ export async function resolverContacto( ctx: CrmCtx, datos: { crmContactId?: string | null; phone?: string | null; email?: string | null; name: string; source?: string; tags?: string[]; attribution?: Record; } ): Promise { // 1. Por id del CRM, si ya lo teníamos anclado. if (datos.crmContactId) { const c = await obtenerContacto(ctx, datos.crmContactId); if (c) return { contact: c, via: "crm_id" }; // El id guardado ya no resuelve: el contacto se borró en el CRM. Se sigue // por los fallbacks en vez de fallar. } // 2. Por teléfono normalizado. const tel = normalizePhone(datos.phone); if (tel) { const c = await buscarPorIdentificador(ctx, tel); if (c) return { contact: c, via: "telefono" }; } // 3. Por correo. if (datos.email) { const c = await buscarPorIdentificador(ctx, datos.email); if (c) return { contact: c, via: "correo" }; } // 4. Crear. La atribución solo entra AQUÍ: después es inmutable. const partes = datos.name.trim().split(/\s+/); const body: Record = { // MEDIDO: `locationId` va en el POST de alta y ROMPE el PUT con // `422 property locationId should not exist`. No reciclar este cuerpo. locationId: ctx.locationId, firstName: partes[0] || datos.name, lastName: partes.slice(1).join(" ") || undefined, country: "MX", source: datos.source ?? "AgendaMax", }; if (tel) body.phone = tel; if (datos.email) body.email = datos.email; if (datos.tags?.length) body.tags = datos.tags; if (datos.attribution && Object.keys(datos.attribution).length) { body.attributionSource = datos.attribution; } try { const r = await crmRequest("POST", "/contacts/", { token: ctx.token, body }); return { contact: r.contact, via: "creado" }; } catch (e) { if (e instanceof CrmError && e.status === 400) { const existente = (e.body as any)?.meta?.contactId; if (existente) { const c = await obtenerContacto(ctx, existente); if (c) return { contact: c, via: "duplicado_400" }; } } throw e; } }