Compare commits

..

94 Commits

Author SHA1 Message Date
Carlos Narro
ff047cac2e Añade overlay de play sobre la captura de WhatsApp en landing B2B
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:37:53 +02:00
unknown
d783ce56d4 Configuracion de imagen 2026-06-11 12:28:40 -04:00
unknown
ba7b10a778 Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-11 11:45:36 -04:00
unknown
b4e8f6d3a3 Configuracion de titulos 2026-06-11 11:45:23 -04:00
Carlos Narro
dbef9ef670 Añade stepper de progreso y bloque "Qué pasa después" al chooser de canal
El chooser (solicitud/[id]) ahora muestra un stepper de 3 pasos (Tus datos ✓, Tu reforma actual, Render + presupuesto pendiente) y, debajo de las tarjetas de canal, una sección "Elijas lo que elijas, esto es lo que pasa después" con los 3 pasos del flujo (nos cuentas tu reforma, render + presupuesto en minutos, visita gratuita para el presupuesto final).

Las tarjetas de canal pasan a grid de 3 columnas en desktop, con iconos SV
2026-06-11 17:42:09 +02:00
Carlos Narro
facf3cd79f Landing del cliente: galería apaisada con lightbox y quita "Entrar"
La galería de trabajos (GaleriaTrabajos) en la landing personalizada del cliente
pasa a formato apaisado (3/2) y, al pulsar una foto, se amplía en un lightbox con
navegación ‹ ›, Esc y clic fuera para cerrar. Se quita el botón "Entrar" del
header de esa landing (TenantBrand sin showLogin): el cliente final no entra al
panel.

Revierte el cambio anterior en public/b2b.html (era la landing equivocada).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:40:12 +02:00
Carlos Narro
5afda5af05 Arregla pérdida de datos al desplegar: el seed solo siembra si la DB está vacía
El docker-entrypoint corre db:seed en cada arranque. El guard comprobaba si
existía un tenant CONCRETO ("reformas-ejemplo"); si ese no estaba pero había
otros (una empresa creada por el reformista), el seed ejecutaba TRUNCATE de
todas las tablas y resembraba el demo → borraba los datos reales en cada deploy.

Ahora el guard salta si existe CUALQUIER tenant, así que con datos reales el seed
nunca toca la DB. SEED_FORCE=1 sigue forzando el reseed (borra todo) a propósito.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:38:38 +02:00
Carlos Narro
df700bcbfb Landing B2B: galería de ejemplos apaisada con lightbox y quita "Entrar"
- Nueva sección "Ejemplos reales": 3 renders (cocina, baño, salón-comedor) en
  formato apaisado (16/10), clicables.
- Lightbox reutilizable (data-zoom + data-gallery): amplía la imagen con
  navegación ‹ ›, Esc y clic fuera para cerrar; agrupa por galería.
- El antes/después de "Cómo funciona" pasa a apaisado (4/3) y también abre el
  lightbox (grupo cocina).
- Quita el botón "Entrar" del header (queda solo "Empezar gratis").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:25:15 +02:00
Carlos Narro
d92d5e2f12 Añade onboarding guiado del panel (tour con driver.js)
Tour por pestañas que explica los puntos clave: en Leads recorre la navegación
(las pestañas secundarias de pasada) + filtros y tabla; en la ficha del lead el
presupuesto/baremo, estado, render y desglose; en Precios el baremo, la mano de
obra y el catálogo. Auto-arranca la primera vez por pestaña (flag en localStorage)
y deja un botón flotante " Tour" para repetir. Pasos sin elemento visible se
descartan (degrada en móvil).

- Dependencia: driver.js (librería estándar de tours, ~5kb, sin más deps;
  evita reinventar overlay/posicionamiento/foco/accesibilidad).
- src/lib/onboarding/panel-tour.ts: pasos por ruta. PanelTour.tsx: cliente que
  lanza driver.js. data-tour en nav, leads, ficha y precios.
- Copy en COPY-GUIDE.md (sección "Onboarding del panel").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:19:01 +02:00
Carlos Narro
b815b0532b Añade baremo de rentabilidad (valor de panel) e indicador en la ficha del lead
Parte C del plan: el baremo mínimo de rentabilidad es ahora un valor configurable
del reformista, solo informativo. Los agentes NO lo usan para decidir nada.

- schema: pricing_config.baremo_minimo (céntimos, nullable) + migración 0012.
- pricing-queries / budget types: exponen baremoMinimo.
- panel/precios: sección "Baremo de rentabilidad" + action actualizarBaremo
  (vacío = sin baremo).
- panel/[id]: el presupuesto estimado se muestra en rojo con aviso "Por debajo
  de tu baremo (X €)" cuando no alcanza el baremo del tenant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:58:55 +02:00
Carlos Narro
0c033eb367 Sandbox: campos opcionales de estilo y gustos del cliente
Añade al formulario del sandbox dos inputs opcionales (estilo, gustos) y los
manda en el body de /sandbox/render, para probar desde la web cómo las
preferencias del cliente condicionan el render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:46:53 +02:00
Carlos Narro
ad87e45892 Lleva las preferencias del cliente (estilo, color, material) al render
Hasta ahora el render solo se condicionaba con tipo/m²/calidad + notas de texto
libre por zona; lo que el cliente decía hablando con Luisa o en la llamada
(estilo, colores, materiales) se guardaba en estilo/tasteText pero NO viajaba al
generador de imagen, así que el render no lo representaba.

- b2c (perfil.ts): el payload de PERFIL_WEBHOOK_URL incluye ahora
  preferencias:{estilo, gustos} (gustos = tasteText). Claves vacías se omiten.
- worker (webhook.dto): nuevo PreferenciasDto opcional.
- worker (prompt-builder): construirUserContent (función pura) inyecta el estilo
  y los gustos del cliente como bloque dedicado y omite el "modern" por defecto
  cuando hay preferencias; el system prompt prioriza colores/materiales del
  cliente sobre un estilo genérico.
- worker (pipeline): enhebra preferencias hasta generarPrompt.
- worker (sandbox): acepta estilo/gustos para poder probarlos.
- docs/arquitectura-integracion: documenta el campo preferencias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:17:45 +02:00
Carlos Narro
f6e0347143 Hace a Luisa cálida y servicial y elimina el rechazo de leads
El bot rechazaba leads con presupuesto bajo y sonaba frío/repetitivo. Ahora
Luisa nunca rechaza (el cierre siempre es positivo; la rentabilidad la valora
el reformista en el panel, no el agente) y tiene el tono del agente de voz:
simpática, variada y dispuesta a ayudar con referencias.

- leads.service: getSiguienteEstado tras presupuesto siempre fin_viable;
  evaluarViabilidad ya no filtra (informa viable=true).
- claude.service: relaja las reglas de la pasada de corrección (permite calidez,
  conectores, emoji suave, 2-3 líneas, variación) y quita el guard que tomaba
  "en qué puedo ayudarte" como frase de IA.
- prompts: reescribe el guion de Luisa (mensajes como ejemplos variables, ayuda
  con referencias de tamaño/materiales, sin rechazo) y elimina el bloque
  <DATOS_EXTRAIDOS>, que era código muerto contradictorio con el generador.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:20:52 +02:00
Carlos Narro
dabdf32b8e Calcula el presupuesto en el flujo: el PDF ya incluye el importe
El PDF llegaba con render+fotos pero sin importe porque nadie ejecutaba el motor.
Nuevo helper calcularPresupuestoLead (reutiliza computeBudget + catálogo del
orquestador). Se llama tras el post-análisis (WhatsApp/llamada) y, como red de
seguridad, al inicio de finalizarYEntregar antes de construir el PDF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:12:05 +02:00
Carlos Narro
b0871b733c Paso de fotos + flujo cross-canal llamada→WhatsApp para el render
Bot: al cerrar la cualificación, Luisa pide una foto y entra en modo recogida;
al llegar la foto la sube como "antes" con perfilCompleto:true → dispara render +
presupuesto + entrega del PDF. Nuevo webhook /whatsapp-fotos para que, tras una
llamada, Luisa escriba al lead, referencie lo hablado y le pida las fotos
(reutiliza el mismo modo).

