Files
reformix-hackaton/mvp/b2c/api-docs
Carlos Narro 8b96037dad 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>
2026-06-07 20:04:11 +02:00
..

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: 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.

# 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 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:

{
  "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}'

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.