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.
|
||||
// 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
|
||||
|
||||
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