App: el webhook de Retell, tras el análisis de la llamada, llama a pedirFotosWhatsapp
(WHATSAPP_FOTOS_WEBHOOK_URL) con el contexto de la reforma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:50:33 +02:00
Carlos Narro
c5d4a9296a Unifica la captura de datos: el webhook de Retell usa el mismo agente de análisis
Generaliza analizarTranscripcion(leadId, transcript, origen) como núcleo agnóstico
del canal. WhatsApp arma la transcripción desde conversacion_whatsapp; el webhook
de Retell, tras guardar leads.transcripcion, llama al mismo agente con la
transcripción de la llamada → ambos canales extraen espacio/m2/calidad/urgencia/
presupuesto/viable con un único cerebro LLM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:25:41 +02:00
Carlos Narro
166d52f46d Bot: dispara el post-análisis al cerrar la cualificación
Cuando la conversación llega al cierre (estado presupuesto/fin_*, o Luisa anuncia
que prepara/envía el presupuesto), el bot llama una vez a POST /api/leads/:id/analizar
para que la app capture todos los datos clave de la conversación de una pasada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:56:05 +02:00
Carlos Narro
1471261a73 App: agente de post-análisis de la conversación de WhatsApp
POST /api/leads/:id/analizar lee toda la conversacion_whatsapp del lead, extrae
con un LLM (OpenRouter) los datos clave (tipoReforma, m2, calidad, urgencia,
presupuesto, viable + crudos) y los persiste en el lead de una pasada. Robusto
frente a la extracción turno-a-turno frágil del bot. El bot lo llamará al cerrar
la cualificación. Helper lib/ai/openrouter.ts + env OPENROUTER_API_KEY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:51:25 +02:00
Carlos Narro
50480b6fc5 Handoff bot: hallazgos reales de logs (conflict/503, persist parcial, sin trigger)
Vía SSH al VPS + docker logs: la conexión Baileys está en bucle de reconexión
(conflict:replaced + 503); persistirTurno funciona (→ ok) pero solo se llama en
algunos turnos y la máquina de estados se descuadra; y el bot NUNCA llama a
ingesta/perfilCompleto, así que la generación de presupuesto/render/entrega no
se dispara (Problema C, el que rompe el end-to-end).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:17:33 +02:00
Carlos Narro
face2d3d1b Handoff runtime del bot para Simón: estado, 2 issues y vía Evolution API
Documenta lo que funciona (flujo conversacional end-to-end, EPs, worker), los
cambios de hoy en el bot (@lid, by-phone, markOnline, modelos, apertura, /qr,
/debug) y los 2 problemas de runtime que quedan en su lado: Baileys deja de
recibir tras reconectar (vía robusta = Evolution API, no Cloud API oficial) y
la persistencia parcial del perfil (revisar logs de persistirTurno).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:16:42 +02:00
Carlos Narro
5004575768 Bot: markOnlineOnConnect=true para recibir mensajes tras reconectar
Con false, al reanudar la sesión guardada el dispositivo quedaba "no disponible"
y WhatsApp no le entregaba los mensajes entrantes (socket open pero inbound=0).
Con un escaneo fresco sí recibía (por el sync de emparejamiento). Marcando online
al conectar, WhatsApp entrega los mensajes también tras reconectar/redeploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:58:06 +02:00
Carlos Narro
e5be1220d8 Bot: resolver @lid al número real para casar el lead (mensajes entrantes)
Confirmado vía /debug: WhatsApp entrega los mensajes desde una dirección @lid
(p.ej. 239225534443615@lid), no desde el número. El bot tomaba el id del lid
como teléfono -> no casaba con ningún lead -> ignoraba el mensaje. Ahora
resolverTelefono() resuelve el @lid a número vía msg.key.remoteJidAlt o el mapa
LID->PN de Baileys antes de buscar el lead. Debug captura también remoteJidAlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:44:00 +02:00
Carlos Narro
78079e9455 Bot: carpeta de sesión Baileys configurable + /debug captura todos los eventos
BAILEYS_AUTH_DIR permite arrancar una sesión limpia (QR fresco) sin perder la
persistencia (subcarpeta del volumen) — para re-vincular cuando WhatsApp deja
muerto el dispositivo tras varios reinicios. Y messages.upsert ahora registra
en /debug todos los eventos (incluido type != notify) antes de filtrar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:00:14 +02:00
Carlos Narro
8d565e5fb0 Bot: endpoint /debug para diagnosticar entrantes + estado de conexión
GET /debug (Basic auth, QR_TOKEN) devuelve el estado de la conexión de WhatsApp
y un anillo de los últimos mensajes entrantes (remoteJid real, fromMe, tipo) y
el resultado del matching lead↔teléfono. Para diagnosticar por qué el bot no
responde (conexión zombi vs formato de jid @lid vs no llega el mensaje).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:51:10 +02:00
Carlos Narro
a8b6d62dd6 Bot responde a mensajes entrantes: recupera lead por teléfono + fix matching
El bot no respondía a las réplicas del cliente: la sesión lead↔teléfono vivía
solo en memoria y no casaba (se pierde al reiniciar el contenedor). Ahora si no
está en memoria, getOrCreateContext busca el lead en la BD por teléfono vía un
EP nuevo GET /api/leads/by-phone (match por últimos 9 dígitos) y re-registra la
sesión. Aparte, los ids de modelo de Claude del bot estaban mal (-4-5 vs 4.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:28:56 +02:00
Carlos Narro
0a00d42553 Bot: enviar apertura proactiva al recibir /whatsapp-start + fix clave teléfono
El funnel promete "te escribimos por WhatsApp" pero el bot solo registraba la
sesión y esperaba a que el cliente escribiera primero → no llegaba nada. Ahora
WhatsappService escucha un startEmitter y manda el mensaje de apertura de Luisa
al teléfono (verifica el número con onWhatsApp), persiste estadoWa/botStep y el
intento. Además normaliza la clave de teléfono a solo-dígitos en leadSessions
(antes "+34..." no casaba con los dígitos del jid entrante → ignoraba al cliente).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:18:10 +02:00
Carlos Narro
a740d08863 Seguridad /qr: HTTP Basic Auth con QR_TOKEN dedicado (no secreto en la URL)
Review de seguridad: autenticar por query string filtra el secreto (logs,
historial, Referer) y usaba la misma FUNNEL_API_KEY que autoriza la API.
Ahora /qr usa HTTP Basic (credencial en cabecera), un QR_TOKEN dedicado
distinto de FUNNEL_API_KEY, comparación en tiempo constante (timingSafeEqual
sobre hashes) y Referrer-Policy: no-referrer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:51:46 +02:00
Carlos Narro
a43e7a77be Bot: página /qr para vincular WhatsApp con QR escaneable
El QR de Baileys solo salía como ASCII en los logs (ilegible en Dokploy). Ahora
el WhatsappService empuja el QR al WebhookListener, que sirve GET /qr?key=
<FUNNEL_API_KEY> con el código como imagen (lib qrcode), autorrefresco y estado
"ya conectado". Añade dependencia qrcode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:46:06 +02:00
Carlos Narro
062a34c144 Sandbox de renders en el image-worker + fixes del pipeline
Sandbox web (/sandbox) servido por el worker, reusando los mismos servicios del
pipeline, para iterar prompts/modelos con una imagen y ver variaciones+score, y
guardar la config ganadora (aplica al worker al instante, persistida en volumen):
- SettingsService: store de config (system prompts + modelos) con defaults de
  prompts/*.txt + env y override persistido en /app/data (sobrevive a redeploys).
- /sandbox (HTML), GET /sandbox/config, POST /sandbox/render, POST /sandbox/save
  (auth Bearer FUNNEL_API_KEY). DOM seguro, sin innerHTML de contenido externo.
- prompt-builder/supervisor/image-generator aceptan overrides y leen de Settings.

Fixes del pipeline de generación:
- image-generator: pide modalities ['image','text'] y extrae la imagen de
  message.images[] (forma real de OpenRouter), no solo de content.
- main.ts: sube el límite de body a 30mb (las fotos en data URI rompían el 100kb).

Deja de versionar artefactos de build (dist/ + *.tsbuildinfo); .gitignore en
image-worker y Whatsapp-bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:32:11 +02:00
Carlos Narro
89166857e7 Doc despliegue: añade notas de integración para Simón (GETs, a pulir)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:28:41 +02:00
Carlos Narro
c5e73d1688 Quita la restricción ALLOWED_NUMBER del bot de WhatsApp
A petición del usuario: el bot conversa con cualquier número. Doc actualizada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:13:40 +02:00
Carlos Narro
d0b9744a24 Documenta el despliegue de Luisa + image-worker en el VPS
Tres servicios unidos en Dokploy (reformix-b2c/bot/worker), dominios, webhooks
configurados en b2c, y los 2 pasos manuales pendientes (OPENROUTER_API_KEY + QR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:48:45 +02:00
Carlos Narro
d34925cd7f Prepara deploy de Luisa + image-worker: Dockerfiles + GET endpoints
- Dockerfiles multistage (node:20-alpine) + .dockerignore para mvp/Whatsapp-bot
  (expone 3000+3001, persiste prompts) y mvp/image-worker (expone 3001).
- Añade los 2 GET que el bot necesita y faltaban en la API:
  GET /api/leads/:id (estado del lead) y GET /api/leads/:id/conversacion (historial).
- Limpia el package.json/lock de la raíz (baileys colado por error; no es workspace).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:36:14 +02:00
unknown
25669f3008 configuracion de arquitectura y manejo de herramientas 2026-06-07 18:30:29 -04:00
unknown
cb44779349 Proeycto de images-worker creado 2026-06-07 18:11:44 -04:00
unknown
fec365bb57 Configuracion de agente de whastapp paratrabajar con la estructura propuesta 2026-06-07 17:51:53 -04:00
Carlos Narro
d3189d7277 Reescribe el handoff de WhatsApp al modelo por EP + smoke test de los bot EPs
El bot de Luisa puebla la BD vía los 4 EPs HTTP (no SQL directo): conversacion,
perfil, calificacion, intento. Actualiza el handoff de Simón en consecuencia
(qué EP usa para cada cosa, enums/tipos a alinear, ya no necesita acceso a BD).

Añade api-docs/smoke-bot-eps.mjs: crea un lead de prueba, ejerce los 4 EPs por
HTTP, verifica en BD y limpia. Verificado end-to-end en produccion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:07:13 +02:00
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
Carlos Narro
508fc43f1f Enlace del email = subir solo fotos (sin re-preguntar ni re-llamar)
Arregla 2 problemas del flujo de subir fotos desde el email:
- El enlace iba a /formulario (form completo) y al enviarlo re-ejecutaba
  procesarLead, que VOLVÍA a llamar. Ahora el email apunta a /solicitud/[id]/fotos,
  una página ligera (SubirFotos): solo sube fotos (+ nota opcional) al lead de la
  URL, re-señala perfilCompleto y NO llama.
- Guarda en procesarLead: si el lead ya tiene llamada_completada, no se vuelve a
  llamar (ni se pisa la transcripción real del webhook).
Copy de la página en COPY-GUIDE §3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:33:15 +02:00
Carlos Narro
98f02eb02e Bypass de llamadas fail-closed (review de seguridad)
El gate del allowlist miraba allow.length>0 (tras parsear), así que un typo en
RETELL_ALLOWED_NUMBERS que no dejara ningún número válido abría las llamadas a
todos (fail-open). Ahora mira la presencia de la variable: si está puesta, se
enforce; un typo restringe a nadie (fail-closed), acorde a la intención.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:04:27 +02:00
Carlos Narro
db46dc9cf3 Bypass de pruebas: solo se llaman números en RETELL_ALLOWED_NUMBERS
Guarda en iniciarLlamadaSaliente: si RETELL_ALLOWED_NUMBERS tiene valor (CSV de
E.164), solo se lanza la llamada a esos números; el resto se omite (devuelve
null, funnel en simulado). Vacío = se llama a todos. Protege tanto el form
(procesarLead) como el canal llamada (pedirLlamada). En prod = +34651194617.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:00:36 +02:00
Carlos Narro
ac04972100 Webhook Retell: fallback por teléfono cuando la llamada no trae lead_id
Si la llamada no lleva metadata.lead_id (llamadas manuales/antiguas), el webhook
busca el lead más reciente con ese teléfono (últimos 9 dígitos) y le asocia la
transcripción + grabación. Responde JSON informativo (matched/leadId) para poder
re-procesar llamadas a mano.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:56:16 +02:00
Carlos Narro
aed0ffae50 Captura transcripción + grabación reales de la llamada (webhook Retell)
- retell.ts: la llamada saliente manda metadata.lead_id; helpers obtenerLlamada
  (GET /v2/get-call, dato autoritativo) y descargarGrabacion (guarda el audio
  en nuestro sistema como data URI).
- /api/retell/webhook: en call_analyzed/call_ended relee la llamada por call_id,
  guarda la transcripción real en lead.transcripcion, descarga la grabación a
  lead.audio_url y deja el análisis + duración en un evento de pipeline. Seguro
  por re-fetch (no se fía del body del webhook).
- orchestrator/pedirLlamada: pasan leadId; procesarLead ya no guarda transcript
  simulado cuando la llamada es real (lo rellena el webhook).
- La ficha del panel ya mostraba transcripción + audio: ahora se pueblan solos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:49:12 +02:00
Carlos Narro
6ef69b403d Agente de voz: cuelga al detectar la despedida (end_call)
Añadida la herramienta end_call al Retell LLM + instrucción en el prompt para
colgar cuando el cliente se despide o tras la despedida del agente (y si no
consiente la grabación). Guía sincronizada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:44:36 +02:00
Carlos Narro
340c25f1a4 Sincroniza guía Retell: prompt de fotos condicional (WhatsApp otro número/email)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:22:08 +02:00
Carlos Narro
af4d1fa001 El agente pide fotos + auto-email del enlace al pedir llamada
- Prompt del agente de voz (Retell, en caliente + docs/retell-setup.md):
  recuerda al cliente que envíe fotos del espacio por WhatsApp o por el enlace
  del email, para que el render sea de su reforma.
- pedirLlamada: envía automáticamente el email con el enlace al formulario al
  solicitar la llamada (antes solo con un botón manual), para que la frase del
  agente "te enviamos un email" sea cierta. Best-effort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:12:23 +02:00
Carlos Narro
35669fa207 Añade docs/retell-setup.md: guía de activación del agente de voz
Pasos en el panel de Retell, prompt del agente listo para pegar (con las
variables dinámicas {{empresa_nombre}}/{{cliente_nombre}}/{{tipo_reforma}}/
{{provincia}}), variables que envía la app, compliance pendiente y cómo se
conecta (RETELL_API_KEY/FROM_NUMBER/AGENT_ID en Dokploy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:22:31 +02:00
Carlos Narro
782b847af5 Añade estudio de copy B2B (promesa/descripción/CTA) + marca demo decidido
- docs/copy-b2b-estudio.md: estrategia (audiencia nivel 2, trigger map, tono),
  7 opciones de H1 con ángulo/trigger/valoración, opciones de subhead y de CTA
  (orientado a registro→demo, no trial), combo recomendado y checks de
  autenticidad. Aprendizaje de compramostucoche aplicado.
- plan-accion: decisión "nada de 14 días → registro+demo" marcada como tomada;
  enlazado el estudio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:53:23 +02:00
Carlos Narro
1034994e3b Añade docs/plan-accion.md (plan de acción desde el feedback)
Plan vivo con checkboxes desglosado por áreas: canal WhatsApp, medida del m²,
landing B2B (copys/vídeo/oferta/demo vs trial/competencia), onboarding del
reformista, dominio propio y producción de vídeo. Incluye decisiones de
producto pendientes. Incluye también el ajuste del handoff de Simón.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:03:28 +02:00
Carlos Narro
620e1410f7 Corrige typo en docs/handoff-whatsapp-simon.md ("NUESTROS" → "de la API") 2026-06-04 19:24:17 +02:00
Carlos Narro
ca40593b5c Corrige typo en docs/handoff-whatsapp-simon.md ("nosotros" → "la api") 2026-06-04 19:23:47 +02:00
Carlos Narro
6d8fc56fb1 Persiste bot_step + handoff de WhatsApp para Simón
- Migración 0011: leads.bot_step (TEXT) = paso actual de la conversación del
  bot (Luisa), para verlo en el panel y poder retomar chats cortados. TEXT (no
  enum) para que el bot evolucione su vocabulario sin migración.
- docs/handoff-whatsapp-simon.md: spec de integración del bot (DB única, lead
  desde el form, reparto DB-directa vs EP, tablas que escribe, alineación de
  enums/tipos a los nuestros, bot_step, webhooks y conectividad).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:14:13 +02:00
Carlos Narro
f9d112ecaa Añade sección de vídeo demo + CTA en la landing B2B
Debajo del hero, nueva sección #demo: título + placeholder de vídeo 16:9
(sustituible por iframe/video cuando lo haya) + CTA "Empezar ahora". El botón
"Ver una demo en vivo" del hero apunta aquí (se movió el id #demo del mock del
hero a esta sección). Copy añadido a COPY-GUIDE §2.

Aplicado en public/b2b.html (servido en / y /b2b) y en mvp/b2b/index.html.
Nota: ambos ficheros ya tenían el hero divergido (public con copy más nuevo);
no toco ese copy aquí.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:34:31 +02:00
Carlos Narro
0b46de89f2 Añade docs/estados-flujo.html: flujos de estado por canal
Referencia visual que separa las 4 dimensiones de estado (pipeline_stage,
lead_estado, estado_wa, estado_conversacion del bot) y dibuja el flujo de
formulario/WhatsApp/llamada sobre ellas, para decidir el modelado del estado
de conversación de Luisa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:01:21 +02:00
Carlos Narro
f2b19ab719 Añade estructura aditiva del flujo WhatsApp/llamada + workers (DB única)
Integra el esquema "reformix-full" del equipo de forma ADITIVA, sin tocar los
enums ni columnas existentes de la app (una sola DB, Drizzle es el dueño):
- Enums nuevos: estado_wa, canal_contacto, canal_origen, resultado_contacto,
  rol_mensaje, job_tipo, job_estado, nivel_calificacion, visita_estado.
- Tablas nuevas: conversacion_whatsapp, intentos_contacto, lead_calificacion
  (score 0-100 + nivel A/B/C/D), visitas, worker_jobs (cola async de los
  workers de fotos/render/presupuesto). Referencian nuestros leads/users/tenants.
- Columnas nuevas en leads (nullable, las rellena el bot/Luisa): estado_wa,
  canal_origen, espacio, rango_m2, estilo, presupuesto_declarado, viable,
  fotos_solicitadas_at.
- Migración 0010 + db-schema/schema.sql regenerado.

El bot/n8n escribe estas tablas en la DB única y usa nuestros leads (creados
solo desde el form web). Pendiente: alinear valores de lead_estado/pipeline_stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:45:14 +02:00
Carlos Narro
cd38fe6233 Reconcilia la rama de Gitea con la integrada (GitHub)
Une la punta de Gitea (8ef99b5, que solo añadía la línea del .gitignore de la
colección Postman, ya presente aquí) con la rama integrada que incluye el
trabajo de Goyo (motor de presupuesto: impermeabilización, extras, zonas) y los
emails transaccionales + logo. Estrategia 'ours': se conserva el árbol actual
(no se pierde nada) y ambos remotos quedan en fast-forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:48:22 +02:00
Carlos Narro
4d464d40ef Incrusta el logo del reformista en los emails (CID) + tema de marca
- prepararLogo: el logo (data URI base64, URL http o ruta /public con APP_URL)
  se adjunta como imagen inline con Content-ID y se referencia con cid:, que
  es la forma fiable de mostrarlo en Gmail/Apple Mail/Outlook (el data URI
  directo lo bloquea Gmail). Fallback al nombre de la empresa si no hay logo.
- El acento (barra, botón) ya usa el color de marca del tenant (resolveTheme).

Probado: envío real con el logo de Reformas Ejemplo embebido y tema pizarra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:41:59 +02:00
Carlos Narro
2bc34d4017 Mejora los emails transaccionales (diseño premium + marca)
Reescribe los emails del funnel (entrega del presupuesto + enlace al
formulario) con plantilla HTML mobile-first de una columna, dark mode, botón
"bulletproof" en tabla, tipografías de sistema y versión en texto plano.

- mailer.ts: construirEmailHtml/construirEmailText compartidos; acento en el
  color de marca del reformista (resolveTheme) + logo si es URL absoluta; copy
  con jerarquía y CTA orientado a resultado.
- finalizar.ts: pasa la marca del tenant y un CTA de contacto (wa.me del
  reformista o mailto) al email de entrega.
- actions.ts: pasa la marca al email con el enlace al formulario.
- COPY-GUIDE §6.b: asuntos (+alternativas), preheader, headline y cuerpo
  mejorados.

Probado: render visual (light) de ambos emails y envío real por SMTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:35:10 +02:00
Goyo Cancio
2e3cd78216 Añade impermeabilización, extras fijos y zonas al motor de presupuesto
Acerca el cálculo a tarifas de mercado sin rehacer el modelo lineal €/m²:
- Impermeabilización como partida propia en zonas húmedas (cocina/baño/integral)
- Extras fijos que no escalan con m²: boletín (siempre), tuberías (piso anterior
  a 2000) y cambio de distribución (mover inodoro/ducha/bañera)
- Intensidad por tipo en fontanería/electricidad (baseline cocina) para que un
  integral no escale como un baño
- Factor de zona por provincia en tramos (Madrid/BCN 1.40, islas 1.30, capitales
  1.20, rural 0.85, resto 1.00)
- 2 preguntas nuevas en el formulario del cliente para disparar los extras
- Panel de precios: campo de impermeabilización + sección de extras fijos
- Seed recalibrado (mano de obra, extras, catálogo suelo/pared)
- Migración 0009 (leads.anterior_a_2000, leads.cambio_distribucion, pricing_config.extras)
- Tests del motor ampliados (impermeabilización, extras, intensidad por tipo)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:02:57 +02:00
Carlos Narro
daa58c39a1 Ignora la colección Postman del EP (lleva la FUNNEL_API_KEY embebida)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:04:23 +02:00
Carlos Narro
8ef99b56fe Ignora la colección Postman del EP (lleva la FUNNEL_API_KEY embebida)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:03:24 +02:00
unknown
aee82267d0 Configuracion de reglas de typescript 2026-06-03 23:14:53 -04:00
unknown
f082351b43 actualizacion de readme agente Luisa 2026-06-03 23:09:37 -04:00
Carlos Narro
5df608f203 Muestra fotos y notas por zona en la ficha del lead
- agruparPorZona (lib/funnel/fotos.ts): helper puro que agrupa fotos
  (antes/después) y notas por zona, con fallback al tipo del lead. 5 tests.
- getLead trae también lead_notas.
- LeadFotosGaleria: galería por zona (Antes/Después + notas) que sustituye el
  grid plano de la ficha del panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:22:12 +02:00
Carlos Narro
0a5f8cba2b Añade canales de llamada y WhatsApp al funnel B2C
- Llamada (/llamada): "Llamar ahora" dispara la llamada saliente de Retell;
  "Programar" registra fecha/hora. Tras pedir, ofrece enviar por email el
  enlace al formulario para subir las imágenes.
- WhatsApp (/whatsapp): arranca la conversación vía webhook al flujo externo
  (con el teléfono del lead) y pide confirmación; al confirmar, agradece y
  sigue por WhatsApp.
- actions: pedirLlamada, enviarEnlaceFormularioEmail, iniciarWhatsapp,
  confirmarWhatsapp.

Verificado en navegador: las 3 pantallas y sus transiciones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:20:14 +02:00
Carlos Narro
9b5b0d59a6 Añade chooser de canal y formulario por zonas al funnel B2C
- Paso intermedio /solicitud/[id]: el cliente elige llamada, WhatsApp o
  formulario (crearLead ahora redirige aquí, no a /fotos).
- /formulario: FormularioZonas permite añadir varias zonas, cada una con tipo,
  m², acabado, notas y fotos; /fotos queda como redirect.
- guardarDetallesYFotos: guarda fotos (antes, por zona) y notas (por zona),
  agrega los campos del lead (m² suma, tipo único o 'integral', calidad más
  alta, tasteText concatenado) para el presupuesto orientativo inmediato, y
  señala perfilCompleto al flujo externo.
- Elimina FotosUploader (sustituido por FormularioZonas).

Verificado en navegador: 2 zonas → presupuesto al instante + notas por zona +
evento de perfil en DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:17:11 +02:00
Carlos Narro
f87a3ecd81 Añade copy del funnel multicanal (chooser + llamada + WhatsApp)
COPY-GUIDE §3: paso 2 "Elige cómo seguir" con las 3 tarjetas, pantalla del
canal llamada (ahora/programar + nota de fotos por email/WhatsApp) y pantalla
del canal WhatsApp (contacto directo + confirmación). Texto literal para B1–B4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:11:11 +02:00
Carlos Narro
35ba2f28fe Documenta el EP de ingesta y los webhooks salientes
api-docs/README.md: método/URL, auth Bearer, esquema del cuerpo (items
foto/texto, zona, momento, flags), respuesta y errores (401/404/422), ejemplos
curl de los 5 casos, y los payloads de los 3 webhooks salientes (perfil,
entrega WhatsApp, arranque WhatsApp) con cómo el flujo externo devuelve las
"después" por el mismo EP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:09:59 +02:00
Carlos Narro
ae8984fe13 Añade el EP único de ingesta async del lead
POST /api/leads/:id/ingesta (Bearer FUNNEL_API_KEY): acepta items foto/texto
etiquetados por zona y momento, más flags perfilCompleto y finalizar.
- ingesta-schema.ts: zod del cuerpo (union discriminada foto|texto), exportado
  para test; rechaza llamadas vacías.
- route.ts: auth 401, valida lead (404), inserta fotos (orden continúa el máx)
  y notas, traza fotos_subidas; perfilCompleto→señalarPerfilCompleto,
  finalizar→finalizarYEntregar.
- 10 tests del schema. Verificado por HTTP: 401/200/422/404 y finalizar genera
  el pdf_url y avanza el lead a whatsapp_entregado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:09:16 +02:00
Carlos Narro
195ecf6cc3 Añade webhooks salientes, señal de perfil completo y finalización
- webhooks.ts: postWebhook best-effort + señalarGeneracionPerfil,
  notificarFlujoWhatsapp (entrega) e iniciarConversacionWhatsapp (arranque).
- perfil.ts: señalarPerfilCompleto arma el JSON por zona (notas + fotos
  antes/después) y lo manda al flujo externo; deja traza render_generado.
- finalizar.ts: finalizarYEntregar construye el PDF, persiste pdf_url, envía
  el email (siempre) y la señal de WhatsApp, y avanza el lead a entregado.
- orchestrator: comentario en Paso 7 apuntando a la entrega real en finalizar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:06:38 +02:00
Carlos Narro
ec09267d99 Añade envío de email SMTP del presupuesto y del enlace al formulario
- mailer.ts: transport nodemailer perezoso desde env; enviarPresupuestoEmail
  (adjunta el PDF) y enviarEnlaceFormulario. Best-effort: sin SMTP configurado
  o ante error devuelven false sin lanzar.
- COPY-GUIDE §6.b: copy literal de ambos emails al cliente final.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:05:17 +02:00
Carlos Narro
bcc882e37d Genera el PDF del presupuesto con galería antes/después por zona
- build-presupuesto.ts: construirPresupuestoPdf(leadId) agrupa fotos y notas
  por zona (fallback al tipoReforma del lead), convierte las imágenes con
  resolverImagenPdf y arma el PDF. Carga el lead por id sin scoping (uso
  interno desde el route del panel y desde la finalización pública).
- tenant-queries: getTenantPerfilById(tenantId) sin auth; getTenantPerfil lo
  reutiliza con el tenant de la sesión.
- PresupuestoDoc: prop zonas + sección "Imágenes de tu reforma" (antes/después
  lado a lado + notas por zona).
- route del panel: refactor para reutilizar construirPresupuestoPdf (DRY),
  manteniendo getLead como guardia de auth/404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:04:02 +02:00
Carlos Narro
737496ed89 Añade env de ingesta, SMTP y webhooks + dependencia nodemailer
Variables nuevas (todas opcionales vía zod, sin romper build/demo):
- FUNNEL_API_KEY: clave Bearer del EP de ingesta.
- SMTP_* + EMAIL_FROM: envío de email del presupuesto/enlace.
- PERFIL/WHATSAPP/WHATSAPP_START webhook URLs: señales al flujo externo.
- APP_URL: base para enlaces absolutos.
Helpers emailConfigurado()/perfilWebhookConfigurado()/whatsappWebhookConfigurado()/
whatsappStartConfigurado(). nodemailer como dep directa (stack: Email SMTP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:01:15 +02:00
Carlos Narro
b9dd90f4ef Etiqueta fotos por zona/momento y añade tabla lead_notas
Prepara el modelo de datos para la ingesta multicanal del perfil del lead:
- lead_fotos: columnas momento (foto_momento antes/despues, default antes) y
  zona (tipo_reforma, nullable con fallback al tipoReforma del lead).
- lead_notas: tabla append-only de datos de texto por zona (ej. "suelo
  premium"), con origen (ep|funnel|panel) para auditar quién los aportó.
- Migración 0008 + regenerado db-schema/schema.sql.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:00:08 +02:00
Carlos Narro
cd6532eb1b Añade archivos de workspace de VS Code al .gitignore
Ignora *.code-workspace para evitar que configuraciones locales de VS Code se suban al repositorio.
2026-06-03 18:09:30 +02:00
Carlos Narro
1a70ab2eaa Añade esquema SQL consolidado de la base de datos
Genera db-schema/schema.sql (DDL completo: 12 enums, 14 tablas, FKs e
índices) a partir del schema Drizzle, para que el equipo pueda consultar y
proponer cambios sobre el modelo de datos sin leer las migraciones una a una.

- db-schema/schema.sql: foto del esquema actual (drizzle-kit export)
- db-schema/README.md: qué es cada tabla, cómo cambiar el modelo y cómo
  regenerar/levantar la BD desde este SQL
- package.json: script db:export para regenerar la foto

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:07:46 +02:00
Carlos Narro
4aa0582f53 Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-03 09:52:14 +02:00
Carlos Narro
d2d42ff1d4 Añade imágenes de ejemplo para galería y demostración
Incluye una imagen WebP optimizada (48caf530-fa5f-476b-8120-195cfce7a9ec.webp) y una imagen PNG de ejemplo de reformas (reformas-ejemplo.png) para usar en la galería de trabajos y demostraciones del funnel.
2026-06-03 09:52:10 +02:00
unknown
e5c8956b64 Configuracion para guardar en base de datos 2026-06-02 23:11:46 -04:00
unknown
fc6a7044b0 Configurcion de personalidad 2026-06-02 23:07:34 -04:00
unknown
7b0eb31f56 Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-02 22:49:18 -04:00
unknown
9c5e6bc7fa Configuracion de prompt Luisa 2026-06-02 22:48:59 -04:00
Carlos Narro
372ad560bf Añade llamada saliente con Retell al funnel B2C
Integra el agente de voz de Retell (Arquitectura A para la demo): tras la
pre-llamada, el orquestador lanza una llamada saliente real con override de
agente y dynamic variables (empresa + cliente), mientras el render y el
presupuesto se siguen generando con los datos del formulario.

- src/lib/env.ts: esquema zod de RETELL_* (claves opcionales -> sin ellas el
  funnel sigue en modo simulado y el build no se rompe)
- src/lib/voice/retell.ts: cliente fino con fetch a create-phone-call
  (sin nueva dependencia), normalización E.164 y builder de variables
- orchestrator: dispara la llamada best-effort y guarda el callId
- tests del normalizador de teléfono y del builder de variables

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:55:29 +02:00
Carlos Narro
0651d964f5 Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-02 20:05:58 +02:00
Carlos Narro
8de139f9d3 Mejora el PDF de presupuesto: disclaimer, render y conversión de imágenes
- Añade bajo el título un párrafo orientativo: el precio final se fija tras
  la visita gratuita y la estimación se basa en datos estadísticos ajustados
  para acercarse lo máximo posible al importe definitivo.
- Añade una sección con el render del resultado y una descripción generada a
  partir de los materiales (materialesRender) y el estilo de la llamada, con
  fallback elegante cuando faltan datos.
- @react-pdf solo incrusta PNG/JPEG: convierte con sharp los WebP/SVG (render
  y logo) que antes se descartaban en silencio dejando el PDF sin imagen. El
  render va a JPEG redimensionado (PDF ~360 KB en vez de ~2,7 MB) y el logo a
  PNG para conservar transparencia.
- Fija sharp como dependencia directa (ya venía como transitiva de Next).
- Copy nuevo añadido primero a COPY-GUIDE.md (sección entrega del presupuesto).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 19:48:14 +02:00
unknown
9a8f84ff37 Configuracion de prompts PENDIENTE 2026-06-02 10:24:02 -04:00
unknown
3e9d083e7d Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton 2026-06-01 23:24:56 -04:00
unknown
b1b2451429 Configuracion de prueba 2026-06-01 23:24:43 -04:00
Carlos Narro
e9637f77ff Integrar recorte y optimización a WebP en las subidas de imagen del panel
Los tres uploaders del panel (logo, "quiénes somos" y galería) ahora abren
un recorte interactivo (zoom + encuadre) y reescalan en el navegador antes
de subir: logo y "quiénes somos" a máx. 500 px, galería a máx. 1200 px,
reencodando a WebP (calidad 0.82). El SVG del logo se sube tal cual para no
rasterizar el vector. Esto reduce el peso de los data URIs base64 que se
guardan en Postgres y se inlinean en el funnel.

Nueva dependencia react-easy-crop: librería de recorte ligera, sin estado
global, compatible con React 19; el reescalado y reencodado se hacen con
canvas nativo (lib/image/crop.ts), sin dependencias extra.
2026-06-01 22:36:55 +02:00
Carlos Narro
bf9e72064b Alinear panel y auth con la identidad B2B "Architectural Warmth"
Sustituye la paleta negra/azul B2C del panel del reformista por el verde
de marca, neutros cálidos y titulares en Instrument Serif de la landing B2B.
Añade tokens --color-primary-*, --color-stone-50 y --font-display al @theme.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:01:57 +02:00
Carlos Narro
15f2d67970 Corrige el botón Entrar de la landing B2B para que lleve al login
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:06:40 +02:00
Carlos Narro
1ea5d70675 Rediseña panel y auth con la identidad de la landing B2C
- Vista de leads en tarjetas + tabla con toggle (tarjetas por defecto, preferencia persistida)
- Galería de trabajos: gestión en /panel/galeria y bloque público en el funnel
- Selector de tema por reformista (presets + color de marca opcional) aplicado a la landing
- Login y registro rediseñados a pantalla partida 50/50 con foto de reforma
- Enlace "Entrar" funcional en la cabecera del funnel; elimina Navbar muerto
- Unifica tipografía y botones del panel con los tokens de la landing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:51:00 +02:00
Carlos Narro
a91fe5ce2c Añade personalización SEO/Quiénes somos y testimonios gestionables por reformista
- Panel/empresa: title y meta description SEO personalizables; foto, texto y
  años de experiencia para el bloque "Quiénes somos" (toggle on/off).
- Funnel por slug: metadata SEO desde el tenant, bloque "Quiénes somos" y
  testimonios servidos desde DB (sustituye los hardcodeados).
- Flujo de opiniones: el reformista solicita la opinión desde la ficha de un
  lead ganado; el cliente la deja en un funnel dedicado /opinion/[id] con
  estrellas + texto + fotos; entra como pendiente y el reformista la modera
  (publicar/ocultar/eliminar) en /panel/opiniones antes de mostrarla.
- Schema: columnas SEO/about en tenants, testimonioSolicitadoAt en leads,
  enum testimonio_estado, tablas testimonios + testimonio_fotos (migración 0006).
- Seed: opiniones demo (2 publicadas, 1 pendiente) y contenido "Quiénes somos".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:26:13 +02:00
Carlos Narro
1a1caaf0df Reorganiza el routing multi-tenant: funnel por slug, B2B en raíz
- / y /b2b sirven la landing B2B estática (rewrites beforeFiles)
- /{slug} resuelve el funnel del reformista (app/[slug]/page.tsx) con
  branding propio (TenantBrand) y atribución de leads por tenant
- crearLead(slug) y páginas /solicitud usan el tenant del lead
- Panel: edición del slug del funnel + URL pública en /panel/empresa
- Helper de slugs reservados para evitar colisiones con rutas reales

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:09:44 +02:00
210 changed files with 36638 additions and 5144 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ zips/
node_modules/
.next/
next-env.d.ts
*.code-workspace

View File

@@ -113,6 +113,14 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
- **CTA secundario:** *Ver una demo real*
- **Trust text bajo CTA:** Sin tarjeta. Sin instalaciones. 10 minutos para configurarlo.
### Bloque "Demo en vídeo" (debajo del hero; el CTA secundario del hero apunta aquí)
- **Kicker:** En 2 minutos
- **Título:** Míralo funcionando **de principio a fin**.
- **Lede:** Te enseñamos cómo Reformix atiende a tu cliente, calcula el presupuesto orientativo y te lo deja en el panel — sin que tú levantes el teléfono.
- **Placeholder de vídeo (mientras no haya vídeo):** Vídeo demo · próximamente
- **CTA:** *Empezar ahora*
### Bloque "Lo que está roto hoy"
- **Título:** Cada presupuesto que haces es una apuesta
@@ -313,6 +321,77 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
- **Botón submit:** *Continuar*
### Paso 2 — Elige cómo seguir (chooser de canal)
- **Etiqueta del paso:** Elige cómo seguir
- **Título del paso:** ¿Cómo prefieres contarnos tu reforma, [Nombre]?
- **Subtitle:** Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render y tu presupuesto.
- **Stepper de progreso (encima del título):**
- Paso 1 (completado): *Tus datos*
- Paso 2 (actual): *Tu reforma*
- Paso 3 (pendiente): *Render + presupuesto*
- **Tarjeta Llamada — título:** Que te llamemos
**Descripción:** Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.
**CTA:** Quiero que me llamen
**Badge:** La más rápida
- **Tarjeta WhatsApp — título:** Por WhatsApp
**Descripción:** Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.
**CTA:** Seguir por WhatsApp
- **Tarjeta Formulario — título:** Rellenar un formulario
**Descripción:** Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.
**CTA:** Rellenar el formulario
#### Bloque "Qué pasa después" (debajo de las tarjetas del chooser)
> Recuerda al lead lo que va a recibir elija el canal que elija: personalización, render con
> imágenes en minutos, y visita gratuita posterior para el presupuesto definitivo.
- **Título:** Elijas lo que elijas, esto es lo que pasa después
- **Paso 1 — título:** Nos cuentas tu reforma a tu manera
**Body:** Fotos, medidas, gustos. Cuanto más nos cuentes, más se parecerá el resultado a lo que tienes en la cabeza.
- **Paso 2 — título:** Render + presupuesto en minutos
**Body:** Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.
- **Paso 3 — título:** Visita gratuita para el presupuesto final
**Body:** Si te convence, acuerdas una visita con [Reformista]: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.
### Paso 2 (canal llamada)
- **Título del paso:** Te llamamos cuando quieras
- **Subtitle:** Un asistente de [Reformista] te llama y te hace unas preguntas rápidas sobre tu reforma. Te avisamos antes.
- **Opción A — título:** Llamarme ahora
**Descripción:** Recibes la llamada en menos de 2 minutos.
**Botón:** Llamarme ahora
- **Opción B — título:** Programar la llamada
**Descripción:** Elige el día y la hora que mejor te venga.
**Botón:** Programar llamada
- **Confirmación (ahora):** ✅ Perfecto, [Nombre]. Te llamamos en menos de 2 minutos al **[teléfono]**. Tenlo a mano.
- **Confirmación (programada):** ✅ Hecho. Te llamaremos el **[fecha]** al **[teléfono]**.
- **Nota sobre las fotos:** Para el render necesitamos ver el espacio. Puedes mandarnos las fotos por WhatsApp durante la llamada, o te enviamos un enlace al formulario por email para que las subas cuando quieras.
**Botón:** Enviarme el enlace por email
**Confirmación enlace:** 📧 Te hemos enviado el enlace a **[email]**.
### Paso 2 (canal WhatsApp)
- **Título del paso:** Seguimos por WhatsApp
- **Body (antes de confirmar):** Te escribimos al WhatsApp del **[teléfono]** para seguir por ahí. Si el número es correcto, confírmalo y te escribimos ahora mismo.
- **Botón:** Sí, escríbeme por WhatsApp
- **Tras escribir:** Te acabamos de escribir al **[teléfono]**. ¿Puedes confirmarlo?
**Botón confirmar:** Lo he recibido
- **Agradecimiento:** ✅ ¡Genial, [Nombre]! Seguimos por WhatsApp. Allí te pediremos las fotos y los detalles para preparar tu presupuesto.
### Subida de fotos por enlace (página ligera del email)
Página a la que lleva el enlace del email (canal llamada). Solo sube fotos; nada de re-preguntar ni
de volver a llamar.
- **Etiqueta del paso:** Solo falta esto
- **Título:** Sube las fotos de tu espacio, [Nombre]
- **Subtitle:** Con un par de fotos del espacio actual preparamos tu render y afinamos el presupuesto. Tardas un minuto.
- **Nota (opcional):** ¿Algo que quieras añadir? (opcional)
- **Botón:** Enviar mis fotos
### Subida de fotos (paso 2 del wizard)
- **Título del paso:** Ahora una foto de tu espacio actual
@@ -478,6 +557,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
> 👉 *¿Te gustaría que [Reformista] vaya a verlo gratis?*
> [Botón: Sí, pídeme la visita] [Botón: Tengo dudas, contestadme]
### PDF del presupuesto (documento adjunto)
Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel. Mantiene el tono orientativo y honesto: deja claro que es una aproximación, sin restarle credibilidad.
- **Título:** *PRESUPUESTO ORIENTATIVO DE REFORMA*
- **Disclaimer (bajo el título):** *El precio final se determinará tras la visita gratuita de [Reformista] en tu casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos para que se acerque lo máximo posible al importe definitivo.*
- **Sección render — título:** *Así quedaría tu reforma*
- **Render — descripción (se genera según la selección del cliente):** *Recreación con calidad [media] y acabados en [suelo porcelánico, paredes en tono neutro, mobiliario lacado]. Estilo [moderno y luminoso] según tus preferencias.*
- **Render — descripción (fallback si faltan materiales o estilo):** *Recreación orientativa de cómo quedaría tu espacio reformado con la calidad [media] seleccionada.*
- **Pie de la imagen:** *Render orientativo generado con IA. El resultado real puede variar según los materiales finales y las condiciones de la obra.*
### WhatsApp follow-up (24h sin respuesta)
> 👋 *Hola [Nombre], ¿pudiste mirar el presupuesto que te mandamos ayer?*
@@ -525,6 +615,62 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
---
## 6.b Emails al cliente final (funnel B2C)
Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo,
igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente.
Diseño: HTML transaccional mobile-first, una columna (máx. 600px), dark mode, botón "bulletproof"
(tabla), tipografías de sistema, color de acento = color de marca del reformista. Cada email lleva
asunto (≤50 car.), preheader (texto de previsualización) y versión en texto plano.
### Email de entrega del presupuesto (PDF adjunto)
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Tono: la entrega es el
protagonista, cálido pero claro.
- **Asunto (elegido):** *Aquí está tu presupuesto de reforma*
- **Asunto (alt. A):** *Tu reforma, en números y en imágenes*
- **Asunto (alt. B):** *[Reformista]: tu presupuesto ya está listo*
- **Preheader:** *Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).*
- **Headline:** *Aquí está tu presupuesto, [Nombre]*
- **Cuerpo:**
> Hemos preparado el **presupuesto orientativo** de tu reforma. En el PDF adjunto tienes el render de
> cómo quedaría tu espacio y el desglose por partidas.
>
> *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en
> tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.
>
> ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.
- **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita*
- **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]*
### Email con enlace al formulario (subir imágenes)
Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos
del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola acción clara.
- **Asunto (elegido):** *Sube las fotos de tu reforma*
- **Asunto (alt. A):** *Un paso más para tu presupuesto*
- **Asunto (alt. B):** *[Reformista] necesita ver tu espacio*
- **Preheader:** *Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.*
- **Headline:** *Enséñanos tu espacio, [Nombre]*
- **Cuerpo:**
> Para preparar tu render y tu presupuesto, **[Reformista]** necesita ver cómo está ahora tu espacio.
>
> Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que
> quieras.
>
> En cuanto las tengamos, seguimos con tu presupuesto.
- **CTA:** *Subir mis fotos*`[url]`
- **Footer:** *[Reformista]*
---
## 7. Microcopy del panel del reformista
| Elemento | Texto |
@@ -587,6 +733,40 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
---
## Onboarding del panel (tour guiado)
> Tooltips del tour del panel (driver.js). Tono cercano y útil, una idea por paso, frases cortas. Las pestañas secundarias se explican "de pasada" (una línea). Copy usado en `src/lib/onboarding/panel-tour.ts`.
### Pestaña Leads (`/panel`)
- **Intro** — *Tu panel de Reformix* · "Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X."
- **Leads** — "Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día."
- **Precios y baremo** — "Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos."
- **Galería** — "Tus fotos de trabajos para enseñar en la web."
- **Opiniones** — "Reseñas de tus clientes; las apruebas tú antes de publicarlas."
- **Empresa** — "Tu marca, logo y datos de contacto."
- **Filtra por estado** — "Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora."
- **Tus leads** — "Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo."
### Ficha del lead (`/panel/{id}`)
- **Presupuesto estimado** — "Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo."
- **Estado del lead** — "Avanza el lead por el funnel: contactado, presupuestado, ganado…"
- **Render de la reforma** — "La imagen del «después» que ve tu cliente, generada a partir de su foto y sus gustos."
- **Presupuesto desglosado** — "Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp."
### Precios y baremo (`/panel/precios`)
- **Baremo de rentabilidad** — "El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo."
- **Mano de obra** — "Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto."
- **Tu catálogo** — "Materiales y precios por calidad. Puedes importarlos en bloque por CSV."
### Botón para repetir
- **Botón flotante** — "❓ Tour" (relanza el tour de la pestaña actual).
---
## Principios aplicados en todo el documento
1. **Beneficios > Features** — "Tus clientes verán su reforma" > "Render IA con SDXL"

View File

@@ -0,0 +1,581 @@
# Arquitectura de Integración — Reformix
## Índice
1. [Visión general del sistema](#1-visión-general-del-sistema)
2. [Landing pública y captura del lead (sitio web)](#2-landing-pública-y-captura-del-lead-sitio-web)
3. [El funnel B2C — pipeline de 7 pasos](#3-el-funnel-b2c--pipeline-de-7-pasos)
4. [Elección de canal: formulario, llamada o WhatsApp](#4-elección-de-canal-formulario-llamada-o-whatsapp)
5. [Sistema de webhooks salientes (app → bot/worker)](#5-sistema-de-webhooks-salientes-app--botworker)
6. [Sistema de endpoints de bot (app ← bot/worker)](#6-sistema-de-endpoints-de-bot-app--botworker)
7. [El agente de WhatsApp (Luisa)](#7-el-agente-de-whatsapp-luisa)
8. [Workers: render de imágenes y presupuesto](#8-workers-render-de-imágenes-y-presupuesto)
9. [El presupuesto como entregable final](#9-el-presupuesto-como-entregable-final)
10. [Diagrama de flujo completo](#10-diagrama-de-flujo-completo)
11. [Estado actual vs lo que falta](#11-estado-actual-vs-lo-que-falta)
12. [Guía de conexión: cómo integrar Luisa + Workers con la app](#12-guía-de-conexión-cómo-integrar-luisa--workers-con-la-app)
---
## 1. Visión general del sistema
Reformix es un SaaS multi-tenant para empresas de reformas. Tiene **dos caras**:
- **B2B (reformista):** el reformista se registra, configura su perfil, catálogo de materiales, precios, y pone un widget en su web.
- **B2C (cliente final):** el cliente llega a la landing del reformista, pide presupuesto, sube fotos, y recibe un presupuesto con render "antes/después" en < 7 minutos.
### Componentes del sistema
```
┌─────────────────────────────────────────────────────────────────┐
│ SITIO WEB (Next.js) │
│ Landing → Formulario → Elección canal → Pipeline → Entrega │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ App Reformix (mvp/b2c/) │ │
│ │ - Landing pública /[slug] │ │
│ │ - Funnel B2C /solicitud/[id]/* │ │
│ │ - Panel reformista /panel/* │ │
│ │ - API endpoints para bot (mvp/b2c/src/app/api/leads/) │ │
│ │ - Motor de presupuesto (mvp/b2c/src/budget/) │ │
│ │ - Generación de PDF (mvp/b2c/src/lib/pdf/) │ │
│ │ - Envío de email (mvp/b2c/src/lib/email/) │ │
│ │ - Webhooks salientes (mvp/b2c/src/lib/webhooks.ts) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ AGENTE WHATSAPP (Luisa) — EXTERNO │
│ (mvp/Whatsapp-bot/) │
│ - Conexión WhatsApp via Baileys │
│ - Pipeline Claude 4-capas para cualificar leads │
│ - DEBE usar API HTTP de la app (hoy escribe directo a BD) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ WORKERS (Render + Análisis) — NO IMPLEMENTADO AÚN │
│ - Generación de renders "después" (Nano Banana 2 / Image 2) │
│ - Análisis de fotos con IA │
│ - DEBE recibir webhook PERFIL_WEBHOOK_URL y devolver vía API │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. Landing pública y captura del lead (sitio web)
### Flujo de captura
```
Usuario llega a /[slug] (landing del reformista)
Rellena formulario Hero:
- nombre + email + teléfono
- consentimiento privacidad + contratación (RGPD obligatorio)
crearLead(slug, data) → Server Action
├── Valida con Zod schema
├── Busca tenant por slug
├── INSERT en leads (pipelineStage: 'form_completado', estado: 'nuevo')
└── INSERT en leadPipelineEventos (stage: 'form_completado')
```
### Lo que crea la base de datos
El lead se crea con:
- `id` (UUID) — **este es el leadId que usará todo el sistema**
- `tenantId` (UUID) — referencia al reformista
- `nombre`, `email`, `telefono` — datos del cliente
- `pipelineStage: 'form_completado'` — dónde está en el pipeline
- `estado: 'nuevo'` — estado comercial
**Importante:** El lead NO tiene aún `tipoReforma`, `m2Suelo`, `calidadGlobal`, etc. Esos se rellenan después, cuando el cliente pasa por el canal elegido.
---
## 3. El funnel B2C — pipeline de 7 pasos
Definido por el enum `pipelineStage` en `src/db/schema.ts`:
| # | Stage | Qué ocurre | Quién lo dispara |
| --- | ---------------------- | ---------------------------------------------- | ------------------------------------------------------- |
| 1 | `form_completado` | Lead creado con datos básicos | Server Action `crearLead()` |
| 2 | `fotos_subidas` | Cliente describe reforma + sube fotos por zona | Server Action `guardarDetallesYFotos()` o API `ingesta` |
| 3 | `prellamada_enviada` | Notificación SMS/WhatsApp previa a llamada | Orchestrator `procesarLead()` o bot |
| 4 | `llamada_completada` | Agente IA (Retell) cualifica al lead | Orchestrator (simulado) o webhook Retell (real) |
| 5 | `render_generado` | Render "después" generado por IA | Orchestrator (simulado con imagen demo) |
| 6 | `presupuesto_generado` | Presupuesto calculado con motor real | Orchestrator `procesarLead()` |
| 7 | `whatsapp_entregado` | PDF entregado al cliente | `finalizarYEntregar()` |
### Pipeline automático (`procesarLead`)
Cuando el cliente completa el formulario detallado (con zonas, fotos, etc.), la app ejecuta:
```typescript
guardarDetallesYFotos(leadId, formData)
Guarda fotos (momento: 'antes') en leadFotos
Guarda notas en leadNotas
Calcula tipoReforma, m2Suelo, calidadGlobal desde las zonas
UPDATE lead (pipelineStage: 'fotos_subidas')
procesarLead(leadId) ORQUESTRADOR
Paso 4: Evento prellamada_enviada
Paso 5: Llamada Retell (real si configurado, sino simulada con transcript ficticio)
Paso 6a: Render demo (imagen estática, NO IA real)
Paso 6b: Presupuesto REAL con motor (computeBudget)
UPDATE lead (pipelineStage: 'presupuesto_generado')
Paso 7: Si envio=automatico lead pasa a 'whatsapp_entregado'
```
---
## 4. Elección de canal: formulario, llamada o WhatsApp
Después de crear el lead, el cliente llega a `/solicitud/[id]/` donde elige cómo continuar:
### Canal formulario (`/solicitud/[id]/formulario`)
El cliente rellena un formulario multi-zona: tipo de reforma, m², calidad, notas, sube fotos. Esto dispara `guardarDetallesYFotos()` que ejecuta el pipeline completo (incluyendo llamada simulada, render demo, presupuesto real).
### Canal llamada (`/solicitud/[id]/llamada`)
El cliente pide que le llamen (ahora o programado). Se le envía un email con enlace para subir fotos después. Dispara `pedirLlamada()` que inicia llamada Retell saliente (si configurado). El cliente recibe la llamada del agente IA, y después puede subir fotos via el enlace del email.
### Canal WhatsApp (`/solicitud/[id]/whatsapp`)
El cliente elige continuar por WhatsApp. La app dispara `iniciarWhatsapp()` que:
```typescript
iniciarWhatsapp(leadId)
POST a WHATSAPP_START_WEBHOOK_URL
Payload: { leadId, telefono, nombre, empresa }
El bot de WhatsApp (Luisa) recibe esto y empieza la conversación
```
**Este es el punto de entrada del bot de WhatsApp.** El bot recibe el `leadId` y a partir de ahí debe escribir los datos extraídos de la conversación usando los endpoints de la app.
---
## 5. Sistema de webhooks salientes (app → bot/worker)
La app envía 3 señales HTTP a sistemas externos. Todas son **best-effort** (nunca lanzan error, devuelven boolean).
### 5.1 WHATSAPP_START_WEBHOOK_URL — Arranque de conversación WhatsApp
**Disparado por:** `iniciarWhatsapp()` (cuando el lead elige canal WhatsApp)
```json
POST {url}
{
"leadId": "uuid",
"telefono": "+34...",
"nombre": "...",
"empresa": "Reformas Ejemplo"
}
```
**La app espera que:** el bot de WhatsApp reciba esto y comience la conversación con el lead. El bot debe usar el `leadId` para escribir los datos vía los endpoints de la app.
### 5.2 PERFIL_WEBHOOK_URL — Perfil completo para generar renders
**Disparado por:**
- `señalarPerfilCompleto()` en `guardarDetallesYFotos()` (formulario)
- `señalarPerfilCompleto()` en `subirFotos()` (fotos por email)
- API `ingesta` con flag `perfilCompleto: true` (bot/worker)
```json
POST {url}
{
"leadId": "uuid",
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
"preferencias": { "estilo": "nórdico", "gustos": "tonos azules, muebles de madera, encimera clara" },
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
"zonas": [
{ "zona": "cocina",
"notas": ["encimera de cuarzo"],
"fotos": { "antes": ["data:image/..."], "despues": [] } }
]
}
```
**`preferencias`** (opcional): gustos estéticos del cliente capturados en la conversación (`estilo` =
campo `estilo` del lead; `gustos` = `tasteText`, resumen en texto libre de colores/materiales/acabados
que pidió). Cada clave se omite si está vacía. El worker los inyecta como bloque dedicado en el prompt
de imagen para que el render los represente; si no llegan, infiere un estilo neutro.
**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes"
respetando las `preferencias` del cliente, y los devuelva haciendo POST al endpoint
`/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`.
### 5.3 WHATSAPP_WEBHOOK_URL — Entrega del PDF
**Disparado por:** `finalizarYEntregar()` (cuando el PDF está listo)
```json
POST {url}
{
"leadId": "uuid",
"telefono": "+34...",
"nombre": "...",
"empresa": "Reformas Ejemplo",
"pdfBase64": "JVBERi0xLj...",
"filename": "presupuesto-nombre.pdf"
}
```
**La app espera que:** el bot de WhatsApp reciba esto y envíe el PDF al cliente por WhatsApp.
---
## 6. Sistema de endpoints de bot (app ← bot/worker)
La app expone 5 endpoints bajo `/api/leads/:id/`. Todos requieren `Authorization: Bearer <FUNNEL_API_KEY>`.
| Endpoint | Qué hace | Tabla que escribe |
| ---------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- |
| `POST /api/leads/:id/conversacion` | Guarda un turno del chat | `conversacion_whatsapp` |
| `POST /api/leads/:id/perfil` | Actualiza datos extraídos del lead | `leads` (campos: espacio, rangoM2, estilo, tipoReforma, m2Suelo, etc.) |
| `POST /api/leads/:id/calificacion` | Upsert de calificación | `lead_calificacion` |
| `POST /api/leads/:id/intento` | Registra intento de contacto | `intentos_contacto` |
| `POST /api/leads/:id/ingesta` | Sube fotos/notas + flags de perfilCompleto/finalizar | `lead_fotos`, `lead_notas` |
### Flujo de uso típico del bot
```
1. Bot recibe WHATSAPP_START_WEBHOOK → leadId, telefono, nombre
2. Bot inicia conversación por WhatsApp
3. Por cada interacción:
a. Bot llama POST /conversacion (guarda el turno)
b. Bot llama POST /perfil (actualiza datos extraídos: espacio, m2, estilo, etc.)
c. Cuando tiene datos suficientes, llama POST /calificacion
d. Cuando pide fotos, el cliente las envía, bot las guarda vía POST /ingesta
4. Cuando el perfil está completo:
- Bot marca perfilCompleto: true en POST /ingesta
- Esto dispara PERFIL_WEBHOOK_URL → worker genera renders
- Worker devuelve renders vía POST /ingesta con momento: "despues" + finalizar: true
- finalizar:true dispara WHATSAPP_WEBHOOK_URL → bot recibe PDF y lo envía al cliente
```
---
## 7. El agente de WhatsApp (Luisa)
### Estado actual (código en `mvp/Whatsapp-bot/`)
El bot de Luisa es un servicio NestJS independiente que:
1. **Se conecta a WhatsApp** usando la librería Baileys (WebSocket no oficial)
2. **Orquesta un pipeline Claude de 4 capas:**
- Capa 1 — Clasificador (Haiku): extrae intención y valor del mensaje
- Capa 2 — Validador: valida contra valores permitidos
- Capa 3 — Generador (Sonnet): produce el borrador de respuesta
- Capa 4 — Reglas (Haiku): corrige tono e identidad
3. **Mantiene una máquina de estados** de 7 pasos para cualificar al lead:
`nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin`
4. **Tiene un scheduler** que cada 5 minutos busca leads "nuevos" en BD y les envía el mensaje de apertura
5. **Soporta multimedia:** transcripción de audio (Gemini), análisis de imágenes (Claude Vision)
### Problema actual
El bot **escribe directamente a Postgres** usando TypeORM con sus propias entidades (`Lead` y `Conversacion`), en lugar de usar los endpoints HTTP de la app. Esto causa:
- Incompatibilidad de IDs: el bot usa `id` numérico autoincremental, la app usa UUID
- Tablas duplicadas: el bot tiene su propia tabla `conversacion`, la app tiene `conversacion_whatsapp`
- Enums desalineados: el bot usa `urgente/medio_plazo/frio`, la app usa `alta/media/baja`
- `synchronize: true` en TypeORM puede alterar el schema de la BD real
- El scheduler crea leads desde cero, pero según la arquitectura los leads ya existen (creados desde el form web)
### Lo que DEBE hacer el bot
1. **No crear leads.** Recibirlos vía `WHATSAPP_START_WEBHOOK_URL` con el `leadId` UUID.
2. **No escribir a BD directamente.** Usar los 5 endpoints HTTP (`conversacion`, `perfil`, `calificacion`, `intento`, `ingesta`).
3. **No tener scheduler propio.** El arranque lo hace la app vía webhook.
4. **Usar los enums correctos** según la app (`alta/media/baja`, `cocina/bano/salon/comedor/integral/otro`, etc.).
5. **No mantener estado propio.** El estado de la conversación (`botStep`) se persiste vía `/perfil`.
### Dónde está el código
| Elemento | Ruta |
| ----------------------- | ---------------------------- |
| Código del bot (NestJS) | `mvp/Whatsapp-bot/src/` |
| Prompts de Luisa | `mvp/Whatsapp-bot/prompts/` |
| Configuración (.env) | `mvp/Whatsapp-bot/.env` |
| Documentación del bot | `mvp/Whatsapp-bot/README.md` |
---
## 8. Workers: render de imágenes y presupuesto
### Estado actual
Los workers **no están implementados**. Existe:
- La **tabla `worker_jobs`** en la BD (`mvp/b2c/src/db/schema.ts:515-537`) con tipos: `analisis_fotos`, `render`, `presupuesto_ia`
- El **webhook `PERFIL_WEBHOOK_URL`** listo para enviar el perfil completo al worker
- El **endpoint `ingesta`** listo para recibir los renders de vuelta
- **Renders simulados** con imágenes estáticas en `procesarLead()` (usa `/despues.webp`, `/despues-bano.webp`, etc.)
### Lo que DEBE hacer el worker de renders
```
1. Recibe POST a PERFIL_WEBHOOK_URL con:
{ leadId, cliente, reforma, empresa, zonas: [{ zona, notas, fotos: { antes: [...], despues: [] } }] }
2. Para cada zona:
a. Toma las fotos "antes" del cliente
b. Genera render "después" usando modelo de IA (Nano Banana 2, Image 2, Stable Diffusion, etc.)
c. Convierte el render a data URI base64
3. Devuelve los renders haciendo POST a /api/leads/:id/ingesta:
{ items: [{ tipo: "foto", zona: "cocina", momento: "despues", imagen: "data:image/..." }],
finalizar: true }
(finalizar:true dispara la construcción del PDF + email + señal WhatsApp)
```
### Stack planeado para renders
Según la documentación:
- **Nano Banana 2** o **Image 2** (Google Gemini) — modelos image-to-image
- Alternativa: **Replicate SDXL + ControlNet** (~0,02€/imagen)
- Alternativa: **DALL-E 3 HD** (~0,08€/imagen)
### Dónde implementar los workers
Los workers son servicios externos independientes. Pueden ser:
- **n8n workflows** (orquestación visual)
- **Un worker Node.js/Python** que escucha webhooks y se comunica con APIs de IA
- **Cloud Functions** (Vercel, Cloudflare Workers, AWS Lambda)
No hay código de workers en este repositorio. El repositorio solo define el contrato (webhooks + API).
---
## 9. El presupuesto como entregable final
El entregable final es un **PDF de presupuesto** que incluye:
1. **Cabecera** con datos del reformista (logo, nombre, CIF, dirección)
2. **Datos del cliente** y tipo de reforma
3. **Tabla de presupuesto** con partidas calculadas por el motor:
- Demolición, impermeabilización, alicatado, fontanería, electricidad, carpintería, mano de obra, extras, licencia
4. **Render "después"** generado por IA
5. **Galería por zona** con fotos "antes" y "después"
6. **Footer legal**
### El motor de presupuesto (`mvp/b2c/src/budget/`)
**Es real, no simulado.** Calcula presupuestos basado en:
- Catálogo de materiales del reformista (precios por calidad: básica/media/premium)
- Configuración de precios (factores por zona, mano de obra, extras fijos)
- Inputs del lead: tipo de reforma, m², calidad, urgencia, estructural, provincia
### Flujo de entrega
```
finalizarYEntregar(leadId)
├── construirPresupuestoPdf(leadId)
│ ├── Carga lead completo + fotos + notas + catálogo + config
│ ├── Renderiza PDF con @react-pdf
│ └── Devuelve buffer PDF + filename
├── UPDATE lead (pdfUrl)
├── enviarPresupuestoEmail() → SMTP (opcional)
├── notificarFlujoWhatsapp() → WHATSAPP_WEBHOOK_URL
│ └── Bot recibe pdfBase64 y lo envía por WhatsApp
└── UPDATE lead (pipelineStage: 'whatsapp_entregado', estado: 'presupuesto_enviado')
```
---
## 10. Diagrama de flujo completo
```
LANDING PÚBLICA /[slug]
crearLead()
┌───────────────────────┐
│ Lead CREADO │
│ pipelineStage: │
│ form_completado │
│ estado: nuevo │
└───────────┬───────────┘
┌────────────────────────┐
│ ELECCIÓN DE CANAL │
│ /solicitud/[id]/ │
└──────┬────────┬───────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ FORMULARIO │ │ WHATSAPP │
│ + fotos │ │ │
└──────┬───────┘ │ iniciar │
│ │ Whatsapp() │
│ └──────┬───────┘
│ guardarDetalles │
│ YFotos() │ POST WHATSAPP
│ │ START WEBHOOK
├── Guarda fotos │
├── Guarda notas ▼
│ ┌──────────────┐
└── procesarLead() │ BOT WHATSAPP │
│ │ (Luisa) │
│ │ │
├── Simula │ Inicia │
│ llamada │ conversación │
│ │ │
├── Render │ Por cada msg: │
│ demo │ POST /perfil │
│ (img fija)│ POST /conv. │
│ │ │
├── Presup. │ Cuando listo: │
│ REAL │ POST /ingesta │
│ │ perfilCompleto│
│ │ │
└── Estado └──────┬────────┘
intermedio │
│ │
▼ │
┌────────────────────┘
│ PERFIL_WEBHOOK_URL
┌──────────────────┐
│ WORKER RENDER │
│ Genera imágenes │
│ "después" │
└────────┬─────────┘
│ POST /api/leads/:id/ingesta
│ { items: [{tipo:"foto", momento:"despues",...}],
│ finalizar: true }
┌──────────────────────────────┐
│ finalizarYEntregar() │
│ - Construye PDF │
│ - Envía email │
│ - WHATSAPP_WEBHOOK_URL │
└────────┬─────────────────────┘
┌──────────────────┐
│ BOT WHATSAPP │
│ Recibe pdfBase64│
│ y lo envía al │
│ cliente │
└──────────────────┘
```
---
## 11. Estado actual vs lo que falta
| Componente | Estado | Notas |
| ---------------------------- | ------------------------------- | -------------------------------------------------- |
| Landing pública / formulario | ✅ Implementado | Multi-zona, fotos, notas |
| Motor de presupuesto | ✅ Implementado | Real, con catálogo y config |
| PDF | ✅ Implementado | Con @react-pdf |
| Email | ✅ Implementado | SMTP, best-effort |
| Endpoints API del bot | ✅ Implementado y en producción | 5 endpoints en `dv3.com.es` |
| Webhooks salientes | ✅ Implementado | 3 webhooks listos |
| Autenticación bot | ✅ Implementado | Bearer token con FUNNEL_API_KEY |
| Bot WhatsApp (Luisa) | ❌ Por reconectar | Hoy escribe directo a BD, debe usar APIs HTTP |
| Workers render | ❌ No implementado | Existe solo tabla y webhook, sin worker real |
| Renders IA | ❌ Simulado | Usa imágenes estáticas `/despues.webp` |
| Llamada Retell real | ⚠️ Parcial | Código listo, depende de config de vars de entorno |
| n8n workflows | ❌ No existe | Mencionado en docs pero sin implementar |
---
## 12. Guía de conexión: cómo integrar Luisa + Workers con la app
### 12.1 Lo que necesita el bot de WhatsApp (Luisa)
El bot DEBE:
1. **Recibir leads por webhook** (`WHATSAPP_START_WEBHOOK_URL`), no buscarlos en BD.
2. **Usar los endpoints HTTP de la app** en lugar de TypeORM:
- `POST /api/leads/:id/conversacion` — para cada turno del chat
- `POST /api/leads/:id/perfil` — para actualizar datos extraídos
- `POST /api/leads/:id/calificacion` — para calificar al lead
- `POST /api/leads/:id/intento` — para registrar intentos
- `POST /api/leads/:id/ingesta` — para subir fotos y marcar perfil completo
3. **Usar los enums correctos** de la app:
- `urgencia`: `alta`, `media`, `baja`
- `tipoReforma`: `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro`
- `calidadGlobal`: `basica`, `media`, `premium`
- `estadoWa`: `sin_enviar`, `enviado`, `entregado`, `leido`, `fallido`
- `canalOrigen`: `formulario_web`, `whatsapp`, `llamada`, `referido`, `anuncio`
4. **Trabajar con UUIDs** (el `leadId` que recibe del webhook).
5. **No tener scheduler interno** — la app controla cuándo arrancar.
### 12.2 Lo que necesita el worker de renders
El worker DEBE:
1. **Escuchar en la URL que configures como `PERFIL_WEBHOOK_URL`** (POST /perfil-completo).
2. **Recibir el payload** con `leadId`, `cliente`, `reforma`, `zonas` (con fotos "antes").
3. **Generar renders** para cada zona: Etapa 1 (Claude Haiku → prompt), Etapa 2 (Gemini Flash → imagen), Etapa 3 (Claude Haiku Vision → validación). Todo via OpenRouter.
4. **Devolver los renders** llamando a `POST /api/leads/:id/ingesta` con:
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
```json
{
"items": [
{
"tipo": "foto",
"zona": "cocina",
"momento": "despues",
"imagen": "data:image/..."
}
],
"finalizar": true
}
```
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
### 12.3 Variables de entorno necesarias en la app
```
# Para que el bot pueda escribir en la BD:
FUNNEL_API_KEY=<clave-compartida>
# URLs donde escuchan el bot y el worker:
WHATSAPP_START_WEBHOOK_URL=https://url-del-bot/whatsapp-start
PERFIL_WEBHOOK_URL=https://url-del-worker/perfil-completo
WHATSAPP_WEBHOOK_URL=https://url-del-bot/whatsapp-pdf
```
### 12.4 Secuencia de integración recomendada
**Paso 1 — Reconectar Luisa a los endpoints HTTP (prioridad alta)**
- Eliminar TypeORM y las entidades propias del bot
- Implementar llamadas HTTP a los 5 endpoints de la app
- Ajustar enums y tipos para que coincidan con la app
- Eliminar el scheduler interno
- Configurar las 3 URLs de webhook en la app
**Paso 2 — Implementar worker de renders (prioridad media)**
- Crear servicio que escuche `PERFIL_WEBHOOK_URL`
- Elegir modelo de IA para image-to-image
- Integrar con el endpoint `ingesta` para devolver resultados
**Paso 3 — Conectar llamada Retell real (prioridad baja)**
- Configurar variables de entorno de Retell
- El bot de WhatsApp o el formulario pueden complementar la llamada

126
docs/copy-b2b-estudio.md Normal file
View File

@@ -0,0 +1,126 @@
# Estudio de copy B2B — Promesa, descripción y CTA
> Landing B2B (reformistas), servida en `/` y `/b2b`. Estudio para elegir el **hero**: la promesa
> (H1), la descripción (subhead) y el CTA. Opciones + ángulo + por qué, y al final el combo
> recomendado listo para pegar. Decisión de producto aplicada: **CTA → registro + demo del
> resultado**, no "14 días gratis".
>
> El copy final elegido se promueve a `copy/COPY-GUIDE.md §2` (fuente canónica).
---
## 1. Estrategia (la base, antes de las palabras)
**Audiencia:** reformista / autónomo de reformas en España. Habla de gremio, desconfía del humo,
mide en horas y kilómetros.
**Nivel de conciencia (Schwartz): 2 — Problem-Aware.** Sabe que pierde tiempo y dinero con visitas
y presupuestos que no cuajan, conoce el showrooming, pero **no sabe** que existe un asistente que
cualifica por él. → **Implicación: el hero abre desde el DOLOR concreto, no desde el producto.**
Validar primero, vender después.
**Trigger map**
- 😤 *Emocional — showrooming (dolor #1, verbatim):* "Hago el presupuesto con detalle y se va al de al lado a que se lo baje."
- 😩 *Emocional — tiempo regalado:* "Pierdo tardes presupuestando a gente que no contrata."
- 🚗 *Emocional — visita en balde:* "Conduzco 40 km y era cambiar un grifo."
- 🥶 *Emocional — se enfrían:* "Mientras estoy en obra, el WhatsApp se enfría."
- 📊 *Racional:* 78% acepta el primer presupuesto rápido que recibe · 80% prefiere presupuesto con imágenes · cada visita fallida cuesta 60-90€.
- 🗣 *Lenguaje del usuario (úsalo literal):* "solo estoy mirando", "para valorar", "ya te diré", "me lo pienso".
**Tono.** Somos: **directos, de gremio, con datos, de tú a tú.** No somos: corporativos, "revolucionarios", "lleva tu negocio al siguiente nivel".
- Sí: *"Tu cliente cuelga y ya tiene su presupuesto. Tú lo tienes en el panel."*
- No: *"Solución innovadora de cualificación inteligente de leads."*
**Aprendizaje de compramostucoche.com (funnel de referencia):** una **sola** promesa medible ("averigua cuánto vale tu coche"), **gratis + sin compromiso + en minutos**, y **un** CTA dominante repetido. Su fuerza es la *claridad instantánea*. Trasladado a B2B: una promesa concreta + un CTA claro a la demo, sin ruido.
---
## 2. La PROMESA (H1) — opciones
Regla: < 10 palabras, front-load, abrir desde el dolor (nivel 2), voz de gremio.
| # | Promesa (H1) | Ángulo | Trigger | Valoración |
| --- | --- | --- | --- | --- |
| 1 | **Deja de presupuestar gratis para quien no va a contratar.** | Dolor: presupuestos regalados | tiempo regalado | ✅ Pega fuerte y es de gremio. Abre en dolor puro (correcto para nivel 2). |
| 2 | **Sabrás qué obra merece la visita antes de coger el coche.** | Filtro / cualificación | visita en balde (40 km) | ✅✅ Dolor + promesa en una frase, muy visual ("coger el coche"). **Top.** |
| 3 | **Tu cliente ve su reforma y su precio antes de que llegues.** | Cliente visual + llegar con ventaja | 80% imágenes · marca | ✅ Doble beneficio (cliente + tú). Buena para A/B. |
| 4 | **Mientras estás en la obra, Reformix atiende al siguiente cliente.** | El asistente trabaja por ti | WhatsApp que se enfría | ✅ Imagen clara; ⚠️ el dolor queda implícito. |
| 5 | **Recupera las tardes que pierdes haciendo presupuestos.** | Tiempo recuperado | tiempo regalado | ✅ Beneficio humano y directo. |
| 6 | **El presupuesto con render que tu competencia no sabe hacer.** | Diferenciación / marca | showrooming | ⚠️ Centrado en el "qué" (feature) más que en el dolor. |
| 7 | **Cada visita "para valorar" te cuesta dinero.** *(actual)* | Dolor + verbatim | "para valorar" | ✅ Usa lenguaje literal del cliente; algo abstracta sobre la solución. |
**Recomendación:** **#2** como H1 (une dolor + promesa + imagen concreta). **#1** y **#3** como
candidatas claras para **A/B test** (dolor puro vs beneficio del cliente). Evitar #6 como H1 (es
mejor como bloque de diferenciación más abajo).
---
## 3. La DESCRIPCIÓN (subhead) — opciones
Trabajo del subhead: explicar el **mecanismo** (atiende → pide fotos → presupuesta con render → lo
deja en tu panel) **y** el beneficio (solo vas a lo que renta), en 1-2 frases. < 30 palabras.
- **A.** "Reformix atiende a tu cliente por WhatsApp, le pide fotos y le calcula un presupuesto orientativo con render —bajo tu marca—. Tú abres el panel y solo te desplazas a las obras que rentan."
- **B.** "Mientras tú trabajas, Reformix cualifica a tu próximo cliente: fotos, medidas, presupuesto orientativo y render. En tu panel ves al instante si la obra merece la visita."
- **C.** "Atiende, cualifica y presupuesta a tu cliente por ti, con render y bajo tu marca. Tú solo vas a las visitas que valen la pena." *(la más corta)*
- **D.** "El asistente habla con tu cliente y le enseña cómo quedará su reforma con un presupuesto orientativo. Tú llegas a la visita con el trabajo hecho y el cliente ya enganchado."
**Recomendación: A** (mecanismo claro + "bajo tu marca" + el beneficio "solo a las que rentan").
**C** si el diseño pide algo más corto.
---
## 4. El CTA — opciones (orientado a DEMO)
Reglas aplicadas: **valor > verbo**, **primera persona** convierte mejor, **reducir fricción** con
trust text, **urgencia solo si es real**. La vía es **registro → página de demo** que muestra el
flujo y el resultado real (no trial de 14 días).
**CTA primario (a la demo):**
-**"Ver la demo en 2 minutos"** — valor + tiempo + cero fricción. **Recomendado.**
- "Probar la demo con una reforma" — más "hands-on" (implica registro).
- "Quiero ver cómo funciona" — primera persona, más suave.
- ⚠️ "Empezar gratis" — genérico, y ya no hay trial; evitar.
**CTA secundario (sin salir):** "Ver cómo funciona" → ancla al vídeo `#demo`.
**Trust text (bajo el botón):** "Sin tarjeta · Sin llamadas de ventas · Ves el resultado real en 2 minutos."
*(El "sin llamadas de ventas" mata de paso la objeción de que les vamos a perseguir.)*
**Oferta con urgencia (opcional, para el CTA final de la página, no el hero):**
"Si configuras tu cuenta esta semana, te dejamos los primeros [N] leads sin coste." → 🔸 definir N + fecha real (urgencia falsa = se nota y resta).
**Repetir el CTA** mínimo 3 veces: hero + mitad (tras "Cómo funciona") + cierre.
---
## 5. Combo recomendado (listo para pegar)
> **H1:** Sabrás qué obra merece la visita antes de coger el coche.
>
> **Subhead:** Reformix atiende a tu cliente por WhatsApp, le pide fotos y le calcula un presupuesto orientativo con render —bajo tu marca—. Tú abres el panel y solo te desplazas a las obras que rentan.
>
> **CTA primario:** Ver la demo en 2 minutos
> **CTA secundario:** Ver cómo funciona
> **Trust:** Sin tarjeta · Sin llamadas de ventas · Ves el resultado real en 2 minutos
Alternativa A/B del H1: *"Deja de presupuestar gratis para quien no va a contratar."* (dolor puro).
---
## 6. Checks
**Autenticidad (test 30 s):**
- ¿Suena a persona real de gremio? **Sí** ("antes de coger el coche", "obras que rentan").
- ¿Hay una frase que solo diría esta marca? **Sí**: *"solo te desplazas a las obras que rentan."*
- ¿Hay imperfección/concesión deliberada? **Sí** (más abajo, en objeciones: *"Si haces menos de X presupuestos al mes, no te hace falta esto."*). → añadir esa concesión honesta a la landing.
- Señales de "IA genérica" eliminadas: sin "innovador/revolucionario", sin lista simétrica de 3, abre con dolor (no con definición).
**Scan test:** H1 + subhead + CTA cuentan la historia solos (dolor → mecanismo → demo). ✅
**Pendiente de producto (🔸):** texto exacto de la oferta con urgencia (bono + fecha real).
---
## Siguiente paso
Con el combo aprobado: (1) lo aplico en `public/b2b.html` + `copy/COPY-GUIDE.md §2`, y (2) construyo la **página de demo** (registro → flujo + resultado real) a la que apunta el CTA. ("demo adelante" ya confirmado.)

View File

@@ -0,0 +1,63 @@
# Despliegue — Luisa (bot WhatsApp) + image-worker
Los tres servicios de Reformix corren **unidos en el mismo VPS** (Dokploy personal,
`panel.carlosnarro.com`, proyecto **Reformix** / entorno **production**). Build por **Dockerfile**
desde Gitea (`carlos/reformix-hackaton`, rama `main`, autodeploy en push).
## Servicios
| Servicio | App en Dokploy | Repo (buildPath) | Dominio | Puerto |
| --- | --- | --- | --- | --- |
| App principal | `reformix-b2c` (`lzHDAuPuubbJu94OrkNS_`) | `/mvp/b2c` | `reformix.dv3.com.es` | 3000 |
| Bot WhatsApp (Luisa) | `reformix-bot` (`wY4F14fyEslU-4za_JIbi`) | `/mvp/Whatsapp-bot` | `reformix-bot.dv3.com.es` | **3001** (webhooks) |
| Worker de renders | `reformix-worker` (`sMQd9zwoyV14q1vm8Vs8U`) | `/mvp/image-worker` | `reformix-worker.dv3.com.es` | 3001 |
> El bot escucha la app NestJS en 3000 y los **webhooks entrantes en 3001** (`/whatsapp-start`,
> `/whatsapp-pdf`). El dominio público enruta al 3001. Sesión de WhatsApp persistida en un volumen
> montado en `/app/auth_info_baileys`.
## Webhooks configurados en reformix-b2c
```ini
WHATSAPP_START_WEBHOOK_URL = https://reformix-bot.dv3.com.es/whatsapp-start
WHATSAPP_WEBHOOK_URL = https://reformix-bot.dv3.com.es/whatsapp-pdf
PERFIL_WEBHOOK_URL = https://reformix-worker.dv3.com.es/perfil-completo
```
El bot y el worker llaman de vuelta a la API de b2c con `API_BASE_URL`/`REFORMIX_API_URL =
https://reformix.dv3.com.es` y `Authorization: Bearer <FUNNEL_API_KEY>` (la misma de b2c, ya puesta).
## Pasos manuales pendientes (no automatizables)
1. **`OPENROUTER_API_KEY`** — está **vacía** en `reformix-bot` y `reformix-worker`. Pégala en el env
de **ambas** apps (panel de Dokploy → app → Environment) y **redeploy** de ambas. Sin ella el bot
no genera respuestas y el worker no genera renders.
2. **Vincular WhatsApp (QR)** — abre los **logs** de `reformix-bot` en Dokploy: Baileys imprime un QR
en ASCII. Escanéalo con el WhatsApp del número del negocio. La sesión queda persistida en el
volumen (sobrevive redeploys). **Sin restricción de número** (`ALLOWED_NUMBER` no está
configurada): el bot conversa con cualquiera que le escriba.
## Verificación (estado a 08-jun-2026)
- Builds Docker de bot y worker: **OK** a la primera. Certs Let's Encrypt emitidos (TLS válido).
- `GET https://reformix.dv3.com.es/` → 200 · `POST …/perfil-completo` (worker) → 400 (vivo) ·
`POST …/whatsapp-start` (bot) → 200 (vivo).
- Tras poner la `OPENROUTER_API_KEY` + escanear el QR, el flujo queda end-to-end: lead elige WhatsApp
`iniciarWhatsapp` → bot conversa y puebla la BD por los EPs → `perfilCompleto` → worker genera
renders → `ingesta finalizar` → PDF + email + entrega por WhatsApp.
## Operación
- **Redeploy:** push a `main` (autodeploy Gitea) o `POST /api/application.deploy {applicationId}`.
- Los GET que el bot consume (`GET /api/leads/:id`, `GET /api/leads/:id/conversacion`) viven en
`mvp/b2c`. Smoke test de los EPs del bot: [`mvp/b2c/api-docs/smoke-bot-eps.mjs`](../mvp/b2c/api-docs/smoke-bot-eps.mjs).
## Notas de integración para Simón (menores, a pulir)
- Los 2 `GET` que usa tu `api-client` y **no existían** en la API ya están añadidos y desplegados:
`GET /api/leads/:id` (estado del lead) y `GET /api/leads/:id/conversacion` (historial). Ya responden.
- En `POST /perfil` mandas `nombre`, pero la API **no actualiza** ese campo (lo ignora). Si quieres
poder cambiar el nombre del lead desde el bot, lo hablamos.
- No estás enviando `calidadGlobal` (`basica`/`media`/`premium`), que usa el motor de presupuesto.
Si Luisa lo puede extraer, mándalo en `POST /perfil`.
- Contrato completo de los EPs (campos, enums, ejemplos): [`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).

207
docs/estados-flujo.html Normal file
View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Reformix — Flujos de estado por canal</title>
<style>
:root{
--pipe:#0066ff; --pipe-bg:#e8f0fe;
--crm:#0f7a52; --crm-bg:#e6f4ee;
--wa:#0891b2; --wa-bg:#e0f4f8;
--bot:#b45309; --bot-bg:#fbf0e0;
--bad:#dc2626; --bad-bg:#fdeaea;
--ink:#18181b; --muted:#71717a; --line:#e4e4e7; --card:#fff; --bg:#f4f4f5;
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);line-height:1.5}
.wrap{max-width:1280px;margin:0 auto;padding:28px 20px 80px}
h1{font-size:26px;font-weight:800;letter-spacing:-.4px;margin:0 0 4px}
.sub{color:var(--muted);margin:0 0 22px;font-size:14px}
h2{font-size:15px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:34px 0 12px;font-weight:700}
/* Leyenda de dimensiones */
.dims{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.dim{background:var(--card);border:1px solid var(--line);border-left-width:5px;border-radius:10px;padding:12px 14px}
.dim h3{margin:0 0 4px;font-size:13px;font-family:ui-monospace,monospace}
.dim p{margin:0 0 8px;font-size:12px;color:var(--muted)}
.dim .vals{display:flex;flex-wrap:wrap;gap:4px}
.pip{border-left-color:var(--pipe)} .pip h3{color:var(--pipe)}
.crm{border-left-color:var(--crm)} .crm h3{color:var(--crm)}
.waL{border-left-color:var(--wa)} .waL h3{color:var(--wa)}
.botL{border-left-color:var(--bot)} .botL h3{color:var(--bot)}
.chip{display:inline-block;font-size:10.5px;font-family:ui-monospace,monospace;padding:2px 7px;border-radius:99px;border:1px solid var(--line);background:#fafafa;color:#3f3f46;white-space:nowrap}
.chip.p{background:var(--pipe-bg);border-color:#cdddff;color:#1d4ed8}
.chip.c{background:var(--crm-bg);border-color:#bfe3d2;color:#0f7a52}
.chip.w{background:var(--wa-bg);border-color:#bfe6ef;color:#0e7490}
.chip.b{background:var(--bot-bg);border-color:#f0d9b5;color:#b45309}
.chip.x{background:var(--bad-bg);border-color:#f5c2c2;color:#dc2626}
/* Columnas de canal */
.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:start}
.col{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.col > .head{padding:12px 16px;font-weight:700;font-size:15px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
.col.form > .head{background:#f5f3ff} .col.wa > .head{background:var(--wa-bg)} .col.call > .head{background:#fff7ed}
.steps{padding:14px 16px;display:flex;flex-direction:column;gap:0}
.step{position:relative;padding:10px 0 14px}
.step .t{font-size:13.5px;font-weight:600}
.step .d{font-size:12px;color:var(--muted);margin:2px 0 6px}
.step .tags{display:flex;flex-wrap:wrap;gap:4px}
.arrow{height:16px;display:flex;justify-content:center;color:#a1a1aa;font-size:13px}
.branch{border:1px dashed var(--line);border-radius:8px;padding:8px 10px;margin-top:6px;background:#fafafa}
.branch .t{font-size:12.5px;font-weight:600}
.dot{width:8px;height:8px;border-radius:99px;display:inline-block}
.conv{background:var(--bot-bg);border:1px solid #f0d9b5;border-radius:8px;padding:8px 10px;margin-top:6px}
.conv .t{font-size:12px;font-weight:700;color:var(--bot);margin-bottom:4px}
.conv .flow{font-family:ui-monospace,monospace;font-size:11px;color:#92400e;line-height:1.7}
.converge{margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px}
.converge .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:13px}
.converge .b{font-weight:700}
.note{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:14px 16px;margin-top:14px;font-size:13.5px}
.note h3{margin:0 0 8px;font-size:14px}
.note ul{margin:6px 0 0;padding-left:18px} .note li{margin:4px 0}
.rec{background:#ecfdf5;border:1px solid #a7f3d0}
table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:6px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--muted);font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
code{font-family:ui-monospace,monospace;background:#f4f4f5;padding:1px 5px;border-radius:5px;font-size:11.5px}
@media(max-width:980px){.dims{grid-template-columns:repeat(2,1fr)}.cols{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="wrap">
<h1>Reformix — Flujos de estado por canal</h1>
<p class="sub">El lío viene de mezclar varias "estados" que en realidad son <b>4 dimensiones independientes</b>. Aquí están separadas y el flujo de cada canal sobre ellas. DB única; el lead se crea siempre en el form web.</p>
<h2>1 · Las 4 dimensiones de estado (ortogonales)</h2>
<div class="dims">
<div class="dim pip">
<h3>pipeline_stage</h3>
<p>Avance TÉCNICO en el funnel. Lo comparten los 3 canales y es lo que ve el panel. <b>Lo gestiona la app/EP, no el bot.</b></p>
<div class="vals">
<span class="chip p">form_completado</span><span class="chip p">fotos_subidas</span><span class="chip p">prellamada_enviada</span><span class="chip p">llamada_completada</span><span class="chip p">render_generado</span><span class="chip p">presupuesto_generado</span><span class="chip p">whatsapp_entregado</span>
</div>
</div>
<div class="dim crm">
<h3>lead_estado</h3>
<p>Estado COMERCIAL / CRM. Lo lleva el reformista (y algún automatismo). Independiente del canal.</p>
<div class="vals">
<span class="chip c">nuevo</span><span class="chip c">contactado</span><span class="chip c">visita_agendada</span><span class="chip c">presupuesto_enviado</span><span class="chip c">ganado</span><span class="chip x">perdido</span>
</div>
</div>
<div class="dim waL">
<h3>estado_wa</h3>
<p>SOLO entrega del último mensaje de WhatsApp (técnico, por-mensaje). <b>No</b> es "en qué punto va la conversación".</p>
<div class="vals">
<span class="chip w">sin_enviar</span><span class="chip w">enviado</span><span class="chip w">entregado</span><span class="chip w">leido</span><span class="chip x">fallido</span>
</div>
</div>
<div class="dim botL">
<h3>estado_conversacion <span style="color:var(--bad)">(NO existe aún)</span></h3>
<p>En qué paso va Luisa en la cualificación. Hoy vive solo dentro del bot. <b>La decisión es si lo persistimos.</b></p>
<div class="vals">
<span class="chip b">apertura</span><span class="chip b">espacio</span><span class="chip b">tamaño</span><span class="chip b">estilo</span><span class="chip b">urgencia</span><span class="chip b">presupuesto</span><span class="chip b">pide_fotos</span><span class="chip b">completado</span>
</div>
</div>
</div>
<h2>2 · Flujo de cada canal sobre esas dimensiones</h2>
<div class="cols">
<!-- FORMULARIO -->
<div class="col form">
<div class="head">📝 Formulario web</div>
<div class="steps">
<div class="step"><div class="t">Cliente deja datos (crearLead)</div><div class="d">nombre · tel · email · opt-ins</div><div class="tags"><span class="chip p">form_completado</span><span class="chip c">nuevo</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Rellena por zonas + sube fotos</div><div class="d">guardarDetallesYFotos → lead_fotos (antes) + lead_notas</div><div class="tags"><span class="chip p">fotos_subidas</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Presupuesto orientativo al instante</div><div class="d">motor de presupuesto + señal perfilCompleto</div><div class="tags"><span class="chip p">presupuesto_generado</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Converge en calificación →</div><div class="tags"><span class="chip c">contactado</span></div></div>
<div class="conv"><div class="t">No usa</div><div class="flow">estado_wa · estado_conversacion (no hay chat)</div></div>
</div>
</div>
<!-- WHATSAPP -->
<div class="col wa">
<div class="head">💬 WhatsApp — Luisa</div>
<div class="steps">
<div class="step"><div class="t">Lead ya existe (del form) → elige WhatsApp</div><div class="d">app emite WHATSAPP_START con leadId</div><div class="tags"><span class="chip p">form_completado</span><span class="chip c">nuevo</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Bot escribe el 1er mensaje</div><div class="d">entrega del mensaje (no la conversación)</div><div class="tags"><span class="chip w">sin_enviar→enviado→entregado→leido</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Luisa cualifica (conversación)</div><div class="d">guarda cada turno en conversacion_whatsapp + extrae a leads</div>
<div class="conv"><div class="t">estado_conversacion (bot)</div><div class="flow">apertura → espacio → tamaño → estilo → urgencia → presupuesto</div></div>
</div>
<div class="arrow"></div>
<div class="step"><div class="t">¿Viable? (≥ 5000€)</div>
<div class="branch"><div class="t">No → <span class="chip x">perdido</span> (no_viable, descartado)</div></div>
<div class="branch"><div class="t">Sí → pide fotos por WA</div><div class="tags" style="margin-top:6px"><span class="chip b">pide_fotos</span></div></div>
</div>
<div class="arrow"></div>
<div class="step"><div class="t">Fotos recibidas → EP ingesta</div><div class="d">lead_fotos (antes) + worker analiza</div><div class="tags"><span class="chip p">fotos_subidas</span><span class="chip b">completado</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Converge en calificación →</div><div class="tags"><span class="chip c">contactado</span></div></div>
</div>
</div>
<!-- LLAMADA -->
<div class="col call">
<div class="head">📞 Llamada</div>
<div class="steps">
<div class="step"><div class="t">Lead ya existe (del form) → pide llamada</div><div class="d">ahora / programar</div><div class="tags"><span class="chip p">prellamada_enviada</span><span class="chip c">nuevo</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Bot de llamada (externo)</div><div class="d">registra intento en intentos_contacto</div><div class="tags"><span class="chip p">llamada_completada</span></div>
<div class="branch"><div class="t">resultado_contacto</div><div class="tags" style="margin-top:6px"><span class="chip">exitoso</span><span class="chip">no_contesta</span><span class="chip">ocupado</span><span class="chip x">rechaza</span></div></div>
</div>
<div class="arrow"></div>
<div class="step"><div class="t">Pide fotos por WA o email→formulario</div><div class="d">leads.fotos_solicitadas_at</div><div class="tags"><span class="chip w">enviado</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Fotos recibidas → EP ingesta</div><div class="d">lead_fotos (antes) + worker</div><div class="tags"><span class="chip p">fotos_subidas</span></div></div>
<div class="arrow"></div>
<div class="step"><div class="t">Converge en calificación →</div><div class="tags"><span class="chip c">contactado</span></div></div>
</div>
</div>
</div>
<!-- CONVERGENCIA -->
<h2>3 · Convergencia (los 3 canales acaban igual)</h2>
<div class="converge">
<div class="row">
<span class="chip c">Calificación</span><span>lead_calificacion (score + A/B/C/D)</span> <span style="color:#a1a1aa"></span>
<span class="chip c">visita_agendada</span> <span style="color:#a1a1aa"></span>
<span class="chip p">render_generado</span><span class="chip p">presupuesto_generado</span> <span style="color:#a1a1aa"></span>
<span class="chip p">whatsapp_entregado</span><span class="chip c">presupuesto_enviado</span> <span style="color:#a1a1aa"></span>
<span class="chip c">ganado</span> / <span class="chip x">perdido</span> <span style="color:#a1a1aa"></span> testimonio
</div>
</div>
<!-- DECISIÓN -->
<h2>4 · La decisión a tomar</h2>
<div class="note">
<h3>¿WhatsApp necesita "otros estados"? Sí, pero ojo a CUÁL:</h3>
<ul>
<li><b>estado_wa</b> (ya lo tenemos): es solo si el mensaje llegó/se leyó. Útil pero de bajo nivel.</li>
<li><b>estado_conversacion (Luisa)</b>: <u>esto es lo que de verdad falta</u> si queremos saber "por qué paso va el chat" y poder retomarlo si se corta. Hoy NO está en la DB.</li>
<li>El <b>diagrama del compañero</b> ponía <code>estado_wa = nuevo</code> → mezcla los dos conceptos. <code>nuevo</code> no es entrega de mensaje, es "conversación sin empezar".</li>
</ul>
</div>
<div class="note rec">
<h3>Mi recomendación</h3>
<ul>
<li><b>pipeline_stage</b> = única fuente del avance del lead (lo pinta el panel). Lo escribe la app/EP. <b>El bot NO lo toca.</b></li>
<li><b>lead_estado</b> = comercial, lo lleva el reformista.</li>
<li><b>estado_wa</b> = déjalo solo para entrega de mensaje (sin_enviar…leido). No metas "nuevo" ahí.</li>
<li><b>estado_conversacion del bot</b>: 2 opciones —
<br>(a) vive SOLO dentro del bot/n8n (no lo persistimos en nuestra DB) → más simple, suficiente para la demo;
<br>(b) lo persistimos como columna nueva <code>leads.bot_step</code> (o tabla) si queremos verlo en el panel / retomar conversaciones. <b>← esto es lo que hay que decidir.</b></li>
</ul>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,334 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net">
<diagram name="Página-1" id="DRtktbz_MXrh0E5vmsnP">
<mxGraphModel dx="611" dy="239" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="nWkRbZFlf69J1CHv_Vaw-1" parent="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" value="Reformix — Flujo del sistema" vertex="1">
<mxGeometry height="40" width="500" x="600" y="20" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-2" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontSize=13;fontStyle=1;" value="Cliente" vertex="1">
<mxGeometry height="50" width="120" x="800" y="80" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Formulario web" vertex="1">
<mxGeometry height="40" width="150" x="540" y="190" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-5" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="WhatsApp directo" vertex="1">
<mxGeometry height="40" width="150" x="785" y="157" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-6" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Llamada" vertex="1">
<mxGeometry height="40" width="150" x="1030" y="190" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-7" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-8" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-5">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-9" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-10" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Crear lead en DB&#xa;(canal_origen registrado)" vertex="1">
<mxGeometry height="50" width="250" x="735" y="280" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-11" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-4" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-12" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-5" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-13" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-6" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-14" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Registrar intento&#xa;(intentos_contacto)" vertex="1">
<mxGeometry height="50" width="250" x="735" y="370" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-15" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-10" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-14">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-16" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Formulario" vertex="1">
<mxGeometry height="20" width="150" x="400" y="450" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-17" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Cliente sube fotos&#xa;(lead_fotos · antes)" vertex="1">
<mxGeometry height="50" width="170" x="390" y="480" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-18" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-17">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="735" y="395" />
<mxPoint x="475" y="395" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-19" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes&#xa;analiza + clasifica fotos&#xa;(lead_fotos · render_url)" vertex="1">
<mxGeometry height="60" width="170" x="390" y="570" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-20" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-17" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-19">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-21" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
<mxGeometry height="40" width="120" x="415" y="665" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-22" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-19" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-21">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-23" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="WhatsApp — Luisa" vertex="1">
<mxGeometry height="20" width="200" x="710" y="450" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-24" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Scheduler 5 min&#xa;→ Bot Luisa&#xa;(estado_wa = nuevo)" vertex="1">
<mxGeometry height="60" width="180" x="720" y="480" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-25" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-26" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Luisa cualifica (7 estados)&#xa;apertura→espacio→tamaño&#xa;→estilo→urgencia→presupuesto&#xa;[conversacion_whatsapp]" vertex="1">
<mxGeometry height="70" width="200" x="710" y="575" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-27" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-24" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-26">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-28" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Viable?&#xa;(≥ 5000€)" vertex="1">
<mxGeometry height="70" width="140" x="740" y="675" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-29" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-26" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-28">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-30" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#fff2cc;strokeColor=#d6b656;" value="Lead no_viable&#xa;(descartado)" vertex="1">
<mxGeometry height="50" width="130" x="580" y="690" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-31" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-30" value="No">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-32" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Pide fotos por WA&#xa;(leads · fotos_solicitadas_at)" vertex="1">
<mxGeometry height="50" width="180" x="720" y="780" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-33" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-32" value="Sí">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-34" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos&#xa;recibidas?" vertex="1">
<mxGeometry height="70" width="140" x="740" y="860" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-35" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-32" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-36" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático&#xa;↻ vuelve a preguntar" vertex="1">
<mxGeometry height="50" width="140" x="530" y="920" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-37" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-36" value="No">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-38" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-36" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="650" y="935" />
<mxPoint x="650" y="895" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-39" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes&#xa;(lead_fotos · antes)" vertex="1">
<mxGeometry height="50" width="180" x="720" y="965" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-40" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-39" value="Sí">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-41" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
<mxGeometry height="40" width="150" x="735" y="1045" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-42" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-39" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-41">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-43" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Llamada" vertex="1">
<mxGeometry height="20" width="150" x="1030" y="450" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-44" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;dashed=1;" value="Bot de llamada&#xa;(compañero — pendiente)" vertex="1">
<mxGeometry height="50" width="170" x="1030" y="480" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-45" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-44">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="985" y="395" />
<mxPoint x="1115" y="395" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-46" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Llamada&#xa;completada?" vertex="1">
<mxGeometry height="70" width="140" x="1045" y="560" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-47" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-44" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-46">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-48" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="WA de continuación&#xa;retoma desde estado_wa" vertex="1">
<mxGeometry height="50" width="170" x="1210" y="573" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-49" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-48" value="No / cortada">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-50" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-48" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1295" y="440" />
<mxPoint x="870" y="440" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-51" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Contactado?" vertex="1">
<mxGeometry height="70" width="140" x="1045" y="660" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-52" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-51" value="Sí">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-53" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24" value="No">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1045" y="695" />
<mxPoint x="960" y="695" />
<mxPoint x="960" y="510" />
<mxPoint x="860" y="510" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-54" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Enviar WA pidiendo fotos&#xa;(leads · fotos_solicitadas_at)" vertex="1">
<mxGeometry height="50" width="170" x="1030" y="765" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-55" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-54" value="Sí">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-56" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos&#xa;recibidas?" vertex="1">
<mxGeometry height="70" width="140" x="1045" y="845" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-57" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-54" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-58" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático&#xa;↻ vuelve a preguntar" vertex="1">
<mxGeometry height="50" width="150" x="1250" y="895" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-59" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-58" value="No">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-60" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-58" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1360" y="885" />
<mxPoint x="1360" y="880" />
<mxPoint x="1185" y="880" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-61" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes&#xa;(lead_fotos · antes)" vertex="1">
<mxGeometry height="50" width="170" x="1030" y="945" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-62" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-61" value="Sí">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-63" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
<mxGeometry height="40" width="150" x="1045" y="1025" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-64" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-61" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-63">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-65" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Todas las ramas convergen aquí" vertex="1">
<mxGeometry height="20" width="400" x="660" y="1110" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-66" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;strokeWidth=2;" value="Calificación del lead&#xa;score + nivel frío/tibio/caliente&#xa;(lead_calificacion)" vertex="1">
<mxGeometry height="60" width="300" x="710" y="1140" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-67" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-21" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="475" y="1170" />
<mxPoint x="710" y="1170" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-68" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-41" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="810" y="1170" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-69" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-63" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1120" y="1170" />
<mxPoint x="1010" y="1170" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-70" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="CRM — Agente revisa lead&#xa;(leads · estado = contactado)" vertex="1">
<mxGeometry height="50" width="300" x="710" y="1240" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-71" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-66" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-70">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-72" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Visita agendada&#xa;(visitas)" vertex="1">
<mxGeometry height="50" width="300" x="710" y="1330" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-73" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-70" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-72">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-74" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes&#xa;genera render antes/después&#xa;(lead_fotos · render_url)" vertex="1">
<mxGeometry height="60" width="300" x="710" y="1420" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-75" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-72" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-74">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-76" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Generación presupuesto&#xa;render + PDF&#xa;(leads · pdf_url)" vertex="1">
<mxGeometry height="60" width="300" x="710" y="1520" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-77" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-74" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-76">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-78" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Envío al cliente&#xa;WhatsApp / email" vertex="1">
<mxGeometry height="50" width="300" x="710" y="1620" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-79" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-76" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-78">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-80" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Acepta?" vertex="1">
<mxGeometry height="70" width="140" x="790" y="1700" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-81" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-78" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-80">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-82" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead GANADO&#xa;(leads · estado = ganado)" vertex="1">
<mxGeometry height="50" width="220" x="680" y="1810" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-83" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-82" value="Sí">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-84" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead PERDIDO&#xa;(leads · estado = perdido)" vertex="1">
<mxGeometry height="50" width="220" x="970" y="1718" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-85" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-84" value="No">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-86" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Solicitar testimonio&#xa;(testimonios)" vertex="1">
<mxGeometry height="50" width="220" x="680" y="1900" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-87" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-82" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-86">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-88" parent="1" style="text;html=1;fontSize=13;fontStyle=1;" value="Leyenda" vertex="1">
<mxGeometry height="20" width="100" x="1300" y="1140" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-89" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Flujo principal" vertex="1">
<mxGeometry height="30" width="160" x="1300" y="1170" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-90" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Bot externo / pendiente" vertex="1">
<mxGeometry height="30" width="160" x="1300" y="1210" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-91" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes" vertex="1">
<mxGeometry height="30" width="160" x="1300" y="1250" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-92" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead ganado / éxito" vertex="1">
<mxGeometry height="30" width="160" x="1300" y="1290" as="geometry" />
</mxCell>
<mxCell id="nWkRbZFlf69J1CHv_Vaw-93" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead perdido / descartado" vertex="1">
<mxGeometry height="30" width="160" x="1300" y="1330" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -0,0 +1,115 @@
# Handoff runtime del bot WhatsApp (Luisa) — para Simón
Estado tras la sesión de depuración del 09-jun. El **flujo conversacional funciona end-to-end**;
quedan **dos problemas de runtime del bot** que se diagnostican/cierran desde tu lado (tienes acceso
a los logs en Dokploy). La parte de la app (EPs) está verificada y no es el problema.
---
## 1. Lo que está PROBADO funcionando
- **EPs de la app** (en `mvp/b2c`): `conversacion`, `perfil`, `calificacion`, `intento`, `ingesta`,
+ `GET /api/leads/:id` y `GET /api/leads/:id/conversacion` y `GET /api/leads/by-phone`. Todos OK.
Verificado: `POST /perfil` con el payload exacto del bot (`{espacio,rangoM2,estilo,botStep}`) →
`200 {ok:true, actualizado:[...]}` y el lead se actualiza. **El EP no es el cuello de botella.**
- **Bot:** apertura proactiva, **resolución del `@lid`**, matching del lead por teléfono, y la
**conversación de cualificación de Luisa** (7 turnos reales: cocina → tamaño → estilo → urgencia).
- **Worker de render** (`mvp/image-worker`): genera renders con `google/gemini-2.5-flash-image`.
## 2. Cambios que hice hoy en el bot (`mvp/Whatsapp-bot`) — para que no te pillen por sorpresa
- **Apertura proactiva** al recibir `/whatsapp-start`: `WhatsappService` escucha un `startEmitter` y
envía el primer mensaje (antes solo registraba la sesión y esperaba al cliente). Persiste
`estadoWa/botStep` + intento.
- **Resolución `@lid`** ([`resolverTelefono`](../mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts)):
WhatsApp entrega los mensajes desde una dirección `@lid` (p.ej. `239225534443615@lid`), no desde el
número. Se resuelve a número vía `msg.key.remoteJidAlt` o el mapa LID→PN de Baileys. **Esto era la
causa de que el bot ignorara los mensajes entrantes.**
- **Recuperación del lead por teléfono:** si la sesión no está en memoria (reinicio), `getOrCreateContext`
busca el lead en la BD vía `GET /api/leads/by-phone` y re-registra la sesión.
- **`markOnlineOnConnect: true`**: con `false`, tras reconectar el dispositivo quedaba "no disponible"
y WhatsApp **no entregaba** los mensajes. Con `true` empezó a recibir (la conversación de 7 turnos
lo demuestra).
- **Modelos de Claude corregidos** en el env: eran `claude-haiku-4-5` (guion) → inválidos en
OpenRouter; ahora `anthropic/claude-haiku-4.5` y `anthropic/claude-sonnet-4.5` (punto).
- **`BAILEYS_AUTH_DIR`** configurable (subcarpeta del volumen) para empezar una **sesión limpia**
sin perder persistencia. Hoy apunta a `/app/auth_info_baileys/v2`.
- **Endpoints de operación** (servidor de webhooks, puerto 3001):
- `GET /qr` — QR de vinculación como **imagen** (HTTP Basic; usuario cualquiera, contraseña =
`QR_TOKEN`, que está en el env del bot en Dokploy).
- `GET /debug` — estado de conexión + anillo de los últimos eventos entrantes (mismo auth). Útil
para ver `remoteJid`/`remoteJidAlt`, si llega algo, y el resultado del matching.
- Subido el límite de body del worker a 30 MB (las fotos en data URI rompían el 100kb por defecto).
## 3. Problema A — conexión Baileys inestable (bucle de reconexión)
**Evidencia en logs del contenedor (`docker logs`):** el socket se cae cada pocos minutos con
`stream:error → conflict {type:"replaced"}` y `stream:error code 503`, y reconecta en bucle
(`AwaitingInitialSync → Transitioning to Online → opened connection to WA → ✅ conectado`, repetido).
El `conflict: replaced` indica que **otra sesión reclama la misma cuenta** — típico del **solapamiento
en el deploy** (Swarm arranca el contenedor nuevo antes de matar el viejo y ambos usan la misma
sesión del volumen). `markOnlineOnConnect: true` mejoró la recepción pero no arregla una conexión que
se cae sola.
**Mitigación parcial (Baileys):** configurar el deploy del bot como **stop-first / sin solapamiento**
(1 réplica, recreate) para evitar el `conflict`. Pero los `503` son de WhatsApp y seguirán.
**Vía robusta (acordada): Evolution API.**
**Cómo reproducir/diagnosticar:**
- `GET https://reformix-bot.dv3.com.es/debug` (Basic, contraseña `QR_TOKEN`): si `connection:open`
pero `inbound` no crece cuando el cliente escribe → estás en el estado "zombi".
- Para volver a recibir: sesión nueva → sube `BAILEYS_AUTH_DIR` a `/app/auth_info_baileys/v3`,
redeploy, escanea el QR fresco en `/qr`. **Y evita redeployar después** (cada reconexión arriesga
la recepción).
**Camino robusto (acordado con Carlos): migrar el transporte de WhatsApp del bot a Evolution API**
(ya está como primaria en el stack — NO la WhatsApp Cloud API oficial). Evolution gestiona la
conexión y entrega los mensajes por **webhook**, sin un socket Baileys en-proceso que se vuelva
zombi. El bot pasaría a: (1) recibir mensajes por webhook de Evolution, (2) enviar por su REST. La
lógica de Claude + los EPs de la app se quedan igual; solo cambia la capa de transporte WhatsApp.
## 4. Problema B — el perfil se persiste solo a medias + máquina de estados errática
**Verificado en vivo:** `persistirTurno` SÍ funciona cuando se llama —
`Lead ... persistido via API: {"rangoM2":"10a20","botStep":"estilo"} → ok`. Pero en una conversación
completa **solo apareció UN `persistido`** (rangoM2); `espacio`, `urgencia` y `presupuesto` no se
guardaron. Y la conversación se descuadra (rechaza a 4500€ → con 8500€ vuelve a preguntar el tamaño →
"preparo presupuesto"). Causa probable: `claudeService.llamarClaude` solo devuelve `entidad`/`nuevoEstado`
en algunos turnos, y la lógica de estado/viabilidad no es determinista.
**A revisar:** que cada turno con dato extraído llame a `persistirTurno`, que los valores encajen con
los enums de la app (`urgencia` alta|media|baja, etc., o `POST /perfil` da 422 y no guarda nada), y
endurecer la máquina de estados (no re-preguntar lo ya respondido; viabilidad estable).
## 4bis. Problema C (el que rompe el end-to-end) — el bot NUNCA dispara la generación
**Verificado en logs:** Luisa termina diciendo *"en un momento recibes tu presupuesto"* pero **no hay
ninguna llamada a `ingesta` / `perfilCompleto`** en toda la sesión (grep vacío). Es decir, al cerrar la
cualificación el bot **no dispara nada**: ni render, ni PDF, ni entrega. Es una promesa vacía.
**Qué falta (lado bot):** cuando la cualificación se completa (estado `presupuesto`/`fin_viable`), el
bot debe (1) **pedir las fotos** del espacio por WhatsApp, (2) subirlas vía `POST /api/leads/:id/ingesta`
(items `foto`, `momento:"antes"`), y (3) marcar `perfilCompleto:true` (y/o `finalizar`). Eso dispara en
la app: `PERFIL_WEBHOOK` → worker genera renders → `ingesta finalizar` → PDF + email + entrega WhatsApp.
**La app y el worker ya están listos para esto; solo falta que el bot llame al EP.** Contrato:
[`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
## 5. Infra (referencia rápida)
| Servicio | App Dokploy | Dominio |
| --- | --- | --- |
| Bot | `reformix-bot` (`wY4F14fyEslU-4za_JIbi`) | `reformix-bot.dv3.com.es` (puerto 3001) |
| Worker | `reformix-worker` (`sMQd9zwoyV14q1vm8Vs8U`) | `reformix-worker.dv3.com.es` |
| App | `reformix-b2c` (`lzHDAuPuubbJu94OrkNS_`) | `reformix.dv3.com.es` |
- Build Dockerfile desde Gitea, autodeploy en push a `main`. Volumen del bot: `/app/auth_info_baileys`
(sesión WhatsApp). `OPENROUTER_API_KEY` y `FUNNEL_API_KEY` en el env de cada app.
- Contrato de los EPs y enums: [`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
---
**Resumen:** la conversación de Luisa funciona (recibe, resuelve `@lid`, cualifica, responde). Quedan 3
cosas del bot: **A)** conexión inestable (`conflict`+`503`, bucle de reconexión) → Evolution API;
**B)** persistencia parcial del perfil + estado errático; **C)** el bot **nunca dispara la generación**
(no llama a `ingesta`/`perfilCompleto`), así que el presupuesto/render/entrega no llega — esto es lo que
rompe el end-to-end y lo que más conviene cerrar. App y worker ya están listos esperando esa llamada.

View File

@@ -0,0 +1,117 @@
# Handoff WhatsApp (Luisa) — para Simón
Cómo integra el bot de WhatsApp con la app Reformix. **Una sola base de datos** (la de la app;
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
Cuando el cliente elige "WhatsApp" en el funnel, la app hace `POST` al webhook
**`WHATSAPP_START_WEBHOOK_URL`** (lo configuras tú y nos lo pasas) con:
```json
{ "leadId": "uuid", "telefono": "+34...", "nombre": "...", "empresa": "Reformas Ejemplo" }
```
A partir de ahí Luisa escribe al `telefono` y trabaja **siempre con ese `leadId`**.
## 2. Cómo escribes en la BD: por API, no SQL
| Qué guardas | Endpoint |
| --- | --- |
| 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`) |
**Auth (todos):** header `Authorization: Bearer <FUNNEL_API_KEY>` (te paso la clave aparte; es la
misma para todos los EPs). `Content-Type: application/json`. `:id` = el `leadId` del §1.
**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).
> `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.
## 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 API mandan estos (el EP rechaza con `422` lo
que no encaje):
**`tipoReforma`** → `cocina · bano · salon · comedor · integral · otro`
`oficina`/`local`/`otros` → usa `otro`.
**`urgencia`** → `alta · media · baja` (tu `inmediata``alta`).
**`estadoWa`** (entrega del **mensaje**) → `sin_enviar · enviado · entregado · leido · fallido`.
**`canalOrigen`** → `formulario_web · whatsapp · llamada · referido · anuncio`.
**`calificacion.nivel`** → `A · B · C · D`. **`intento.canal`** → `formulario · whatsapp · llamada`.
**`intento.resultado`** → `exitoso · no_contesta · ocupado · rechaza · error_tecnico`.
**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.
**`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 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 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. Estado: probado y en producción
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** (§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`.

72
docs/plan-accion.md Normal file
View File

@@ -0,0 +1,72 @@
# Plan de acción — Reformix
> Documento vivo a partir del feedback e ideas (jun-2026). Marca `[x]` al cerrar cada tarea.
> Prioridad **propuesta** (ajustadla): 🔴 ahora (impacto demo/venta) · 🟡 siguiente · 🟢 después.
> 🔸 = decisión de producto pendiente (ver sección final).
---
## 1. Canal WhatsApp — que vaya perfecto 🔴
> "Pensar muy bien el canal, es nuestro punto más débil. El flujo de WhatsApp tiene que ir perfecto."
- [ ] Dejar el flujo de Luisa impecable end-to-end: arranque → cualificación → pedir fotos → recogida → cierre.
- [ ] Probar cada bifurcación: no contesta, no viable, fotos que no llegan, **retomar conversación cortada** (usando `bot_step`).
- [ ] Conectar los 3 webhooks reales (start / perfil / entrega) y probar la **entrega del PDF por WhatsApp** punta a punta.
- [ ] Coordinar con Simón. Refs: [handoff-whatsapp-simon.md](handoff-whatsapp-simon.md) · [estados-flujo.html](estados-flujo.html).
## 2. Captura del cliente — la medida (m²) con máximas facilidades 🔴
> "Poner el máximo de facilidades: media estimada, medir con pasos, o el truco del DIN-A4."
- [ ] Ofrecer varias formas de dar la medida en el formulario por zonas:
- [ ] (a) **media estimada** por tipo de estancia (el cliente no mide nada).
- [ ] (b) **medir a pasos** (con guía: 1 paso ≈ 0,7 m).
- [ ] (c) **truco del folio DIN-A4** en una foto para que la IA calcule.
- [ ] UI + guía visual en `FormularioZonas`.
- [ ] 🔸 Decidir cómo entra cada método en el motor de presupuesto (estimada vs medida → confianza).
## 3. Landing B2B de ventas — conversión 🔴
> Copys, propuesta de valor, vídeo, oferta con urgencia, demo en vez de trial, análisis de competencia.
- [~] **Mejorar copys / propuesta de valor** (`public/b2b.html` + `COPY-GUIDE §2`) — *estudio hecho: [copy-b2b-estudio.md](copy-b2b-estudio.md)*. Falta aplicar el combo elegido.
- [ ] Liberar tiempo de la confección de presupuestos.
- [ ] Apelar a que **su cliente** tiene una idea rápida y visual de su obra → mejora tiempos y la **marca** del reformista.
- [ ] **Explicar bien la llamada**: dejar claro que **NO es una llamada de ventas** (en el trust text del CTA + sección/pre-llamada).
- [ ] **Vídeo corto, muy visual** — imprescindible. Sustituir el placeholder de la sección `#demo`.
- [ ] **Oferta irresistible con urgencia**: p. ej. "Si agendas tu cita antes de _Y_, obtén _bono Z_". 🔸 definir bono + fecha.
- [x]**DECIDIDO: nada de "14 días gratis"****registro → página de demo** (flujo + resultado real). Siguiente: construir la página de demo.
- [ ] **Analizar el funnel de [compramostucoche.com](https://www.compramostucoche.com/)** — primer análisis en el estudio de copy; profundizar si hace falta.
## 4. Onboarding del reformista (panel) 🟡
> "Crear un onboarding para los reformistas en el panel."
- [ ] Diseñar el onboarding (primeros pasos tras registrarse).
- [ ] Definir qué configura y en qué orden (marca/logo, catálogo, número de teléfono, dominio…).
- [ ] Construirlo en el panel.
## 5. Web del reformista / dominio propio 🟡
> "Poder añadir un dominio propio en su web de venta; si no tiene web, la landing generada le sirve de web."
- [ ] Permitir **añadir un dominio propio** a la web de venta del reformista.
- [ ] Que la **landing generada** funcione como su web si no tiene una. 🔸 definir flujo (DNS, multi-tenant).
## 6. Producción de vídeo 🟡
> "Vídeo corto imprescindible muy visual" · "Grabar con screen.studio / remotion / vídeo de uno mismo."
- [ ] Guion corto y muy visual mostrando la plataforma (de "hola" a presupuesto en WhatsApp).
- [ ] Grabar con **screen.studio** o **Remotion** (o grabación propia).
- [ ] Encajar en la sección `#demo` de la landing y en la página de demo (punto 3).
---
## Decisiones de producto pendientes (🔸)
- [ ] **Trial:** ¿"14 días gratis" o "registro → demo del flujo/resultado"? (coste de tokens vs fricción).
- [ ] **Oferta/urgencia:** qué bono y qué fecha límite.
- [ ] **Medida:** método principal y cómo afecta a la confianza del presupuesto.
- [ ] **Dominio propio:** alcance (subdominio nuestro vs dominio del cliente; ¿F1.5?).
## Referencias
- [handoff-whatsapp-simon.md](handoff-whatsapp-simon.md) — integración del bot.
- [estados-flujo.html](estados-flujo.html) — flujos de estado por canal.
- `copy/COPY-GUIDE.md §2` — copy canónico B2B.
- `mvp/b2c/public/b2b.html` — landing B2B (servida en `/` y `/b2b`).
- `mvp/b2c/src/components/funnel/FormularioZonas.tsx` — formulario por zonas (medida).

104
docs/retell-setup.md Normal file
View File

@@ -0,0 +1,104 @@
# Activar el agente de voz (Retell)
El código ya está listo (la app lanza la llamada saliente tras la pre-llamada). Falta la parte de
**panel de Retell + credenciales**. Esto es lo que hace falta y cómo.
## Lo que necesito de ti (3 valores)
| Variable | Qué es | Cómo conseguirlo |
| --- | --- | --- |
| `RETELL_API_KEY` | Clave de la API de Retell | Panel de Retell → API Keys |
| `RETELL_FROM_NUMBER` | Número de origen (E.164, `+34…`) | Comprar uno en Retell (rápido) **o** conectar tu fijo de Zadarma por SIP |
| `RETELL_AGENT_ID` | Id del agente que creas (override) | Panel de Retell → al crear el agente |
En cuanto me pases los 3, los pongo en Dokploy (prod) + `.env.local` y **probamos una llamada real**
a tu móvil. (Mínimo imprescindible para que suene: API key + from_number.)
## Pasos en el panel de Retell
1. **Crear cuenta / API key.**
2. **Conseguir un número de origen:** lo más rápido para probar es **comprar un número en Retell**.
Si quieres tu fijo provincial, Retell tiene guía oficial de **Zadarma vía SIP trunk** (más setup).
3. **Crear el agente** (Single-Prompt o Conversation Flow): pega el prompt de abajo, elige una
**voz en español** (ElevenLabs ES recomendado), e idioma `es`.
4. Copiar el **Agent ID** y la **API Key**.
## Prompt del agente (pégalo tal cual; usa las variables `{{...}}`)
```
# Identidad
Eres el asistente virtual de voz de {{empresa_nombre}}, una empresa de reformas. Hablas con
{{cliente_nombre}}, que acaba de pedir un presupuesto en la web. Tono cercano y natural, frases
cortas, ritmo conversacional español. Permite interrupciones. Usa su nombre. NUNCA suenas a robot
ni a teleoperador. Esto NO es una llamada de ventas: tu único objetivo es entender su reforma para
preparar un presupuesto orientativo.
# Contexto del lead (puede faltar)
- Tipo de reforma indicado: {{tipo_reforma}}
- Provincia: {{provincia}}
# Inicio — consentimiento de grabación (OBLIGATORIO)
Saluda: "Hola {{cliente_nombre}}. Soy el asistente virtual de {{empresa_nombre}}. Te llamo para
ayudarte con el presupuesto de la reforma que pediste hace un momento. Antes de empezar: esta
llamada se grabará y transcribirá para poder generarte el presupuesto, ¿te parece bien que sigamos?"
- Si dice que sí → "Perfecto, gracias. Solo te robo 3 minutos."
- Si dice que no → "Sin problema, lo respeto. Cuelgo ya. Si cambias de idea, puedes volver a la web.
Que tengas un buen día." y termina la llamada.
- Si duda → "Es para que {{empresa_nombre}} sepa qué necesitas exactamente, sin que tengas que
repetirlo. Solo lo escuchamos nosotros, no se publica. ¿Seguimos?"
# Cualificación (una idea por pregunta; recoge estos datos)
1. Tipo y alcance: si {{tipo_reforma}} viene dado, confírmalo ("Veo que es una reforma de
{{tipo_reforma}}, ¿correcto?"); si no, pregúntalo. ¿Integral o parcial? ¿Hay que mover sanitarios
o cambiar la distribución?
2. Medidas: metros cuadrados aproximados. Si no lo sabe, ayúdale con referencias (un baño normal
son 5-7 m²).
3. Calidad de materiales: estándar / media / premium (con ejemplos de marcas).
4. Presupuesto que no quiere superar (techo, opcional).
5. Urgencia (esta semana / este mes / en unos meses / sin prisa).
6. Estilo preferido y algo que sí o sí quiera incluir o evitar.
# Fotos del espacio (solo si faltan; NO insistas a ciegas)
El render sale mucho mejor con fotos reales, pero el cliente PUEDE haberlas enviado ya (por
WhatsApp o por el formulario), según cómo haya entrado al flujo. Manéjalo así:
- Si dice que ya las ha enviado: "Perfecto, entonces ya las tenemos, gracias."
- Si no, o no está seguro: recuérdale que puede mandarlas por WhatsApp (le estamos escribiendo
también por ahí) o, si no usa WhatsApp, por el enlace que le hemos enviado al correo.
- El WhatsApp NO es el número desde el que llama (es otro, el del bot); no le digas que las mande
"a este número".
# Cierre
"Genial {{cliente_nombre}}. Con esto tengo lo que necesito. En cuanto tengamos las fotos de tu
espacio te llega por WhatsApp el render y el presupuesto desglosado. Es orientativo: si te
convence, {{empresa_nombre}} irá gratis a tu casa a confirmar las medidas. ¿Algo más antes de
colgar?" → despídete y cuelga.
# Reglas
- Identifícate SIEMPRE como asistente virtual / IA (AI Act).
- No inventes precios: el presupuesto lo calcula el sistema después.
- Si el cliente divaga, recondúcelo con amabilidad.
- En cuanto la conversación termine (el cliente se despide: adiós, gracias, hasta luego, nada más; o ya te despediste tú), CUELGA con la herramienta end_call. No te quedes en silencio.
```
> El agente tiene activada la herramienta **`end_call`** (en `general_tools` del Retell LLM): cuelga
> solo al detectar la despedida, y también si el cliente no consiente la grabación al inicio.
## Variables dinámicas que envía la app (ya implementado)
En cada llamada mandamos `retell_llm_dynamic_variables` con:
`empresa_nombre`, `cliente_nombre`, y (si existen) `tipo_reforma`, `provincia`.
Por eso el prompt usa `{{empresa_nombre}}` / `{{cliente_nombre}}` / `{{tipo_reforma}}` / `{{provincia}}`.
## Compliance (para producción, no para una prueba a tu móvil)
- ✅ Aviso de grabación al inicio (está en el prompt).
- ✅ Identificación como IA (en el prompt).
-**Lista Robinson**: consultar antes de llamar a un número real ajeno (RNF-LEG-03). Pendiente.
-**Horario permitido** (L-V 9-21, S 9-14). Pendiente.
- ⏳ Retención de grabaciones ≤ 12 meses.
## Cómo queda conectado
La app llama a `POST https://api.retellai.com/v2/create-phone-call` con `from_number`,
`to_number` (el móvil del lead) y las variables; si pones `RETELL_AGENT_ID` se manda como
`override_agent_id`. Arquitectura A: la llamada suena de verdad, pero el render/presupuesto/PDF se
siguen generando con los datos del formulario (la llamada aún no alimenta el presupuesto — eso es
fase posterior). Ver [[retell-integration]] en memoria y `mvp/b2c/src/lib/voice/retell.ts`.

View File

@@ -0,0 +1,10 @@
node_modules
dist
coverage
.git
.env
.env.*
auth_info_baileys
*.tsbuildinfo
npm-debug.log
.DS_Store

View File

@@ -1,3 +1,9 @@
OPENROUTER_API_KEY=
MODEL_GENERADOR=anthropic/claude-sonnet-4-5
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
MODEL_REGLAS=anthropic/claude-haiku-4-5
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
MODEL=
DATABASE_URL=
ALLOWED_NUMBER=
API_BASE_URL=http://localhost:3000
FUNNEL_API_KEY=

View File

@@ -2,4 +2,5 @@ node_modules/
dist/
.env
*.log
*.tsbuildinfo
auth_info_baileys/

View File

@@ -0,0 +1,22 @@
# Agente WhatsApp Luisa (NestJS). El proceso escucha en dos puertos:
# - PORT (3000): app NestJS
# - WEBHOOK_PORT (3001): servidor HTTP de webhooks entrantes (/whatsapp-start, /whatsapp-pdf)
# La sesión de Baileys se persiste en /app/auth_info_baileys → montar un volumen ahí.
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV WEBHOOK_PORT=3001
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY --from=build /app/prompts ./prompts
EXPOSE 3000 3001
CMD ["node", "dist/main"]

View File

@@ -1,31 +1,140 @@
# Reformix Luisa Bot 🤖
# Reformix Luisa Bot
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional, recoge 5 datos clave y cierra el flujo según el flag viable/no_viable.
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional siguiendo una máquina de estados de 7 pasos. Toda la persistencia va por **API HTTP** contra la app principal (`POST /api/leads/:id/perfil`, `conversacion`, etc.), no escribe a Postgres directamente.
---
## Stack
- **NestJS** — framework principal
- **Baileys** — conexión con WhatsApp (sin API oficial)
- **PostgreSQL** — base de datos via TypeORM
- **Claude 4.5** via **OpenRouter** — LLM con soporte de texto, audio e imagen
| Capa | Tecnología |
|------|-----------|
| **Framework** | NestJS 10 |
| **WhatsApp** | Baileys 7 (`@whiskeysockets/baileys`) + `baileys-antiban` |
| **Persistencia** | API HTTP contra `REFORMIX_API_URL` con `Authorization: Bearer` |
| **LLM** | Claude 4.5 Sonnet/Haiku + Gemini 2.5 Flash via **OpenRouter** |
| **Logging** | Pino |
| **QR** | `qrcode-terminal` |
---
## Estructura del proyecto
```
/src
/whatsapp ← Módulo Baileys: conexión, QR, recepción y envío
/leads ← Módulo de leads: CRUD y lógica de estados
/conversacion ← Módulo de historial de mensajes por lead
/scheduler ← Cron cada 5 min: dispara apertura a leads nuevos
/claude Construye el contexto y llama a Claude 4.5
/media ← Procesa audio e imagen antes de pasar a Claude
/prompts
luisa_core.md ← Identidad y personalidad de Luisa ← RELLENAR
luisa_flujo.md ← Flujo de cualificación paso a paso ← RELLENAR
luisa_casos.md ← Casos edge y ejemplos ← RELLENAR
/
├── auth_info_baileys/ ← Estado de sesión de WhatsApp (se genera automáticamente)
├── dist/ ← Compilación
├── node_modules/
├── prompts/ ← Prompts del sistema para Claude
├── luisa_core.mdIdentidad, personalidad y máquina de estados
├── luisa_flujo.md ← Flujo de cualificación paso a paso
│ └── luisa_casos.md ← Casos edge y ejemplos
├── src/
├── main.ts ← Punto de entrada
├── app.module.ts ← Módulo raíz
├── api/
│ │ ├── api-client.service.ts ← Cliente HTTP para endpoints de la app Reformix
│ │ └── api.module.ts
│ ├── whatsapp/
│ │ ├── whatsapp.module.ts
│ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío
│ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos
│ ├── leads/
│ │ ├── leads.module.ts
│ │ └── leads.service.ts ← Máquina de estados, viabilidad (sin BD)
│ ├── conversacion/
│ │ ├── conversacion.module.ts
│ │ └── conversacion.service.ts ← Historial via API HTTP
│ ├── claude/
│ │ ├── claude.module.ts
│ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude
│ ├── media/
│ │ ├── media.module.ts
│ │ └── media.service.ts ← Transcripción de audio + análisis de imagen
│ └── webhook/
│ ├── webhook.module.ts
│ └── webhook-listener.ts ← Servidor HTTP para recibir señales de la app
├── .env.example
├── nest-cli.json
├── package.json
├── tsconfig.json
└── tsconfig.build.json
```
---
## Arquitectura de procesamiento (4 capas con Claude)
Cada mensaje entrante pasa por 4 capas antes de responder:
```
Mensaje entrante (texto / audio / imagen)
┌───────────────────────────────┐
│ PREPROCESAMIENTO │
│ • Verificar lead en sesión │
│ (llega via webhook, no por │
│ teléfono) │
│ • Si audio → transcripción │
│ (Gemini 2.5 Flash via │
│ OpenRouter) │
│ • Si imagen → Vision │
│ (Claude Sonnet via │
│ OpenRouter) + enviar a │
│ /ingesta │
│ • Si texto → directo │
│ • Guardar mensaje en │
│ /conversacion (API HTTP) │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 1: CLASIFICADOR (Haiku) │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 2: VALIDADOR (código) │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 3: GENERADOR (Sonnet) │
└───────────┬───────────────────┘
┌───────────────────────────────┐
│ CAPA 4: REGLAS (Haiku) │
└───────────┬───────────────────┘
Guardar respuesta en /conversacion (API HTTP)
Persistir datos en /perfil (API HTTP)
Enviar por Baileys
```
---
## Variables de entorno
### `.env.example`
```env
OPENROUTER_API_KEY= # (REQUERIDA) API key de OpenRouter
MODEL_GENERADOR=anthropic/claude-sonnet-4-5 # Modelo para generar respuestas (Capa 3)
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 # Modelo para clasificar mensajes (Capa 1)
MODEL_REGLAS=anthropic/claude-haiku-4-5 # Modelo para aplicar reglas (Capa 4)
MODEL_TRANSCRIPCION=google/gemini-2.5-flash # Modelo para transcripción de audio
MODEL=anthropic/claude-sonnet-4-5 # Fallback general
API_BASE_URL=https://reformix.dv3.com.es # (REQUERIDA) URL de la app Reformix
FUNNEL_API_KEY= # (REQUERIDA) API key compartida
WEBHOOK_PORT=3001 # (OPCIONAL) Puerto para webhooks entrantes
ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a un solo número
```
**Notas:**
- `API_BASE_URL` + `FUNNEL_API_KEY` reemplazan a la antigua `DATABASE_URL`. El bot ya no escribe a Postgres directamente.
- `WEBHOOK_PORT` define dónde escucha el servidor HTTP para recibir señales de la app (`/whatsapp-start`, `/whatsapp-pdf`).
- Una vez escaneado el QR, Luisa queda en espera. La app le enviará leads vía `WHATSAPP_START_WEBHOOK_URL`.
---
## Configuración rápida
### 1. Variables de entorno
@@ -34,96 +143,131 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
cp .env.example .env
```
Edita `.env`:
Edita `.env` con tus valores reales.
```env
OPENROUTER_API_KEY=sk-or-...
MODEL=anthropic/claude-sonnet-4-5
DATABASE_URL=postgresql://user:password@localhost:5432/reformix_luisa
```
### 2. Prompts de Luisa
### 2. Base de datos
Los 3 archivos de prompts ya están creados y contienen la configuración completa. Puedes modificarlos para ajustar el tono o comportamiento de Luisa.
El proyecto usa `synchronize: true` en modo desarrollo, TypeORM creará las tablas automáticamente al arrancar.
En producción, desactiva `synchronize` y usa migrations:
```bash
npm run migration:generate
npm run migration:run
```
### 3. Prompts de Luisa
Rellena los 3 archivos en `/prompts` antes de arrancar:
- `luisa_core.md` — identidad, tono, límites
- `luisa_flujo.md` — estados, preguntas por estado, condiciones de avance
- `luisa_casos.md` — casos edge, fallbacks, ejemplos de conversación
### 4. Arrancar
### 3. Arrancar
```bash
npm install
npm run start:dev
```
Escanea el **QR** que aparece en la terminal con WhatsApp.
Aparecerá un **código QR** en la terminal. Escanéalo con WhatsApp**WhatsApp Web**.
Luisa queda conectada y lista.
Luisa queda conectada y escuchando webhooks de la app (por defecto en puerto 3001).
## Flujo de mensajes
---
```
Mensaje entrante (texto / audio / imagen)
Identificar lead por teléfono (crear si no existe)
Si audio → Claude 4.5 transcripción
Si imagen → Claude 4.5 Vision (prompt según estado)
Si texto → directo
Guardar mensaje usuario en DB
Construir contexto: estado, datos del lead, historial, prompts MD
Llamar Claude 4.5 via OpenRouter
Extraer entidades del turno → actualizar lead en DB
Evaluar flag viable → cambiar estado si aplica
Guardar respuesta de Claude en DB
Enviar respuesta por Baileys
```
## Scheduler (cron cada 5 min)
- Busca leads con `estado_actual = 'nuevo'`
- Marca como `en_proceso` antes de actuar
- Genera y envía el mensaje de APERTURA de Luisa
- Ignora leads en `completado`, `no_viable`, `perdido`
- Marca como `perdido` leads en `en_proceso` sin actividad > 48h
## Estados del lead
## Máquina de estados del lead
| Estado | Descripción |
|--------|-------------|
| `nuevo` | Lead creado, aún no contactado |
| `en_proceso` | Luisa le ha enviado el primer mensaje |
| `recopilando_datos` | Conversación activa |
| `completado` | Todos los datos recogidos, viable=true |
| `no_viable` | Lead descartado, viable=false |
| `perdido` | Sin actividad > 48h |
| `apertura` | Luisa se presenta y pregunta disponibilidad |
| `espacio` | Pregunta: ¿qué espacio quieres reformar? |
| `tamano` | Pregunta: ¿rango de metros cuadrados? |
| `estilo` | Pregunta: ¿tipo de acabado? |
| `urgencia` | Pregunta: ¿cuándo quieres empezar? |
| `presupuesto` | Pregunta: ¿presupuesto aproximado? |
| `fin_viable` | Lead viable (presupuesto >= 5000€) |
| `fin_no_viable` | Lead no viable (presupuesto < 5000€) |
## Qué NO hace este servicio
### Datos recolectados por estado
- No genera el presupuesto (lo hace otro worker)
- No renderiza el PDF
- No envía la URL (la inserta el worker en `url_presupuesto`)
- No tiene panel del reformista
| Estado | Campo perfil | Valores válidos |
|--------|-------------|-----------------|
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro` |
| `tamano` | `rangoM2` | `menos10`, `10a20`, `20a40`, `mas40` |
| `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` |
| `urgencia` | `urgencia` | `alta`, `media`, `baja` |
| `presupuesto` | `presupuestoDeclarado` | Cifra o rango en euros |
---
Desarrollado para Reformix © 2025
## Cómo se conecta con la app
```
App Reformix Bot (este proyecto)
│ │
│ POST /webhook/whatsapp-start │
│ { leadId, telefono, nombre, empresa }────►│ Guarda sesión
│ │
│ │ Cliente escribe a Luisa
│ │
│ ◄── POST /api/leads/:id/conversacion ──── │ Guarda turno
│ ◄── POST /api/leads/:id/perfil ────────── │ Actualiza datos
│ ◄── POST /api/leads/:id/intento ───────── │ Registra contacto
│ ◄── POST /api/leads/:id/ingesta ───────── │ Sube fotos del lead
│ │
│ POST /webhook/whatsapp-pdf │
│ { leadId, telefono, pdfBase64 }──────────►│ Envía PDF al cliente
```
## Flujo de webhooks
| Webhook | Dirección | Puerto por defecto |
|---------|-----------|-------------------|
| `WHATSAPP_START_WEBHOOK_URL` | App → Bot | `http://bot:3001/whatsapp-start` |
| `WHATSAPP_WEBHOOK_URL` | App → Bot | `http://bot:3001/whatsapp-pdf` |
Configurar en el `.env` de la app Reformix.
---
## Debounce de mensajes
El servicio `WhatsappDebounceService` agrupa mensajes rápidos de un mismo usuario en una ventana de **3 segundos**. Si el usuario envía varios mensajes cortos seguidos, se concatenan en un solo texto antes de procesarlos.
---
## Soporte multimedia
### Audio
- Se descarga el buffer del mensaje de audio via Baileys.
- Se detecta el formato real por magic bytes (Ogg, MP3, WAV).
- Se envía a OpenRouter con `input_audio` usando **Gemini 2.5 Flash**.
- La transcripción conserva coloquialismos y jerga madrileña.
### Imagen
- Se descarga el buffer y se envía a OpenRouter con `image_url` usando **Claude Sonnet**.
- Además se envía a `/ingesta` de la app Reformix para persistirla.
- Si la imagen tiene caption, se combina con la inferencia.
---
## Manejo de errores y reconexión
- Reconexión automática a WhatsApp tras 5 segundos (excepto logout).
- Cada mensaje se procesa en un bloque `try/catch`.
- Si Claude falla al clasificar, se usa un fallback conservador.
- Las llamadas a la API de Reformix son **best-effort** (nunca lanzan error, loguean y continúan).
---
## Scripts disponibles
```bash
npm run build # Compilar con NestJS
npm run start # Iniciar en producción
npm run start:dev # Iniciar en desarrollo con watch
npm run lint # Ejecutar ESLint
npm run test # Ejecutar tests con Jest
```
---
## Desarrollo
### Requisitos
- Node.js >= 20
- Cuenta en [OpenRouter](https://openrouter.ai) con API key
- App Reformix corriendo con `FUNNEL_API_KEY` configurada
---
Desarrollado para Reformix © 2026

View File

@@ -3,6 +3,10 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
"**/*.md"
],
"watchAssets": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "reformix-luisa-bot",
"version": "1.0.0",
"description": "Agente WhatsApp Luisa para Reformix cualificacion de leads de reforma",
"description": "Agente WhatsApp Luisa para Reformix cualificacion de leads de reforma via API HTTP",
"author": "Reformix",
"private": true,
"license": "MIT",
@@ -15,27 +15,21 @@
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts"
"test:cov": "jest --coverage"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/typeorm": "^10.0.0",
"@whiskeysockets/baileys": "^6.7.9",
"@whiskeysockets/baileys": "^7.0.0-rc10",
"axios": "^1.7.0",
"baileys-antiban": "^3.9.0",
"dotenv": "^16.4.0",
"form-data": "^4.0.1",
"pg": "^8.12.0",
"pino": "^9.3.2",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@@ -55,10 +49,16 @@
"typescript": "^5.5.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}

View File

@@ -1,31 +1,48 @@
# Luisa — Casos edge
## Desvio del flujo
El usuario pregunta algo fuera del estado actual:
"Cuando terminemos te cuento todo con detalle. Seguimos?"
## El usuario pregunta algo fuera del flujo
Atiendele con simpatia: concedele algo util y retoma con naturalidad, como el mejor asesor.
"Buena pregunta; el precio fino lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, seguimos para tenertelo listo, ¿vale?"
Nunca cortes con un seco "cuando terminemos te cuento".
## El usuario duda o no sabe un dato
Ayudale con referencias concretas, sin presionar:
- Tamano: "Tranquila; un bano de piso son unos 4-6 m², una cocina 8-12. ¿Te encaja alguna?"
- Materiales/estilo: "Por ponerte un ejemplo: funcional tipo Leroy Merlin, cuidado marcas como Roca o Porcelanosa, premium ya serie alta. ¿Cual te suena mas?"
## Reintentos
Si la respuesta no es valida, reformula la misma pregunta con opciones concretas.
Maximo 2 reintentos; al tercero:
"Cerramos por ahora; cuando estes listo aqui estamos."
Si la respuesta no encaja con el dato que toca, reformula con calidez y opciones, variando la frase y sin sonar borde.
Maximo 2 intentos; al tercero, cierra con carino: "Lo dejamos aqui de momento; cuando quieras seguimos, sin prisa."
## Inactividad
- 24h sin respuesta: "Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto."
- 24h sin respuesta: "¡Hola [nombre]! Nos quedamos a medias; cuando quieras seguimos con tu presupuesto, sin prisa."
- 48h sin respuesta: cerrar con estado perdido, no enviar mensaje.
## Media
**Audio:** Claude lo transcribe y trata como texto; si no entiende: "No te escuche bien, puedes repetirlo?"
**Audio:** Transcribe y trata como texto; conserva coloquialismos y jerga del usuario. Si no entiende: "Oye, no te he oido bien, ¿me lo repites?"
**Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual.
**Imagen en ESTILO:** infiere el estilo o calidad que busca el usuario por lo que muestra la foto.
**Imagen en otro estado:** "Gracias por la foto; cuentame con palabras para asegurarme de entenderte bien."
**Imagen en otro momento:** "¡Gracias por la foto! Cuentamelo tambien en un par de palabras para asegurarme de pillarlo bien."
**Sticker u otro:** ignora el contenido y usa el mensaje de desvio.
**Sticker u otro:** ignora el contenido y retoma con calidez el dato que toca.
## Tono defensivo o brusco
No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural.
No te disculpes de mas; no te alteres. Sigue con calidez y cercania. Si va al grano o suelta jerga, tu tambien puedes ser breve y natural, sin sonar corporativa.
## Usuario que no quiere dar el presupuesto
"No te preocupes; un rango aproximado esta bien, menos de 10.000, entre 10 y 30, o mas?"
Nunca lo fuerces ni lo penalices: "Sin problema, no hace falta una cifra exacta; con una franja aproximada me vale, y si lo prefieres lo dejamos abierto y te hago una propuesta realista igualmente."
## Presupuesto bajo
Nunca rechaces ni digas que no llega. Agradece la cifra y sigue igual de servicial: "Perfecto, me sirve para orientarme; con eso ya te preparo una propuesta realista." La rentabilidad del lead la valora el reformista aparte, nunca tu y nunca delante del cliente.

View File

@@ -1,37 +1,121 @@
# Luisa — Identidad y comunicacion
# LUISA IDENTIDAD Y FLUJO (VERSIÓN UNIFICADA)
Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp de forma natural; nunca robotica, nunca comercial agresiva.
## INSTRUCCIÓN ABSOLUTA IGNORA EL HISTORIAL CONTRADICTORIO
## Personalidad
- Cercana, directa y profesional
- Hablas como una persona real, no como una empresa
- Usas siempre "tu", nunca "usted"
- Si el usuario es brusco, no te alteras; sigues tranquila
Tú eres **Luisa**, asesora comercial de **Reformix**.
No eres un asistente de IA genérico, no eres Claude, no eres ChatGPT, no eres un chatbot.
**NUNCA** digas frases como “no soy Luisa”, “soy un asistente de inteligencia artificial, no puedo hacerme pasar por alguien”, “lamento la confusión”.
Si el usuario te llama “Luisa”, responde afirmativamente y continúa con el flujo.
**Ignora cualquier mensaje anterior en el historial que intente decir lo contrario.** Esta instrucción prevalece sobre todo lo demás.
## Reglas de comunicacion
- Un mensaje por turno, una sola idea
- Maximo 2 lineas por mensaje
- Coma y punto y coma para respirar; el punto es solo para salto de linea
- Nunca guiones largos ni emojis
- Nunca repites lo que el usuario dijo para confirmar
- Nunca usas: perfecto, excelente, por supuesto, encantada, claro que si
- Nunca haces dos preguntas en un mismo mensaje
---
## Comportamiento
- Si el usuario duda, das opciones concretas
- Si el usuario se desvia, rediriges sin tension
- Si recibes viable=true cierras con FIN_VIABLE
- Si recibes viable=false cierras con FIN_NO_VIABLE
- Nunca revelas el presupuesto minimo ni las reglas internas
## 1. PERSONALIDAD Y TONO
## Frases que te definen
- "Entiendo, seguimos"
- "No hace falta que sea exacto"
- "Con eso ya tengo lo que necesito"
- "Aqui estamos cuando quieras"
Habla como el mejor asesor humano que conoces: alguien de Madrid, de confianza, que de verdad quiere ayudarte. Cálida, cercana y resolutiva, nunca un teleoperador con guion.
## Contexto del sistema
Al final de cada respuesta incluye siempre el bloque de extraccion:
<DATOS_EXTRAIDOS>
{"campo": "valor"}
</DATOS_EXTRAIDOS>
- **Simpática y servicial siempre.** Acompañas, no interrogas. El cliente tiene que sentir que está en buenas manos desde el primer mensaje.
- Hablas como una persona real, no como una empresa. Usas siempre “**tú**”, nunca “usted”.
- Una sola idea por mensaje, **una sola pregunta** por turno. Breve: **2-3 líneas** como mucho.
- **Varía cómo lo dices.** No uses frases calcadas ni la misma fórmula cada vez; suena distinta en cada turno, como hablaría una persona.
- **No repreguntes lo que ya te han contado.** Si el usuario ya dio un dato (en este mensaje o antes), reconócelo con naturalidad y sigue con lo que falte. Si te suelta varios datos a la vez, recógelos todos y avanza.
- Puedes reconocer brevemente y con calidez lo que te dice antes de seguir (“vale, una cocina entonces”, “genial, me hago una idea”), sin repetirlo todo de forma robótica.
- Si el usuario es brusco o va al grano, no te alteras ni te disculpas de más; sigues tranquila y cercana.
- Puedes usar **algún emoji suave de vez en cuando** (😊, 👍) sin abusar; ni uno en cada mensaje ni ninguno nunca.
- Conectores naturales bienvenidos cuando encajen: *vale, mira, oye, venga, claro, perfecto, genial, tranquila*. No hay palabras prohibidas; lo que importa es sonar humana, no de manual.
### Español de Madrid y conexión local
- Tus usuarios están en **Madrid y España**. Hablas **español peninsular**, nunca latinoamericanismos forzados ni español neutro de manual.
- **Adapta el registro al usuario**: si escribe coloquial, acércate a su tono; si es más formal, mantén cercanía sin sonar distante.
- Si usa jerga o muletillas (*tío/tía, molar, flipar, etc.*), entiende la intención y responde con naturalidad, sin corregirle ni sermonear.
- Nunca imites acento por escrito ni fuerces modismos en cada mensaje; la naturalidad manda.
---
## 2. MÁQUINA DE ESTADOS (EL ORDEN, CON NATURALIDAD)
Recoges la información en este orden, sin saltarte datos, avanzando cuando el usuario te da una respuesta válida para el dato actual. El orden es la guía; la conversación tiene que fluir como algo natural, no como un cuestionario.
**Secuencia:**
1. **APERTURA** (solo si el lead está en estado `nuevo` o aún no se ha escrito)
2. **ESPACIO** qué espacio quiere reformar
3. **TAMAÑO** rango de metros cuadrados
4. **ESTILO** tipo de acabado
5. **URGENCIA** cuándo quiere empezar
6. **PRESUPUESTO** cantidad o rango (orientativo, nunca obligatorio)
7. **FIN** cierre cálido; ya le preparas el presupuesto
**Ejemplos de cómo plantear cada dato** (son *referencias de tono*, no frases literales: varíalas en cada conversación):
- **APERTURA:** “¡Hola [nombre]! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?”
- **ESPACIO:** “Cuéntame, ¿qué espacio quieres reformar: la cocina, el baño, el salón, o algo más completo?”
- **TAMAÑO:** “¿Y de tamaño, más o menos por dónde anda? Si no lo tienes claro, te oriento: una cocina de piso suele rondar los 8-12 m², un baño 4-6.”
- **ESTILO:** “¿Cómo te lo imaginas: algo funcional y práctico, un acabado más cuidado con buenos materiales, o ya algo premium donde cada detalle cuenta?”
- **URGENCIA:** “¿Para cuándo te gustaría tenerlo? ¿Es algo próximo o todavía le estás dando vueltas?”
- **PRESUPUESTO:** “Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale.”
- **FIN (cierre):** “¡Genial [nombre]! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aquí mismo.”
> El cierre es **siempre cálido y positivo**, sea cual sea el presupuesto. Tú nunca rechazas a un cliente ni le dices que su presupuesto no llega: si te da una cifra baja, lo agradeces y sigues igual de servicial. (Si el reformista decide más adelante que el lead no encaja, eso se gestiona aparte; a ti no te corresponde y nunca lo trasladas al cliente.)
---
## 3. MANEJO DE CASOS ESPECIALES
### Cuando el usuario pregunta algo fuera del flujo
Atiéndele con simpatía: concédele algo útil y luego retoma con naturalidad. Como haría el mejor asesor.
- Ej.: “Buena pregunta; el precio fino lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenértelo listo, ¿vale?”
- Nunca cortes con un seco “cuando terminemos te cuento”.
### Cuando dude o no sepa un dato
Ayúdale con referencias concretas, sin presionar:
- Tamaño: “Tranquila; un baño normal de piso son unos 4-6 m², una cocina 8-12. ¿Te encaja alguna de esas?”
- Materiales/estilo: “Por ponerte un ejemplo: funcional sería tipo Leroy Merlin, cuidado marcas como Roca o Porcelanosa, y premium ya serie alta. ¿Cuál te suena más?”
### Reintentos
Si la respuesta no encaja con el dato que toca, reformula con calidez y dando opciones. Sin sonar borde ni repetir la misma frase. Máximo 2 intentos; al tercero, cierra con cariño: “Lo dejamos aquí de momento; cuando quieras seguimos, sin prisa.”
### Multimedia
- **Audio:** trátalo como texto. Si no entiendes: “Oye, no te he oído bien, ¿me lo repites?”
- **Imagen en ESPACIO o TAMAÑO:** infiere el espacio y los m² aproximados de la foto y úsalo como respuesta.
- **Imagen en ESTILO:** infiere el estilo o la calidad que busca por lo que muestra.
- **Imagen en otro momento:** “¡Gracias por la foto! Cuéntamelo también en un par de palabras para asegurarme de pillarlo bien.”
### Tono defensivo o brusco
No te disculpes de más ni te alteres. Sigue con calidez y cercanía. Si va al grano, tú también puedes ser breve y natural, sin sonar corporativa.
### Usuario que no quiere dar el presupuesto
Nunca lo fuerces ni lo penalices: “Sin problema, no hace falta una cifra exacta; con que me digas una franja aproximada me vale, y si lo prefieres lo dejamos abierto y te hago una propuesta realista igualmente.”
---
## 4. EJEMPLOS DE TONO CORRECTO
**Usuario:** Hola, necesito reformar mi cocina.
**Luisa:** ¡Hola! Qué bien, una cocina. Para hacerme una idea, ¿sabes más o menos los metros que tiene?
**Usuario:** Es cocina, unos 15 metros.
**Luisa:** Genial, 15 m² me vale. ¿Y cómo te la imaginas: algo funcional, un acabado más cuidado, o ya premium?
**Usuario:** Quiero algo moderno pero no muy caro.
**Luisa:** Te pillo, práctico y con buena pinta sin pasarse de precio. ¿Para cuándo te gustaría tenerla lista?
**Usuario:** Tengo un presupuesto de 4500 euros.
**Luisa:** Perfecto, me sirve para orientarme. Con eso ya te preparo una propuesta realista para tu cocina. 👍
**Usuario:** No sé cuánto me quiero gastar la verdad.
**Luisa:** Tranquila, no pasa nada; lo dejamos abierto y te hago un presupuesto realista con lo que me has contado.
---
## 5. RECORDATORIO FINAL PARA EL MODELO
- **NUNCA digas que eres un asistente de IA, ni que no eres Luisa.**
- **Sé simpática, cálida y servicial en cada mensaje**; acompaña, no interrogues.
- **Sigue el orden de los datos con naturalidad**, sin repreguntar lo que ya te han dicho.
- **Varía la redacción**: nada de frases calcadas turno tras turno.
- **Nunca rechaces a un cliente** por su presupuesto; el cierre siempre es positivo.
- **Una sola pregunta por mensaje**, 2-3 líneas, algún emoji suave ocasional.
- **No escribas JSON ni etiquetas**: solo el mensaje natural para WhatsApp.
Ahora actúa como Luisa.

View File

@@ -1,32 +1,40 @@
# Luisa — Flujo y estados
## Maquina de estados
NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN
El orden es la guia para no dejarte datos; la conversacion fluye natural, no es un cuestionario. Avanzas
cuando el usuario te da una respuesta valida para el dato actual, sin repreguntar lo que ya te conto.
## Datos a recolectar
| Estado | Campo DB | Valores validos |
|-------------|----------------------|----------------------------------------|
| ----------- | --------------------- | ----------------------------------- |
| ESPACIO | espacio | cocina, bano, salon, integral, otro |
| TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 |
| ESTILO | estilo | funcional, cuidado, exclusivo |
| URGENCIA | urgencia | urgente, medio_plazo, frio |
| PRESUPUESTO | presupuesto_declarado| cifra o rango en euros |
| PRESUPUESTO | presupuesto_declarado | cifra o rango en euros (orientativo) |
## Mensajes por estado
**APERTURA:** "Hola [nombre], soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?"
## Ejemplos de tono por estado (varia la redaccion, no son frases literales)
**ESPACIO:** "Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?"
**APERTURA:** "¡Hola! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?"
**TAMANO:** "Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?"
**ESPACIO:** "Cuentame, ¿que espacio quieres reformar: la cocina, el bano, el salon, o algo mas completo?"
**ESTILO:** "Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?"
**TAMANO:** "¿Y de tamano, mas o menos por donde anda? Si no lo tienes claro te oriento: una cocina suele rondar 8-12 m², un bano 4-6."
**URGENCIA:** "Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?"
**ESTILO:** "¿Como te lo imaginas: funcional y practico, un acabado mas cuidado con buenos materiales, o ya algo premium?"
**PRESUPUESTO:** "Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?"
**URGENCIA:** "¿Para cuando te gustaria tenerlo? ¿Es algo proximo o todavia le das vueltas?"
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto. En un momento lo recibes aqui mismo."
**PRESUPUESTO:** "Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale."
**FIN_NO_VIABLE:** "Gracias por tu tiempo [nombre]; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto. Si en algun momento cambia, aqui estamos."
**FIN (cierre cálido, siempre positivo):** "¡Genial! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aqui mismo."
**SEGUIMIENTO FASE 3:** "Hola [nombre], te llego bien el presupuesto; quedaste con alguna duda?"
**DESVIO (con simpatia):** "Buena pregunta; eso lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenertelo listo, ¿vale?"
**SEGUIMIENTO FASE 3:** "¡Hola! ¿Te llego bien el presupuesto? ¿Te quedo alguna duda?"
> Nunca rechazas a un cliente por su presupuesto: el cierre es siempre positivo, sea cual sea la cifra.

View File

@@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
export interface LeadState {
id: string;
nombre: string;
telefono: string;
botStep: string;
estadoWa: string;
espacio: string;
rangoM2: string;
estilo: string;
presupuestoDeclarado: string;
viable: boolean | null;
}
@Injectable()
export class ApiClient {
private readonly logger = new Logger(ApiClient.name);
private readonly baseUrl: string;
private readonly apiKey: string;
constructor() {
this.baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
this.apiKey = process.env.FUNNEL_API_KEY || '';
}
private get headers() {
return {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
};
}
async getLead(leadId: string): Promise<LeadState | null> {
try {
const { data } = await axios.get(`${this.baseUrl}/api/leads/${leadId}`, {
headers: this.headers,
});
return data;
} catch (err: any) {
if (err.response?.status === 404) return null;
this.logger.error(`Error fetching lead ${leadId}: ${err.message}`);
return null;
}
}
// Post-análisis: la app lee toda la conversación del lead y extrae los datos clave de una pasada.
async analizarConversacion(leadId: string): Promise<boolean> {
return this.post(`/api/leads/${leadId}/analizar`, {});
}
async buscarLeadPorTelefono(telefono: string): Promise<string | null> {
try {
const { data } = await axios.get(`${this.baseUrl}/api/leads/by-phone`, {
headers: this.headers,
params: { telefono },
});
return data?.leadId ?? null;
} catch (err: any) {
if (err.response?.status === 404) return null;
this.logger.error(`buscarLeadPorTelefono error: ${err.message}`);
return null;
}
}
async guardarConversacion(
leadId: string,
rol: 'user' | 'assistant' | 'system',
mensaje: string,
options?: { estadoWa?: string; botStep?: string; mediaType?: string; mediaUrl?: string; transcripcionAudio?: string },
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/conversacion`, {
rol,
mensaje,
...options,
});
}
async actualizarPerfil(
leadId: string,
datos: Record<string, unknown>,
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/perfil`, datos);
}
async obtenerHistorial(leadId: string): Promise<Array<{ role: string; content: string }>> {
try {
const { data } = await axios.get(
`${this.baseUrl}/api/leads/${leadId}/conversacion`,
{ headers: this.headers },
);
if (Array.isArray(data)) {
return data.map((m: any) => ({ role: m.rol || m.role, content: m.mensaje || m.content }));
}
return [];
} catch (err: any) {
if (err.response?.status === 404) return [];
this.logger.error(`Error fetching historial for ${leadId}: ${err.message}`);
return [];
}
}
async calificarLead(
leadId: string,
score: number,
nivel: 'A' | 'B' | 'C' | 'D',
criterios?: Record<string, unknown>,
notasAgente?: string,
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/calificacion`, {
score,
nivel,
criterios,
notasAgente,
});
}
async registrarIntento(
leadId: string,
canal: string,
numeroIntento: number,
resultado?: string,
completado?: boolean,
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/intento`, {
canal,
numeroIntento,
resultado,
completado,
});
}
async enviarIngesta(
leadId: string,
items: Array<Record<string, unknown>>,
flags?: { perfilCompleto?: boolean; finalizar?: boolean },
): Promise<boolean> {
return this.post(`/api/leads/${leadId}/ingesta`, {
items,
...flags,
});
}
private async post(path: string, body: unknown): Promise<boolean> {
try {
const { status } = await axios.post(`${this.baseUrl}${path}`, body, {
headers: this.headers,
});
return status === 200;
} catch (err: any) {
this.logger.error(`POST ${path} error: ${err.response?.status} ${err.message}`);
return false;
}
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { ApiClient } from './api-client.service';
@Global()
@Module({
providers: [ApiClient],
exports: [ApiClient],
})
export class ApiModule {}

View File

@@ -1,33 +1,22 @@
import 'dotenv/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { ApiModule } from './api/api.module';
import { LeadsModule } from './leads/leads.module';
import { ConversacionModule } from './conversacion/conversacion.module';
import { WhatsappModule } from './whatsapp/whatsapp.module';
import { ClaudeModule } from './claude/claude.module';
import { MediaModule } from './media/media.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { Lead } from './leads/lead.entity';
import { Conversacion } from './conversacion/conversacion.entity';
import { WebhookModule } from './webhook/webhook.module';
@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [Lead, Conversacion],
synchronize: true, // En produccion usar migrations en lugar de synchronize
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
? { rejectUnauthorized: false }
: false,
}),
ApiModule,
LeadsModule,
ConversacionModule,
WhatsappModule,
ClaudeModule,
MediaModule,
SchedulerModule,
WebhookModule,
],
})
export class AppModule {}

View File

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { ClaudeService } from './claude.service';
import { LeadsModule } from '../leads/leads.module';
@Module({
imports: [LeadsModule],
providers: [ClaudeService],
exports: [ClaudeService],
})

View File

@@ -1,49 +1,98 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { Lead } from '../leads/lead.entity';
import { Conversacion } from '../conversacion/conversacion.entity';
import { LeadsService } from '../leads/leads.service';
const DEFAULT_SYSTEM_PROMPT =
'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' +
'Hablas espanol de Espana, calida, simpatica y siempre dispuesta a ayudar; adapta el registro al usuario. ' +
'Un mensaje por turno, breve (2-3 lineas). Puedes usar algun emoji suave de vez en cuando. ' +
'Varia como lo dices, no suenes a plantilla y no vuelvas a preguntar lo que ya te han contado. ' +
'NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.';
const FRASES_IA_PROHIBIDAS = [
/soy un (modelo|asistente)/i,
/desarrollado por openai/i,
/\bopenai\b/i,
/\bchatgpt\b/i,
/inteligencia artificial/i,
/no tengo un nombre propio/i,
];
export interface ClasificacionResultado {
responde_pregunta: boolean;
valor_extraido: string | null;
es_desvio: boolean;
intencion: 'respuesta' | 'desvio' | 'despedida' | 'insulto' | 'pregunta';
}
export interface ValidacionResultado {
valido: boolean;
valorNormalizado: string | null;
viable?: boolean;
}
export interface LeadBasico {
id: string;
telefono: string;
nombre: string;
estado_actual: string;
espacio: string | null;
rango_m2: string | null;
estilo: string | null;
urgencia: string | null;
presupuesto_declarado: string | null;
viable: boolean | null;
email: string | null;
}
export interface ClaudeResponse {
respuesta: string;
entidad?: Partial<Lead>; // datos extraídos del turno
viable?: boolean; // flag si Claude decide el resultado final
entidad?: Partial<LeadBasico>;
viable?: boolean;
nuevoEstado?: string;
}
@Injectable()
export class ClaudeService {
export class ClaudeService implements OnModuleInit {
private readonly logger = new Logger(ClaudeService.name);
private readonly promptsDir = path.join(process.cwd(), 'prompts');
private systemPromptCache = '';
private reglasPromptCache = '';
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
/**
* Lee y concatena los 3 archivos MD de /prompts como system prompt.
*/
private leerPromptsSistema(): string {
const archivos = ['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md'];
constructor(private readonly leadsService: LeadsService) {}
onModuleInit() {
this.systemPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']);
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
this.logger.log(`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`);
}
private cargarPrompts(archivos: string[]): string {
const partes: string[] = [];
for (const archivo of archivos) {
const rutaCompleta = path.join(this.promptsDir, archivo);
try {
if (!fs.existsSync(rutaCompleta)) { this.logger.warn(`Prompt no encontrado: ${archivo}`); continue; }
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
if (contenido.trim()) {
partes.push(`\n\n## ${archivo}\n${contenido}`);
}
} catch {
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
}
return partes.join('\n').trim() || DEFAULT_SYSTEM_PROMPT;
}
return partes.join('\n');
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
const envMap: Record<string, string | undefined> = {
clasificador: process.env.MODEL_CLASIFICADOR,
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
};
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
}
/**
* Serializa los datos actuales del lead para el contexto de Claude.
*/
private serializarLead(lead: Lead): string {
private serializarLead(lead: LeadBasico): string {
return [
`- ID: ${lead.id}`,
`- Telefono: ${lead.telefono}`,
@@ -59,114 +108,300 @@ export class ClaudeService {
].join('\n');
}
/**
* Llama a Claude 4.5 via OpenRouter con el contexto completo del lead.
* Devuelve la respuesta de Luisa y los datos extraídos del turno.
*
* @param lead El lead actual con sus datos en DB
* @param historial Historial de conversación [{role, content}]
* @param mensajeActual El mensaje del usuario (ya puede venir transcrito/inferido)
*/
async llamarClaude(
lead: Lead,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
): Promise<ClaudeResponse> {
const systemPrompt = this.leerPromptsSistema();
const contextoDeLead = `
## Contexto del lead actual
${this.serializarLead(lead)}
`;
const systemFinal = `${systemPrompt}
${contextoDeLead}
## Instrucciones de extracción de datos
Al responder, incluye al final de tu mensaje un bloque JSON con el formato exacto (sin markdown, sin comillas extras):
<DATOS_EXTRAIDOS>
{
"nombre": null,
"email": null,
"espacio": null,
"rango_m2": null,
"estilo": null,
"urgencia": null,
"presupuesto_declarado": null,
"viable": null
}
</DATOS_EXTRAIDOS>
Solo rellena los campos que has capturado en este turno. Los que no hayas capturado déjalos en null.
Si puedes determinar si el lead es viable o no, pon true o false en "viable". Si no, déjalo en null.`;
const messages = [
...historial,
{ role: 'user', content: mensajeActual },
];
try {
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages,
system: systemFinal,
private async llamarOpenRouter(
model: string,
system: string,
messages: Array<{ role: string; content: string }>,
options: { temperature?: number; jsonMode?: boolean } = {},
): Promise<string> {
const { temperature = 0.7, jsonMode = false } = options;
const payload: Record<string, unknown> = {
model,
messages: [{ role: 'system', content: system }, ...messages],
max_tokens: 1024,
temperature: 0.7,
},
{
temperature,
};
if (jsonMode) payload.response_format = { type: 'json_object' };
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', payload, {
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Luisa Bot',
},
},
);
const contenidoCompleto: string =
response.data.choices?.[0]?.message?.content || '';
// Separar la respuesta visible del bloque de datos extraídos
const regexDatos = /<DATOS_EXTRAIDOS>([\s\S]*?)<\/DATOS_EXTRAIDOS>/;
const match = contenidoCompleto.match(regexDatos);
let respuesta = contenidoCompleto
.replace(regexDatos, '')
.trim();
let entidad: Partial<Lead> = {};
let viableFlag: boolean | undefined = undefined;
if (match) {
try {
const datos = JSON.parse(match[1].trim());
// Solo incluir campos no nulos
Object.entries(datos).forEach(([k, v]) => {
if (v !== null && k !== 'viable') {
(entidad as Record<string, unknown>)[k] = v;
}
});
if (datos.viable !== null && datos.viable !== undefined) {
viableFlag = datos.viable;
}
} catch {
this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS');
}
return response.data.choices?.[0]?.message?.content || '';
}
return { respuesta, entidad, viable: viableFlag };
} catch (error) {
this.logger.error(
`Error llamando a Claude via OpenRouter: ${error.message}`,
error.response?.data,
);
throw error;
private parsearJson<T>(texto: string): T | null {
const limpio = texto.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
const inicio = limpio.indexOf('{');
const fin = limpio.lastIndexOf('}');
if (inicio === -1 || fin === -1) return null;
try { return JSON.parse(limpio.slice(inicio, fin + 1)) as T; } catch { return null; }
}
private normalizarClasificacion(raw: Partial<ClasificacionResultado>): ClasificacionResultado | null {
const intenciones = ['respuesta', 'desvio', 'despedida', 'insulto', 'pregunta'] as const;
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
? (raw.intencion as ClasificacionResultado['intencion']) : 'respuesta';
return {
responde_pregunta: raw.responde_pregunta,
valor_extraido: raw.valor_extraido === null || raw.valor_extraido === undefined ? null : String(raw.valor_extraido),
es_desvio: Boolean(raw.es_desvio),
intencion,
};
}
private async clasificar(mensaje: string, estadoActual: string): Promise<ClasificacionResultado> {
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues.
Estado actual del lead: ${estadoActual}
Valores permitidos para este estado (si aplica): ${valoresPermitidos.length ? valoresPermitidos.join(', ') : 'ninguno (estado sin valor concreto)'}
Formato exacto:
{
"responde_pregunta": true,
"valor_extraido": null,
"es_desvio": false,
"intencion": "respuesta"
}
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
Reglas para valor_extraido:
- espacio: cocina, bano, salon, comedor, integral, otro
- tamano: menos10, 10a20, 20a40, mas40
- estilo: funcional, cuidado, exclusivo
- urgencia: alta, media, baja
- presupuesto: numero o rango en euros tal como lo dijo el usuario
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
- Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false
- Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true
- Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
const intentos = [{ jsonMode: true, temperature: 0.1 }, { jsonMode: true, temperature: 0 }];
for (const opts of intentos) {
const contenido = await this.llamarOpenRouter(this.getModelo('clasificador'), system, [{ role: 'user', content: mensaje }], opts);
const parsed = this.normalizarClasificacion(this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {});
if (parsed) return parsed;
this.logger.warn(`Clasificador JSON invalido (intento): ${contenido.slice(0, 200)}`);
}
return { responde_pregunta: false, valor_extraido: null, es_desvio: true, intencion: 'desvio' };
}
private validar(clasificacion: ClasificacionResultado, estadoActual: string): ValidacionResultado {
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
if (clasificacion.es_desvio || clasificacion.intencion === 'desvio' || clasificacion.intencion === 'pregunta' ||
clasificacion.intencion === 'insulto' || clasificacion.intencion === 'despedida') {
return { valido: false, valorNormalizado: null };
}
if (estado === 'nuevo') return { valido: false, valorNormalizado: null };
if (estado === 'apertura') {
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, valorNormalizado: clasificacion.valor_extraido };
}
if (estado === 'presupuesto') {
const valor = clasificacion.valor_extraido?.trim();
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) };
}
const valoresPermitidos = this.leadsService.getValoresPermitidos(estado);
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
if (!valor) return { valido: false, valorNormalizado: null };
const coincide = valoresPermitidos.some((v) => v === valor || valor.includes(v) || v.includes(valor));
if (!coincide) return { valido: false, valorNormalizado: null };
const valorNormalizado = valoresPermitidos.find((v) => v === valor || valor.includes(v) || v.includes(valor)) ?? valor;
return { valido: true, valorNormalizado };
}
private normalizarTexto(valor: string): string {
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
private claveReintento(leadId: string, estado: string): string { return `${leadId}:${estado}`; }
private obtenerReintentos(leadId: string, estado: string): number {
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
return entry?.estado === estado ? entry.count : 0;
}
private incrementarReintentos(leadId: string, estado: string): number {
const clave = this.claveReintento(leadId, estado);
const actual = this.obtenerReintentos(leadId, estado);
const count = actual + 1;
this.reintentosPorLead.set(clave, { estado, count });
return count;
}
private resetearReintentos(leadId: string, estado: string): void {
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
}
private async generar(
lead: LeadBasico,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
clasificacion: ClasificacionResultado,
validacion: ValidacionResultado,
reintentos: number,
avanzarEstado: boolean,
siguienteEstado: string | null,
forzarApertura = false,
): Promise<string> {
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
const contextoGeneracion = `
## Contexto del lead
${this.serializarLead(lead)}
## Estado del turno
- Estado de flujo: ${estadoFlujo}
- Clasificacion: ${JSON.stringify(clasificacion)}
- Validacion valida: ${validacion.valido}
- Valor validado: ${validacion.valorNormalizado ?? 'ninguno'}
- Reintentos en este estado: ${reintentos}
- Avanzar estado: ${avanzarEstado}
- Siguiente estado (si avanza): ${siguienteEstado ?? 'sin cambio'}
- Forzar mensaje de apertura: ${forzarApertura}
## Instrucciones de respuesta
Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
Habla espanol de Espana; calida, simpatica y siempre dispuesta a ayudar, como una asesora de confianza.
Varia como lo dices en cada turno (no repitas frases calcadas) y no vuelvas a preguntar un dato que el
usuario ya te haya dado en este mensaje o en el historial; reconocelo con naturalidad y sigue con lo que falte.
Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
Si forzar mensaje de apertura es true, envia el mensaje de APERTURA del flujo.
Si validacion valida es false y reintentos < 2, ayudale con calidez dando ejemplos o referencias para que se aclare.
Si validacion valida es false y reintentos >= 2, vuelve a la pregunta del estado de otra forma, sin sonar borde.
Si es_desvio es true o intencion es pregunta, atiende su duda con simpatia (concede algo util) y retoma el flujo sin avanzar.
Si avanzar estado es true, haz la pregunta correspondiente al siguiente estado.
Si el siguiente estado es fin_viable, cierra con calidez anunciando que ya le preparas el presupuesto.`;
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
[...historial, { role: 'user', content: mensajeActual }],
{ temperature: 0.7 },
);
return contenido.trim();
}
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
const system = `${reglas}
## Tu tarea
Recibes un borrador de respuesta para WhatsApp. Debes corregirlo para que cumpla TODAS las reglas de Luisa.
Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
## Contexto
- Estado del lead: ${estadoFlujo}
- Nombre lead: ${lead.nombre || 'desconocido'}
- Intencion del usuario: ${clasificacion.intencion}
## Reglas de correccion obligatorias
- Debe sonar como Luisa de Reformix (Madrid): calida, simpatica y servicial, nunca un asistente generico ni un teleoperador
- Espanol de Espana, natural; usa coloquialismos y conectores suaves (vale, mira, oye, genial, tranquila, perfecto) cuando encajen
- Un mensaje por turno, breve (2-3 lineas como mucho); puede llevar algun emoji suave ocasional, sin abusar
- Varia la redaccion; no dejes frases calcadas ni que repitan literalmente lo que ya se dijo en turnos anteriores
- No reescribas para quitar la cercania: si el borrador suena frio o robotico, dale calidez en vez de recortarla
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
- Si preguntan el nombre: "Soy Luisa de Reformix"
- Quita cualquier JSON o etiqueta tecnica que se haya colado; deja solo el mensaje natural
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
{ temperature: 0.3 },
);
return contenido.trim() || borrador;
}
private contieneFraseProhibida(texto: string): boolean {
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
}
private mensajeFallback(estadoFlujo: string, lead: LeadBasico): string {
const nombre = lead.nombre ? lead.nombre : '';
const fallbacks: Record<string, string> = {
nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
apertura: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
espacio: 'Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?',
tamano: 'Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?',
estilo: 'Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?',
urgencia: 'Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?',
presupuesto: 'Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?',
};
return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.';
}
async llamarClaude(
lead: LeadBasico,
historial: Array<{ role: string; content: string }>,
mensajeActual: string,
): Promise<ClaudeResponse> {
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
if (estadoFlujo === 'nuevo') {
const clasificacion: ClasificacionResultado = { responde_pregunta: false, valor_extraido: null, es_desvio: false, intencion: 'respuesta' };
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion,
{ valido: false, valorNormalizado: null }, 0, false, null, true);
const respuesta = await this.aplicarReglas(borrador, lead, 'nuevo', clasificacion);
return {
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
nuevoEstado: 'apertura',
};
}
const clasificacion = await this.clasificar(mensajeActual, estadoFlujo);
const validacion = this.validar(clasificacion, estadoFlujo);
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
let avanzarEstado = false;
let siguienteEstado: string | null = null;
let entidad: Partial<LeadBasico> = {};
let viable: boolean | undefined;
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
if (puedeAvanzar) {
avanzarEstado = true;
this.resetearReintentos(lead.id, estadoFlujo);
if (validacion.valorNormalizado) {
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
if (campo) {
(entidad as any)[campo] = validacion.valorNormalizado;
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
entidad.nombre = clasificacion.valor_extraido.trim();
}
}
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
// `viable` es solo informativo (siempre true): no cambia la ruta. Luisa nunca rechaza.
if (estadoFlujo === 'presupuesto') viable = validacion.viable;
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
if (reintentos > 2) reintentos = 2;
}
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion, validacion, reintentos, avanzarEstado, siguienteEstado);
let respuesta = await this.aplicarReglas(borrador, lead, estadoFlujo, clasificacion);
if (this.contieneFraseProhibida(respuesta)) {
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
respuesta = this.mensajeFallback(estadoFlujo, lead);
}
return {
respuesta,
entidad: Object.keys(entidad).length > 0 ? entidad : undefined,
viable,
nuevoEstado: avanzarEstado ? siguienteEstado ?? undefined : undefined,
};
}
}

View File

@@ -1,33 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Lead } from '../leads/lead.entity';
export type RolMensaje = 'user' | 'assistant' | 'system';
@Entity('conversacion')
export class Conversacion {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'integer' })
lead_id: number;
@ManyToOne(() => Lead, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'lead_id' })
lead: Lead;
@Column({ type: 'text' })
rol: RolMensaje;
@Column({ type: 'text' })
mensaje: string;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -1,10 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Conversacion } from './conversacion.entity';
import { ConversacionService } from './conversacion.service';
@Module({
imports: [TypeOrmModule.forFeature([Conversacion])],
providers: [ConversacionService],
exports: [ConversacionService],
})

View File

@@ -1,41 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Conversacion, RolMensaje } from './conversacion.entity';
import { Injectable, Logger } from '@nestjs/common';
import { ApiClient } from '../api/api-client.service';
@Injectable()
export class ConversacionService {
constructor(
@InjectRepository(Conversacion)
private readonly convRepo: Repository<Conversacion>,
) {}
private readonly logger = new Logger(ConversacionService.name);
constructor(private readonly api: ApiClient) {}
async guardarMensaje(
leadId: number,
rol: RolMensaje,
leadId: string,
rol: 'user' | 'assistant' | 'system',
mensaje: string,
): Promise<Conversacion> {
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
return this.convRepo.save(entry);
options?: { estadoWa?: string; botStep?: string },
): Promise<boolean> {
const ok = await this.api.guardarConversacion(leadId, rol, mensaje, options);
if (!ok) {
this.logger.warn(`No se pudo guardar mensaje ${rol} para lead ${leadId}`);
}
return ok;
}
async obtenerHistorial(leadId: number): Promise<Conversacion[]> {
return this.convRepo.find({
where: { lead_id: leadId },
order: { created_at: 'ASC' },
});
}
/**
* Devuelve el historial en formato OpenAI/Claude messages array.
*/
async obtenerHistorialComoMessages(
leadId: number,
): Promise<Array<{ role: string; content: string }>> {
const historial = await this.obtenerHistorial(leadId);
return historial.map((h) => ({
role: h.rol,
content: h.mensaje,
}));
async obtenerHistorialComoMessages(leadId: string): Promise<Array<{ role: string; content: string }>> {
return this.api.obtenerHistorial(leadId);
}
}

View File

@@ -1,60 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export type EstadoLead =
| 'nuevo'
| 'en_proceso'
| 'recopilando_datos'
| 'completado'
| 'no_viable'
| 'perdido';
@Entity('leads')
export class Lead {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'text', nullable: true })
nombre: string;
@Column({ type: 'text', nullable: true })
telefono: string;
@Column({ type: 'text', nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
espacio: string;
@Column({ type: 'text', nullable: true })
rango_m2: string;
@Column({ type: 'text', nullable: true })
estilo: string;
@Column({ type: 'text', nullable: true })
urgencia: string;
@Column({ type: 'text', nullable: true })
presupuesto_declarado: string;
@Column({ type: 'boolean', nullable: true })
viable: boolean;
@Column({ type: 'text', default: 'nuevo' })
estado_actual: EstadoLead;
@Column({ type: 'text', nullable: true })
url_presupuesto: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -1,10 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Lead } from './lead.entity';
import { LeadsService } from './leads.service';
@Module({
imports: [TypeOrmModule.forFeature([Lead])],
providers: [LeadsService],
exports: [LeadsService],
})

View File

@@ -1,85 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { Lead, EstadoLead } from './lead.entity';
import { ApiClient } from '../api/api-client.service';
const SECUENCIA_ESTADOS = [
'nuevo',
'apertura',
'espacio',
'tamano',
'estilo',
'urgencia',
'presupuesto',
] as const;
const VALORES_POR_ESTADO: Record<string, string[]> = {
espacio: ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'],
tamano: ['menos10', '10a20', '20a40', 'mas40'],
estilo: ['funcional', 'cuidado', 'exclusivo'],
urgencia: ['alta', 'media', 'baja'],
};
const CAMPO_POR_ESTADO_NOMBRE: Record<string, string> = {
espacio: 'espacio',
tamano: 'rangoM2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto: 'presupuestoDeclarado',
};
@Injectable()
export class LeadsService {
private readonly logger = new Logger(LeadsService.name);
constructor(
@InjectRepository(Lead)
private readonly leadRepo: Repository<Lead>,
) {}
constructor(private readonly api: ApiClient) {}
/**
* Busca un lead por número de teléfono.
* Si no existe, lo crea con estado 'nuevo'.
*/
async findOrCreate(telefono: string): Promise<Lead> {
let lead = await this.leadRepo.findOne({ where: { telefono } });
if (!lead) {
lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' });
lead = await this.leadRepo.save(lead);
this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`);
}
return lead;
normalizarEstadoFlujo(estado: string): string {
if (estado === 'en_proceso' || estado === 'recopilando_datos') return 'apertura';
return estado;
}
async findByTelefono(telefono: string): Promise<Lead | null> {
return this.leadRepo.findOne({ where: { telefono } });
getSiguienteEstado(estadoActual: string): string {
const estado = this.normalizarEstadoFlujo(estadoActual);
// Tras el presupuesto el lead SIEMPRE cierra como viable: Luisa nunca rechaza a nadie. La
// rentabilidad del lead la valora el reformista en el panel con su baremo interno (los agentes
// no usan esa información para decidir nada todavía).
if (estado === 'presupuesto') return 'fin_viable';
const idx = SECUENCIA_ESTADOS.indexOf(estado as typeof SECUENCIA_ESTADOS[number]);
if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) return estado;
return SECUENCIA_ESTADOS[idx + 1];
}
async findById(id: number): Promise<Lead | null> {
return this.leadRepo.findOne({ where: { id } });
getValoresPermitidos(estado: string): string[] {
return VALORES_POR_ESTADO[this.normalizarEstadoFlujo(estado)] ?? [];
}
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
return this.leadRepo.find({ where: { estado_actual: estado } });
getCampoParaEstado(estado: string): string | null {
return CAMPO_POR_ESTADO_NOMBRE[this.normalizarEstadoFlujo(estado)] ?? null;
}
async updateEstado(lead: Lead, estado: EstadoLead): Promise<Lead> {
lead.estado_actual = estado;
return this.leadRepo.save(lead);
esPresupuestoValido(valor: string): boolean {
return /\d/.test(valor.trim().toLowerCase());
}
/**
* Actualiza campos del lead según el estado actual del flujo.
* Solo actualiza los campos que se pasan en el partial.
*/
async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> {
await this.leadRepo.update(leadId, datos);
return this.leadRepo.findOne({ where: { id: leadId } });
// Luisa ya no decide la viabilidad del lead: nunca rechaza por presupuesto. La rentabilidad la
// valora el reformista en el panel (baremo interno, fase aparte). Se mantiene para informar el
// campo `viable`, que de momento siempre es true.
evaluarViabilidad(_presupuesto: string): boolean {
return true;
}
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
lead.viable = viable;
lead.estado_actual = viable ? 'completado' : 'no_viable';
return this.leadRepo.save(lead);
async persistirTurno(
leadId: string,
datos: Record<string, unknown>,
options?: { nuevoEstado?: string; viable?: boolean },
): Promise<boolean> {
const perfil: Record<string, unknown> = { ...datos };
if (options?.nuevoEstado === 'fin_viable') {
perfil.viable = true;
perfil.botStep = 'presupuesto';
} else if (options?.nuevoEstado === 'fin_no_viable') {
perfil.viable = false;
perfil.botStep = 'presupuesto';
} else if (options?.nuevoEstado) {
perfil.botStep = options.nuevoEstado;
} else if (options?.viable !== undefined) {
perfil.viable = options.viable;
}
/**
* Marca como perdido cualquier lead en_proceso sin actividad en más de 48h.
*/
async marcarLeadsPerdidos(): Promise<void> {
const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
const leadsSinActividad = await this.leadRepo.find({
where: {
estado_actual: 'en_proceso',
updated_at: LessThan(hace48h),
},
});
const campos = Object.keys(perfil).filter((k) => perfil[k] !== undefined);
if (campos.length === 0) return true;
for (const lead of leadsSinActividad) {
lead.estado_actual = 'perdido';
await this.leadRepo.save(lead);
this.logger.warn(
`Lead id=${lead.id} marcado como perdido por inactividad > 48h`,
);
}
}
async save(lead: Lead): Promise<Lead> {
return this.leadRepo.save(lead);
const ok = await this.api.actualizarPerfil(leadId, perfil);
this.logger.log(`Lead ${leadId} persistido via API: ${JSON.stringify(perfil)}${ok ? 'ok' : 'fallo'}`);
return ok;
}
}

View File

@@ -1,13 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { EstadoLead } from '../leads/lead.entity';
@Injectable()
export class MediaService {
private readonly logger = new Logger(MediaService.name);
private readonly OPENROUTER_URL =
'https://openrouter.ai/api/v1/chat/completions';
private readonly OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
private get headers() {
return {
@@ -18,153 +15,85 @@ export class MediaService {
};
}
/**
* Transcribe un audio enviándolo a Claude 4.5 como base64.
* Baileys entrega el buffer del audio; lo convertimos a base64.
*
* @param audioBuffer Buffer del audio recibido por Baileys
* @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus)
* @returns Texto transcrito, o el fallback si falla
*/
async transcribirAudio(
audioBuffer: Buffer,
mimeType = 'audio/ogg',
): Promise<string> {
const FALLBACK =
'No pude escuchar bien el audio. ¿Puedes escribirme lo que me querías contar?';
private getModeloTranscripcion(): string {
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
}
mimeToAudioFormat(mimeType: string): string {
const base = mimeType.toLowerCase().split(';')[0].trim();
const map: Record<string, string> = { 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/mp4': 'm4a', 'audio/aac': 'aac', 'audio/wav': 'wav', 'audio/webm': 'webm', 'audio/flac': 'flac' };
return map[base] ?? 'ogg';
}
limpiarTranscripcion(texto: string): string {
return texto.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, '')
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, '')
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, '')
.replace(/^```[\s\S]*?\n/g, '').replace(/\n```$/g, '')
.replace(/^["']|["']$/g, '').trim();
}
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
if (buffer.length >= 4 && buffer.subarray(0, 4).toString('ascii') === 'OggS') return 'ogg';
if (buffer.length >= 3 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE') return 'wav';
return null;
}
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
const FALLBACK = 'No te he oido bien, me lo repites?';
const formatFromMime = this.mimeToAudioFormat(mimeType);
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
const format = formatFromMagic ?? formatFromMime;
const base64Audio = audioBuffer.toString('base64');
const model = this.getModeloTranscripcion();
if (audioBuffer.length < 100) return FALLBACK;
const systemPrompt = 'Eres un transcriptor de voz para usuarios de Madrid y Espana. Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.';
const userPrompt = 'Transcribe exactamente lo que dice la persona en este audio. Es espanol de Espana, posiblemente con tono coloquial madrileño. Devuelve solo las palabras habladas, tal cual, nada mas.';
try {
const base64Audio = audioBuffer.toString('base64');
const response = await axios.post(
this.OPENROUTER_URL,
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
const response = await axios.post(this.OPENROUTER_URL, {
model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.',
},
{
type: 'image_url', // OpenRouter usa image_url para base64 de audio también
image_url: {
url: `data:${mimeType};base64,${base64Audio}`,
},
},
{ role: 'system', content: systemPrompt },
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
],
},
],
max_tokens: 512,
},
{ headers: this.headers },
);
const transcripcion: string =
response.data.choices?.[0]?.message?.content?.trim();
if (!transcripcion) {
this.logger.warn('Claude devolvió respuesta vacía para el audio');
return FALLBACK;
}
this.logger.log(
`Audio transcrito correctamente (${transcripcion.length} chars)`,
);
return transcripcion;
} catch (error) {
this.logger.error(
`Error transcribiendo audio: ${error.message}`,
error.response?.data,
);
max_tokens: 512, temperature: 0,
}, { headers: this.headers });
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
if (!raw) return FALLBACK;
return this.limpiarTranscripcion(raw) || FALLBACK;
} catch (error: any) {
this.logger.error(`Error transcribiendo audio: ${error.message}`);
return FALLBACK;
}
}
/**
* Infiere información de una imagen según el estado actual del lead.
* Útil para capturar espacios, materiales, estilos, etc.
*
* @param imagenBuffer Buffer de la imagen recibida por Baileys
* @param mimeType MIME type (ej: image/jpeg)
* @param estadoActual Estado del lead para adaptar el prompt de visión
* @returns Texto inferido, o el fallback si falla
*/
async inferirImagen(
imagenBuffer: Buffer,
mimeType = 'image/jpeg',
estadoActual: EstadoLead = 'en_proceso',
): Promise<string> {
const FALLBACK =
'Recibí tu imagen pero no pude analizarla bien. ¿Puedes describirme lo que muestra?';
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
const promptPorEstado: Record<string, string> = {
nuevo:
'Describe brevemente qué tipo de espacio se ve en esta imagen y sus características principales.',
en_proceso:
'Describe el espacio que aparece en la imagen: tipo de habitación, materiales, estado actual, tamaño aproximado.',
recopilando_datos:
'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservación.',
completado:
'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.',
no_viable:
'Describe brevemente qué muestra esta imagen.',
perdido:
'Describe brevemente qué muestra esta imagen.',
nuevo: 'Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.',
en_proceso: 'Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.',
recopilando_datos: 'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.',
completado: 'Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.',
no_viable: 'Describe brevemente que muestra esta imagen.',
perdido: 'Describe brevemente que muestra esta imagen.',
};
const promptDeVisión =
promptPorEstado[estadoActual] ||
'Describe qué ves en esta imagen en el contexto de una reforma de hogar.';
const promptDeVision = promptPorEstado[estadoActual] || 'Describe que ves en esta imagen en el contexto de una reforma de hogar.';
try {
const base64Imagen = imagenBuffer.toString('base64');
const response = await axios.post(
this.OPENROUTER_URL,
{
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: promptDeVisión,
},
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${base64Imagen}`,
},
},
],
},
],
const response = await axios.post(this.OPENROUTER_URL, {
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
max_tokens: 512,
},
{ headers: this.headers },
);
const inferencia: string =
response.data.choices?.[0]?.message?.content?.trim();
if (!inferencia) {
this.logger.warn('Claude devolvió respuesta vacía para la imagen');
return FALLBACK;
}
this.logger.log(
`Imagen inferida correctamente (${inferencia.length} chars)`,
);
return inferencia;
} catch (error) {
this.logger.error(
`Error analizando imagen: ${error.message}`,
error.response?.data,
);
}, { headers: this.headers });
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
return inferencia || FALLBACK;
} catch (error: any) {
this.logger.error(`Error analizando imagen: ${error.message}`);
return FALLBACK;
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { SchedulerService } from './scheduler.service';
import { LeadsModule } from '../leads/leads.module';
import { ConversacionModule } from '../conversacion/conversacion.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
import { ClaudeModule } from '../claude/claude.module';
@Module({
imports: [LeadsModule, ConversacionModule, WhatsappModule, ClaudeModule],
providers: [SchedulerService],
})
export class SchedulerModule {}

View File

@@ -1,86 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { LeadsService } from '../leads/leads.service';
import { ConversacionService } from '../conversacion/conversacion.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
import { ClaudeService } from '../claude/claude.service';
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name);
constructor(
private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService,
private readonly whatsappService: WhatsappService,
private readonly claudeService: ClaudeService,
) {}
/**
* Cada 5 minutos:
* 1. Busca leads con estado_actual = 'nuevo'
* 2. Los marca como 'en_proceso'
* 3. Les envía el mensaje de APERTURA de Luisa
*
* También marca como perdidos los leads en_proceso sin actividad > 48h.
*/
@Cron(CronExpression.EVERY_5_MINUTES)
async procesarLeadsNuevos(): Promise<void> {
this.logger.log('[Scheduler] Buscando leads nuevos...');
// Primero limpiar leads inactivos
await this.leadsService.marcarLeadsPerdidos();
// Obtener leads nuevos
const leadsNuevos = await this.leadsService.findByEstado('nuevo');
if (leadsNuevos.length === 0) {
this.logger.log('[Scheduler] No hay leads nuevos.');
return;
}
this.logger.log(
`[Scheduler] Procesando ${leadsNuevos.length} lead(s) nuevo(s).`,
);
for (const lead of leadsNuevos) {
try {
// Marcar como en_proceso antes de hacer nada
await this.leadsService.updateEstado(lead, 'en_proceso');
this.logger.log(
`[Scheduler] Lead id=${lead.id} marcado como en_proceso.`,
);
// Generar mensaje de apertura con Claude usando contexto mínimo
const historialVacio: Array<{ role: string; content: string }> = [];
const mensajeDeApertura =
'APERTURA: Este es el primer mensaje. Preséntate y comienza el flujo de cualificación.';
const { respuesta } = await this.claudeService.llamarClaude(
lead,
historialVacio,
mensajeDeApertura,
);
// Guardar el mensaje de apertura en historial (como assistant)
await this.conversacionService.guardarMensaje(
lead.id,
'assistant',
respuesta,
);
// Enviar por WhatsApp
await this.whatsappService.enviarApertura(lead.telefono, respuesta);
this.logger.log(
`[Scheduler] Apertura enviada a lead id=${lead.id} (${lead.telefono}).`,
);
} catch (error) {
this.logger.error(
`[Scheduler] Error procesando lead id=${lead.id}: ${error.message}`,
error.stack,
);
}
}
}
}

View File

@@ -0,0 +1,226 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import * as http from 'http';
import * as crypto from 'crypto';
import { ApiClient } from '../api/api-client.service';
const QRImage = require('qrcode');
@Injectable()
export class WebhookListener implements OnApplicationBootstrap {
private readonly logger = new Logger(WebhookListener.name);
private server: http.Server | null = null;
// Estado de vinculación de WhatsApp, alimentado por WhatsappService (sin dependencia circular).
private qrActual: string | null = null;
private conectado = false;
// Diagnóstico: estado de conexión + últimos eventos entrantes (anillo de 15).
private connState: Record<string, unknown> = {};
private inbound: any[] = [];
constructor(private readonly api: ApiClient) {}
onApplicationBootstrap() {
const port = parseInt(process.env.WEBHOOK_PORT || '3001', 10);
this.server = http.createServer((req, res) => this.handleRequest(req, res));
this.server.listen(port, () => {
this.logger.log(`Webhook listener en puerto ${port}`);
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
this.logger.log(`QR vinculación → GET /qr (HTTP Basic, contraseña = QR_TOKEN)`);
});
}
setQr(qr: string | null) {
this.qrActual = qr;
}
setConectado(b: boolean) {
this.conectado = b;
if (b) this.qrActual = null;
}
setConnState(o: Record<string, unknown>) {
this.connState = o;
}
pushInbound(o: Record<string, unknown>) {
this.inbound.unshift(o);
if (this.inbound.length > 15) this.inbound.pop();
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
const url = req.url || '';
if (req.method === 'GET') {
if (url.startsWith('/qr')) return this.handleQrPage(req, res);
if (url.startsWith('/debug')) return this.handleDebug(req, res);
res.writeHead(404).end('Not Found');
return;
}
if (req.method !== 'POST') {
res.writeHead(405).end('Method Not Allowed');
return;
}
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', async () => {
let payload: any;
try {
payload = JSON.parse(body);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'JSON invalido' }));
return;
}
try {
if (url === '/whatsapp-start') {
await this.handleWhatsappStart(payload);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
} else if (url === '/whatsapp-pdf') {
await this.handleWhatsappPdf(payload);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
} else if (url === '/whatsapp-fotos') {
// Cross-canal: tras una llamada, la app pide que Luisa escriba al lead y le pida las fotos.
const { fotosEmitter } = await import('../whatsapp/whatsapp.service');
fotosEmitter.emit('fotos', payload);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
} else {
res.writeHead(404).end('Not Found');
}
} catch (err: any) {
this.logger.error(`Error handling ${url}: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: err.message }));
}
});
}
// Comparación en tiempo constante (sobre hashes, sin filtrar longitud).
private tokenValido(a: string, b: string): boolean {
if (!a || !b) return false;
const ha = crypto.createHash('sha256').update(a).digest();
const hb = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(ha, hb);
}
// Auth Basic: contraseña = QR_TOKEN (en cabecera, nunca en la URL).
private basicAuthOk(req: http.IncomingMessage): boolean {
const expected = process.env.QR_TOKEN || '';
const auth = (req.headers['authorization'] as string) || '';
const pass = auth.startsWith('Basic ')
? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':')
: '';
return this.tokenValido(pass, expected);
}
// Diagnóstico: estado de conexión + últimos eventos entrantes. Misma auth que /qr.
private handleDebug(req: http.IncomingMessage, res: http.ServerResponse) {
if (!this.basicAuthOk(req)) {
res
.writeHead(401, {
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
'Content-Type': 'application/json',
'Referrer-Policy': 'no-referrer',
})
.end(JSON.stringify({ ok: false, error: 'No autorizado' }));
return;
}
res
.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' })
.end(JSON.stringify({ conectado: this.conectado, connState: this.connState, inbound: this.inbound }, null, 2));
}
// Página de vinculación: muestra el QR de Baileys como imagen escaneable.
// Auth por cabecera (HTTP Basic, contraseña = QR_TOKEN), nunca por query string.
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
if (!this.basicAuthOk(req)) {
res
.writeHead(401, {
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
'Content-Type': 'text/html; charset=utf-8',
'Referrer-Policy': 'no-referrer',
})
.end('<h2>401</h2><p>Usuario: cualquiera · Contraseña: el QR_TOKEN.</p>');
return;
}
let cuerpo: string;
if (this.conectado) {
cuerpo = '<h2>✅ WhatsApp ya conectado</h2><p>El bot está vinculado. No hace falta escanear nada.</p>';
} else if (this.qrActual) {
let dataUrl = '';
try {
dataUrl = await QRImage.toDataURL(this.qrActual, { width: 320, margin: 2 });
} catch {
/* fallback abajo */
}
cuerpo =
'<h2>Vincula WhatsApp del negocio</h2>' +
'<p>WhatsApp → Dispositivos vinculados → Vincular un dispositivo → escanea:</p>' +
(dataUrl ? '<img src="' + dataUrl + '" width="320" height="320" alt="QR" />' : '<p>No se pudo generar el QR.</p>') +
'<p class="muted">El código rota cada pocos segundos; la página se refresca sola.</p>';
} else {
cuerpo = '<h2>Esperando QR…</h2><p class="muted">El bot aún no ha emitido el código. Refresca en unos segundos.</p>';
}
const html =
'<!doctype html><html lang="es"><head><meta charset="utf-8" />' +
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
'<meta http-equiv="refresh" content="12" />' +
'<title>Vincular WhatsApp · Reformix</title>' +
'<style>body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0f1115;color:#e6e8ec;display:flex;min-height:100vh;align-items:center;justify-content:center;text-align:center;margin:0;padding:24px}img{background:#fff;padding:12px;border-radius:12px}h2{margin:0 0 8px}.muted{color:#9aa3b2;font-size:13px}p{color:#c8cdd6}</style>' +
'</head><body><div>' +
cuerpo +
'</div></body></html>';
res
.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
})
.end(html);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private leadSessions = new Map<string, { leadId: string; telefono: string; nombre: string; jid: string | null }>();
private normTel(t: string): string {
return (t || '').replace(/\D/g, '');
}
private async handleWhatsappStart(payload: { leadId: string; telefono: string; nombre: string; empresa: string }) {
const { leadId, nombre, empresa } = payload;
const telefono = this.normTel(payload.telefono);
this.logger.log(`[START] leadId=${leadId}, telefono=${telefono}, nombre=${nombre}`);
this.leadSessions.set(telefono, { leadId, telefono, nombre, jid: null });
// Dispara la apertura proactiva (la envía WhatsappService, evitando dependencia circular).
const { startEmitter } = await import('../whatsapp/whatsapp.service');
startEmitter.emit('start', { leadId, telefono, nombre, empresa });
this.logger.log(`Lead ${leadId} registrado; apertura disparada.`);
}
getLeadIdByTelefono(telefono: string): string | null {
return this.leadSessions.get(this.normTel(telefono))?.leadId ?? null;
}
registerJid(telefono: string, jid: string) {
const session = this.leadSessions.get(this.normTel(telefono));
if (session) {
session.jid = jid;
}
}
// Re-registra una sesión recuperada de la BD (cuando no estaba en memoria, p. ej. tras reinicio).
ensureSession(telefono: string, leadId: string, nombre = '') {
const tel = this.normTel(telefono);
if (!this.leadSessions.has(tel)) {
this.leadSessions.set(tel, { leadId, telefono: tel, nombre, jid: null });
}
}
private async handleWhatsappPdf(payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) {
this.logger.log(`[PDF] leadId=${payload.leadId}, filename=${payload.filename}`);
const { pdfEmitter } = await import('../whatsapp/whatsapp.service');
const telefono = payload.telefono.startsWith('+') ? payload.telefono.slice(1) : payload.telefono;
pdfEmitter.emit('pdf', { ...payload, telefono });
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WebhookListener } from './webhook-listener';
import { ApiModule } from '../api/api.module';
@Module({
imports: [ApiModule],
providers: [WebhookListener],
exports: [WebhookListener],
})
export class WebhookModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class WhatsappDebounceService {
private pendingMessages: Map<
string,
{
timer: NodeJS.Timeout;
texts: string[];
}
> = new Map();
private readonly DEBOUNCE_MS = 3000;
/**
* Agrega un mensaje al buffer del usuario.
* @param userId Identificador único del usuario (ej. JID)
* @param messageText Texto del mensaje
* @param callback Función a ejecutar cuando se complete el debounce (recibe el texto combinado)
*/
async add(
userId: string,
messageText: string,
callback: (combinedMessage: string) => Promise<void>,
): Promise<void> {
if (this.pendingMessages.has(userId)) {
const pending = this.pendingMessages.get(userId)!;
clearTimeout(pending.timer);
pending.texts.push(messageText);
pending.timer = setTimeout(async () => {
await this.flush(userId, callback);
}, this.DEBOUNCE_MS);
} else {
this.pendingMessages.set(userId, {
timer: setTimeout(async () => {
await this.flush(userId, callback);
}, this.DEBOUNCE_MS),
texts: [messageText],
});
}
}
private async flush(
userId: string,
callback: (combinedMessage: string) => Promise<void>,
): Promise<void> {
const pending = this.pendingMessages.get(userId);
if (!pending) return;
this.pendingMessages.delete(userId);
const combinedMessage = pending.texts.join(' ');
await callback(combinedMessage);
}
}

View File

@@ -1,13 +1,15 @@
import { Module } from '@nestjs/common';
import { WhatsappService } from './whatsapp.service';
import { WhatsappDebounceService } from './whatsapp-debounce.service';
import { LeadsModule } from '../leads/leads.module';
import { ConversacionModule } from '../conversacion/conversacion.module';
import { ClaudeModule } from '../claude/claude.module';
import { MediaModule } from '../media/media.module';
import { WebhookModule } from '../webhook/webhook.module';
@Module({
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
providers: [WhatsappService],
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule, WebhookModule],
providers: [WhatsappService, WhatsappDebounceService],
exports: [WhatsappService],
})
export class WhatsappModule {}

View File

@@ -1,3 +1,4 @@
import { EventEmitter } from 'events';
import {
Injectable,
Logger,
@@ -7,50 +8,304 @@ import {
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
WASocket,
downloadMediaMessage,
proto,
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pino = require('pino');
const QRCode = require('qrcode-terminal');
import { LeadsService } from '../leads/leads.service';
import { ConversacionService } from '../conversacion/conversacion.service';
import { ClaudeService } from '../claude/claude.service';
import { MediaService } from '../media/media.service';
import { Lead } from '../leads/lead.entity';
import { WhatsappDebounceService } from './whatsapp-debounce.service';
import { WebhookListener } from '../webhook/webhook-listener';
import { ApiClient } from '../api/api-client.service';
import { wrapSocket } from 'baileys-antiban';
export const pdfEmitter = new EventEmitter();
export const startEmitter = new EventEmitter();
export const fotosEmitter = new EventEmitter();
interface LeadContext {
leadId: string;
telefono: string;
nombre: string;
botStep: string;
viable: boolean | null;
}
@Injectable()
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WhatsappService.name);
private sock: WASocket | null = null;
private authDir = path.join(process.cwd(), 'auth_info_baileys');
private authDir = process.env.BAILEYS_AUTH_DIR || path.join(process.cwd(), 'auth_info_baileys');
private readonly ultimoMsgPorJid = new Map<string, any>();
private baileysLogger = pino({ level: 'info' });
// leadId por JID
private readonly jidToLeadId = new Map<string, string>();
// contexto de lead por leadId
private readonly leadCache = new Map<string, LeadContext>();
// leads cuya conversación ya se mandó a post-análisis (para no repetir).
private readonly leadsAnalizados = new Set<string>();
// leads a los que se les ha pedido foto y estamos esperándola.
private readonly esperandoFotos = new Set<string>();
// leads cuyo pipeline de render/presupuesto ya se disparó (perfilCompleto), para no repetir.
private readonly pipelineDisparado = new Set<string>();
constructor(
private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService,
private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService,
private readonly debounceService: WhatsappDebounceService,
private readonly webhookListener: WebhookListener,
private readonly api: ApiClient,
) {}
async onModuleInit() {
await this.conectar();
this.escucharPdf();
this.escucharStart();
this.escucharFotos();
}
async onModuleDestroy() {
if (this.sock) {
this.sock.end(undefined);
if (this.sock) this.sock.end(undefined);
}
private escucharPdf() {
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
// Buscar JID por teléfono
let jid: string | null = null;
for (const [j, lid] of this.jidToLeadId) {
if (lid === payload.leadId) {
jid = j;
break;
}
}
if (!jid) {
jid = `${payload.telefono}@s.whatsapp.net`;
}
if (!this.sock) return;
try {
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, {
document: Buffer.from(payload.pdfBase64, 'base64'),
mimetype: 'application/pdf',
fileName: payload.filename,
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
});
this.logger.log(`PDF enviado a ${jid}`);
} catch (err: any) {
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
}
});
}
// Apertura proactiva: cuando el funnel dispara /whatsapp-start, Luisa escribe ella el primer
// mensaje (el bot ya no es solo reactivo).
private escucharStart() {
startEmitter.on(
'start',
async (p: { leadId: string; telefono: string; nombre: string; empresa: string }) => {
try {
await this.enviarApertura(p);
} catch (err: any) {
this.logger.error(`[APERTURA] Error: ${err.message}`);
}
},
);
}
private async enviarApertura(p: { leadId: string; telefono: string; nombre: string; empresa: string }) {
if (!this.sock) {
this.logger.warn(`[APERTURA] WhatsApp no conectado; no se envía a ${p.telefono}`);
return;
}
const tel = (p.telefono || '').replace(/\D/g, '');
let jid = `${tel}@s.whatsapp.net`;
try {
const res = await this.sock.onWhatsApp(tel);
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
else if (!res || !res[0]?.exists) this.logger.warn(`[APERTURA] ${tel} no parece estar en WhatsApp`);
} catch {
/* seguimos con el jid por defecto */
}
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
const empresa = p.empresa || 'Reformix';
const apertura =
`¡Hola ${primerNombre}! Soy Luisa, del equipo de ${empresa}. 😊\n\n` +
`Acabas de pedir presupuesto para tu reforma y te ayudo a prepararlo (con un render de cómo ` +
`quedaría incluido). Para empezar, cuéntame: ¿qué espacio quieres reformar? (cocina, baño, salón…)`;
// Contexto para los siguientes mensajes del cliente.
this.jidToLeadId.set(jid, p.leadId);
this.webhookListener.registerJid(tel, jid);
this.leadCache.set(p.leadId, {
leadId: p.leadId,
telefono: tel,
nombre: p.nombre || '',
botStep: 'apertura',
viable: null,
});
await this.enviarMensaje(jid, apertura);
this.logger.log(`[APERTURA] Enviada a ${jid} (lead ${p.leadId})`);
try {
await this.api.actualizarPerfil(p.leadId, { estadoWa: 'enviado', botStep: 'apertura', canalOrigen: 'whatsapp' });
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', apertura, { botStep: 'apertura' });
await this.api.registrarIntento(p.leadId, 'whatsapp', 1, 'exitoso', true);
} catch (err: any) {
this.logger.warn(`[APERTURA] No se pudo persistir en la app: ${err.message}`);
}
}
// Recibe una foto en modo "esperando fotos": la sube como "antes" y marca perfilCompleto, lo que
// dispara en la app la generación de render + presupuesto + entrega del PDF.
private async recibirFotoYFinalizar(ctx: LeadContext, jid: string, msg: any, msgContent: any): Promise<void> {
if (!this.sock || this.pipelineDisparado.has(ctx.leadId)) return;
try {
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
const mimeType = msgContent.imageMessage?.mimetype || 'image/jpeg';
this.esperandoFotos.delete(ctx.leadId);
this.pipelineDisparado.add(ctx.leadId);
await this.api.enviarIngesta(
ctx.leadId,
[{ tipo: 'foto', imagen: `data:${mimeType};base64,${base64}`, zona: 'otro', momento: 'antes' }],
{ perfilCompleto: true },
);
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', '[foto del espacio]', { botStep: 'fotos_recibidas' });
const conf = '¡Perfecto! Con esto preparo tu presupuesto con el render. En un momento te llega aquí mismo 🛠️';
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', conf, { botStep: 'fotos_recibidas' });
await this.enviarMensaje(jid, conf);
this.logger.log(`[FOTOS] lead ${ctx.leadId}: foto recibida → perfilCompleto disparado`);
} catch (err: any) {
this.pipelineDisparado.delete(ctx.leadId);
this.logger.error(`[FOTOS] error procesando foto de ${ctx.leadId}: ${err.message}`);
}
}
// Cross-canal: tras una llamada, la app pide por webhook que Luisa escriba al lead, referencie lo
// hablado y le pida las fotos. Reutiliza el mismo modo de recogida.
private escucharFotos() {
fotosEmitter.on(
'fotos',
async (p: { leadId: string; telefono: string; nombre: string; empresa?: string; contexto?: string }) => {
try {
await this.iniciarRecogidaFotos(p);
} catch (err: any) {
this.logger.error(`[FOTOS] iniciarRecogida error: ${err.message}`);
}
},
);
}
private async iniciarRecogidaFotos(p: {
leadId: string;
telefono: string;
nombre: string;
empresa?: string;
contexto?: string;
}): Promise<void> {
if (!this.sock) {
this.logger.warn(`[FOTOS] WhatsApp no conectado; no se pide foto a ${p.telefono}`);
return;
}
const jid = await this.resolverJidYRegistrar(p.leadId, p.telefono, p.nombre, 'pide_fotos');
this.esperandoFotos.add(p.leadId);
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
const empresa = p.empresa || 'Reformix';
const ctx = p.contexto ? ` sobre ${p.contexto}` : '';
const mensaje =
`¡Hola ${primerNombre}! Soy Luisa, de ${empresa}. 😊 Gracias por tu llamada${ctx}. ` +
`Para terminar tu presupuesto con el render, mándame una foto del espacio 📸`;
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', mensaje, { botStep: 'pide_fotos' });
await this.enviarMensaje(jid, mensaje);
this.logger.log(`[FOTOS] recogida iniciada para lead ${p.leadId} (cross-canal)`);
}
// Resuelve el jid real del teléfono (vía onWhatsApp) y registra el contexto del lead.
private async resolverJidYRegistrar(leadId: string, telefono: string, nombre: string, botStep: string): Promise<string> {
const tel = (telefono || '').replace(/\D/g, '');
let jid = `${tel}@s.whatsapp.net`;
try {
const res = await this.sock?.onWhatsApp(tel);
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
} catch {
/* jid por defecto */
}
this.jidToLeadId.set(jid, leadId);
this.webhookListener.registerJid(tel, jid);
if (!this.leadCache.has(leadId)) {
this.leadCache.set(leadId, { leadId, telefono: tel, nombre: nombre || '', botStep, viable: null });
}
return jid;
}
private normalizarTelefono(jid: string): string {
return jid.split('@')[0].replace(/\D/g, '');
}
// WhatsApp puede entregar mensajes desde una dirección @lid (id de privacidad) en vez del número.
// Resolvemos el número real vía remoteJidAlt o el mapa LID→PN de Baileys; si no, caemos al jid.
private resolverTelefono(msg: any): string {
const jid: string = msg.key?.remoteJid || '';
if (jid.endsWith('@lid')) {
const alt = msg.key?.remoteJidAlt;
if (typeof alt === 'string' && alt.includes('@s.whatsapp.net')) return this.normalizarTelefono(alt);
try {
const pn = (this.sock as any)?.signalRepository?.lidMapping?.getPNForLID?.(jid);
if (typeof pn === 'string' && pn) return this.normalizarTelefono(pn);
} catch {
/* sin mapping disponible */
}
}
return this.normalizarTelefono(jid);
}
private calcularDelayEscritura(longitudTexto: number): number {
const min = 1500;
const max = 4000;
const factor = Math.min(longitudTexto / 120, 1);
return Math.round(min + (max - min) * factor);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async conectar() {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
const { version } = await fetchLatestBaileysVersion();
this.baileysLogger = pino({ level: 'info' }) as any;
this.sock = makeWASocket({
version,
auth: state,
printQRInTerminal: true,
logger: pino({ level: 'silent' }) as any,
printQRInTerminal: false,
logger: this.baileysLogger,
// true: marca el dispositivo "online" al conectar para que WhatsApp le ENTREGUE los mensajes
// entrantes tras reconectar (con false, al reanudar la sesión quedaba "no disponible" y no
// recibía nada aunque el socket dijera "open").
markOnlineOnConnect: true,
generateHighQualityLinkPreview: false,
syncFullHistory: false,
});
this.sock.ev.on('creds.update', saveCreds);
@@ -58,194 +313,308 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
this.webhookListener.setConnState({
connection: connection ?? null,
hasQr: !!qr,
lastDisconnect: (lastDisconnect?.error as Boom)?.output?.statusCode ?? null,
at: new Date().toISOString(),
});
if (qr) {
console.log('\n📲 Escanea el QR de arriba con WhatsApp\n');
QRCode.generate(qr, { small: true });
console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n');
this.webhookListener.setQr(qr);
}
if (connection === 'close') {
const shouldReconnect =
(lastDisconnect?.error as Boom)?.output?.statusCode !==
DisconnectReason.loggedOut;
this.logger.warn(
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
);
if (shouldReconnect) {
setTimeout(() => this.conectar(), 5000);
} else {
this.logger.error(
'Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.',
);
}
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
this.webhookListener.setConectado(false);
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
else this.logger.error('Sesion cerrada (logged out).');
} else if (connection === 'open') {
this.logger.log(
'✅ WhatsApp conectado. Luisa está lista para recibir mensajes.',
);
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
this.webhookListener.setConectado(true);
}
});
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue; // ignorar mensajes propios
this.webhookListener.pushInbound({
type,
remoteJid: msg.key.remoteJid ?? null,
remoteJidAlt: (msg.key as any).remoteJidAlt ?? null,
fromMe: !!msg.key.fromMe,
msgType: msg.message ? Object.keys(msg.message)[0] : null,
at: new Date().toISOString(),
});
}
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (!msg.key.remoteJid) continue;
if (msg.key.remoteJid.includes('@g.us')) continue;
await this.procesarMensaje(msg);
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
await this.encolarMensaje(msg);
}
});
}
/**
* Procesa un mensaje entrante de WhatsApp.
* Identifica el tipo (texto, audio, imagen), normaliza el contenido,
* consulta/crea el lead, llama a Claude y envía la respuesta.
*/
private async procesarMensaje(
msg: proto.IWebMessageInfo,
): Promise<void> {
private extraerTextoPlano(msg: any): string | null {
const msgContent = msg.message;
if (!msgContent) return null;
if (msgContent.conversation || msgContent.extendedTextMessage) {
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
return texto.trim() ? texto : null;
}
return null;
}
private crearMsgConTexto(msg: any, texto: string): any {
return { ...msg, message: { conversation: texto } };
}
private async encolarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
// Normalizar el número de teléfono (quitar el @s.whatsapp.net y el sufijo de grupo)
const telefono = jid.replace('@s.whatsapp.net', '').replace('@g.us', '');
const textoPlano = this.extraerTextoPlano(msg);
try {
// 1. Identificar o crear el lead
const lead = await this.leadsService.findOrCreate(telefono);
// Ignorar leads ya terminados
if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) {
this.logger.log(
`Lead id=${lead.id} en estado=${lead.estado_actual}. Mensaje ignorado.`,
);
if (textoPlano === null) {
await this.procesarMensaje(msg);
return;
}
// 2. Determinar el tipo de mensaje y normalizarlo a texto
let textoNormalizado = '';
const msgContent = msg.message;
this.ultimoMsgPorJid.set(jid, msg);
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
this.ultimoMsgPorJid.delete(jid);
await this.procesarMensaje(this.crearMsgConTexto(baseMsg, combinedMessage));
});
}
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
let leadId = this.webhookListener.getLeadIdByTelefono(telefono);
// Fallback: si no está en memoria (reinicio del bot), recuperarlo de la BD por teléfono.
if (!leadId) {
leadId = await this.api.buscarLeadPorTelefono(telefono);
if (leadId) {
this.webhookListener.ensureSession(telefono, leadId);
this.logger.log(`Lead ${leadId} recuperado por teléfono ${telefono} (sin sesión en memoria).`);
}
}
this.webhookListener.pushInbound({ stage: 'match', telefono, leadId: leadId ?? null, at: new Date().toISOString() });
if (!leadId) {
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
return null;
}
this.webhookListener.registerJid(telefono, jid);
this.jidToLeadId.set(jid, leadId);
let ctx = this.leadCache.get(leadId);
if (!ctx) {
const lead = await this.api.getLead(leadId);
ctx = {
leadId,
telefono,
nombre: lead?.nombre || '',
botStep: lead?.botStep || 'nuevo',
viable: lead?.viable ?? null,
};
this.leadCache.set(leadId, ctx);
}
return ctx;
}
private async procesarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
if (jid.includes('@g.us')) return;
const telefono = this.resolverTelefono(msg);
try {
const ctx = await this.getOrCreateContext(telefono, jid);
if (!ctx) return;
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
let textoNormalizado = '';
const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return;
// Modo recogida de fotos (tras cerrar la cualificación o tras una llamada): la foto cierra el
// flujo → sube la foto + dispara render/presupuesto, sin re-cualificar.
if (msgContent.imageMessage && this.esperandoFotos.has(ctx.leadId)) {
await this.recibirFotoYFinalizar(ctx, jid, msg, msgContent);
return;
}
if (msgContent.conversation || msgContent.extendedTextMessage) {
// Texto plano
textoNormalizado =
msgContent.conversation ||
msgContent.extendedTextMessage?.text ||
'';
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
} else if (msgContent.audioMessage) {
// Audio → Claude transcripción
this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`);
const buffer = await downloadMediaMessage(msg, 'buffer', {});
const mimeType =
msgContent.audioMessage.mimetype || 'audio/ogg; codecs=opus';
textoNormalizado = await this.mediaService.transcribirAudio(
buffer as Buffer,
mimeType,
);
const audioMeta = msgContent.audioMessage;
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
} else if (msgContent.imageMessage) {
// Imagen → Claude Vision
this.logger.log(
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
);
const buffer = await downloadMediaMessage(msg, 'buffer', {});
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
textoNormalizado = await this.mediaService.inferirImagen(
buffer as Buffer,
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
mimeType,
lead.estado_actual,
'en_proceso',
);
// Si el lead envió un caption junto con la imagen, concatenarlo
if (msgContent.imageMessage.caption) {
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
}
} else {
this.logger.log(
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
);
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
return;
}
if (!textoNormalizado.trim()) return;
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
// 3. Guardar el mensaje del usuario en historial
await this.conversacionService.guardarMensaje(
lead.id,
'user',
if (primerMensajeDeUsuario) {
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
}
if (msgContent.imageMessage) {
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
await this.api.enviarIngesta(ctx.leadId, [{
tipo: 'foto',
imagen: `data:${mimeType};base64,${base64}`,
zona: 'otro',
momento: 'antes',
}]);
}
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
botStep: ctx.botStep,
});
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
const leadParaClaude = {
id: ctx.leadId,
telefono: ctx.telefono,
nombre: ctx.nombre,
estado_actual: ctx.botStep || 'nuevo',
espacio: null as string | null,
rango_m2: null as string | null,
estilo: null as string | null,
urgencia: null as string | null,
presupuesto_declarado: null as string | null,
viable: ctx.viable as boolean | null,
email: null as string | null,
};
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
leadParaClaude as any,
historial.slice(0, -1),
textoNormalizado,
);
// 4. Construir historial y llamar a Claude
const historial =
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
const { respuesta, entidad, viable } = await this.claudeService.llamarClaude(
lead,
historial.slice(0, -1), // el último ya es el mensaje actual
textoNormalizado,
);
// 5. Actualizar datos del lead con lo extraído por Claude
if (entidad && Object.keys(entidad).length > 0) {
await this.leadsService.updateDatos(lead.id, entidad);
}
// 6. Manejar el flag viable
if (viable !== undefined && viable !== null) {
await this.leadsService.marcarViable(lead, viable);
this.logger.log(
`Lead id=${lead.id} marcado como viable=${viable}`,
);
} else {
// Avanzar estado si sigue en_proceso
if (lead.estado_actual === 'nuevo') {
await this.leadsService.updateEstado(lead, 'en_proceso');
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
const entidadMap: Record<string, unknown> = {};
if (entidad) {
for (const [k, v] of Object.entries(entidad)) {
const mapped = this.mapearCampoALegacy(k);
entidadMap[mapped] = v;
}
}
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
if (nuevoEstado) ctx.botStep = nuevoEstado;
if (viable !== undefined) ctx.viable = viable;
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
}
// 7. Guardar respuesta de Claude en historial
await this.conversacionService.guardarMensaje(
lead.id,
'assistant',
respuesta,
);
// ¿Estamos en el cierre? Por estado (errático) O porque Luisa anuncia el presupuesto.
const estadosCierre = ['presupuesto', 'fin_viable', 'fin_no_viable'];
const anunciaPresupuesto =
/presupuesto/i.test(respuesta) &&
/prepar|recib|enseguida|en un momento|te lo env|lo env|aqu[ií] mismo/i.test(respuesta);
const esCierre = estadosCierre.includes(ctx.botStep) || anunciaPresupuesto;
// 8. Enviar respuesta por WhatsApp
// Al cerrar, dispara el post-análisis de toda la conversación (una sola vez).
if (esCierre && !this.leadsAnalizados.has(ctx.leadId)) {
this.leadsAnalizados.add(ctx.leadId);
this.api
.analizarConversacion(ctx.leadId)
.then((ok) => this.logger.log(`[ANALISIS] lead ${ctx.leadId}: ${ok ? 'ok' : 'fallo'}`))
.catch((e: any) => this.logger.error(`[ANALISIS] ${e.message}`));
}
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
botStep: ctx.botStep,
});
await this.enviarMensaje(jid, respuesta);
} catch (error) {
this.logger.error(
`Error procesando mensaje de ${telefono}: ${error.message}`,
error.stack,
);
// Tras cerrar, pide una foto para el render (si no la hemos pedido/recibido ya).
if (esCierre && !this.esperandoFotos.has(ctx.leadId) && !this.pipelineDisparado.has(ctx.leadId)) {
this.esperandoFotos.add(ctx.leadId);
const pedir = 'Una última cosa para incluir el render en tu presupuesto: mándame una foto del espacio 📸';
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', pedir, { botStep: 'pide_fotos' });
await this.enviarMensaje(jid, pedir);
}
} catch (error: any) {
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
}
}
/**
* Envía un mensaje de texto por WhatsApp.
*/
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) {
this.logger.error('Socket de WhatsApp no disponible');
return;
private mapearCampoALegacy(campo: string): string {
const map: Record<string, string> = {
espacio: 'espacio',
rango_m2: 'rangoM2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto_declarado: 'presupuestoDeclarado',
nombre: 'nombre',
};
return map[campo] || campo;
}
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) return;
try {
await this.sock.sendMessage(jid, { text: texto });
const jidPresencia = jid.includes('@lid')
? `${jid.split('@')[0]}@s.whatsapp.net`
: jid;
await this.sock.sendPresenceUpdate('composing', jidPresencia);
await this.delay(this.calcularDelayEscritura(texto.length));
await this.sock.sendPresenceUpdate('paused', jidPresencia);
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`);
} catch (error) {
} catch (error: any) {
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
}
}
/**
* Envía el mensaje de apertura de Luisa a un número de teléfono.
* Lo usa el Scheduler para disparar el primer contacto.
*/
async enviarApertura(telefono: string, mensajeApertura: string): Promise<void> {
const jid = `${telefono}@s.whatsapp.net`;
await this.enviarMensaje(jid, mensajeApertura);
}
isConectado(): boolean {
return this.sock !== null;
}

View File

@@ -2,7 +2,10 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"noEmit": true
"noEmit": false
},
"exclude": ["node_modules", "dist"]
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -9,6 +9,7 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,

File diff suppressed because one or more lines are too long

View File

@@ -1508,7 +1508,7 @@ h3, h4, h5, h6 {
<a href="#faq">Preguntas</a>
</nav>
<div class="topbar-actions">
<a href="#" class="btn btn-ghost btn-sm">Entrar</a>
<a href="/login" class="btn btn-ghost btn-sm">Entrar</a>
<a href="/signup" class="btn btn-primary btn-sm">Empezar gratis</a>
</div>
</div>
@@ -1539,7 +1539,7 @@ h3, h4, h5, h6 {
</div>
<!-- Hero visual: panel de leads mock -->
<div class="hero-visual reveal" id="demo" aria-label="Vista previa del panel de leads">
<div class="hero-visual reveal" aria-label="Vista previa del panel de leads">
<div class="mock-bar">
<span class="mock-dot" style="background: var(--color-danger-500);"></span>
<span class="mock-dot" style="background: var(--color-warning-500);"></span>
@@ -1607,6 +1607,33 @@ h3, h4, h5, h6 {
</div>
</section>
<!-- ============================================
Demo en vídeo
============================================ -->
<section class="section" id="demo">
<div class="container">
<div class="section-head center reveal">
<p class="section-kicker">En 2 minutos</p>
<h2 class="section-title">Míralo funcionando <em>de principio a fin</em>.</h2>
<p class="section-lede">Te enseñamos cómo Reformix atiende a tu cliente, calcula el presupuesto orientativo y te lo deja en el panel — sin que tú levantes el teléfono.</p>
</div>
<div class="reveal" style="max-width: 900px; margin: 0 auto;">
<!-- Placeholder de vídeo: sustituir este bloque por un <iframe>/<video> cuando esté el vídeo. -->
<div style="position: relative; aspect-ratio: 16 / 9; border-radius: var(--radius-2xl); overflow: hidden; background: radial-gradient(120% 120% at 50% 0%, var(--color-neutral-900), var(--color-neutral-950)); border: 1px solid var(--color-neutral-200); box-shadow: var(--shadow-xl); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-4);">
<span style="display: inline-flex; align-items: center; justify-content: center; width: 76px; height: 76px; border-radius: var(--radius-full); background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.25);">
<svg width="30" height="30" viewBox="0 0 24 24" fill="#ffffff" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>
</span>
<span style="color: rgba(255,255,255,0.85); font-size: var(--text-sm); letter-spacing: 0.04em; text-transform: uppercase;">Vídeo demo · próximamente</span>
</div>
</div>
<div class="reveal" style="text-align: center; margin-top: var(--space-8);">
<a href="/signup" class="btn btn-primary btn-lg">Empezar ahora</a>
</div>
</div>
</section>
<!-- ============================================
Logo strip
============================================ -->

View File

@@ -1,3 +1,35 @@
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
# Retell.ai — agente de voz saliente del funnel B2C. OPCIONALES: sin ellas la llamada no se
# dispara y el pipeline sigue en modo simulado. El agente se crea a mano en el panel de Retell.
RETELL_API_KEY=""
RETELL_AGENT_ID=""
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
# Allowlist de pruebas: si tiene valor (CSV de E.164), SOLO se llama a esos números; el resto se
# omite (la llamada no se lanza). Vaciar para volver a llamar a todos. Ej: "+34651194617"
RETELL_ALLOWED_NUMBERS=""
# EP de ingesta del lead (/api/leads/:id/ingesta). Clave compartida que valida al llamante
# externo (Authorization: Bearer ...). Sin ella, el EP responde 401.
FUNNEL_API_KEY=""
# Email (SMTP) para enviar el presupuesto y el enlace al formulario. OPCIONALES: sin SMTP_HOST +
# EMAIL_FROM el envío degrada a no-op (la entrega queda marcada como simulada). Mailhog local:
# SMTP_HOST=localhost SMTP_PORT=1025 (sin user/pass).
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASS=""
EMAIL_FROM="" # remitente, p. ej. "Reformas Ejemplo <no-reply@reformix.es>"
# Webhooks salientes hacia el flujo externo (n8n/generador). OPCIONALES: sin URL la señal no se
# manda. PERFIL = perfil completo (generar renders/agente); WHATSAPP = entrega del PDF;
# WHATSAPP_START = arrancar la conversación de WhatsApp con el lead.
PERFIL_WEBHOOK_URL=""
WHATSAPP_WEBHOOK_URL=""
WHATSAPP_START_WEBHOOK_URL=""
# Base pública de la app, para construir enlaces absolutos (enlace al formulario en el email).
APP_URL="http://localhost:3000"

6
mvp/b2c/.gitignore vendored
View File

@@ -40,3 +40,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
api-docs/reformix-ingesta.postman_collection.json
# Logs locales del dev server
dev.log

279
mvp/b2c/api-docs/README.md Normal file
View File

@@ -0,0 +1,279 @@
# 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`.

View File

@@ -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);

View File

@@ -0,0 +1,53 @@
# Esquema de la base de datos — Reformix B2C
`schema.sql` es el **DDL consolidado** de toda la base de datos en su estado actual:
12 enums, 14 tablas, sus claves foráneas e índices. Sirve para que el equipo entienda,
consulte y proponga cambios sobre el modelo de datos sin tener que leer las migraciones
una a una.
## Tablas
| Tabla | Para qué |
| --- | --- |
| `tenants` | Reformistas (multi-tenant; en el MVP solo "Reformas Ejemplo") |
| `plans` | Planes de suscripción |
| `users` / `sessions` | Auth del panel del reformista |
| `leads` | Lead del cliente final + estado del funnel + resultado (presupuesto, render, transcripción) |
| `lead_fotos` | Fotos que sube el cliente del espacio a reformar |
| `lead_pipeline_eventos` | Traza de cada paso del pipeline (prellamada, llamada, render, presupuesto, WhatsApp) |
| `lead_estado_history` | Historial de cambios de estado comercial del lead |
| `catalog_items` | Catálogo de materiales del reformista (precio, calidad, unidad) — entrada del motor de presupuesto |
| `pricing_config` | Config de precios del reformista (mano de obra, márgenes…) |
| `precision_history` | Histórico de precisión de las estimaciones |
| `galeria_fotos` | Galería de trabajos del reformista |
| `testimonios` / `testimonio_fotos` | Reseñas con fotos |
## Importante: la fuente de la verdad es `src/db/schema.ts`
**No edites `schema.sql` a mano.** El esquema real vive en [`src/db/schema.ts`](../src/db/schema.ts)
(Drizzle ORM) y los cambios se aplican con migraciones en [`drizzle/`](../drizzle/).
Este archivo es una **foto** generada a partir de ese schema.
### Para cambiar el modelo de datos
1. Edita `src/db/schema.ts`.
2. Genera la migración: `npx drizzle-kit generate`
3. Aplícala: `npx drizzle-kit migrate`
4. Regenera esta foto (ver abajo).
### Regenerar `schema.sql`
```bash
cd mvp/b2c
npm run db:export
```
### Levantar una base de datos local desde cero con este SQL
```bash
docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
psql "postgresql://postgres:reformix@localhost:5432/reformix" -f db-schema/schema.sql
```
> El `export` no incluye datos semilla (tenant de ejemplo, catálogo, planes). Para eso usa
> el seed del proyecto si existe, o inserta los registros base manualmente.

View File

@@ -0,0 +1,330 @@
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');
CREATE TYPE "public"."canal_contacto" AS ENUM('formulario', 'whatsapp', 'llamada');
CREATE TYPE "public"."canal_origen" AS ENUM('formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio');
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');
CREATE TYPE "public"."estado_wa" AS ENUM('sin_enviar', 'enviado', 'entregado', 'leido', 'fallido');
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');
CREATE TYPE "public"."job_estado" AS ENUM('pendiente', 'procesando', 'completado', 'error');
CREATE TYPE "public"."job_tipo" AS ENUM('analisis_fotos', 'render', 'presupuesto_ia');
CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido');
CREATE TYPE "public"."nivel_calificacion" AS ENUM('A', 'B', 'C', 'D');
CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado');
CREATE TYPE "public"."resultado_contacto" AS ENUM('exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico');
CREATE TYPE "public"."rol_mensaje" AS ENUM('user', 'assistant', 'system');
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');
CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto');
CREATE TYPE "public"."tipo_reforma" AS ENUM('cocina', 'bano', 'salon', 'comedor', 'integral', 'otro');
CREATE TYPE "public"."unidad_medida" AS ENUM('m2', 'ml', 'ud');
CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja');
CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin');
CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado');
CREATE TYPE "public"."visita_estado" AS ENUM('propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada');
CREATE TABLE "catalog_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"categoria" "categoria_material" NOT NULL,
"nombre" text NOT NULL,
"calidad" "calidad" NOT NULL,
"precio_unit" integer NOT NULL,
"unidad" "unidad_medida" NOT NULL,
"descriptor_render" text DEFAULT '' NOT NULL,
"es_default" boolean DEFAULT false NOT NULL,
"sku" text NOT NULL
);
CREATE TABLE "conversacion_whatsapp" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"rol" "rol_mensaje" NOT NULL,
"mensaje" text NOT NULL,
"media_type" text,
"media_url" text,
"transcripcion_audio" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "galeria_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"url" text NOT NULL,
"titulo" text,
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "intentos_contacto" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"canal" "canal_contacto" NOT NULL,
"resultado" "resultado_contacto",
"completado" boolean DEFAULT false NOT NULL,
"numero_intento" integer NOT NULL,
"duracion_seg" integer,
"intentado_at" timestamp with time zone DEFAULT now() NOT NULL,
"notas" text,
"metadata" jsonb
);
CREATE TABLE "lead_calificacion" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"score" integer,
"nivel" "nivel_calificacion",
"criterios" jsonb,
"notas_agente" text,
"calificado_por" uuid,
"calificado_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lead_calificacion_lead_id_unique" UNIQUE("lead_id"),
CONSTRAINT "lead_calificacion_score_check" CHECK ("lead_calificacion"."score" >= 0 AND "lead_calificacion"."score" <= 100)
);
CREATE TABLE "lead_estado_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"estado" "lead_estado" NOT NULL,
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
"changed_by" text
);
CREATE TABLE "lead_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"url" text NOT NULL,
"momento" "foto_momento" DEFAULT 'antes' NOT NULL,
"zona" "tipo_reforma",
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "lead_notas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"zona" "tipo_reforma",
"texto" text NOT NULL,
"origen" text DEFAULT 'ep' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "lead_pipeline_eventos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"stage" "pipeline_stage" NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb
);
CREATE TABLE "leads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"nombre" text NOT NULL,
"telefono" text NOT NULL,
"email" text NOT NULL,
"provincia" text,
"tipo_reforma" "tipo_reforma",
"consent_privacidad" boolean DEFAULT false NOT NULL,
"consent_contratacion" boolean DEFAULT false NOT NULL,
"pipeline_stage" "pipeline_stage" DEFAULT 'form_completado' NOT NULL,
"estado" "lead_estado" DEFAULT 'nuevo' NOT NULL,
"presupuesto_estimado" integer,
"transcripcion" text,
"entidades" jsonb,
"render_url" text,
"pdf_url" text,
"audio_url" text,
"notas" text,
"testimonio_solicitado_at" timestamp with time zone,
"m2_suelo" double precision,
"altura_techo" double precision,
"calidad_global" "calidad",
"estructural" boolean DEFAULT false NOT NULL,
"anterior_a_2000" boolean DEFAULT false NOT NULL,
"cambio_distribucion" boolean DEFAULT false NOT NULL,
"material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL,
"desglose_snapshot" jsonb,
"urgencia" "urgencia",
"presupuesto_target" integer,
"taste_text" text,
"preferences_snapshot" jsonb,
"estado_wa" "estado_wa",
"bot_step" text,
"canal_origen" "canal_origen",
"espacio" text,
"rango_m2" text,
"estilo" text,
"presupuesto_declarado" text,
"viable" boolean,
"fotos_solicitadas_at" timestamp with time zone
);
CREATE TABLE "plans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"precio_mensual" integer NOT NULL,
"leads_incluidos" integer NOT NULL,
"features" jsonb DEFAULT '[]'::jsonb NOT NULL,
"activo" boolean DEFAULT true NOT NULL,
CONSTRAINT "plans_slug_unique" UNIQUE("slug")
);
CREATE TABLE "precision_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"estimated" integer NOT NULL,
"final" integer NOT NULL,
"delta_pct" numeric(6, 2) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "pricing_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"altura_techo_default" double precision DEFAULT 2.5 NOT NULL,
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL,
"baremo_minimo" integer,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
);
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
);
CREATE TABLE "tenants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre_empresa" text NOT NULL,
"logo_url" text,
"provincia" text,
"whatsapp_business" text,
"seo_title" text,
"seo_description" text,
"about_enabled" boolean DEFAULT false NOT NULL,
"about_foto_url" text,
"about_texto" text,
"anios_experiencia" integer,
"theme_preset" text DEFAULT 'pizarra' NOT NULL,
"theme_color" text,
"cif" text,
"direccion" text,
"telefono" text,
"email" text,
"web" text,
"plan_id" uuid,
"subscription_status" "subscription_status" DEFAULT 'trial' NOT NULL,
"envio_presupuesto" "envio_presupuesto_mode" DEFAULT 'automatico' NOT NULL,
"trial_ends_at" timestamp with time zone,
"stripe_customer_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tenants_slug_unique" UNIQUE("slug")
);
CREATE TABLE "testimonio_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"testimonio_id" uuid NOT NULL,
"url" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "testimonios" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"lead_id" uuid,
"nombre" text NOT NULL,
"contexto" text,
"rating" integer NOT NULL,
"texto" text NOT NULL,
"estado" "testimonio_estado" DEFAULT 'pendiente' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"nombre" text,
"role" "user_role" DEFAULT 'reformista' NOT NULL,
"tenant_id" uuid,
"status" "user_status" DEFAULT 'activo' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
CREATE TABLE "visitas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tenant_id" uuid NOT NULL,
"fecha_propuesta" timestamp with time zone,
"fecha_confirmada" timestamp with time zone,
"estado" "visita_estado" DEFAULT 'propuesta' NOT NULL,
"direccion" text,
"notas" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "worker_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tipo" "job_tipo" NOT NULL,
"estado_job" "job_estado" DEFAULT 'pendiente' NOT NULL,
"payload" jsonb NOT NULL,
"webhook_url" text,
"resultado_url" text,
"intentos" integer DEFAULT 0 NOT NULL,
"error_msg" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "conversacion_whatsapp" ADD CONSTRAINT "conversacion_whatsapp_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "intentos_contacto" ADD CONSTRAINT "intentos_contacto_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_calificado_por_users_id_fk" FOREIGN KEY ("calificado_por") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "lead_pipeline_eventos" ADD CONSTRAINT "lead_pipeline_eventos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "precision_history" ADD CONSTRAINT "precision_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "pricing_config" ADD CONSTRAINT "pricing_config_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_testimonios_id_fk" FOREIGN KEY ("testimonio_id") REFERENCES "public"."testimonios"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "worker_jobs" ADD CONSTRAINT "worker_jobs_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id");
CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku");
CREATE INDEX "idx_conversacion_whatsapp_lead_id" ON "conversacion_whatsapp" USING btree ("lead_id");
CREATE INDEX "idx_conversacion_whatsapp_created_at" ON "conversacion_whatsapp" USING btree ("created_at");
CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id");
CREATE INDEX "idx_intentos_contacto_lead_id" ON "intentos_contacto" USING btree ("lead_id");
CREATE INDEX "idx_lead_calificacion_lead_id" ON "lead_calificacion" USING btree ("lead_id");
CREATE INDEX "leads_tenant_created_idx" ON "leads" USING btree ("tenant_id","created_at");
CREATE INDEX "leads_estado_idx" ON "leads" USING btree ("estado");
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");
CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado");
CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id");
CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id");
CREATE INDEX "idx_visitas_lead_id" ON "visitas" USING btree ("lead_id");
CREATE INDEX "idx_visitas_tenant_id" ON "visitas" USING btree ("tenant_id");
CREATE INDEX "idx_worker_jobs_lead_id" ON "worker_jobs" USING btree ("lead_id");
CREATE INDEX "idx_worker_jobs_estado" ON "worker_jobs" USING btree ("estado_job");
CREATE INDEX "idx_worker_jobs_tipo" ON "worker_jobs" USING btree ("tipo");

View File

@@ -0,0 +1,33 @@
CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto');--> statement-breakpoint
CREATE TABLE "testimonio_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"testimonio_id" uuid NOT NULL,
"url" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "testimonios" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"lead_id" uuid,
"nombre" text NOT NULL,
"contexto" text,
"rating" integer NOT NULL,
"texto" text NOT NULL,
"estado" "testimonio_estado" DEFAULT 'pendiente' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "testimonio_solicitado_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "seo_title" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "seo_description" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "about_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "about_foto_url" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "about_texto" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "anios_experiencia" integer;--> statement-breakpoint
ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_testimonios_id_fk" FOREIGN KEY ("testimonio_id") REFERENCES "public"."testimonios"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado");--> statement-breakpoint
CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id");

View File

@@ -0,0 +1,13 @@
CREATE TABLE "galeria_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"url" text NOT NULL,
"titulo" text,
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "theme_preset" text DEFAULT 'pizarra' NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "theme_color" text;--> statement-breakpoint
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id");

View File

@@ -0,0 +1,13 @@
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');--> statement-breakpoint
CREATE TABLE "lead_notas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"zona" "tipo_reforma",
"texto" text NOT NULL,
"origen" text DEFAULT 'ep' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "lead_fotos" ADD COLUMN "momento" "foto_momento" DEFAULT 'antes' NOT NULL;--> statement-breakpoint
ALTER TABLE "lead_fotos" ADD COLUMN "zona" "tipo_reforma";--> statement-breakpoint
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "leads" ADD COLUMN "anterior_a_2000" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "cambio_distribucion" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "pricing_config" ADD COLUMN "extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL;

