diff --git a/docs/handoff-whatsapp-simon.md b/docs/handoff-whatsapp-simon.md index f4ad04d..743be69 100644 --- a/docs/handoff-whatsapp-simon.md +++ b/docs/handoff-whatsapp-simon.md @@ -4,6 +4,10 @@ Cómo integra el bot de WhatsApp con la app Reformix. **Una sola base de datos** Postgres). El **lead se crea siempre desde el form web**, así que cuando el cliente elige WhatsApp el lead **ya existe** y te pasamos su `leadId`. No creas leads tú. +> **Modelo de integración (decidido):** el bot **no toca Postgres directamente**. Toda la escritura +> va por **endpoints HTTP** autenticados (no necesitas credenciales de BD ni estar en la red de +> Dokploy). Ya están **desplegados y probados** en `https://reformix.dv3.com.es`. + --- ## 1. Cómo arranca tu flujo @@ -17,77 +21,97 @@ Cuando el cliente elige "WhatsApp" en el funnel, la app hace `POST` al webhook A partir de ahí Luisa escribe al `telefono` y trabaja **siempre con ese `leadId`**. -## 2. Reparto: qué escribes en la DB directamente vs qué mandas por el EP +## 2. Cómo escribes en la BD: por API, no SQL -| Acción | Cómo | +| Qué guardas | Endpoint | | --- | --- | -| Historial del chat, calificación, intentos, jobs, estado del bot | **DB directa** (tus tablas, ver §3) | -| Subir **fotos** del cliente + **notas/datos** que extraes | **EP** `POST /api/leads/:id/ingesta` | -| Señalar "perfil completo" / devolver renders / cerrar y entregar | **EP** (flags `perfilCompleto` / `finalizar`) | +| Cada turno del chat (+ estado del mensaje y paso del bot) | `POST /api/leads/:id/conversacion` | +| Lo que vas extrayendo del lead (espacio, m², estilo, urgencia, presupuesto, viabilidad…) | `POST /api/leads/:id/perfil` | +| Calificación del lead (score/nivel/criterios) | `POST /api/leads/:id/calificacion` | +| Intento de contacto (resultado de cada intento) | `POST /api/leads/:id/intento` | +| **Fotos** del cliente + **notas/datos** por zona | `POST /api/leads/:id/ingesta` | +| Señalar "perfil completo" / devolver renders / cerrar y entregar | `POST /api/leads/:id/ingesta` (flags `perfilCompleto` / `finalizar`) | -**Por qué el EP para fotos/cierre:** ahí se dispara nuestra lógica (motor de presupuesto, PDF, -email, señales). Si escribieras `lead_fotos` a mano, esa lógica no corre. Doc del EP: -[`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md). Auth: `Authorization: Bearer `. +**Auth (todos):** header `Authorization: Bearer ` (te paso la clave aparte; es la +misma para todos los EPs). `Content-Type: application/json`. `:id` = el `leadId` del §1. -## 3. Tablas que escribes directamente (ya creadas en la DB única) +**Por qué por EP y no SQL:** así se dispara nuestra lógica (motor de presupuesto, PDF, email, +señales) y el esquema queda blindado (validación + tipos). Si escribieras las tablas a mano, esa +lógica no corre y un valor inválido rompería la fila. **Doc completa con campos y ejemplos curl:** +[`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md). -- **`conversacion_whatsapp`** — un registro por turno: `lead_id`, `rol` (`user`/`assistant`/`system`), `mensaje`, `media_type`, `media_url`, `transcripcion_audio`. -- **`intentos_contacto`** — `lead_id`, `canal` (`whatsapp`), `resultado`, `completado`, `numero_intento`, `duracion_seg`. -- **`lead_calificacion`** — 1 por lead: `lead_id` (único), `score` (0-100), `nivel` (`A`/`B`/`C`/`D`), `criterios` (jsonb). -- **`worker_jobs`** — cola async si la usas: `tipo` (`analisis_fotos`/`render`/`presupuesto_ia`), `estado_job`, `payload`, `webhook_url`. -- **`leads`** (UPDATE sobre el lead existente), columnas que te tocan: - - `bot_step` (TEXT) — paso actual de la conversación (ver §5). - - `estado_wa` — entrega del **mensaje** (`sin_enviar`/`enviado`/`entregado`/`leido`/`fallido`). **No** es el paso de la conversación. - - Extracción en crudo: `espacio`, `rango_m2`, `estilo`, `presupuesto_declarado` (TEXT), `viable` (boolean), `fotos_solicitadas_at` (timestamp), `canal_origen`. - - Datos normalizados para el presupuesto: `tipo_reforma`, `m2_suelo`, `calidad_global`, `urgencia`, `presupuesto_target`, `taste_text`. +> `visitas` y `worker_jobs` quedan **fuera** de tu integración por ahora (son cola interna / panel +> del reformista). Si los necesitas, lo hablamos y abrimos EP. -> Los `id` se autogeneran (`gen_random_uuid()`); **no** llames a `uuid_generate_v4()` en SQL (esta DB no tiene la extensión `uuid-ossp`). +## 3. Resumen de los 4 EPs del bot + +Campos completos y curls en api-docs; aquí el mínimo de cada uno. + +- **`conversacion`** — `{ rol: user|assistant|system, mensaje, [mediaType, mediaUrl, transcripcionAudio, estadoWa, botStep] }` → `{ ok, id }`. +- **`perfil`** (update parcial, solo lo que mandes) — cualquier subconjunto de: `botStep, estadoWa, canalOrigen, viable, espacio, rangoM2, estilo, presupuestoDeclarado, fotosSolicitadasAt, tipoReforma, m2Suelo, calidadGlobal, urgencia, presupuestoTarget, tasteText, estructural` → `{ ok, actualizado:[...] }`. +- **`calificacion`** (upsert, 1 por lead) — `{ [score 0-100, nivel A|B|C|D, criterios{}, notasAgente] }` → `{ ok }`. +- **`intento`** — `{ canal: formulario|whatsapp|llamada, numeroIntento, [resultado, completado, duracionSeg, notas, metadata{}] }` → `{ ok, id }`. + +Errores comunes a todos: `401` (sin Bearer o clave mala), `404` (lead no existe), `422` (JSON o +validación). Body de error: `{ ok:false, error:"..." }`. ## 4. Enums y tipos: usa los valores de la API (esto es lo que hay que alinear en el bot) -Tu esquema `reformix-full` tenía otros valores. En la DB única mandan estos: +Tu esquema `reformix-full` tenía otros valores. En la API mandan estos (el EP rechaza con `422` lo +que no encaje): -**`tipo_reforma`** → `cocina · bano · salon · comedor · integral · otro` +**`tipoReforma`** → `cocina · bano · salon · comedor · integral · otro` `oficina`/`local`/`otros` → usa `otro`. **`urgencia`** → `alta · media · baja` (tu `inmediata` → `alta`). -**`lead_estado`** → `nuevo · contactado · visita_agendada · presupuesto_enviado · ganado · perdido` -`en_proceso`/`calificado` → `contactado`; `cliente` → `ganado`; `archivado` → `perdido`. +**`estadoWa`** (entrega del **mensaje**) → `sin_enviar · enviado · entregado · leido · fallido`. -**`pipeline_stage`** → **no lo escribas**. Lo gestiona nuestro funnel/EP automáticamente. +**`canalOrigen`** → `formulario_web · whatsapp · llamada · referido · anuncio`. -**Tipos:** `estructural` = **boolean** (no texto). `calidad_global` = enum **`basica`/`media`/`premium`** (no 1-10; la extracción cruda de calidad va en `estilo`/`taste_text`). +**`calificacion.nivel`** → `A · B · C · D`. **`intento.canal`** → `formulario · whatsapp · llamada`. +**`intento.resultado`** → `exitoso · no_contesta · ocupado · rechaza · error_tecnico`. -## 5. `bot_step` (estado de la conversación de Luisa) — NUEVO, persistido +**Tipos:** `estructural` = **boolean** (no texto). `calidadGlobal` = enum **`basica`/`media`/`premium`** +(no 1-10; la extracción cruda de calidad va en `estilo`/`tasteText`). `m2Suelo` = número (>0). +`presupuestoTarget` = entero en **céntimos**. `fotosSolicitadasAt` = string ISO datetime. -Columna `leads.bot_step` (TEXT, nullable). Guarda el paso actual para verlo en el panel y poder -retomar si el chat se corta. Valores sugeridos (puedes ajustar el vocabulario, es TEXT): +**`pipeline_stage` / `estado`** → **no los escribas**. Los gestiona nuestro funnel/EP. + +## 5. `bot_step` (estado de la conversación de Luisa) — persistido + +Texto libre (lo mandas en `conversacion.botStep` o `perfil.botStep`). Lo guardamos en +`leads.bot_step` para verlo en el panel y poder retomar si el chat se corta. Valores sugeridos +(puedes ajustar el vocabulario, es TEXT): `apertura → espacio → tamano → estilo → urgencia → presupuesto → pide_fotos → fotos_recibidas → completado` Terminales: `no_viable`, `abandonado`. +> Ojo: `estadoWa` es la **entrega del mensaje** (enviado/leído…), **no** el paso de la conversación. +> El paso es `botStep`. + ## 6. Webhooks salientes de la app (los recibes/encadenas tú) - `WHATSAPP_START_WEBHOOK_URL` — inicio (§1). -- `PERFIL_WEBHOOK_URL` — cuando se marca `perfilCompleto`, te llega toda la data por zona para generar renders/agente (payload en api-docs §webhooks). -- `WHATSAPP_WEBHOOK_URL` — entrega: cuando el PDF está listo, te llega `{ pdfBase64, telefono, ... }` para mandarlo por WhatsApp. +- `PERFIL_WEBHOOK_URL` — cuando marcas `perfilCompleto` en ingesta, te llega toda la data por zona para generar renders/agente (payload en api-docs §webhooks). +- `WHATSAPP_WEBHOOK_URL` — entrega: cuando el PDF está listo (`finalizar`), te llega `{ pdfBase64, telefono, ... }` para mandarlo por WhatsApp. Pásanos las **3 URLs** y las ponemos en producción (Dokploy). ## 7. Lo que hace la app sola (no lo dupliques) `pipeline_stage`, cálculo del **presupuesto** orientativo, generación del **PDF** y envío del -**email** los hace la app cuando llamas al EP con `finalizar`. Tú aportas fotos/notas y el estado -de la conversación; la api producimos el entregable. +**email** los hace la app cuando llamas a ingesta con `finalizar`. Tú aportas fotos/notas, el +historial del chat y el estado de la conversación; la app produce el entregable. -## 8. Conectividad (ops) +## 8. Estado: probado y en producción -La DB es el Postgres de la app en Dokploy (host interno `reformix-db-…`). Si tu bot corre en la -misma red de Dokploy puede conectarse directo; si es externo, hay que exponer credenciales/host. -Decidir con Carlos. Para el EP solo necesitas la URL pública + `FUNNEL_API_KEY`. +Los 4 EPs están desplegados en `https://reformix.dv3.com.es` y verificados end-to-end (lead real → +`200` con id de fila insertada, perfil reflejado en el panel, upsert de calificación correcto). +Smoke test reutilizable: [`mvp/b2c/api-docs/smoke-bot-eps.mjs`](../mvp/b2c/api-docs/smoke-bot-eps.mjs). --- -**Resumen de lo que necesito de ti (Simón):** (1) las 3 URLs de webhook, (2) confirmar que el bot -usa nuestros enums/tipos (§4), (3) cómo te conectas a la DB (directo en red Dokploy o externo). +**Resumen de lo que necesito de ti (Simón):** (1) las **3 URLs de webhook** (§6), (2) confirmar que +el bot usa nuestros **enums/tipos** (§4). La conexión a la BD ya no hace falta: trabajas solo con la +URL pública + `FUNNEL_API_KEY`. diff --git a/mvp/b2c/api-docs/smoke-bot-eps.mjs b/mvp/b2c/api-docs/smoke-bot-eps.mjs new file mode 100644 index 0000000..bbe07f5 --- /dev/null +++ b/mvp/b2c/api-docs/smoke-bot-eps.mjs @@ -0,0 +1,107 @@ +// Smoke test de los EPs del bot de WhatsApp contra un entorno real. +// Crea un lead de PRUEBA, ejerce los 4 EPs por HTTP, verifica las escrituras en la BD y borra +// el lead (el cascade limpia las filas hijas). No toca leads reales. +// +// Uso (PowerShell): +// $env:DATABASE_URL="postgres://..."; $env:BASE_URL="https://reformix.dv3.com.es"; $env:FUNNEL_API_KEY="..." +// node mvp/b2c/api-docs/smoke-bot-eps.mjs +// +// DATABASE_URL solo hace falta para crear/verificar/limpiar el lead de prueba; el bot real +// únicamente necesita BASE_URL + FUNNEL_API_KEY para usar los EPs. + +import postgres from 'postgres'; + +const { DATABASE_URL, BASE_URL, FUNNEL_API_KEY } = process.env; +if (!DATABASE_URL || !BASE_URL || !FUNNEL_API_KEY) { + console.error('Faltan env: DATABASE_URL, BASE_URL y FUNNEL_API_KEY son obligatorias.'); + process.exit(1); +} + +const sql = postgres(DATABASE_URL, { prepare: false }); + +async function api(path, body) { + const r = await fetch(`${BASE_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${FUNNEL_API_KEY}` }, + body: JSON.stringify(body), + }); + const json = await r.json().catch(() => null); + return { status: r.status, json }; +} + +let leadId; +let ok = true; +const check = (label, cond, extra) => { + ok = ok && cond; + console.log(`${cond ? 'OK ' : 'FAIL'} ${label}${extra ? ` ${extra}` : ''}`); +}; + +try { + const [tenant] = await sql`select id, nombre from tenants limit 1`; + if (!tenant) throw new Error('No hay tenants en la BD.'); + console.log(`tenant: ${tenant.nombre} (${tenant.id})`); + + const inserted = await sql` + insert into leads (tenant_id, nombre, telefono, email, tipo_reforma, consent_privacidad, consent_contratacion) + values (${tenant.id}, 'TEST EP Bot (smoke)', '+34600000000', 'smoke-bot-ep@example.com', 'cocina', true, true) + returning id`; + leadId = inserted[0].id; + console.log(`lead de prueba creado: ${leadId}\n`); + + const r1 = await api(`/api/leads/${leadId}/conversacion`, { + rol: 'user', mensaje: 'Quiero reformar la cocina', estadoWa: 'leido', botStep: 'espacio', + }); + check('POST conversacion → 200', r1.status === 200, JSON.stringify(r1.json)); + + const r2 = await api(`/api/leads/${leadId}/perfil`, { + tipoReforma: 'cocina', m2Suelo: 12.5, calidadGlobal: 'premium', urgencia: 'alta', viable: true, botStep: 'tamano', + }); + check('POST perfil → 200', r2.status === 200, JSON.stringify(r2.json)); + + const r3a = await api(`/api/leads/${leadId}/calificacion`, { score: 60, nivel: 'C', criterios: { fase: 'inicial' } }); + check('POST calificacion #1 (insert) → 200', r3a.status === 200, JSON.stringify(r3a.json)); + const r3b = await api(`/api/leads/${leadId}/calificacion`, { score: 82, nivel: 'A', notasAgente: 'Lead caliente' }); + check('POST calificacion #2 (upsert) → 200', r3b.status === 200, JSON.stringify(r3b.json)); + + const r4 = await api(`/api/leads/${leadId}/intento`, { + canal: 'whatsapp', numeroIntento: 1, resultado: 'exitoso', completado: true, metadata: { origen: 'smoke' }, + }); + check('POST intento → 200', r4.status === 200, JSON.stringify(r4.json)); + + console.log('\n— verificación en BD —'); + const conv = await sql`select rol, mensaje from conversacion_whatsapp where lead_id=${leadId}`; + check('conversacion_whatsapp: 1 turno', conv.length === 1, JSON.stringify(conv)); + + const [lead] = await sql` + select tipo_reforma, m2_suelo, calidad_global, urgencia, viable, bot_step, estado_wa from leads where id=${leadId}`; + check( + 'leads(perfil): cocina/12.5/premium/alta/viable + bot_step=tamano + estado_wa=leido', + lead.tipo_reforma === 'cocina' && Number(lead.m2_suelo) === 12.5 && lead.calidad_global === 'premium' && + lead.urgencia === 'alta' && lead.viable === true && lead.bot_step === 'tamano' && lead.estado_wa === 'leido', + JSON.stringify(lead), + ); + + const calif = await sql`select score, nivel, notas_agente from lead_calificacion where lead_id=${leadId}`; + check( + 'lead_calificacion: 1 fila tras upsert, score=82 nivel=A notas conservadas', + calif.length === 1 && calif[0].score === 82 && calif[0].nivel === 'A' && calif[0].notas_agente === 'Lead caliente', + JSON.stringify(calif), + ); + + const intentos = await sql`select canal, resultado, completado, numero_intento from intentos_contacto where lead_id=${leadId}`; + check( + 'intentos_contacto: 1 intento whatsapp exitoso completado', + intentos.length === 1 && intentos[0].canal === 'whatsapp' && intentos[0].resultado === 'exitoso' && + intentos[0].completado === true && intentos[0].numero_intento === 1, + JSON.stringify(intentos), + ); +} finally { + if (leadId) { + await sql`delete from leads where id=${leadId}`; + console.log(`\nlead de prueba borrado: ${leadId}`); + } + await sql.end(); +} + +console.log(`\n${ok ? 'TODO OK ✅' : 'HAY FALLOS ❌'}`); +process.exit(ok ? 0 : 1);