From 8b96037dad680a83270fd24cf32e7d0bca2ca3b1 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sun, 7 Jun 2026 20:04:11 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20EPs=20HTTP=20para=20que=20el=20bot?= =?UTF-8?q?=20de=20WhatsApp=20pueble=20la=20BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mvp/b2c/api-docs/README.md | 116 +++++++++++++++ .../app/api/leads/[id]/calificacion/route.ts | 38 +++++ .../app/api/leads/[id]/conversacion/route.ts | 38 +++++ .../src/app/api/leads/[id]/ingesta/route.ts | 16 +- .../src/app/api/leads/[id]/intento/route.ts | 31 ++++ .../src/app/api/leads/[id]/perfil/route.ts | 28 ++++ mvp/b2c/src/lib/api/bot-request.ts | 34 +++++ mvp/b2c/src/lib/api/funnel-auth.ts | 17 +++ mvp/b2c/src/lib/funnel/bot-schemas.ts | 69 +++++++++ mvp/b2c/tests/api/bot-schemas.test.ts | 140 ++++++++++++++++++ 10 files changed, 512 insertions(+), 15 deletions(-) create mode 100644 mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts create mode 100644 mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts create mode 100644 mvp/b2c/src/app/api/leads/[id]/intento/route.ts create mode 100644 mvp/b2c/src/app/api/leads/[id]/perfil/route.ts create mode 100644 mvp/b2c/src/lib/api/bot-request.ts create mode 100644 mvp/b2c/src/lib/api/funnel-auth.ts create mode 100644 mvp/b2c/src/lib/funnel/bot-schemas.ts create mode 100644 mvp/b2c/tests/api/bot-schemas.test.ts diff --git a/mvp/b2c/api-docs/README.md b/mvp/b2c/api-docs/README.md index 61f73ca..4501568 100644 --- a/mvp/b2c/api-docs/README.md +++ b/mvp/b2c/api-docs/README.md @@ -154,6 +154,122 @@ Disparado cuando el lead elige continuar por WhatsApp en el funnel. Payload: { "leadId": "uuid", "telefono": "+34...", "nombre": "...", "empresa": "Reformas Ejemplo" } ``` +--- + +# API — EPs del bot de WhatsApp (Luisa) + +El bot de WhatsApp es externo y **no toca Postgres directamente**: puebla la BD vía estos EPs HTTP. +Todos comparten la misma auth que la ingesta (`Authorization: Bearer `), +`Content-Type: application/json`, y `:id` = UUID del lead. Errores comunes: + +| Código | Cuándo | +| --- | --- | +| `401` | Falta `Authorization: Bearer`, o la clave no coincide con `FUNNEL_API_KEY`. | +| `404` | El lead `:id` no existe. | +| `422` | JSON inválido, o cuerpo que no pasa validación. | + +## `POST /api/leads/:id/conversacion` + +Añade **un turno** del chat al historial (`conversacion_whatsapp`) y, opcionalmente, actualiza el +estado del mensaje y el paso del bot en el lead. + +| Campo | Tipo | Notas | +| --- | --- | --- | +| `rol` | `"user"`\|`"assistant"`\|`"system"` | Obligatorio. | +| `mensaje` | string | Obligatorio, no vacío. | +| `mediaType` | string | Opcional (ej. `"image"`, `"audio"`). | +| `mediaUrl` | string | Opcional. | +| `transcripcionAudio` | string | Opcional (transcripción de una nota de voz). | +| `estadoWa` | enum | Opcional: `sin_enviar`,`enviado`,`entregado`,`leido`,`fallido`. Actualiza `leads.estado_wa`. | +| `botStep` | string | Opcional. Actualiza `leads.bot_step` (texto libre, ej. `pide_fotos`). | + +Respuesta `200`: `{ "ok": true, "id": "" }`. + +```bash +curl -X POST "$HOST/api/leads/$LEAD/conversacion" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"rol":"user","mensaje":"Quiero reformar la cocina","estadoWa":"leido","botStep":"espacio"}' +``` + +## `POST /api/leads/:id/perfil` + +Actualización **parcial** del lead con lo que el bot va extrayendo. Solo escribe los campos +enviados; el cuerpo debe traer **al menos uno**. + +| Campo | Tipo | Notas | +| --- | --- | --- | +| `botStep` | string | Paso del bot. | +| `estadoWa` | enum | `sin_enviar`,`enviado`,`entregado`,`leido`,`fallido`. | +| `canalOrigen` | enum | `formulario_web`,`whatsapp`,`llamada`,`referido`,`anuncio`. | +| `viable` | boolean | Si el lead es viable. | +| `espacio` | string | Extracción cruda del espacio. | +| `rangoM2` | string | Rango de m² en crudo. | +| `estilo` | string | Estilo en crudo. | +| `presupuestoDeclarado` | string | Presupuesto en crudo. | +| `fotosSolicitadasAt` | string (ISO datetime) | Cuándo se pidieron las fotos. | +| `tipoReforma` | enum | Normalizado: `cocina`,`bano`,`salon`,`comedor`,`integral`,`otro`. | +| `m2Suelo` | number (>0) | Normalizado. | +| `calidadGlobal` | enum | `basica`,`media`,`premium`. | +| `urgencia` | enum | `alta`,`media`,`baja`. | +| `presupuestoTarget` | number (int ≥0) | Normalizado, en **céntimos**. | +| `tasteText` | string | Texto libre de preferencias. | +| `estructural` | boolean | Si hay obra estructural. | + +Respuesta `200`: `{ "ok": true, "actualizado": ["tipoReforma","m2Suelo"] }`. + +```bash +curl -X POST "$HOST/api/leads/$LEAD/perfil" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"tipoReforma":"cocina","m2Suelo":12.5,"calidadGlobal":"premium","urgencia":"alta","viable":true}' +``` + +## `POST /api/leads/:id/calificacion` + +**Upsert** de la calificación del lead (una por lead). Recalculable: `onConflict` actualiza la fila +existente. Cuerpo con **al menos un campo**. + +| Campo | Tipo | Notas | +| --- | --- | --- | +| `score` | number (int 0-100) | Opcional. | +| `nivel` | `"A"`\|`"B"`\|`"C"`\|`"D"` | Opcional. | +| `criterios` | objeto/JSON libre | Opcional (desglose de criterios). | +| `notasAgente` | string | Opcional. | + +Respuesta `200`: `{ "ok": true }`. + +```bash +curl -X POST "$HOST/api/leads/$LEAD/calificacion" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"score":78,"nivel":"B","criterios":{"presupuesto":"ok","urgencia":"media"},"notasAgente":"Lead caliente"}' +``` + +## `POST /api/leads/:id/intento` + +Registra un intento de contacto (`intentos_contacto`). + +| Campo | Tipo | Notas | +| --- | --- | --- | +| `canal` | `"formulario"`\|`"whatsapp"`\|`"llamada"` | Obligatorio. | +| `numeroIntento` | number (int ≥1) | Obligatorio. | +| `resultado` | enum | Opcional: `exitoso`,`no_contesta`,`ocupado`,`rechaza`,`error_tecnico`. | +| `completado` | boolean | Opcional (por defecto `false`). | +| `duracionSeg` | number (int ≥0) | Opcional. | +| `notas` | string | Opcional. | +| `metadata` | objeto/JSON libre | Opcional (ej. `{ "retellCallId": "call_123" }`). | + +Respuesta `200`: `{ "ok": true, "id": "" }`. + +```bash +curl -X POST "$HOST/api/leads/$LEAD/intento" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"canal":"whatsapp","numeroIntento":1,"resultado":"exitoso","completado":true}' +``` + +> `visitas` y `worker_jobs` quedan fuera de estos EPs: son cola interna / panel del reformista, no +> los puebla el bot por API. Si el flujo externo necesita escribirlos, se abre como decisión aparte. + +--- + ## Notas - **Storage:** las imágenes se guardan tal cual se reciben (data URI o URL) en `lead_fotos.url`; diff --git a/mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts b/mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts new file mode 100644 index 0000000..2f4054b --- /dev/null +++ b/mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts @@ -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); +} diff --git a/mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts b/mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts new file mode 100644 index 0000000..90787e0 --- /dev/null +++ b/mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts @@ -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); +} diff --git a/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts b/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts index b195c98..0bc6cde 100644 --- a/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts +++ b/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts @@ -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). diff --git a/mvp/b2c/src/app/api/leads/[id]/intento/route.ts b/mvp/b2c/src/app/api/leads/[id]/intento/route.ts new file mode 100644 index 0000000..1dc7228 --- /dev/null +++ b/mvp/b2c/src/app/api/leads/[id]/intento/route.ts @@ -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); +} diff --git a/mvp/b2c/src/app/api/leads/[id]/perfil/route.ts b/mvp/b2c/src/app/api/leads/[id]/perfil/route.ts new file mode 100644 index 0000000..01559c9 --- /dev/null +++ b/mvp/b2c/src/app/api/leads/[id]/perfil/route.ts @@ -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); +} diff --git a/mvp/b2c/src/lib/api/bot-request.ts b/mvp/b2c/src/lib/api/bot-request.ts new file mode 100644 index 0000000..40033e0 --- /dev/null +++ b/mvp/b2c/src/lib/api/bot-request.ts @@ -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( + req: Request, + params: Promise<{ id: string }>, + schema: S, +): Promise<{ error: Response } | { leadId: string; body: z.infer }> { + 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 }; +} diff --git a/mvp/b2c/src/lib/api/funnel-auth.ts b/mvp/b2c/src/lib/api/funnel-auth.ts new file mode 100644 index 0000000..6913da5 --- /dev/null +++ b/mvp/b2c/src/lib/api/funnel-auth.ts @@ -0,0 +1,17 @@ +import { env } from '@/lib/env'; + +// Auth compartida de los EPs públicos del funnel/bot: header Authorization: Bearer . +// 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' }, + }); +} diff --git a/mvp/b2c/src/lib/funnel/bot-schemas.ts b/mvp/b2c/src/lib/funnel/bot-schemas.ts new file mode 100644 index 0000000..e40f77c --- /dev/null +++ b/mvp/b2c/src/lib/funnel/bot-schemas.ts @@ -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; diff --git a/mvp/b2c/tests/api/bot-schemas.test.ts b/mvp/b2c/tests/api/bot-schemas.test.ts new file mode 100644 index 0000000..87bcd17 --- /dev/null +++ b/mvp/b2c/tests/api/bot-schemas.test.ts @@ -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); + }); +});