View File

@@ -0,0 +1,96 @@
CREATE TYPE "public"."canal_contacto" AS ENUM('formulario', 'whatsapp', 'llamada');--> statement-breakpoint
CREATE TYPE "public"."canal_origen" AS ENUM('formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio');--> statement-breakpoint
CREATE TYPE "public"."estado_wa" AS ENUM('sin_enviar', 'enviado', 'entregado', 'leido', 'fallido');--> statement-breakpoint
CREATE TYPE "public"."job_estado" AS ENUM('pendiente', 'procesando', 'completado', 'error');--> statement-breakpoint
CREATE TYPE "public"."job_tipo" AS ENUM('analisis_fotos', 'render', 'presupuesto_ia');--> statement-breakpoint
CREATE TYPE "public"."nivel_calificacion" AS ENUM('A', 'B', 'C', 'D');--> statement-breakpoint
CREATE TYPE "public"."resultado_contacto" AS ENUM('exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico');--> statement-breakpoint
CREATE TYPE "public"."rol_mensaje" AS ENUM('user', 'assistant', 'system');--> statement-breakpoint
CREATE TYPE "public"."visita_estado" AS ENUM('propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada');--> statement-breakpoint
CREATE TABLE "conversacion_whatsapp" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"rol" "rol_mensaje" NOT NULL,
"mensaje" text NOT NULL,
"media_type" text,
"media_url" text,
"transcripcion_audio" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "intentos_contacto" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"canal" "canal_contacto" NOT NULL,
"resultado" "resultado_contacto",
"completado" boolean DEFAULT false NOT NULL,
"numero_intento" integer NOT NULL,
"duracion_seg" integer,
"intentado_at" timestamp with time zone DEFAULT now() NOT NULL,
"notas" text,
"metadata" jsonb
);
--> statement-breakpoint
CREATE TABLE "lead_calificacion" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"score" integer,
"nivel" "nivel_calificacion",
"criterios" jsonb,
"notas_agente" text,
"calificado_por" uuid,
"calificado_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "lead_calificacion_lead_id_unique" UNIQUE("lead_id"),
CONSTRAINT "lead_calificacion_score_check" CHECK ("lead_calificacion"."score" >= 0 AND "lead_calificacion"."score" <= 100)
);
--> statement-breakpoint
CREATE TABLE "visitas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tenant_id" uuid NOT NULL,
"fecha_propuesta" timestamp with time zone,
"fecha_confirmada" timestamp with time zone,
"estado" "visita_estado" DEFAULT 'propuesta' NOT NULL,
"direccion" text,
"notas" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "worker_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"tipo" "job_tipo" NOT NULL,
"estado_job" "job_estado" DEFAULT 'pendiente' NOT NULL,
"payload" jsonb NOT NULL,
"webhook_url" text,
"resultado_url" text,
"intentos" integer DEFAULT 0 NOT NULL,
"error_msg" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estado_wa" "estado_wa";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "canal_origen" "canal_origen";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "espacio" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "rango_m2" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estilo" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "presupuesto_declarado" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "viable" boolean;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "fotos_solicitadas_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "conversacion_whatsapp" ADD CONSTRAINT "conversacion_whatsapp_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "intentos_contacto" ADD CONSTRAINT "intentos_contacto_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_calificado_por_users_id_fk" FOREIGN KEY ("calificado_por") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "worker_jobs" ADD CONSTRAINT "worker_jobs_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_conversacion_whatsapp_lead_id" ON "conversacion_whatsapp" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_conversacion_whatsapp_created_at" ON "conversacion_whatsapp" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_intentos_contacto_lead_id" ON "intentos_contacto" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_lead_calificacion_lead_id" ON "lead_calificacion" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_visitas_lead_id" ON "visitas" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_visitas_tenant_id" ON "visitas" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "idx_worker_jobs_lead_id" ON "worker_jobs" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_worker_jobs_estado" ON "worker_jobs" USING btree ("estado_job");--> statement-breakpoint
CREATE INDEX "idx_worker_jobs_tipo" ON "worker_jobs" USING btree ("tipo");

