import { Router } from "express"; import { pool, withTx } from "../db/pool.ts"; import { err, h, type AuthedRequest } from "../lib/auth.ts"; import { writeAudit } from "../lib/audit.ts"; import { obtenerConexion } from "../crm/connection.ts"; import { ctxDe } from "../crm/ctx.ts"; import { buscarConversaciones, mensajesDeConversacion } from "../crm/conversations.ts"; import { enviarCorreo } from "../crm/messages.ts"; import { loadEnv } from "../lib/env.ts"; export const messagesRouter = Router(); /** * MODO PRUEBA — restricción deliberada del MVP. * * Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa * dirección**, sin importar a quién apunte la interfaz. La subcuenta es la de un * cliente real con 3 200 contactos: un bucle mal escrito o un clic de más * escribiría a personas de verdad, y eso no se arregla pidiendo perdón. * * Se quita definiendo `CRM_ALLOW_REAL_SENDS=1`, y esa es una decisión del dueño * del proyecto, no un descuido de configuración. */ function destinoPermitido(deseado: string): { to: string; forzado: boolean } { loadEnv(); const prueba = process.env.CRM_TEST_EMAIL; const libre = process.env.CRM_ALLOW_REAL_SENDS === "1"; if (prueba && !libre) { return { to: prueba, forzado: prueba.toLowerCase() !== deseado.toLowerCase() }; } return { to: deseado, forzado: false }; } /** Bandeja: conversaciones del CRM, con la clienta local enlazada si se conoce. */ messagesRouter.get( "/", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const conexion = await obtenerConexion(bid); if (!conexion) { err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM"); return; } const ctx = await ctxDe(bid); const { conversations, total } = await buscarConversaciones(ctx, { limit: Number(req.query.limit) || 20, }); // Se enlazan con las clientas locales por el id del CRM para poder abrir su // ficha desde la bandeja: es el "al lado" que hace útil esta pantalla. const ids = conversations.map((c) => c.contactId).filter(Boolean) as string[]; const locales = ids.length ? await pool.query( `SELECT id, name, crm_contact_id, phone_e164, contactable FROM clients WHERE business_id = $1 AND crm_contact_id = ANY($2::text[])`, [bid, ids] ) : { rows: [] as any[] }; const porCrmId = new Map(locales.rows.map((r: any) => [r.crm_contact_id, r])); res.json({ total, conversations: conversations.map((c) => ({ crm_conversation_id: c.id, crm_contact_id: c.contactId ?? null, contact_name: c.fullName || c.contactName || "Sin nombre", last_message_body: c.lastMessageBody ?? null, last_message_type: c.lastMessageType ?? null, last_message_at: c.lastMessageDate ?? null, unread_count: c.unreadCount ?? 0, client: porCrmId.get(c.contactId ?? "") ?? null, })), }); }) ); /** Los mensajes de un hilo. Solo lectura: el CRM es el dueño del histórico. */ messagesRouter.get( "/:conversationId", h(async (req: AuthedRequest, res) => { const conexion = await obtenerConexion(req.user!.business_id!); if (!conexion) { err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM"); return; } const ctx = await ctxDe(req.user!.business_id!); const { mensajes, hayMas } = await mensajesDeConversacion(ctx, req.params.conversationId, { limit: 50, }); res.json({ hay_mas: hayMas, messages: mensajes.map((m) => ({ id: m.id, body: m.body ?? null, direction: m.direction ?? null, channel: m.messageType ?? null, status: m.status ?? null, sent_at: m.dateAdded ?? null, })), }); }) ); /** * Responder por correo. * * WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único * canal ejercible hoy, y la respuesta lo dice explícitamente para que la * interfaz no prometa lo que no puede cumplir. */ messagesRouter.post( "/send", h(async (req: AuthedRequest, res) => { const bid = req.user!.business_id!; const { client_id, crm_contact_id, subject, body } = req.body ?? {}; if (!subject || !body) { err(res, 400, "El asunto y el mensaje son obligatorios"); return; } const conexion = await obtenerConexion(bid); if (!conexion) { err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM"); return; } let contactId: string | null = crm_contact_id ?? null; let correoDestino: string | null = null; let clienteLocal: any = null; if (client_id) { const { rows } = await pool.query( `SELECT id, name, email, crm_contact_id FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`, [Number(client_id), bid] ); clienteLocal = rows[0] ?? null; if (!clienteLocal) { err(res, 404, "Clienta no encontrada"); return; } contactId = contactId ?? clienteLocal.crm_contact_id; correoDestino = clienteLocal.email; } if (!contactId) { err(res, 409, "Esta clienta todavía no está sincronizada con el CRM"); return; } const { to, forzado } = destinoPermitido(correoDestino || ""); if (!to) { err(res, 409, "No hay dirección de correo a la que escribir"); return; } const ctx = await ctxDe(bid); const r = await enviarCorreo(ctx, { contactId, emailTo: to, subject: String(subject).slice(0, 200), html: `
${String( body ) .split("\n") .map((l) => `

${escaparHtml(l)}

`) .join("")}
`, }); await withTx((tx) => writeAudit(tx, { businessId: bid, actorUserId: req.user!.id, entity: "messages", entityId: clienteLocal?.id ?? null, action: "send_email", after: { to, forzado, crm: r }, ip: req.ip ?? null, }) ); res.json({ // El CRM responde "Email queued successfully": es acuse de ENCOLADO, no // de entrega. La interfaz debe decir "en camino", nunca "entregado". queued: true, crm: r, sent_to: to, redirigido: forzado, aviso: forzado ? `Modo prueba: el mensaje se envió a ${to}, no a la clienta.` : null, }); }) ); function escaparHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }