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

@@ -154,6 +154,122 @@ Disparado cuando el lead elige continuar por WhatsApp en el funnel. Payload:
{ "leadId": "uuid", "telefono": "+34...", "nombre": "...", "empresa": "Reformas Ejemplo" } { "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 ## Notas
- **Storage:** las imágenes se guardan tal cual se reciben (data URI o URL) en `lead_fotos.url`; - **Storage:** las imágenes se guardan tal cual se reciben (data URI o URL) en `lead_fotos.url`;

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 { db } from '@/db';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema'; import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } 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 { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
import { señalarPerfilCompleto } from '@/lib/funnel/perfil'; import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { finalizarYEntregar } from '@/lib/funnel/finalizar'; import { finalizarYEntregar } from '@/lib/funnel/finalizar';
@@ -10,20 +10,6 @@ import { finalizarYEntregar } from '@/lib/funnel/finalizar';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; 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, // 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 // 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). // 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>;

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);
});
});