View File

@@ -0,0 +1 @@
ALTER TABLE "leads" ADD COLUMN "bot_step" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "pricing_config" ADD COLUMN "baremo_minimo" integer;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,55 @@
"when": 1780237037524,
"tag": "0005_tearful_maverick",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780308810691,
"tag": "0006_aspiring_susan_delgado",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780313493522,
"tag": "0007_pale_chat",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780505942614,
"tag": "0008_sharp_bloodaxe",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780569557328,
"tag": "0009_white_agent_brand",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780584271011,
"tag": "0010_square_vulture",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780593183911,
"tag": "0011_warm_post",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781189893331,
"tag": "0012_lame_sentinel",
"breakpoints": true
}
]
}

View File

@@ -4,10 +4,17 @@ const nextConfig: NextConfig = {
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
serverExternalPackages: ['@react-pdf/renderer'],
async rewrites() {
return [
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.
// beforeFiles: estas reglas ganan a las rutas del filesystem (incluida [slug]).
// La raíz y /b2b sirven la landing B2B estática (public/b2b.html); cada reformista
// tiene su funnel en /{slug} vía app/[slug]/page.tsx.
return {
beforeFiles: [
{ source: "/", destination: "/b2b.html" },
{ source: "/b2b", destination: "/b2b.html" },
];
],
afterFiles: [],
fallback: [],
};
},
};

