Añade webhooks salientes, señal de perfil completo y finalización

- webhooks.ts: postWebhook best-effort + señalarGeneracionPerfil,
  notificarFlujoWhatsapp (entrega) e iniciarConversacionWhatsapp (arranque).
- perfil.ts: señalarPerfilCompleto arma el JSON por zona (notas + fotos
  antes/después) y lo manda al flujo externo; deja traza render_generado.
- finalizar.ts: finalizarYEntregar construye el PDF, persiste pdf_url, envía
  el email (siempre) y la señal de WhatsApp, y avanza el lead a entregado.
- orchestrator: comentario en Paso 7 apuntando a la entrega real en finalizar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:06:38 +02:00
parent ec09267d99
commit 195ecf6cc3
4 changed files with 200 additions and 0 deletions

View File

@@ -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<ResultadoFinalizar> {
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 };
}

View File

@@ -150,6 +150,9 @@ export async function procesarLead(leadId: string): Promise<void> {
}); });
// Paso 7: entrega por WhatsApp si el reformista tiene envío automático. // 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); const envio = await getEnvioModeFor(lead.tenantId);
if (envio === 'automatico') { if (envio === 'automatico') {
await db await db

View File

@@ -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<Lead['tipoReforma']>;
// 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<boolean> {
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<Tipo, { notas: string[]; antes: string[]; despues: string[] }>();
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;
}

View File

@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
if (!whatsappStartConfigurado()) return false;
return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload);
}