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>
280 lines
11 KiB
Markdown
280 lines
11 KiB
Markdown
# API — Ingesta del perfil del lead
|
|
|
|
Endpoint único y asíncrono para enriquecer el perfil de un lead del funnel B2C. Cada llamada puede
|
|
traer **imágenes, imagen+texto o solo texto**, etiquetado por **zona** y, en fotos, por **momento**
|
|
(antes/después). Lo usan tanto el formulario web como los flujos externos (agente de llamada, bot de
|
|
WhatsApp, generador de renders).
|
|
|
|
## Endpoint
|
|
|
|
```
|
|
POST /api/leads/:id/ingesta
|
|
```
|
|
|
|
- `:id` = UUID del lead (el que crea el funnel al capturar nombre/teléfono/email).
|
|
- **Auth:** header `Authorization: Bearer <FUNNEL_API_KEY>`. Sin él, o con clave incorrecta → `401`.
|
|
- **Content-Type:** `application/json`.
|
|
|
|
## Cuerpo (JSON)
|
|
|
|
| Campo | Tipo | Notas |
|
|
| --- | --- | --- |
|
|
| `items` | array | Lista de items `foto` o `texto`. Por defecto `[]`. |
|
|
| `perfilCompleto` | boolean | Opcional. `true` señala al flujo externo que genere renders/agente. |
|
|
| `finalizar` | boolean | Opcional. `true` construye el PDF y lo entrega (email + señal WhatsApp). |
|
|
|
|
Debe llegar **al menos un item o un flag**; una llamada totalmente vacía → `422`.
|
|
|
|
### Item `foto`
|
|
|
|
| Campo | Tipo | Notas |
|
|
| --- | --- | --- |
|
|
| `tipo` | `"foto"` | Obligatorio. |
|
|
| `imagen` | string | Obligatorio. Data URI (`data:image/...;base64,...`) o URL `http(s)`. |
|
|
| `zona` | enum | Opcional: `cocina`,`bano`,`salon`,`comedor`,`integral`,`otro`. Si falta, se asume el tipo de reforma del lead. |
|
|
| `momento` | `"antes"`\|`"despues"` | Por defecto `"antes"`. Los renders del flujo externo se mandan como `despues`. |
|
|
| `orden` | number | Opcional. Si falta, continúa el máximo actual del lead. |
|
|
|
|
### Item `texto`
|
|
|
|
| Campo | Tipo | Notas |
|
|
| --- | --- | --- |
|
|
| `tipo` | `"texto"` | Obligatorio. |
|
|
| `texto` | string | Obligatorio, no vacío. Ej. `"suelo premium"`. |
|
|
| `zona` | enum | Opcional (mismo enum que en `foto`). |
|
|
|
|
## Respuesta
|
|
|
|
`200`:
|
|
|
|
```json
|
|
{
|
|
"ok": true,
|
|
"fotos": 1,
|
|
"notas": 1,
|
|
"perfilSenalado": false,
|
|
"finalizado": null
|
|
}
|
|
```
|
|
|
|
- `fotos` / `notas`: cuántos items de cada tipo se guardaron.
|
|
- `perfilSenalado`: `true` si `perfilCompleto` disparó el webhook y respondió ok.
|
|
- `finalizado`: `null` si no se pidió `finalizar`; si sí,
|
|
`{ ok, emailEnviado, whatsappSenal }`.
|
|
|
|
### Códigos de error
|
|
|
|
| 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, item mal formado, o llamada vacía (sin items ni flag). |
|
|
|
|
## Ejemplos (curl)
|
|
|
|
Sustituye `LEAD`, `KEY` y el host según tu entorno.
|
|
|
|
```bash
|
|
# 1) Solo texto: añade un dato a la zona baño
|
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
|
-d '{"items":[{"tipo":"texto","zona":"bano","texto":"suelo premium"}]}'
|
|
|
|
# 2) Solo imagen (antes)
|
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
|
-d '{"items":[{"tipo":"foto","zona":"bano","imagen":"https://cdn/ejemplo/bano-antes.jpg"}]}'
|
|
|
|
# 3) Imagen + texto en la misma llamada
|
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
|
-d '{"items":[
|
|
{"tipo":"foto","zona":"cocina","imagen":"data:image/jpeg;base64,..."},
|
|
{"tipo":"texto","zona":"cocina","texto":"encimera de cuarzo"}
|
|
]}'
|
|
|
|
# 4) Perfil completo: que el flujo externo genere renders / corra el agente
|
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
|
-d '{"perfilCompleto":true}'
|
|
|
|
# 5) Devolver renders "después" y finalizar (genera PDF + email + señal WhatsApp)
|
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
|
-d '{"items":[{"tipo":"foto","zona":"cocina","momento":"despues","imagen":"https://cdn/render-cocina.jpg"}],"finalizar":true}'
|
|
```
|
|
|
|
## Webhooks salientes (hacia el flujo externo)
|
|
|
|
La app **emite** estas señales; el flujo externo (n8n/generador) las recibe y, cuando toca,
|
|
devuelve los resultados llamando de nuevo a este mismo EP (las "después" con `finalizar:true`).
|
|
Cada webhook es opcional: sin su URL en el entorno, la señal simplemente no se manda.
|
|
|
|
### `PERFIL_WEBHOOK_URL` — perfil completo
|
|
|
|
Disparado por `perfilCompleto:true`. Payload:
|
|
|
|
```json
|
|
{
|
|
"leadId": "uuid",
|
|
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
|
|
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
|
|
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
|
|
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
|
|
"zonas": [
|
|
{ "zona": "cocina", "notas": ["encimera de cuarzo"],
|
|
"fotos": { "antes": ["url", "..."], "despues": [] } }
|
|
]
|
|
}
|
|
```
|
|
|
|
> El flujo externo genera los renders y los devuelve como items `foto` con `momento:"despues"`
|
|
> por `POST /api/leads/:id/ingesta`, y cierra con `finalizar:true`.
|
|
|
|
### `WHATSAPP_WEBHOOK_URL` — entrega del PDF
|
|
|
|
Disparado por `finalizar:true`. Payload:
|
|
|
|
```json
|
|
{
|
|
"leadId": "uuid",
|
|
"telefono": "+34...",
|
|
"nombre": "...",
|
|
"empresa": "Reformas Ejemplo",
|
|
"pdfBase64": "JVBERi0xLj...",
|
|
"filename": "presupuesto-nombre.pdf"
|
|
}
|
|
```
|
|
|
|
### `WHATSAPP_START_WEBHOOK_URL` — arranque de conversación
|
|
|
|
Disparado cuando el lead elige continuar por WhatsApp en el funnel. Payload:
|
|
|
|
```json
|
|
{ "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`;
|
|
las notas en `lead_notas`. Ver `mvp/b2c/db-schema/`.
|
|
- **Email:** la entrega por email es real vía SMTP (`SMTP_*` + `EMAIL_FROM`). Sin configurar,
|
|
`finalizado.emailEnviado` será `false` y el evento queda marcado como simulado.
|
|
- Variables de entorno: ver `mvp/b2c/.env.example`.
|