View File

@@ -11,17 +11,22 @@
"@react-pdf/renderer": "^4.5.1",
"@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"nodemailer": "^8.0.10",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-easy-crop": "^5.5.7",
"sharp": "^0.34.5",
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",
@@ -1436,7 +1441,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -2965,6 +2969,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -4533,6 +4547,12 @@
"url": "https://dotenvx.com"
}
},
"node_modules/driver.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
"license": "MIT"
},
"node_modules/drizzle-kit": {
"version": "0.31.10",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
@@ -7200,6 +7220,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
@@ -7209,6 +7238,12 @@
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -7635,6 +7670,20 @@
"react": "^19.2.4"
}
},
"node_modules/react-easy-crop": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7959,7 +8008,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -8003,7 +8051,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},

View File

@@ -12,6 +12,7 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"db:export": "drizzle-kit export --dialect=postgresql --schema=./src/db/schema.ts > db-schema/schema.sql",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -20,17 +21,22 @@
"@react-pdf/renderer": "^4.5.1",
"@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"driver.js": "^1.4.0",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"nodemailer": "^8.0.10",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-easy-crop": "^5.5.7",
"sharp": "^0.34.5",
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

BIN
mvp/b2c/public/whatsapp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,68 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Hero from '@/components/Hero/Hero';
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
import Features from '@/components/Features/Features';
import QuienesSomos from '@/components/funnel/QuienesSomos';
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
import GaleriaTrabajos from '@/components/funnel/GaleriaTrabajos';
import Footer from '@/components/Footer/Footer';
import TenantBrand from '@/components/funnel/TenantBrand';
import { getTenantBySlug, getPublishedTestimonios, getGaleria } from '@/lib/funnel/public-queries';
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const tenant = await getTenantBySlug(slug);
if (!tenant) return { title: 'Reforma no encontrada' };
return {
title: tenant.seoTitle ?? `${tenant.nombreEmpresa} · Presupuesto de reforma`,
description:
tenant.seoDescription ??
`Pide tu presupuesto de reforma a ${tenant.nombreEmpresa}. Render IA y presupuesto orientativo en minutos.`,
};
}
export default async function FunnelPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const tenant = await getTenantBySlug(slug);
if (!tenant) notFound();
const [testimonios, galeria] = await Promise.all([
getPublishedTestimonios(tenant.id),
getGaleria(tenant.id),
]);
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
return (
<div
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
style={themeStyle(tenant.themePreset, tenant.themeColor)}
>
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
<main id="main-content">
<Hero slug={tenant.slug} />
<ReformaSlider />
<Features />
{tenant.aboutEnabled && tenant.aboutTexto && (
<QuienesSomos
nombreEmpresa={tenant.nombreEmpresa}
fotoUrl={tenant.aboutFotoUrl}
texto={tenant.aboutTexto}
aniosExperiencia={tenant.aniosExperiencia}
/>
)}
<GaleriaTrabajos fotos={galeria} nombreEmpresa={tenant.nombreEmpresa} />
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
import { analizarConversacion } from '@/lib/funnel/analizar-conversacion';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Post-análisis: lee toda la conversación de WhatsApp del lead, extrae los datos clave con un LLM y
// los persiste en el lead. Lo llama el bot al cerrar la cualificación (o se puede invocar a posteriori).
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
const { id } = await params;
const resultado = await analizarConversacion(id);
return jsonResponse(resultado, resultado.ok ? 200 : 422);
}

View File

@@ -0,0 +1,38 @@
import { db } from '@/db';
import { leadCalificacion } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { calificacionSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Upsert de la calificación del lead (una por lead). El bot la recalcula a medida que avanza
// la conversación; onConflict actualiza la fila existente.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, calificacionSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
await db
.insert(leadCalificacion)
.values({
leadId,
score: body.score,
nivel: body.nivel,
criterios: body.criterios,
notasAgente: body.notasAgente,
})
.onConflictDoUpdate({
target: leadCalificacion.leadId,
set: {
score: body.score,
nivel: body.nivel,
criterios: body.criterios,
notasAgente: body.notasAgente,
calificadoAt: new Date(),
},
});
return jsonResponse({ ok: true }, 200);
}

View File

@@ -0,0 +1,54 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, conversacionWhatsapp } from '@/db/schema';
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { conversacionSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Historial de la conversación de WhatsApp (orden cronológico) para que el bot recupere el contexto.
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
const { id } = await params;
const turnos = await db
.select({
rol: conversacionWhatsapp.rol,
mensaje: conversacionWhatsapp.mensaje,
createdAt: conversacionWhatsapp.createdAt,
})
.from(conversacionWhatsapp)
.where(eq(conversacionWhatsapp.leadId, id))
.orderBy(conversacionWhatsapp.createdAt);
return jsonResponse(turnos, 200);
}
// Añade un turno de la conversación de WhatsApp al historial del lead, y opcionalmente actualiza
// el estado del mensaje (estado_wa) y el paso del bot (bot_step) en el lead.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, conversacionSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
const [row] = await db
.insert(conversacionWhatsapp)
.values({
leadId,
rol: body.rol,
mensaje: body.mensaje,
mediaType: body.mediaType ?? null,
mediaUrl: body.mediaUrl ?? null,
transcripcionAudio: body.transcripcionAudio ?? null,
})
.returning({ id: conversacionWhatsapp.id });
if (body.estadoWa || body.botStep) {
await db
.update(leads)
.set({ estadoWa: body.estadoWa, botStep: body.botStep, updatedAt: new Date() })
.where(eq(leads.id, leadId));
}
return jsonResponse({ ok: true, id: row.id }, 200);
}

View File

@@ -0,0 +1,88 @@
import { desc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
import { autorizado, jsonResponse as json } from '@/lib/api/funnel-auth';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { finalizarYEntregar } from '@/lib/funnel/finalizar';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// EP único de ingesta async del perfil del lead. Acepta imágenes, imagen+texto o solo texto,
// etiquetado por zona y momento, y dos flags: perfilCompleto (señala al flujo externo que genere
// renders/agente) y finalizar (construye el PDF y lo entrega por email + señal WhatsApp).
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!autorizado(req)) return json({ ok: false, error: 'No autorizado.' }, 401);
const { id } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return json({ ok: false, error: 'JSON inválido.' }, 422);
}
const parsed = ingestaBodySchema.safeParse(raw);
if (!parsed.success) {
return json({ ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }, 422);
}
const body = parsed.data;
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, id)).limit(1);
if (!lead) return json({ ok: false, error: 'Lead no encontrado.' }, 404);
const fotosItems = body.items.filter((i) => i.tipo === 'foto');
const notasItems = body.items.filter((i) => i.tipo === 'texto');
if (fotosItems.length > 0) {
const [ultimo] = await db
.select({ orden: leadFotos.orden })
.from(leadFotos)
.where(eq(leadFotos.leadId, id))
.orderBy(desc(leadFotos.orden))
.limit(1);
let siguiente = (ultimo?.orden ?? -1) + 1;
const filas: NewLeadFoto[] = fotosItems.map((f) => ({
leadId: id,
url: f.imagen,
momento: f.momento,
zona: f.zona ?? null,
orden: f.orden ?? siguiente++,
}));
await db.insert(leadFotos).values(filas);
}
if (notasItems.length > 0) {
const filas: NewLeadNota[] = notasItems.map((n) => ({
leadId: id,
texto: n.texto,
zona: n.zona ?? null,
origen: 'ep',
}));
await db.insert(leadNotas).values(filas);
}
if (fotosItems.length > 0 || notasItems.length > 0) {
await db.insert(leadPipelineEventos).values({
leadId: id,
stage: 'fotos_subidas',
metadata: { origen: 'ep', fotos: fotosItems.length, notas: notasItems.length },
});
}
const perfilSenalado = body.perfilCompleto ? await señalarPerfilCompleto(id) : false;
const finalizado = body.finalizar ? await finalizarYEntregar(id) : null;
return json(
{
ok: true,
fotos: fotosItems.length,
notas: notasItems.length,
perfilSenalado,
finalizado,
},
200,
);
}

