import { pool } from "../db/pool.ts"; import { crmRequest } from "./client.ts"; import type { CrmCtx } from "./ctx.ts"; import type { EtapasPipeline } from "./opportunities.ts"; export interface CrmConnection { id: number; business_id: number; location_id: string; pipeline_id: string | null; stage_open_id: string | null; stage_won_id: string | null; stage_lost_id: string | null; allow_duplicate_opp: boolean; last_sync_at: string | null; last_sync_status: string | null; } export async function obtenerConexion(businessId: number): Promise { const { rows } = await pool.query( `SELECT * FROM crm_connections WHERE business_id = $1`, [businessId] ); return rows[0] ?? null; } export function etapasDe(c: CrmConnection): EtapasPipeline { if (!c.pipeline_id) throw new Error("La conexión con el CRM no tiene pipeline configurado"); return { pipelineId: c.pipeline_id, open: c.stage_open_id, won: c.stage_won_id, lost: c.stage_lost_id, }; } /** * Detecta el pipeline y las etapas de la subcuenta y las guarda. * * Las etapas se eligen por nombre porque sus identificadores son opacos y * distintos en cada subcuenta. Se busca «ganado» y «perdido»; si no aparecen, * se cae a la primera y la última por posición, que es lo que un embudo suele * significar. Ese respaldo se registra en `last_sync_status` para que no pase * inadvertido. */ export async function autoconfigurar(ctx: CrmCtx): Promise { const r = await crmRequest("GET", "/opportunities/pipelines", { token: ctx.token, query: { locationId: ctx.locationId }, }); const pipelines: any[] = r?.pipelines ?? []; if (!pipelines.length) { throw new Error("La subcuenta del CRM no tiene ningún pipeline"); } const pipe = pipelines[0]; const stages: any[] = [...(pipe.stages ?? [])].sort( (a, b) => (a.position ?? 0) - (b.position ?? 0) ); const porNombre = (...palabras: string[]) => stages.find((s) => { const n = String(s.name ?? "").toLowerCase(); return palabras.some((p) => n.includes(p)); })?.id ?? null; const won = porNombre("ganado", "won", "asisti", "complet"); const lost = porNombre("perdido", "lost", "cancel", "no asis"); const open = stages[0]?.id ?? null; // MEDIDO: la subcuenta expone el ajuste que decide si una cita puede estrenar // su propia oportunidad o hay que reciclar la de la clienta. let permiteDuplicados = false; try { const loc = await crmRequest("GET", `/locations/${ctx.locationId}`, { token: ctx.token, }); permiteDuplicados = Boolean(loc?.location?.settings?.allowDuplicateOpportunity); } catch { // Si no se puede leer, se asume el caso restrictivo: reciclar nunca rompe, // crear a ciegas sí. } const nota = won && lost ? `pipeline «${pipe.name}»; etapas detectadas por nombre` : `pipeline «${pipe.name}»; OJO: no se hallaron etapas de ganado/perdido por nombre`; // `location_id` NO se escribe aquí: lo puso `guardarCredencial` junto al token, // y son la misma decisión. Escribirlo desde dos sitios permite que se separen. const { rows } = await pool.query( `INSERT INTO crm_connections (business_id, location_id, pipeline_id, stage_open_id, stage_won_id, stage_lost_id, allow_duplicate_opp, last_sync_status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (business_id) DO UPDATE SET pipeline_id = EXCLUDED.pipeline_id, stage_open_id = EXCLUDED.stage_open_id, stage_won_id = EXCLUDED.stage_won_id, stage_lost_id = EXCLUDED.stage_lost_id, allow_duplicate_opp = EXCLUDED.allow_duplicate_opp, last_sync_status = EXCLUDED.last_sync_status RETURNING *`, [ctx.businessId, ctx.locationId, pipe.id, open, won, lost, permiteDuplicados, nota] ); return rows[0]; }