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>
11 KiB
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:
{
"ok": true,
"fotos": 1,
"notas": 1,
"perfilSenalado": false,
"finalizado": null
}
fotos/notas: cuántos items de cada tipo se guardaron.perfilSenalado:truesiperfilCompletodisparó el webhook y respondió ok.finalizado:nullsi 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.
# 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:
{
"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
fotoconmomento:"despues"porPOST /api/leads/:id/ingesta, y cierra confinalizar:true.
WHATSAPP_WEBHOOK_URL — entrega del PDF
Disparado por finalizar:true. Payload:
{
"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:
{ "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>" }.
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"] }.
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 }.
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>" }.
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}'
visitasyworker_jobsquedan 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 enlead_notas. Vermvp/b2c/db-schema/. - Email: la entrega por email es real vía SMTP (
SMTP_*+EMAIL_FROM). Sin configurar,finalizado.emailEnviadoseráfalsey el evento queda marcado como simulado. - Variables de entorno: ver
mvp/b2c/.env.example.