Añade EPs HTTP para que el bot de WhatsApp pueble la BD

El bot (Luisa) es externo y no toca Postgres directamente. Cuatro endpoints
autenticados con Bearer FUNNEL_API_KEY, validados con zod:

- POST /api/leads/:id/conversacion  → turno de chat (+ estado_wa/bot_step)
- POST /api/leads/:id/perfil         → update parcial del lead (extracción)
- POST /api/leads/:id/calificacion   → upsert de lead_calificacion
- POST /api/leads/:id/intento        → registro en intentos_contacto

Helpers compartidos lib/api/funnel-auth.ts (autorizado + jsonResponse) y
lib/api/bot-request.ts (validarBotRequest: auth + JSON + zod + lead existe).
La ruta de ingesta se refactoriza para reutilizar funnel-auth (DRY).
Schemas puros en lib/funnel/bot-schemas.ts con tests, y doc en api-docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-07 20:04:11 +02:00
parent 508fc43f1f
commit 8b96037dad
10 changed files with 512 additions and 15 deletions

View File

@@ -0,0 +1,38 @@
import { db } from '@/db';
import { leadCalificacion } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { calificacionSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Upsert de la calificación del lead (una por lead). El bot la recalcula a medida que avanza
// la conversación; onConflict actualiza la fila existente.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, calificacionSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
await db
.insert(leadCalificacion)
.values({
leadId,
score: body.score,
nivel: body.nivel,
criterios: body.criterios,
notasAgente: body.notasAgente,
})
.onConflictDoUpdate({
target: leadCalificacion.leadId,
set: {
score: body.score,
nivel: body.nivel,
criterios: body.criterios,
notasAgente: body.notasAgente,
calificadoAt: new Date(),
},
});
return jsonResponse({ ok: true }, 200);
}

View File

@@ -0,0 +1,38 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, conversacionWhatsapp } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { conversacionSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Añade un turno de la conversación de WhatsApp al historial del lead, y opcionalmente actualiza
// el estado del mensaje (estado_wa) y el paso del bot (bot_step) en el lead.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, conversacionSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
const [row] = await db
.insert(conversacionWhatsapp)
.values({
leadId,
rol: body.rol,
mensaje: body.mensaje,
mediaType: body.mediaType ?? null,
mediaUrl: body.mediaUrl ?? null,
transcripcionAudio: body.transcripcionAudio ?? null,
})
.returning({ id: conversacionWhatsapp.id });
if (body.estadoWa || body.botStep) {
await db
.update(leads)
.set({ estadoWa: body.estadoWa, botStep: body.botStep, updatedAt: new Date() })
.where(eq(leads.id, leadId));
}
return jsonResponse({ ok: true, id: row.id }, 200);
}

View File

@@ -2,7 +2,7 @@ import { desc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
import { env } from '@/lib/env';
import { autorizado, jsonResponse as json } from '@/lib/api/funnel-auth';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { finalizarYEntregar } from '@/lib/funnel/finalizar';
@@ -10,20 +10,6 @@ import { finalizarYEntregar } from '@/lib/funnel/finalizar';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function autorizado(req: Request): boolean {
if (!env.FUNNEL_API_KEY) return false;
const auth = req.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
return token.length > 0 && token === env.FUNNEL_API_KEY;
}
function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
}
// EP único de ingesta async del perfil del lead. Acepta imágenes, imagen+texto o solo texto,
// etiquetado por zona y momento, y dos flags: perfilCompleto (señala al flujo externo que genere
// renders/agente) y finalizar (construye el PDF y lo entrega por email + señal WhatsApp).

View File

@@ -0,0 +1,31 @@
import { db } from '@/db';
import { intentosContacto } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { intentoSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Registra un intento de contacto (formulario/whatsapp/llamada) con su resultado.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, intentoSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
const [row] = await db
.insert(intentosContacto)
.values({
leadId,
canal: body.canal,
resultado: body.resultado,
completado: body.completado ?? false,
numeroIntento: body.numeroIntento,
duracionSeg: body.duracionSeg,
notas: body.notas,
metadata: body.metadata,
})
.returning({ id: intentosContacto.id });
return jsonResponse({ ok: true, id: row.id }, 200);
}

View File

