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:
@@ -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 <FUNNEL_API_KEY>`),
|
||||
`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": "<uuid del turno>" }`.
|
||||
|
||||
```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": "<uuid del intento>" }`.
|
||||
|
||||
```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`;
|
||||
|
||||
38
mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts
Normal file
38
mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts
Normal 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);
|
||||
}
|
||||
38
mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts
Normal file
38
mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
31
mvp/b2c/src/app/api/leads/[id]/intento/route.ts
Normal file
31
mvp/b2c/src/app/api/leads/[id]/intento/route.ts
Normal 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);
|
||||
}
|
||||
28
mvp/b2c/src/app/api/leads/[id]/perfil/route.ts
Normal file
28
mvp/b2c/src/app/api/leads/[id]/perfil/route.ts
Normal 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);
|
||||
}
|
||||
34
mvp/b2c/src/lib/api/bot-request.ts
Normal file
34
mvp/b2c/src/lib/api/bot-request.ts
Normal 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 };
|
||||
}
|
||||
17
mvp/b2c/src/lib/api/funnel-auth.ts
Normal file
17
mvp/b2c/src/lib/api/funnel-auth.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
69
mvp/b2c/src/lib/funnel/bot-schemas.ts
Normal file
69
mvp/b2c/src/lib/funnel/bot-schemas.ts
Normal 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>;
|
||||
140
mvp/b2c/tests/api/bot-schemas.test.ts
Normal file
140
mvp/b2c/tests/api/bot-schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user