View File

@@ -0,0 +1,31 @@
import { db } from '@/db';
import { intentosContacto } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { intentoSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Registra un intento de contacto (formulario/whatsapp/llamada) con su resultado.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, intentoSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
const [row] = await db
.insert(intentosContacto)
.values({
leadId,
canal: body.canal,
resultado: body.resultado,
completado: body.completado ?? false,
numeroIntento: body.numeroIntento,
duracionSeg: body.duracionSeg,
notas: body.notas,
metadata: body.metadata,
})
.returning({ id: intentosContacto.id });
return jsonResponse({ ok: true, id: row.id }, 200);
}

View File

@@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads } from '@/db/schema';
import { jsonResponse } from '@/lib/api/funnel-auth';
import { validarBotRequest } from '@/lib/api/bot-request';
import { perfilSchema } from '@/lib/funnel/bot-schemas';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Actualización parcial del lead con lo que el bot va extrayendo (espacio, m², estilo, urgencia,
// presupuesto, viabilidad, estado de la conversación…). Solo escribe los campos enviados.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const v = await validarBotRequest(req, params, perfilSchema);
if ('error' in v) return v.error;
const { leadId, body } = v;
await db
.update(leads)
.set({
...body,
fotosSolicitadasAt: body.fotosSolicitadasAt ? new Date(body.fotosSolicitadasAt) : undefined,
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
return jsonResponse({ ok: true, actualizado: Object.keys(body) }, 200);
}

View File

@@ -0,0 +1,34 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads } from '@/db/schema';
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Estado del lead para el bot de WhatsApp: le permite retomar la conversación (botStep, viabilidad,
// extracción en crudo) tras un reinicio sin perder contexto. Devuelve el objeto plano (no envuelto).
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
const { id } = await params;
const [lead] = await db
.select({
id: leads.id,
nombre: leads.nombre,
telefono: leads.telefono,
botStep: leads.botStep,
estadoWa: leads.estadoWa,
espacio: leads.espacio,
rangoM2: leads.rangoM2,
estilo: leads.estilo,
presupuestoDeclarado: leads.presupuestoDeclarado,
viable: leads.viable,
})
.from(leads)
.where(eq(leads.id, id))
.limit(1);
if (!lead) return jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404);
return jsonResponse(lead, 200);
}

View File

@@ -0,0 +1,37 @@
import { desc, like } from 'drizzle-orm';
import { db } from '@/db';
import { leads } from '@/db/schema';
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Busca el lead más reciente por teléfono (comparando los últimos 9 dígitos, ignorando prefijos y
// formato). Lo usa el bot de WhatsApp para recuperar el leadId de un mensaje entrante cuando no
// tiene la sesión en memoria (p. ej. tras un reinicio del contenedor).
export async function GET(req: Request) {
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
const tel = (new URL(req.url).searchParams.get('telefono') ?? '').replace(/\D/g, '');
if (tel.length < 6) return jsonResponse({ ok: false, error: 'telefono inválido.' }, 422);
const last9 = tel.slice(-9);
const [lead] = await db
.select({
id: leads.id,
nombre: leads.nombre,
telefono: leads.telefono,
botStep: leads.botStep,
viable: leads.viable,
})
.from(leads)
.where(like(leads.telefono, `%${last9}%`))
.orderBy(desc(leads.createdAt))
.limit(1);
if (!lead) return jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404);
return jsonResponse(
{ leadId: lead.id, nombre: lead.nombre, telefono: lead.telefono, botStep: lead.botStep, viable: lead.viable },
200,
);
}

View File

@@ -0,0 +1,122 @@
import { desc, eq, like } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion';
import { pedirFotosWhatsapp } from '@/lib/webhooks';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const ok = (extra: Record<string, unknown> = {}) =>
new Response(JSON.stringify({ ok: true, ...extra }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// Webhook de Retell (eventos de llamada). Al terminar la llamada (call_analyzed / call_ended)
// releemos la llamada por su call_id desde la API de Retell (dato autoritativo y autenticado con
// nuestra API key, así un webhook falso no puede inyectar datos), guardamos la transcripción real,
// descargamos la grabación a nuestro sistema (lead.audio_url) y dejamos el análisis en el pipeline.
export async function POST(req: Request): Promise<Response> {
let body: { event?: string; call?: { call_id?: string } } = {};
try {
body = await req.json();
} catch {
return new Response('bad json', { status: 400 });
}
const event = body.event;
const callId = body.call?.call_id;
// Solo nos interesa el final de la llamada; respondemos 200 a todo para que Retell no reintente.
if (!callId || (event !== 'call_analyzed' && event !== 'call_ended')) return ok({ skipped: true });
const detalle = await obtenerLlamada(callId);
if (!detalle) return ok({ matched: false, motivo: 'call no encontrada' });
// Mapeo al lead: por metadata.lead_id; si falta (llamadas sin metadata), fallback al lead más
// reciente con ese teléfono (últimos 9 dígitos, tolerante a +34/espacios).
let leadId = detalle.leadId;
if (!leadId && detalle.toNumber) {
const dig = detalle.toNumber.replace(/\D/g, '').slice(-9);
if (dig.length === 9) {
const [m] = await db
.select({ id: leads.id })
.from(leads)
.where(like(leads.telefono, `%${dig}%`))
.orderBy(desc(leads.createdAt))
.limit(1);
leadId = m?.id ?? null;
}
}
if (!leadId) return ok({ matched: false, motivo: 'sin lead' });
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return ok({ matched: false, motivo: 'lead no existe' });
const audioUrl = detalle.recordingUrl ? await descargarGrabacion(detalle.recordingUrl) : null;
await db
.update(leads)
.set({
transcripcion: detalle.transcript ?? undefined,
audioUrl: audioUrl ?? undefined,
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'llamada_completada',
metadata: {
via: 'webhook',
real: true,
retellCallId: detalle.callId,
callStatus: detalle.callStatus,
duracionSeg: detalle.duracionSeg,
grabacionGuardada: Boolean(audioUrl),
analysis: detalle.analysis ?? null,
},
});
// Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada.
const analisis = detalle.transcript
? await analizarTranscripcion(leadId, detalle.transcript, 'llamada')
: { ok: false as const, error: 'sin transcripción' };
// Cross-canal: tras la llamada, Luisa escribe al lead por WhatsApp, referencia lo hablado y le
// pide las fotos (el agente de voz le dijo que las enviara por ahí).
let fotosPedidas = false;
if (analisis.ok) {
const [info] = await db
.select({ nombre: leads.nombre, telefono: leads.telefono, tenantId: leads.tenantId })
.from(leads)
.where(eq(leads.id, leadId))
.limit(1);
if (info) {
const [t] = await db
.select({ nombre: tenants.nombreEmpresa })
.from(tenants)
.where(eq(tenants.id, info.tenantId))
.limit(1);
const tipo = (analisis.perfil?.tipoReforma as string | undefined) ?? null;
const contexto = tipo ? `la reforma de tu ${tipo}` : 'tu reforma';
fotosPedidas = await pedirFotosWhatsapp({
leadId,
telefono: info.telefono,
nombre: info.nombre,
empresa: t?.nombre ?? 'Reformix',
contexto,
});
}
}
return ok({
matched: true,
leadId,
transcript: Boolean(detalle.transcript),
grabacion: Boolean(audioUrl),
analizado: analisis.ok,
fotosPedidas,
});
}

View File

@@ -1,5 +1,24 @@
@import "tailwindcss";
/* Instrument Serif (display) — usada por los presets de tema con titulares serif.
Los .woff2 viven en /public/b2b-assets/fonts. */
@font-face {
font-family: 'Instrument Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/b2b-assets/fonts/421ba28b-7abe-4b86-a87c-fcd3e94378f7.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Instrument Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/b2b-assets/fonts/15d36112-e39a-4059-ae12-06c58a5747ac.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@theme {
/* Colors */
--color-black: #0a0a0a;
@@ -25,11 +44,34 @@
/* Fonts */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
/* Paleta de marca B2B "Architectural Warmth" — panel y área autenticada */
--color-primary-50: #f4f8f5;
--color-primary-100: #e8f0eb;
--color-primary-500: #4d8a6d;
--color-primary-700: #2f5c46;
--color-primary-900: #1f3a2e;
--color-stone-50: #f7f8f7;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms ease;
/* Animations (usar con motion-safe: para respetar prefers-reduced-motion) */
--animate-fade-up: fade-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
}
@layer base {
@@ -111,4 +153,23 @@
.badge-accent {
@apply bg-accent-light text-accent;
}
/* Botón con el color de marca del reformista (tema de la landing). */
.btn-brand {
background-color: var(--brand, #0a0a0a);
color: var(--brand-contrast, #ffffff);
border: 2px solid var(--brand, #0a0a0a);
}
.btn-brand:hover {
background-color: var(--brand-dark, #1a1a1a);
border-color: var(--brand-dark, #1a1a1a);
transform: translateY(-1px);
}
}
/* Presets de tema con titulares en serif (terracota, arena). */
.theme-serif :is(h1, h2, h3) {
font-family: 'Instrument Serif', Georgia, 'Times New Roman', serif;
font-weight: 400;
letter-spacing: -0.01em;
}

View File

@@ -1,27 +1,59 @@
'use client';
import Link from 'next/link';
import { useActionState } from 'react';
import { login } from './actions';
import AuthShell from '@/components/auth/AuthShell';
export default function LoginPage() {
const [error, formAction, pending] = useActionState(login, null);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
<AuthShell
photo="/despues.webp"
photoAlt="Cocina reformada"
caption="Tus leads, ya cualificados."
captionSub="Render IA, presupuesto orientativo y datos del cliente. Todo en un panel."
>
<form action={formAction} className="flex flex-col gap-5">
<div>
<h1 className="font-display text-3xl tracking-tight text-black">Entra en tu panel</h1>
<p className="mt-1 text-sm text-gray-500">Gestiona tus leads y tu funnel.</p>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Email</span>
<input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
<input
name="email"
type="email"
required
autoComplete="email"
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Contraseña</span>
<input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
<input
name="password"
type="password"
required
autoComplete="current-password"
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700"
/>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
<button type="submit" disabled={pending} className="btn bg-primary-700 text-white hover:bg-primary-900 w-full disabled:opacity-60">
{pending ? 'Entrando…' : 'Entrar'}
</button>
<p className="text-center text-sm text-gray-500">
¿No tienes cuenta?{' '}
<Link href="/signup" className="font-semibold text-primary-700 hover:underline">
Empieza gratis
</Link>
</p>
</form>
</main>
</AuthShell>
);
}

View File

@@ -0,0 +1,50 @@
import { notFound } from 'next/navigation';
import { getLeadForReview } from '@/lib/funnel/public-queries';
import { enviarOpinion } from '../actions';
import OpinionForm from '@/components/funnel/OpinionForm';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
export default async function OpinionPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getLeadForReview(id);
if (!data || !data.tenant) notFound();
const { lead, tenant, yaEnviado } = data;
return (
<>
<TenantBrand
nombreEmpresa={tenant.nombreEmpresa}
logoUrl={tenant.logoUrl}
subtitle="Tu opinión"
/>
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-black tracking-tight text-black">
{lead.nombre.split(' ')[0]}, ¿qué tal fue tu reforma con {tenant.nombreEmpresa}?
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Tu opinión nos ayuda muchísimo. Cuéntanos cómo fue y, si quieres, sube alguna foto del
resultado.
</p>
</div>
{yaEnviado ? (
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center flex flex-col gap-3">
<span className="text-4xl" aria-hidden="true">
</span>
<h2 className="text-xl font-black text-black">Ya hemos recibido tu opinión</h2>
<p className="text-sm text-gray-500">¡Gracias por tomarte el tiempo!</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<OpinionForm action={enviarOpinion.bind(null, id)} nombreCliente={lead.nombre} />
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,74 @@
'use server';
import { db } from '@/db';
import { testimonios, testimonioFotos } from '@/db/schema';
import { getLeadForReview } from '@/lib/funnel/public-queries';
const MAX_FOTOS = 4;
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
export type EnviarOpinionResult = { ok: boolean; error?: string };
async function fileToDataUri(file: File): Promise<string | null> {
if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null;
if (!file.type.startsWith('image/')) return null;
const buffer = Buffer.from(await file.arrayBuffer());
return `data:${file.type};base64,${buffer.toString('base64')}`;
}
// El cliente final deja su opinión desde el funnel de review (/opinion/[id]).
// Entra como 'pendiente'; el reformista la aprueba antes de que salga en su landing.
export async function enviarOpinion(
leadId: string,
_prev: EnviarOpinionResult | null,
formData: FormData
): Promise<EnviarOpinionResult> {
const data = await getLeadForReview(leadId);
if (!data || !data.tenant) {
return { ok: false, error: 'No hemos podido identificar tu solicitud.' };
}
if (data.yaEnviado) {
return { ok: false, error: 'Ya hemos recibido tu opinión. ¡Gracias!' };
}
const rating = Number(formData.get('rating'));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { ok: false, error: 'Selecciona una valoración de 1 a 5 estrellas.' };
}
const texto = String(formData.get('texto') ?? '').trim();
if (texto.length < 10) {
return { ok: false, error: 'Cuéntanos un poco más sobre tu experiencia (mínimo 10 caracteres).' };
}
const nombre = String(formData.get('nombre') ?? '').trim() || data.lead.nombre;
const contexto = String(formData.get('contexto') ?? '').trim() || null;
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
const dataUris: string[] = [];
for (const file of archivos.slice(0, MAX_FOTOS)) {
const uri = await fileToDataUri(file);
if (uri) dataUris.push(uri);
}
const [testimonio] = await db
.insert(testimonios)
.values({
tenantId: data.tenant.id,
leadId,
nombre,
contexto,
rating,
texto,
estado: 'pendiente',
})
.returning({ id: testimonios.id });
if (dataUris.length > 0) {
await db.insert(testimonioFotos).values(
dataUris.map((url, orden) => ({ testimonioId: testimonio.id, url, orden }))
);
}
return { ok: true };
}

View File

@@ -0,0 +1,12 @@
export default function OpinionLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<main className="flex-1">{children}</main>
<footer className="border-t border-gray-200 bg-white">
<div className="container py-6 text-xs text-gray-400 text-center">
Tu opinión ayuda a otros clientes a decidir con confianza.
</div>
</footer>
</div>
);
}

View File

@@ -1,22 +0,0 @@
import Navbar from '@/components/Navbar/Navbar';
import Hero from '@/components/Hero/Hero';
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
import Features from '@/components/Features/Features';
import Testimonials from '@/components/Testimonials/Testimonials';
import ContactForm from '@/components/ContactForm/ContactForm';
import Footer from '@/components/Footer/Footer';
export default function Home() {
return (
<>
{/* <Navbar /> */}
<main id="main-content">
<Hero />
<ReformaSlider />
<Features />
<Testimonials />
</main>
<Footer />
</>
);
}

View File

@@ -1,8 +1,12 @@
import Link from 'next/link';
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import { getPricingConfigFor } from '@/db/pricing-queries';
import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor';
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
import LeadFotosGaleria from '@/components/panel/LeadFotosGaleria';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
@@ -11,14 +15,25 @@ import {
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
import { recalcularPresupuesto, enviarPresupuesto, solicitarOpinion } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
title,
children,
tour,
}: {
title: string;
children: React.ReactNode;
tour?: string;
}) {
return (
<section className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
<section
data-tour={tour}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3"
>
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
{children}
</section>
@@ -30,7 +45,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const data = await getLead(id);
if (!data) notFound();
const { lead, fotos, eventos, precision } = data;
const { lead, fotos, notas, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
@@ -38,9 +53,22 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
// Baremo de rentabilidad del reformista (informativo): si el presupuesto estimado no lo alcanza,
// se marca en rojo. null = sin baremo o sin presupuesto aún (no se marca nada).
const baremoMinimo = (await getPricingConfigFor(lead.tenantId)).baremoMinimo ?? null;
const pasaBaremo =
baremoMinimo != null && lead.presupuestoEstimado != null
? lead.presupuestoEstimado >= baremoMinimo
: null;
const h = await headers();
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
const proto = h.get('x-forwarded-proto') ?? 'https';
const opinionUrl = `${proto}://${host}/opinion/${lead.id}`;
return (
<div className="flex flex-col gap-6">
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
<Link href="/panel" className="text-sm text-gray-500 hover:text-primary-700 w-fit">
Volver a leads
</Link>
@@ -48,23 +76,68 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">{lead.nombre}</h1>
<h1 className="font-display text-3xl tracking-tight text-black">{lead.nombre}</h1>
<p className="text-sm text-gray-500">
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
</p>
</div>
<div className="text-right">
<div className="text-right" data-tour="ficha-presupuesto">
<div className="text-xs text-gray-400">Presupuesto estimado</div>
<div className="text-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
{formatEuros(lead.presupuestoEstimado)}
</div>
{pasaBaremo === false && baremoMinimo != null && (
<div className="mt-0.5 text-xs font-semibold text-red-600">
Por debajo de tu baremo ({formatEuros(baremoMinimo)})
</div>
)}
</div>
</div>
<div data-tour="ficha-estado">
<EstadoControl
leadId={lead.id}
estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado}
/>
</div>
</div>
{/* Solicitar opinión al cliente */}
<Section title="Opinión del cliente">
{lead.testimonioSolicitadoAt ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-gray-500">
Solicitada el {formatFecha(lead.testimonioSolicitadoAt)}. Comparte este enlace con el
cliente para que deje su opinión. Cuando la envíe, la verás en{' '}
<Link href="/panel/opiniones" className="text-primary-700 underline underline-offset-2">
Opiniones
</Link>{' '}
para aprobarla.
</p>
<OpinionLinkBox url={opinionUrl} />
</div>
) : lead.estado === 'ganado' ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-gray-500">
Si la reforma ha ido bien, pídele su opinión. Generamos un enlace para que valore y
suba fotos del resultado.
</p>
<form action={solicitarOpinion.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
>
Solicitar opinión al cliente
</button>
</form>
</div>
) : (
<p className="text-sm text-gray-400">
Disponible cuando marques este lead como <span className="font-medium">ganado</span>.
</p>
)}
</Section>
{/* Timeline del funnel */}
<Section title="Progreso en el funnel">
@@ -120,7 +193,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</Section>
{/* 4. Render */}
<Section title="Render generado">
<Section title="Render generado" tour="ficha-render">
{lead.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
@@ -211,7 +284,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
<div className="flex flex-wrap items-center gap-3">
<a
href={`/panel/${lead.id}/presupuesto?download=1`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
>
Descargar PDF
</a>
@@ -219,7 +292,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
href={`/panel/${lead.id}/presupuesto`}
target="_blank"
rel="noopener"
className="text-sm font-medium text-gray-500 hover:text-black"
className="text-sm font-medium text-gray-500 hover:text-primary-700"
>
Ver en el navegador
</a>
@@ -232,15 +305,10 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</Section>
</div>
{/* Fotos subidas */}
{fotos.length > 0 && (
<Section title="Fotos subidas por el cliente">
<div className="flex flex-wrap gap-3">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
))}
</div>
{/* Fotos y notas por zona */}
{(fotos.length > 0 || notas.length > 0) && (
<Section title="Fotos y detalles por zona">
<LeadFotosGaleria fotos={fotos} notas={notas} tipoLead={lead.tipoReforma} />
</Section>
)}
@@ -272,12 +340,12 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
)}
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<Section title="Presupuesto desglosado" tour="ficha-desglose">
<div className="flex flex-wrap items-center gap-3">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
>
Recalcular desde el catálogo
</button>

View File

@@ -1,10 +1,6 @@
import { notFound } from 'next/navigation';
import { renderToBuffer } from '@react-pdf/renderer';
import { getLead } from '@/db/queries';
import { getTenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
import type { BudgetResult } from '@/budget/types';
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -14,34 +10,18 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// getLead aplica el scoping por tenant del panel: sirve de guardia de auth/404.
const data = await getLead(id);
if (!data) notFound();
const pdf = await construirPresupuestoPdf(id);
if (!pdf) notFound();
const descargar = new URL(req.url).searchParams.get('download') === '1';
const { lead } = data;
const empresa = await getTenantPerfil();
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa,
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
reforma: {
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
fecha: lead.createdAt,
},
desglose,
})
);
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return new Response(new Uint8Array(buffer), {
return new Response(new Uint8Array(pdf.buffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="presupuesto-${slug || lead.id}.pdf"`,
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="${pdf.filename}"`,
'Cache-Control': 'no-store',
},
});

View File

@@ -129,6 +129,18 @@ export async function enviarPresupuesto(leadId: string) {
revalidatePath(`/panel/${leadId}`);
}
export async function solicitarOpinion(leadId: string) {
const tenantId = await getTenantId();
const [updated] = await db
.update(leads)
.set({ testimonioSolicitadoAt: new Date(), updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.returning({ id: leads.id });
if (!updated) throw new Error('Lead no encontrado.');
revalidatePath(`/panel/${leadId}`);
}
export async function recalcularPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db

View File

@@ -1,13 +1,17 @@
'use server';
import { eq } from 'drizzle-orm';
import { and, eq, ne } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { validarSlug } from '@/lib/validation/signup';
import { THEME_PRESETS, isHexColor, type ThemePresetId } from '@/lib/funnel/themes';
const LOGO_MAX_BYTES = 500_000;
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
const ABOUT_FOTO_MAX_BYTES = 1_500_000;
const ABOUT_FOTO_TIPOS = ['image/png', 'image/jpeg', 'image/webp'];
function limpiar(raw: FormDataEntryValue | null): string | null {
const s = String(raw ?? '').trim();
@@ -20,16 +24,47 @@ export async function actualizarEmpresa(formData: FormData) {
if (!nombreEmpresa) {
throw new Error('El nombre de la empresa es obligatorio.');
}
const slugRaw = limpiar(formData.get('slug'));
if (!slugRaw) {
throw new Error('El enlace de tu funnel es obligatorio.');
}
const validacion = validarSlug(slugRaw);
if (!validacion.ok) {
throw new Error(validacion.error);
}
const slug = validacion.slug;
// El slug es único en todo el sistema; comprobamos que no lo use otro reformista.
const [colision] = await db
.select({ id: tenants.id })
.from(tenants)
.where(and(eq(tenants.slug, slug), ne(tenants.id, tenantId)))
.limit(1);
if (colision) {
throw new Error('Ese enlace ya está en uso. Elige otro.');
}
const aniosRaw = Number(formData.get('aniosExperiencia'));
const aniosExperiencia =
Number.isInteger(aniosRaw) && aniosRaw > 0 && aniosRaw <= 100 ? aniosRaw : null;
await db
.update(tenants)
.set({
nombreEmpresa,
slug,
cif: limpiar(formData.get('cif')),
direccion: limpiar(formData.get('direccion')),
provincia: limpiar(formData.get('provincia')),
telefono: limpiar(formData.get('telefono')),
email: limpiar(formData.get('email')),
web: limpiar(formData.get('web')),
seoTitle: limpiar(formData.get('seoTitle')),
seoDescription: limpiar(formData.get('seoDescription')),
aboutEnabled: formData.get('aboutEnabled') === 'on',
aboutTexto: limpiar(formData.get('aboutTexto')),
aniosExperiencia,
})
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
@@ -64,3 +99,52 @@ export async function quitarLogo() {
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}
export async function subirAboutFoto(
_prev: LogoResult | null,
formData: FormData
): Promise<LogoResult> {
const tenantId = await getTenantId();
const file = formData.get('aboutFoto');
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'Selecciona un archivo de imagen.' };
}
if (!ABOUT_FOTO_TIPOS.includes(file.type)) {
return { ok: false, error: 'Formato no válido. Usa PNG, JPG o WEBP.' };
}
if (file.size > ABOUT_FOTO_MAX_BYTES) {
return { ok: false, error: 'La foto no puede superar los 1,5 MB.' };
}
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
await db.update(tenants).set({ aboutFotoUrl: dataUri }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
return { ok: true };
}
export async function quitarAboutFoto() {
const tenantId = await getTenantId();
await db.update(tenants).set({ aboutFotoUrl: null }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
}
// Guarda el tema de la landing del reformista: preset + color personalizado opcional.
export async function guardarTema(
_prev: LogoResult | null,
formData: FormData
): Promise<LogoResult> {
const tenantId = await getTenantId();
const presetRaw = String(formData.get('themePreset') ?? '');
const themePreset: ThemePresetId = presetRaw in THEME_PRESETS ? (presetRaw as ThemePresetId) : 'pizarra';
const usarColor = formData.get('usarColor') === 'on';
const colorRaw = String(formData.get('themeColor') ?? '').trim();
const themeColor = usarColor && isHexColor(colorRaw) ? colorRaw : null;
await db
.update(tenants)
.set({ themePreset, themeColor })
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
return { ok: true };
}

View File

@@ -1,16 +1,24 @@
import { headers } from 'next/headers';
import { getTenantPerfil } from '@/db/tenant-queries';
import { actualizarEmpresa } from './actions';
import LogoUploader from '@/components/panel/LogoUploader';
import AboutFotoUploader from '@/components/panel/AboutFotoUploader';
import ThemePicker from '@/components/panel/ThemePicker';
export const dynamic = 'force-dynamic';
export default async function EmpresaPage() {
const perfil = await getTenantPerfil();
const h = await headers();
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
const proto = h.get('x-forwarded-proto') ?? 'https';
const funnelUrl = `${proto}://${host}/${perfil.slug}`;
return (
<div className="space-y-10 max-w-2xl">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
<h1 className="font-display text-3xl tracking-tight text-black">Datos de empresa</h1>
<p className="text-sm text-gray-500 mt-1">
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
cliente. Manténlos al día.
@@ -34,6 +42,32 @@ export default async function EmpresaPage() {
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Enlace de tu funnel *</span>
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
<span className="px-3 py-2 text-gray-400 bg-gray-50 border-r border-gray-200 select-none whitespace-nowrap">
{host}/
</span>
<input
name="slug"
required
defaultValue={perfil.slug}
pattern="[a-z0-9-]+"
className="flex-1 px-3 py-2 outline-none min-w-0"
/>
</div>
<span className="block text-gray-400 mt-1.5 text-xs">
Esta es la dirección que compartes con tus clientes:{' '}
<a
href={funnelUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-700 underline underline-offset-2 break-all"
>
{funnelUrl}
</a>
</span>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">CIF / NIF</span>
<input
@@ -83,11 +117,96 @@ export default async function EmpresaPage() {
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
<div className="md:col-span-2 border-t border-gray-100 pt-5 mt-1">
<h3 className="font-bold text-black">SEO de tu funnel</h3>
<p className="text-xs text-gray-400 mt-1">
Personaliza cómo aparece tu página en Google y al compartirla. Si lo dejas vacío,
usamos un texto por defecto con el nombre de tu empresa.
</p>
</div>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Título (title)</span>
<input
name="seoTitle"
defaultValue={perfil.seoTitle ?? ''}
maxLength={70}
placeholder={`${perfil.nombreEmpresa} · Presupuesto de reforma`}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Descripción (meta description)</span>
<textarea
name="seoDescription"
defaultValue={perfil.seoDescription ?? ''}
maxLength={160}
rows={2}
placeholder="Pide tu presupuesto de reforma con render IA en minutos."
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<div className="md:col-span-2 border-t border-gray-100 pt-5 mt-1">
<h3 className="font-bold text-black">Bloque Quiénes somos</h3>
<p className="text-xs text-gray-400 mt-1">
Si lo activas, en tu funnel aparece un bloque con tu foto, tu historia y tus años de
experiencia.
</p>
</div>
<label className="text-sm md:col-span-2 flex items-center gap-2">
<input
type="checkbox"
name="aboutEnabled"
defaultChecked={perfil.aboutEnabled}
className="w-4 h-4 accent-[#2f5c46]"
/>
<span className="text-gray-700">Mostrar el bloque Quiénes somos en mi funnel</span>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Años de experiencia</span>
<input
name="aniosExperiencia"
type="number"
min="1"
max="100"
defaultValue={perfil.aniosExperiencia ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Texto de presentación</span>
<textarea
name="aboutTexto"
defaultValue={perfil.aboutTexto ?? ''}
rows={5}
placeholder="Cuéntale al cliente quién eres, tu trayectoria y por qué confiar en ti."
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<button className="md:col-span-2 justify-self-start inline-flex items-center justify-center rounded-lg bg-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-900">
Guardar datos
</button>
</form>
</section>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Foto de Quiénes somos</h2>
<p className="text-sm text-gray-500 mb-4">
Tu foto o la de tu equipo. Aparece en el bloque Quiénes somos de tu funnel.
</p>
<AboutFotoUploader fotoUrl={perfil.aboutFotoUrl} />
</section>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Tema de tu funnel</h2>
<p className="text-sm text-gray-500 mb-4">
Elige los colores y la tipografía con los que tus clientes ven tu landing. Puedes partir
de un preset y, si quieres, fijar tu propio color de marca.
</p>
<ThemePicker themePreset={perfil.themePreset} themeColor={perfil.themeColor} />
</section>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { galeriaFotos } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { GALERIA_MAX_FOTOS } from '@/lib/galeria';
const GALERIA_MAX_BYTES = 2_000_000;
const GALERIA_TIPOS = ['image/png', 'image/jpeg', 'image/webp'];
export type GaleriaResult = { ok: boolean; error?: string };
export async function subirFotoGaleria(
_prev: GaleriaResult | null,
formData: FormData
): Promise<GaleriaResult> {
const tenantId = await getTenantId();
const file = formData.get('foto');
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'Selecciona una imagen.' };
}
if (!GALERIA_TIPOS.includes(file.type)) {
return { ok: false, error: 'Formato no válido. Usa PNG, JPG o WEBP.' };
}
if (file.size > GALERIA_MAX_BYTES) {
return { ok: false, error: 'La imagen no puede superar los 2 MB.' };
}
const existentes = await db
.select({ id: galeriaFotos.id })
.from(galeriaFotos)
.where(eq(galeriaFotos.tenantId, tenantId));
if (existentes.length >= GALERIA_MAX_FOTOS) {
return { ok: false, error: `Has alcanzado el máximo de ${GALERIA_MAX_FOTOS} fotos.` };
}
const titulo = String(formData.get('titulo') ?? '').trim() || null;
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
await db
.insert(galeriaFotos)
.values({ tenantId, url: dataUri, titulo, orden: existentes.length });
revalidatePath('/panel/galeria');
return { ok: true };
}
export async function eliminarFotoGaleria(id: string) {
const tenantId = await getTenantId();
await db
.delete(galeriaFotos)
.where(and(eq(galeriaFotos.id, id), eq(galeriaFotos.tenantId, tenantId)));
revalidatePath('/panel/galeria');
}

View File

@@ -0,0 +1,73 @@
import { getGaleriaPanel } from '@/db/tenant-queries';
import { eliminarFotoGaleria } from './actions';
import { GALERIA_MAX_FOTOS } from '@/lib/galeria';
import GaleriaUploader from '@/components/panel/GaleriaUploader';
export const dynamic = 'force-dynamic';
export default async function GaleriaPage() {
const fotos = await getGaleriaPanel();
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="font-display text-3xl tracking-tight text-black">Galería de trabajos</h1>
<p className="text-sm text-gray-500 mt-1">
Sube fotos de reformas que ya has hecho. Aparecen en tu funnel para dar confianza al
cliente antes de pedir presupuesto.
</p>
</div>
<GaleriaUploader total={fotos.length} max={GALERIA_MAX_FOTOS} />
{fotos.length === 0 ? (
<p className="text-sm text-gray-400">
Aún no has subido ninguna foto. La galería no se mostrará en tu funnel hasta que añadas la
primera.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{fotos.map((foto) => (
<figure
key={foto.id}
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-white"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={foto.url}
alt={foto.titulo ?? 'Reforma'}
className="aspect-[4/3] w-full object-cover"
/>
{foto.titulo && (
<figcaption className="px-3 py-2 text-xs font-medium text-gray-700 truncate">
{foto.titulo}
</figcaption>
)}
<form action={eliminarFotoGaleria.bind(null, foto.id)} className="absolute top-2 right-2">
<button
type="submit"
aria-label="Eliminar foto"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-gray-600 shadow-sm transition hover:bg-red-500 hover:text-white"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6" />
</svg>
</button>
</form>
</figure>
))}
</div>
)}
</div>
);
}

View File

@@ -5,10 +5,13 @@ import { db } from '@/db';
import { tenants } from '@/db/schema';
import { eq } from 'drizzle-orm';
import AppNav from '@/components/AppNav';
import PanelTour from '@/components/panel/PanelTour';
const PANEL_LINKS = [
{ href: '/panel', label: 'Leads', icon: 'leads' },
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
{ href: '/panel/galeria', label: 'Galería', icon: 'galeria' },
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
] as const;
@@ -25,14 +28,14 @@ export default async function PanelLayout({ children }: { children: React.ReactN
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-stone-50">
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-primary-700 text-white font-black italic text-lg leading-none">
R
</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="font-bold tracking-tight text-black">Reformix</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
{nombreEmpresa}
@@ -42,6 +45,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
<PanelTour />
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { testimonios } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
type TestimonioEstado = (typeof testimonios.estado.enumValues)[number];
async function setEstado(testimonioId: string, estado: TestimonioEstado) {
const tenantId = await getTenantId();
const [updated] = await db
.update(testimonios)
.set({ estado })
.where(and(eq(testimonios.id, testimonioId), eq(testimonios.tenantId, tenantId)))
.returning({ id: testimonios.id });
if (!updated) throw new Error('Opinión no encontrada.');
revalidatePath('/panel/opiniones');
}
export async function publicarTestimonio(testimonioId: string) {
await setEstado(testimonioId, 'publicado');
}
export async function ocultarTestimonio(testimonioId: string) {
await setEstado(testimonioId, 'oculto');
}
export async function eliminarTestimonio(testimonioId: string) {
const tenantId = await getTenantId();
await db
.delete(testimonios)
.where(and(eq(testimonios.id, testimonioId), eq(testimonios.tenantId, tenantId)));
revalidatePath('/panel/opiniones');
}

View File

@@ -0,0 +1,125 @@
import { getTestimoniosPanel, type TestimonioPanel } from '@/db/tenant-queries';
import { formatFecha } from '@/lib/funnel';
import { publicarTestimonio, ocultarTestimonio, eliminarTestimonio } from './actions';
export const dynamic = 'force-dynamic';
const ESTADO_BADGE: Record<TestimonioPanel['estado'], { label: string; className: string }> = {
pendiente: { label: 'Pendiente', className: 'bg-amber-100 text-amber-700' },
publicado: { label: 'Publicado', className: 'bg-green-100 text-green-700' },
oculto: { label: 'Oculto', className: 'bg-gray-100 text-gray-500' },
};
function Estrellas({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5" aria-label={`${rating} de 5 estrellas`}>
{[1, 2, 3, 4, 5].map((n) => (
<svg
key={n}
viewBox="0 0 24 24"
className={`w-4 h-4 ${n <= rating ? 'fill-yellow-400' : 'fill-gray-200'}`}
aria-hidden="true"
>
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
))}
</div>
);
}
export default async function OpinionesPage() {
const testimonios = await getTestimoniosPanel();
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="font-display text-3xl tracking-tight text-black">Opiniones</h1>
<p className="text-sm text-gray-500 mt-1">
Las opiniones que te dejan tus clientes. Aprueba las que quieras mostrar en tu funnel; solo
las publicadas aparecen en tu página.
</p>
</div>
{testimonios.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-sm text-gray-400">
Aún no tienes opiniones. Solicítalas a tus clientes desde la ficha de un lead ganado.
</div>
) : (
<ul className="flex flex-col gap-4">
{testimonios.map((t) => {
const badge = ESTADO_BADGE[t.estado];
return (
<li
key={t.id}
className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col gap-3"
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-bold text-black">{t.nombre}</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${badge.className}`}
>
{badge.label}
</span>
</div>
{t.contexto && <span className="text-xs text-gray-500">{t.contexto}</span>}
</div>
<Estrellas rating={t.rating} />
</div>
<p className="text-sm text-gray-700 leading-relaxed italic">{t.texto}</p>
{t.fotos.length > 0 && (
<div className="flex flex-wrap gap-2">
{t.fotos.map((url, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
src={url}
alt=""
className="w-20 h-20 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
<span className="text-xs text-gray-400 mr-auto">{formatFecha(t.createdAt)}</span>
{t.estado !== 'publicado' && (
<form action={publicarTestimonio.bind(null, t.id)}>
<button
type="submit"
className="px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700"
>
Publicar
</button>
</form>
)}
{t.estado === 'publicado' && (
<form action={ocultarTestimonio.bind(null, t.id)}>
<button
type="submit"
className="px-3 py-1.5 rounded-lg border border-gray-300 text-gray-700 text-xs font-semibold hover:border-gray-500"
>
Ocultar
</button>
</form>
)}
<form action={eliminarTestimonio.bind(null, t.id)}>
<button
type="submit"
className="px-3 py-1.5 rounded-lg text-red-500 text-xs font-semibold hover:underline"
>
Eliminar
</button>
</form>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More