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,140 @@
import { describe, it, expect } from 'vitest';
import {
conversacionSchema,
perfilSchema,
calificacionSchema,
intentoSchema,
} from '@/lib/funnel/bot-schemas';
describe('conversacionSchema', () => {
it('acepta un turno mínimo (rol + mensaje)', () => {
const r = conversacionSchema.safeParse({ rol: 'user', mensaje: 'Hola' });
expect(r.success).toBe(true);
});
it('acepta media y estado opcionales', () => {
const r = conversacionSchema.safeParse({
rol: 'assistant',
mensaje: 'Te mando una foto',
mediaType: 'image',
mediaUrl: 'https://x/y.jpg',
estadoWa: 'entregado',
botStep: 'pide_fotos',
});
expect(r.success).toBe(true);
});
it('rechaza mensaje vacío', () => {
expect(conversacionSchema.safeParse({ rol: 'user', mensaje: ' ' }).success).toBe(false);
});
it('rechaza un rol fuera del enum', () => {
expect(conversacionSchema.safeParse({ rol: 'bot', mensaje: 'Hola' }).success).toBe(false);
});
it('rechaza un estadoWa inválido', () => {
const r = conversacionSchema.safeParse({ rol: 'user', mensaje: 'Hola', estadoWa: 'visto' });
expect(r.success).toBe(false);
});
});
describe('perfilSchema', () => {
it('acepta una actualización parcial con un solo campo', () => {
expect(perfilSchema.safeParse({ botStep: 'espacio' }).success).toBe(true);
});
it('acepta campos normalizados del motor de presupuesto', () => {
const r = perfilSchema.safeParse({
tipoReforma: 'cocina',
m2Suelo: 12.5,
calidadGlobal: 'premium',
urgencia: 'alta',
presupuestoTarget: 1500000,
viable: true,
});
expect(r.success).toBe(true);
});
it('acepta fotosSolicitadasAt como datetime ISO', () => {
const r = perfilSchema.safeParse({ fotosSolicitadasAt: '2026-06-07T10:00:00.000Z' });
expect(r.success).toBe(true);
});
it('rechaza un cuerpo vacío', () => {
expect(perfilSchema.safeParse({}).success).toBe(false);
});
it('rechaza tipoReforma fuera del enum', () => {
expect(perfilSchema.safeParse({ tipoReforma: 'jardin' }).success).toBe(false);
});
it('rechaza m2Suelo no positivo', () => {
expect(perfilSchema.safeParse({ m2Suelo: 0 }).success).toBe(false);
expect(perfilSchema.safeParse({ m2Suelo: -3 }).success).toBe(false);
});
it('rechaza fotosSolicitadasAt que no es datetime', () => {
expect(perfilSchema.safeParse({ fotosSolicitadasAt: 'ayer' }).success).toBe(false);
});
});
describe('calificacionSchema', () => {
it('acepta score + nivel + criterios libres', () => {
const r = calificacionSchema.safeParse({
score: 78,
nivel: 'B',
criterios: { presupuesto: 'ok', urgencia: 'media' },
notasAgente: 'Lead caliente',
});
expect(r.success).toBe(true);
});
it('acepta solo notasAgente', () => {
expect(calificacionSchema.safeParse({ notasAgente: 'Pendiente de fotos' }).success).toBe(true);
});
it('rechaza un cuerpo vacío', () => {
expect(calificacionSchema.safeParse({}).success).toBe(false);
});
it('rechaza score fuera de 0-100', () => {
expect(calificacionSchema.safeParse({ score: 120 }).success).toBe(false);
expect(calificacionSchema.safeParse({ score: -1 }).success).toBe(false);
});
it('rechaza un nivel fuera del enum', () => {
expect(calificacionSchema.safeParse({ nivel: 'E' }).success).toBe(false);
});
});
describe('intentoSchema', () => {
it('acepta un intento mínimo (canal + numeroIntento)', () => {
const r = intentoSchema.safeParse({ canal: 'whatsapp', numeroIntento: 1 });
expect(r.success).toBe(true);
});
it('acepta resultado, duración y metadata', () => {
const r = intentoSchema.safeParse({
canal: 'llamada',
resultado: 'no_contesta',
completado: false,
numeroIntento: 2,
duracionSeg: 0,
metadata: { retellCallId: 'call_123' },
});
expect(r.success).toBe(true);
});
it('rechaza canal fuera del enum', () => {
expect(intentoSchema.safeParse({ canal: 'email', numeroIntento: 1 }).success).toBe(false);
});
it('rechaza numeroIntento menor que 1', () => {
expect(intentoSchema.safeParse({ canal: 'whatsapp', numeroIntento: 0 }).success).toBe(false);
});
it('rechaza resultado fuera del enum', () => {
const r = intentoSchema.safeParse({ canal: 'llamada', resultado: 'colgado', numeroIntento: 1 });
expect(r.success).toBe(false);
});
});