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:
68
mvp/b2c/src/lib/funnel/finalizar.ts
Normal file
68
mvp/b2c/src/lib/funnel/finalizar.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
72
mvp/b2c/src/lib/funnel/perfil.ts
Normal file
72
mvp/b2c/src/lib/funnel/perfil.ts
Normal 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;
|
||||||
|
}
|
||||||
57
mvp/b2c/src/lib/webhooks.ts
Normal file
57
mvp/b2c/src/lib/webhooks.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user