# 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 `. 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 `), `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": "" }`. ```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": "" }`. ```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`.