diff --git a/mvp/b2c/src/lib/funnel/finalizar.ts b/mvp/b2c/src/lib/funnel/finalizar.ts new file mode 100644 index 0000000..0841583 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/finalizar.ts @@ -0,0 +1,68 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { leads, leadPipelineEventos } from '@/db/schema'; +import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto'; +import { enviarPresupuestoEmail } from '@/lib/email/mailer'; +import { notificarFlujoWhatsapp } from '@/lib/webhooks'; + +export type ResultadoFinalizar = { + ok: boolean; + emailEnviado: boolean; + whatsappSenal: boolean; +}; + +// Cierra el funnel de un lead cuando ya están las imágenes "después": arma el PDF con la galería +// por zona, lo persiste, lo envía SIEMPRE por email y manda la señal de entrega por WhatsApp al +// flujo externo. Entrega real (la rama simulada de orchestrator.ts:Paso 7 es solo el estado +// intermedio del funnel). Best-effort en email/WhatsApp: el lead avanza igualmente. +export async function finalizarYEntregar(leadId: string): Promise { + const pdf = await construirPresupuestoPdf(leadId); + if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false }; + + const { buffer, filename, lead, tenant } = pdf; + const pdfBase64 = buffer.toString('base64'); + + await db + .update(leads) + .set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() }) + .where(eq(leads.id, leadId)); + + const [emailEnviado, whatsappSenal] = await Promise.all([ + enviarPresupuestoEmail({ + to: lead.email, + nombre: lead.nombre, + empresa: tenant.nombreEmpresa, + pdf: buffer, + filename, + }), + notificarFlujoWhatsapp({ + leadId, + telefono: lead.telefono, + nombre: lead.nombre, + empresa: tenant.nombreEmpresa, + pdfBase64, + filename, + }), + ]); + + await db + .update(leads) + .set({ + pipelineStage: 'whatsapp_entregado', + estado: 'presupuesto_enviado', + updatedAt: new Date(), + }) + .where(eq(leads.id, leadId)); + + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'whatsapp_entregado', + metadata: { + via: [emailEnviado ? 'email' : null, whatsappSenal ? 'whatsapp' : null].filter(Boolean), + emailEnviado, + simulado: !emailEnviado && !whatsappSenal, + }, + }); + + return { ok: true, emailEnviado, whatsappSenal }; +} diff --git a/mvp/b2c/src/lib/funnel/orchestrator.ts b/mvp/b2c/src/lib/funnel/orchestrator.ts index cd7c198..0b884b6 100644 --- a/mvp/b2c/src/lib/funnel/orchestrator.ts +++ b/mvp/b2c/src/lib/funnel/orchestrator.ts @@ -150,6 +150,9 @@ export async function procesarLead(leadId: string): Promise { }); // Paso 7: entrega por WhatsApp si el reformista tiene envío automático. + // NOTA: esto es el estado intermedio del funnel (entrega simulada con el presupuesto + // orientativo). La entrega REAL con el PDF (email + señal WhatsApp) ocurre en + // finalizarYEntregar (lib/funnel/finalizar.ts), cuando llegan las imágenes "después". const envio = await getEnvioModeFor(lead.tenantId); if (envio === 'automatico') { await db diff --git a/mvp/b2c/src/lib/funnel/perfil.ts b/mvp/b2c/src/lib/funnel/perfil.ts new file mode 100644 index 0000000..4f63be4 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/perfil.ts @@ -0,0 +1,72 @@ +import { asc, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema'; +import type { Lead } from '@/db/schema'; +import { getTenantPerfilById } from '@/db/tenant-queries'; +import { señalarGeneracionPerfil } from '@/lib/webhooks'; + +type Tipo = NonNullable; + +// Construye el JSON "bien hecho" con toda la data acumulada del lead agrupada por zona y lo manda +// al flujo externo para que genere los renders "después" y homologue el texto con su agente. +// Best-effort: devuelve true solo si el webhook respondió ok. Deja traza en el pipeline. +export async function señalarPerfilCompleto(leadId: string): Promise { + const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); + if (!lead) return false; + + const [fotos, notas, tenant] = await Promise.all([ + db.select().from(leadFotos).where(eq(leadFotos.leadId, leadId)).orderBy(asc(leadFotos.orden)), + db.select().from(leadNotas).where(eq(leadNotas.leadId, leadId)).orderBy(asc(leadNotas.createdAt)), + getTenantPerfilById(lead.tenantId), + ]); + + const tipoLead: Tipo = lead.tipoReforma ?? 'otro'; + const zonas = new Map(); + const slot = (zona: Tipo) => { + let s = zonas.get(zona); + if (!s) { + s = { notas: [], antes: [], despues: [] }; + zonas.set(zona, s); + } + return s; + }; + for (const f of fotos) slot((f.zona ?? tipoLead) as Tipo)[f.momento].push(f.url); + for (const n of notas) { + const t = n.texto.trim(); + if (t) slot((n.zona ?? tipoLead) as Tipo).notas.push(t); + } + + const payload = { + leadId, + cliente: { + nombre: lead.nombre, + telefono: lead.telefono, + email: lead.email, + provincia: lead.provincia, + }, + reforma: { + tipo: lead.tipoReforma, + m2Suelo: lead.m2Suelo, + calidad: lead.calidadGlobal, + estructural: lead.estructural, + urgencia: lead.urgencia, + presupuestoTarget: lead.presupuestoTarget, + }, + empresa: { tenantId: lead.tenantId, nombre: tenant.nombreEmpresa }, + zonas: Array.from(zonas, ([zona, d]) => ({ + zona, + notas: d.notas, + fotos: { antes: d.antes, despues: d.despues }, + })), + }; + + const ok = await señalarGeneracionPerfil(payload); + + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'render_generado', + metadata: { perfilCompleto: true, simulado: !ok, zonas: payload.zonas.length }, + }); + + return ok; +} diff --git a/mvp/b2c/src/lib/webhooks.ts b/mvp/b2c/src/lib/webhooks.ts new file mode 100644 index 0000000..c33027a --- /dev/null +++ b/mvp/b2c/src/lib/webhooks.ts @@ -0,0 +1,57 @@ +import { + env, + perfilWebhookConfigurado, + whatsappWebhookConfigurado, + whatsappStartConfigurado, +} from '@/lib/env'; + +// POST JSON best-effort: nunca lanza. Devuelve true solo si el destino respondió 2xx. +export async function postWebhook(url: string, payload: unknown): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + console.error(`webhook ${url} → ${res.status}`); + return false; + } + return true; + } catch (err) { + console.error(`webhook ${url} error:`, err); + return false; + } +} + +// Señal al flujo externo de que el perfil del lead está completo: que genere renders y homologue +// el texto con su agente. El payload lo arma señalarPerfilCompleto (lib/funnel/perfil.ts). +export async function señalarGeneracionPerfil(payload: unknown): Promise { + if (!perfilWebhookConfigurado()) return false; + return postWebhook(env.PERFIL_WEBHOOK_URL!, payload); +} + +// Señal de entrega: el PDF está listo, que el flujo externo lo mande por WhatsApp al cliente. +export async function notificarFlujoWhatsapp(payload: { + leadId: string; + telefono: string; + nombre: string; + empresa: string; + pdfBase64: string; + filename: string; +}): Promise { + if (!whatsappWebhookConfigurado()) return false; + return postWebhook(env.WHATSAPP_WEBHOOK_URL!, payload); +} + +// Señal de arranque: que el flujo externo escriba el primer mensaje de WhatsApp al lead para +// continuar la personalización por ese canal. +export async function iniciarConversacionWhatsapp(payload: { + leadId: string; + telefono: string; + nombre: string; + empresa: string; +}): Promise { + if (!whatsappStartConfigurado()) return false; + return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload); +}