@@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { perfilSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Actualización parcial del lead con lo que el bot va extrayendo (espacio, m², estilo, urgencia,
// presupuesto, viabilidad, estado de la conversación…). Solo escribe los campos enviados.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, perfilSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
await db
.update(leads)
.set({
...body,
fotosSolicitadasAt: body.fotosSolicitadasAt ? new Date(body.fotosSolicitadasAt) : undefined,
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
return jsonResponse({ ok: true, actualizado: Object.keys(body) }, 200);
}

View File

@@ -0,0 +1,34 @@
import type { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads } from '@/db/schema';
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
// Valida una petición de los EPs del bot: auth Bearer + JSON + zod + que el lead exista.
// Devuelve { error } con la Response lista, o { leadId, body } ya validado.
export async function validarBotRequest<S extends z.ZodTypeAny>(
req: Request,
params: Promise<{ id: string }>,
schema: S,
): Promise<{ error: Response } | { leadId: string; body: z.infer<S> }> {
if (!autorizado(req)) return { error: jsonResponse({ ok: false, error: 'No autorizado.' }, 401) };
const { id } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return { error: jsonResponse({ ok: false, error: 'JSON inválido.' }, 422) };
}
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return {
error: jsonResponse({ ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }, 422),
};
}
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, id)).limit(1);
if (!lead) return { error: jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404) };
return { leadId: id, body: parsed.data };
}

View File

@@ -0,0 +1,17 @@
import { env } from '@/lib/env';
// Auth compartida de los EPs públicos del funnel/bot: header Authorization: Bearer <FUNNEL_API_KEY>.
// Sin clave configurada o sin coincidencia → no autorizado.
export function autorizado(req: Request): boolean {
if (!env.FUNNEL_API_KEY) return false;
const auth = req.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
return token.length > 0 && token === env.FUNNEL_API_KEY;
}
export function jsonResponse(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
}

View File

@@ -0,0 +1,69 @@
import { z } from 'zod';
import { ZONAS } from '@/lib/funnel/ingesta-schema';
// Schemas de los EPs que el bot de WhatsApp usa para poblar la BD por API. Puros (sin DB) para
// poder testearlos. Los enums son espejo de los de src/db/schema.ts.
const ESTADO_WA = ['sin_enviar', 'enviado', 'entregado', 'leido', 'fallido'] as const;
const CANAL_ORIGEN = ['formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio'] as const;
const CALIDAD = ['basica', 'media', 'premium'] as const;
const URGENCIA = ['alta', 'media', 'baja'] as const;
const NIVEL = ['A', 'B', 'C', 'D'] as const;
const CANAL_CONTACTO = ['formulario', 'whatsapp', 'llamada'] as const;
const RESULTADO = ['exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico'] as const;
// Un turno de la conversación de WhatsApp (+ estado opcional del mensaje/conversación).
export const conversacionSchema = z.object({
rol: z.enum(['user', 'assistant', 'system']),
mensaje: z.string().trim().min(1),
mediaType: z.string().optional(),
mediaUrl: z.string().optional(),
transcripcionAudio: z.string().optional(),
estadoWa: z.enum(ESTADO_WA).optional(),
botStep: z.string().optional(),
});
// Actualización parcial de la extracción/estado del lead (lo que Luisa va sacando).
export const perfilSchema = z
.object({
botStep: z.string().optional(),
estadoWa: z.enum(ESTADO_WA).optional(),
canalOrigen: z.enum(CANAL_ORIGEN).optional(),
viable: z.boolean().optional(),
espacio: z.string().optional(),
rangoM2: z.string().optional(),
estilo: z.string().optional(),
presupuestoDeclarado: z.string().optional(),
fotosSolicitadasAt: z.string().datetime().optional(),
// Normalizados (los que alimentan el motor de presupuesto):
tipoReforma: z.enum(ZONAS).optional(),
m2Suelo: z.number().positive().optional(),
calidadGlobal: z.enum(CALIDAD).optional(),
urgencia: z.enum(URGENCIA).optional(),
presupuestoTarget: z.number().int().min(0).optional(), // céntimos
tasteText: z.string().optional(),
estructural: z.boolean().optional(),
})
.refine((o) => Object.keys(o).length > 0, { message: 'Cuerpo vacío: aporta al menos un campo.' });
// Calificación del lead (upsert; 1 por lead).
export const calificacionSchema = z
.object({
score: z.number().int().min(0).max(100).optional(),
nivel: z.enum(NIVEL).optional(),
criterios: z.unknown().optional(),
notasAgente: z.string().optional(),
})
.refine((o) => Object.keys(o).length > 0, { message: 'Cuerpo vacío: aporta al menos un campo.' });
// Registro de un intento de contacto.
export const intentoSchema = z.object({
canal: z.enum(CANAL_CONTACTO),
resultado: z.enum(RESULTADO).optional(),
completado: z.boolean().optional(),
numeroIntento: z.number().int().min(1),
duracionSeg: z.number().int().min(0).optional(),
notas: z.string().optional(),
metadata: z.unknown().optional(),
});
export type PerfilBody = z.infer<typeof perfilSchema>;