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" }
```
---
# 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`;