import { Router } from "express"; import { pool } from "../db/pool.ts"; import { err, h, ownerOnly, type AuthedRequest } from "../lib/auth.ts"; import { writeAudit } from "../lib/audit.ts"; import { withTx } from "../db/pool.ts"; import { obtenerConexion, autoconfigurar } from "../crm/connection.ts"; import { ctxDe } from "../crm/ctx.ts"; import { sincronizarContactos } from "../crm/syncContacts.ts"; import { proyectarCita } from "../crm/syncAppointments.ts"; import { despachar, estadoOutbox } from "../crm/outbox.ts"; import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts"; import { sincronizarConversaciones } from "../crm/syncConversations.ts"; export const crmRouter = Router(); /** Estado de la conexión: lo que la pantalla de clientes necesita para el botón. */ crmRouter.get( "/status", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const conexion = await obtenerConexion(bid); if (!conexion) { res.json({ connected: false }); return; } const stats = await pool.query( `SELECT count(*)::int AS clientes, count(*) FILTER (WHERE crm_contact_id IS NOT NULL)::int AS sincronizados, count(*) FILTER (WHERE contactable)::int AS contactables, count(*) FILTER (WHERE attr_campaign IS NOT NULL)::int AS con_campana FROM clients WHERE business_id = $1 AND deleted_at IS NULL`, [bid] ); const ultima = await pool.query( `SELECT id, kind, status, fetched, created, updated, started_at, finished_at, error FROM crm_sync_runs WHERE business_id = $1 ORDER BY id DESC LIMIT 1`, [bid] ); res.json({ connected: true, location_id: conexion.location_id, pipeline_id: conexion.pipeline_id, allow_duplicate_opp: conexion.allow_duplicate_opp, last_sync_at: conexion.last_sync_at, last_sync_status: conexion.last_sync_status, stats: stats.rows[0], last_run: ultima.rows[0] ?? null, outbox: await estadoOutbox(bid), }); }) ); /** Conecta o reconfigura la subcuenta. El token vive en el entorno, no en el body. */ crmRouter.post( "/connect", ownerOnly, h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; // Las credenciales las pone la administración de la plataforma, no el // negocio: son de la subcuenta del cliente y no deben viajar por aquí. // Esta ruta solo redetecta pipeline y etapas de la subcuenta ya vinculada. const ctx = await ctxDe(bid); // lanza 409 si no está vinculado const c = await autoconfigurar(ctx); await withTx((tx) => writeAudit(tx, { businessId: bid, actorUserId: req.user!.id, entity: "crm_connections", entityId: c.id, action: "connect", after: { location_id: c.location_id, pipeline_id: c.pipeline_id }, ip: req.ip ?? null, }) ); res.json({ connection: c }); }) ); /** * El botón de sincronizar contactos. * * Es una corrida en primer plano y no una tarea de fondo a propósito: 3 200 * contactos tardan ~22 s y quien pulsa el botón quiere ver el resultado. Si el * volumen crece hasta molestar, se mueve a la bandeja; hoy sería complejidad * sin problema que resolver. */ crmRouter.post( "/sync/contacts", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; try { const r = await sincronizarContactos(bid, { userId: req.user!.id, maxPaginas: Number(req.body?.max_paginas) || 60, }); res.json(r); } catch (e: any) { if (e?.status) { err(res, e.status, e.message); return; } err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`); } }) ); /** Empuja una cita concreta al CRM como oportunidad. */ crmRouter.post( "/sync/appointment/:id", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; try { const r = await proyectarCita(bid, Number(req.params.id)); res.json(r); } catch (e: any) { if (e?.status) { err(res, e.status, e.message); return; } err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`); } }) ); /** Vacía la bandeja de salida. */ crmRouter.post( "/outbox/flush", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const r = await despachar(bid, Number(req.body?.limite) || 25); res.json(r); }) ); /** Lo que no se pudo sincronizar, para que alguien pueda mirarlo. */ crmRouter.get( "/outbox", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const { rows } = await pool.query( `SELECT id, entity, entity_id, operation, status, attempts, last_error, crm_id, created_at, sent_at FROM crm_outbox WHERE business_id = $1 ORDER BY CASE status WHEN 'indeterminado' THEN 0 WHEN 'fallido' THEN 1 WHEN 'pendiente' THEN 2 ELSE 3 END, id DESC LIMIT 100`, [bid] ); res.json({ items: rows, resumen: await estadoOutbox(bid) }); }) ); /** * Espejo de las conversaciones recientes con sus mensajes. * * Va declarada ANTES que `/sync/:entidad/:id` no por ambigüedad —tienen distinto * número de segmentos— sino para que el orden del archivo diga cuál es la ruta * concreta y cuál la genérica. */ crmRouter.post( "/sync/conversations", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; try { res.json( await sincronizarConversaciones(bid, { limit: Math.min(Number(req.body?.limite) || 50, 200), 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}`); } }) ); /** * Sincroniza UNA entidad por su identificador. * * Es el punto de entrada único que pedía el encargo. La dirección la decide la * entidad, no quien llama: contactos, conversaciones y mensajes se traen del * CRM; citas y servicios se empujan hacia él. Ver `crm/syncOne.ts`. */ 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}`); } }) );