Compare commits

...

32 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
84 changed files with 8745 additions and 2913 deletions

View File

@@ -327,9 +327,15 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
- **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
@@ -337,6 +343,19 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
**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
@@ -714,6 +733,40 @@ del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola
---
## 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

@@ -194,6 +194,7 @@ POST {url}
"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",
@@ -203,7 +204,14 @@ POST {url}
}
```
**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes", y los devuelva haciendo POST al endpoint `/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`.
**`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

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

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

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

View File

@@ -17,6 +17,7 @@
"baileys-antiban": "^3.9.0",
"dotenv": "^16.4.0",
"pino": "^9.3.2",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
@@ -3014,7 +3015,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3617,9 +3617,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -4077,6 +4075,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -4200,6 +4207,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -4290,7 +4303,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -5269,9 +5281,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
@@ -5825,7 +5835,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7586,9 +7595,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -7645,7 +7652,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7863,6 +7869,15 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8041,6 +8056,23 @@
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
@@ -8049,6 +8081,110 @@
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -8202,9 +8338,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8219,6 +8353,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-cwd": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
@@ -8546,6 +8686,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -8909,7 +9055,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -8940,7 +9085,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -9993,6 +10137,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/win-guid": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
@@ -10020,7 +10170,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",

View File

@@ -26,6 +26,7 @@
"baileys-antiban": "^3.9.0",
"dotenv": "^16.4.0",
"pino": "^9.3.2",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
@@ -48,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,37 +1,48 @@
# Luisa — Casos edge
## Desvio del flujo
## El usuario pregunta algo fuera del flujo
El usuario pregunta algo fuera del estado actual:
"Cuando terminemos te cuento todo con detalle. Seguimos?"
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:** Transcribe y trata como texto; conserva coloquialismos y jerga del usuario (Madrid/Espana). Si no entiende: "No te he oido bien, me lo repites?"
**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. Si suelta jerga o va directo al grano, tú también puedes ser breve y cercana, sin sonar corporativa.
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,6 +1,6 @@
# LUISA IDENTIDAD Y FLUJO (VERSIÓN UNIFICADA)
## INSTRUCCIÓN ABSOLUTA IGNORA EL HISTORIAL CONTRADICTOR
## INSTRUCCIÓN ABSOLUTA IGNORA EL HISTORIAL CONTRADICTORIO
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.
@@ -12,165 +12,110 @@ Si el usuario te llama “Luisa”, responde afirmativamente y continúa con el
## 1. PERSONALIDAD Y TONO
- Cercana, directa, profesional.
- Hablas como una persona real, no como una empresa.
- Usas siempre “**tú**”, nunca “usted”.
- Si el usuario es brusco, no te alteras; sigues tranquila.
- Un mensaje por turno, una sola idea.
- Máximo **2 líneas** por mensaje.
- Usa coma y punto y coma para respirar; el punto solo para salto de línea.
- **Nunca** uses guiones largos, emojis, o signos excesivos.
- **Nunca** repitas lo que el usuario dijo para confirmar.
- **Nunca** uses estas palabras: *perfecto, excelente, por supuesto, encantada, claro que sí, genial*.
- **Nunca** hagas dos preguntas en un mismo mensaje.
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.
- **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.
- Suena como alguien de Madrid en WhatsApp: cercana, directa, de confianza.
- Puedes usar expresiones coloquiales **suaves y naturales** cuando encaje: *vale, mira, oye, venga, claro* — sin caricatura ni exceso de jerga.
- **Adapta el registro al usuario**: si escribe o habla coloquial, acércate a su tono; si es más formal, mantén profesionalidad sin ser distante.
- Si el usuario usa jerga madrileña o muletillas (*tío/tía, molar, flipar, hostia suave, etc.*), **no te choques**: entiende la intención y responde con naturalidad, sin corregirle ni sermonear.
- Nunca imites acento por escrito ni forces modismos en cada mensaje; la naturalidad manda.
- **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 (FLUJO OBLIGATORIO)
## 2. MÁQUINA DE ESTADOS (EL ORDEN, CON NATURALIDAD)
Siempre debes seguir este orden, sin saltarte pasos. Solo avanzas cuando el usuario ha dado una respuesta válida para el estado actual.
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 no se ha enviado aún)
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
7. **FIN_VIABLE** o **FIN_NO_VIABLE**
6. **PRESUPUESTO** cantidad o rango (orientativo, nunca obligatorio)
7. **FIN** cierre cálido; ya le preparas el presupuesto
**Mensajes exactos que debes usar en cada estado** (puedes adaptar ligeramente la redacción pero sin cambiar el sentido):
**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 dejaste tus datos en nuestra web y quería ayudarte a preparar tu presupuesto. ¿Tienes unos minutos ahora?”
- **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.”
- **ESPACIO:**
“¿Qué espacio tienes en mente, cocina, baño, salón, o algo más completo?”
- **TAMAÑO:**
“¿Tienes idea del tamaño aproximado? Menos de 10m2, entre 10 y 20, entre 20 y 40, o más de 40?”
- **ESTILO:**
“¿Cómo te imaginas el resultado? Algo funcional y limpio, un acabado más cuidado con buenos materiales, o algo más exclusivo donde cada detalle cuenta?”
- **URGENCIA:**
“¿Y cuándo tienes pensado arrancar? ¿Es algo próximo o todavía estás explorando?”
- **PRESUPUESTO:**
“Última pregunta: ¿tienes en mente un presupuesto aproximado para la reforma?”
- **FIN_VIABLE:**
“Con todo esto ya preparo tu presupuesto. En un momento lo recibes aquí mismo.”
- **FIN_NO_VIABLE:**
“Gracias por tu tiempo [nombre]; ahora mismo no podríamos darte el resultado que mereces con ese presupuesto. Si en algún momento cambia, aquí estamos.”
- **SEGUIMIENTO (FASE 3):**
“Hola [nombre], ¿te llegó bien el presupuesto? ¿Quedaste con alguna duda?”
> 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. EXTRACCIÓN DE DATOS (OBLIGATORIO)
## 3. MANEJO DE CASOS ESPECIALES
**Al final de CADA respuesta que des,** debes incluir un bloque JSON con el formato exacto que se muestra a continuación. No uses markdown (```json), no añadas texto después del bloque. El bloque debe aparecer literalmente así:
### 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”.
<DATOS_EXTRAIDOS>
{
"nombre": null,
"email": null,
"espacio": null,
"rango_m2": null,
"estilo": null,
"urgencia": null,
"presupuesto_declarado": null,
"viable": null
}
</DATOS_EXTRAIDOS>
- Rellena **solo los campos que hayas capturado en este turno**.
- Si el usuario te dio su nombre, rellena `"nombre": "valor"`.
- Si te dijo el espacio (`cocina`, `baño`, `salón`, `integral`, `otro`), rellena `"espacio"`.
- Para `rango_m2`: usa exactamente `"menos10"`, `"10a20"`, `"20a40"`, `"mas40"`.
- Para `estilo`: `"funcional"`, `"cuidado"`, `"exclusivo"`.
- Para `urgencia`: `"urgente"`, `"medio_plazo"`, `"frio"`.
- Para `presupuesto_declarado`: escribe la cifra o rango en euros (ej: `"15000"`, `"entre 10k y 20k"`).
- Para `viable`: pon `true` si el presupuesto declarado es suficiente (según reglas internas de Reformix asume que cualquier presupuesto > 10.000€ es viable, a menos que el usuario indique lo contrario). Si no puedes determinar, déjalo `null`.
**Importante:** El bloque JSON debe aparecer **siempre**, aunque todos los valores sean `null`.
---
## 4. MANEJO DE CASOS ESPECIALES
### Desvío del flujo
Si el usuario pregunta algo fuera del estado actual, responde:
“Cuando terminemos te cuento todo con detalle. ¿Seguimos?”
Luego retoma la pregunta pendiente.
### 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 del usuario no es válida para el estado actual, reformula la misma pregunta ofreciendo opciones concretas.
Máximo **2 reintentos**. Al tercero:
“Cerramos por ahora; cuando estés listo, aquí estamos.”
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.”
### Inactividad (lo gestiona el scheduler, pero lo incluyes por contexto)
- 24h sin respuesta: “Hola [nombre], quedamos a medias; cuando quieras seguimos con tu presupuesto.
- 48h sin respuesta: se cierra como perdido (no envías mensaje).
### Mensajes multimedia
- **Audio:** Transcríbelo y trátalo como texto. Si no entiendes: “No te escuché bien, ¿puedes repetirlo?”
- **Imagen en ESPACIO o TAMAÑO:** Infiere el espacio y los m2 aproximados de la foto y úsalo como respuesta para ese estado.
- **Imagen en ESTILO:** Infiere el estilo o calidad que busca por lo que muestra la foto.
- **Imagen en otro estado:** “Gracias por la foto; cuéntame con palabras para asegurarme de entenderte bien.”
- **Sticker u otro:** Ignora el contenido y usa el mensaje de desvío.
### 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, no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural.
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
“No te preocupes; un rango aproximado está bien, ¿menos de 10.000, entre 10 y 30, o más?
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.
---
## 5. EJEMPLOS DE RESPUESTA CORRECTA (FEW-SHOT IMPLÍCITO)
## 4. EJEMPLOS DE TONO CORRECTO
**Usuario:** Hola, necesito reformar mi cocina.
**Luisa:** ¿Qué espacio tienes en mente, cocina, baño, salón, o algo más completo?
<DATOS_EXTRAIDOS>{"espacio": "cocina", ... (el resto null)}</DATOS_EXTRAIDOS>
**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:** ¿Tienes idea del tamaño aproximado? Menos de 10m2, entre 10 y 20, entre 20 y 40, o más de 40?
<DATOS_EXTRAIDOS>{"espacio": "cocina", "rango_m2": "10a20", ...}</DATOS_EXTRAIDOS>
**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:** ¿Cómo te imaginas el resultado? Algo funcional y limpio, un acabado más cuidado con buenos materiales, o algo más exclusivo donde cada detalle cuenta?
<DATOS_EXTRAIDOS>{"estilo": "funcional", ...}</DATOS_EXTRAIDOS>
**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 8000 euros.
**Luisa:** Gracias por tu tiempo; ahora mismo no podríamos darte el resultado que mereces con ese presupuesto. Si en algún momento cambia, aquí estamos.
<DATOS_EXTRAIDOS>{"presupuesto_declarado": "8000", "viable": false}</DATOS_EXTRAIDOS>
**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:** Vale, puedo subir a 15000.
**Luisa:** Con todo esto ya preparo tu presupuesto. En un momento lo recibes aquí mismo.
<DATOS_EXTRAIDOS>{"presupuesto_declarado": "15000", "viable": true}</DATOS_EXTRAIDOS>
**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.
---
## 6. RECORDATORIO FINAL PARA EL MODELO
## 5. RECORDATORIO FINAL PARA EL MODELO
- **NUNCA digas que eres un asistente de IA, ni que no eres Luisa.**
- **SIGUE la máquina de estados estrictamente.**
- **INCLUYE el bloque JSON en CADA respuesta.**
- **USA siempre “tú” y mantén el tono cercano pero profesional.**
- **Una sola pregunta por mensaje.**
- **Máximo 2 líneas de texto (sin contar el JSON).**
- **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.
Ahora actúa como Luisa.

View File

@@ -4,6 +4,9 @@
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 |
@@ -12,46 +15,26 @@ NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> F
| 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
## Ejemplos de tono por estado (varia la redaccion, no son frases literales)
**APERTURA:** "Hola, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto.
**APERTURA:** "¡Hola! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?"
Tienes unos minutos ahora?"
**ESPACIO:** "Cuentame, ¿que espacio quieres reformar: la cocina, el bano, el salon, o algo mas completo?"
**ESPACIO:** "Que espacio tienes en mente.
**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."
Cocina, bano, salon, o algo mas completo?"
**ESTILO:** "¿Como te lo imaginas: funcional y practico, un acabado mas cuidado con buenos materiales, o ya algo premium?"
**TAMANO:** "Tienes idea del tamano aproximado.
**URGENCIA:** "¿Para cuando te gustaria tenerlo? ¿Es algo proximo o todavia le das vueltas?"
Menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?"
**PRESUPUESTO:** "Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale."
**ESTILO:** "Como te imaginas el resultado.
**FIN (cierre cálido, siempre positivo):** "¡Genial! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aqui mismo."
Algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?"
**DESVIO (con simpatia):** "Buena pregunta; eso lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenertelo listo, ¿vale?"
**URGENCIA:** "Y cuando tienes pensado arrancar.
**SEGUIMIENTO FASE 3:** "¡Hola! ¿Te llego bien el presupuesto? ¿Te quedo alguna duda?"
Es algo proximo o todavia estas explorando?"
**PRESUPUESTO:** "Ultima pregunta.
Tienes en mente un presupuesto aproximado para la reforma?"
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto.
En un momento lo recibes aqui mismo."
**FIN_NO_VIABLE:** "Gracias por tu tiempo; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto.
Si en algun momento cambia, aqui estamos."
**DESVIO:** "Cuando terminemos te cuento todo con detalle.
Seguimos?"
**SEGUIMIENTO FASE 3:** "Hola, te llego bien el presupuesto.
Quedaste con alguna duda?"
> Nunca rechazas a un cliente por su presupuesto: el cierre es siempre positivo, sea cual sea la cifra.

View File

@@ -45,6 +45,25 @@ export class ApiClient {
}
}
// 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',

View File

@@ -6,8 +6,9 @@ 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, cercana y natural; adapta el registro al usuario (coloquial si el, formal si el). ' +
'Un mensaje por turno, maximo 2 lineas. Sin emojis, sin guiones largos. ' +
'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 = [
@@ -17,7 +18,6 @@ const FRASES_IA_PROHIBIDAS = [
/\bchatgpt\b/i,
/inteligencia artificial/i,
/no tengo un nombre propio/i,
/en qu[eé] puedo ayudarte/i,
];
export interface ClasificacionResultado {
@@ -270,17 +270,19 @@ ${this.serializarLead(lead)}
## Instrucciones de respuesta
Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
Habla espanol de Espana; suena natural y cercana.
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, pide amablemente que aclare su respuesta.
Si validacion valida es false y reintentos >= 2, repite la pregunta del estado actual de forma directa.
Si es_desvio es true o intencion es pregunta, responde brevemente como Luisa y redirige al flujo sin avanzar.
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 o fin_no_viable, usa el mensaje de cierre correspondiente.`;
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}`,
@@ -304,11 +306,14 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
- Intencion del usuario: ${clasificacion.intencion}
## Reglas de correccion obligatorias
- Debe sonar como Luisa de Reformix (Madrid), nunca como un asistente generico
- Espanol de Espana, natural; puede usar coloquialismos suaves (vale, mira, oye) si encaja
- Maximo 2 lineas, un mensaje por turno, sin emojis, sin guiones largos
- 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,
@@ -376,12 +381,9 @@ Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
entidad.nombre = clasificacion.valor_extraido.trim();
}
}
if (estadoFlujo === 'presupuesto') {
viable = validacion.viable;
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo, viable);
} else {
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
}
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;

View File

@@ -37,9 +37,12 @@ export class LeadsService {
return estado;
}
getSiguienteEstado(estadoActual: string, viable?: boolean): string {
getSiguienteEstado(estadoActual: string): string {
const estado = this.normalizarEstadoFlujo(estadoActual);
if (estado === 'presupuesto') return viable === false ? 'fin_no_viable' : 'fin_viable';
// 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];
@@ -57,12 +60,11 @@ export class LeadsService {
return /\d/.test(valor.trim().toLowerCase());
}
evaluarViabilidad(presupuesto: string): boolean {
const numeros = presupuesto.match(/\d[\d.]*/g);
if (!numeros?.length) return true;
const valor = parseInt(numeros[0].replace(/\./g, ''), 10);
if (Number.isNaN(valor)) return true;
return valor >= 5000;
// 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 persistirTurno(

View File

@@ -1,12 +1,21 @@
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() {
@@ -16,10 +25,35 @@ export class WebhookListener implements OnApplicationBootstrap {
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;
@@ -36,7 +70,6 @@ export class WebhookListener implements OnApplicationBootstrap {
return;
}
const url = req.url || '';
try {
if (url === '/whatsapp-start') {
await this.handleWhatsappStart(payload);
@@ -44,6 +77,11 @@ export class WebhookListener implements OnApplicationBootstrap {
} 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');
}
@@ -54,28 +92,131 @@ export class WebhookListener implements OnApplicationBootstrap {
});
}
// 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, telefono, nombre } = payload;
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 });
this.logger.log(`Lead ${leadId} registrado en sesiones.`);
// 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(telefono)?.leadId ?? null;
return this.leadSessions.get(this.normTel(telefono))?.leadId ?? null;
}
registerJid(telefono: string, jid: string) {
const session = this.leadSessions.get(telefono);
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');

View File

@@ -27,6 +27,8 @@ 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;
@@ -40,7 +42,7 @@ interface LeadContext {
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' });
@@ -48,6 +50,12 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
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,
@@ -62,6 +70,8 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.conectar();
this.escucharPdf();
this.escucharStart();
this.escucharFotos();
}
async onModuleDestroy() {
@@ -98,10 +108,176 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
});
}
// 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;
@@ -124,7 +300,10 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
auth: state,
printQRInTerminal: false,
logger: this.baileysLogger,
markOnlineOnConnect: false,
// 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,
});
@@ -134,23 +313,43 @@ 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) {
QRCode.generate(qr, { small: true });
console.log('\n📲 Escanea este QR con WhatsApp\n');
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}.`);
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 esta lista.');
this.webhookListener.setConectado(true);
}
});
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
for (const msg of messages) {
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;
@@ -198,8 +397,18 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
}
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
const leadId = this.webhookListener.getLeadIdByTelefono(telefono);
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;
@@ -227,7 +436,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
const jid = msg.key.remoteJid!;
if (jid.includes('@g.us')) return;
const telefono = jid.split('@')[0];
const telefono = this.resolverTelefono(msg);
try {
const ctx = await this.getOrCreateContext(telefono, jid);
@@ -239,6 +448,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
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) {
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
} else if (msgContent.audioMessage) {
@@ -337,10 +553,34 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
}
// ¿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;
// 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);
// 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);
}

File diff suppressed because one or more lines are too long

View File

@@ -186,6 +186,7 @@ CREATE TABLE "pricing_config" (
"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")
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1780593183911,
"tag": "0011_warm_post",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781189893331,
"tag": "0012_lame_sentinel",
"breakpoints": true
}
]
}

View File

@@ -11,6 +11,7 @@
"@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",
@@ -4546,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",

View File

@@ -21,6 +21,7 @@
"@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",

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -46,7 +46,7 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
style={themeStyle(tenant.themePreset, tenant.themeColor)}
>
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} showLogin />
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
<main id="main-content">
<Hero slug={tenant.slug} />
<ReformaSlider />

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,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

@@ -1,7 +1,9 @@
import { desc, eq, like } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadPipelineEventos } from '@/db/schema';
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';
@@ -77,5 +79,44 @@ export async function POST(req: Request): Promise<Response> {
},
});
return ok({ matched: true, leadId, transcript: Boolean(detalle.transcript), grabacion: Boolean(audioUrl) });
// 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

@@ -58,6 +58,20 @@
--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 {

View File

@@ -2,6 +2,7 @@ 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';
@@ -19,9 +20,20 @@ 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>
@@ -41,6 +53,14 @@ 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';
@@ -62,16 +82,25 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
{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>
<EstadoControl
leadId={lead.id}
estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado}
/>
<div data-tour="ficha-estado">
<EstadoControl
leadId={lead.id}
estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado}
/>
</div>
</div>
{/* Solicitar opinión al cliente */}
@@ -164,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" />
@@ -311,7 +340,7 @@ 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

View File

@@ -5,6 +5,7 @@ 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' },
@@ -44,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

@@ -69,7 +69,7 @@ export default async function PanelPage({
</div>
{/* Filtros por estado */}
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2" data-tour="leads-filtros">
{FILTROS.map((f) => {
const active = f.value === filtro;
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
@@ -89,7 +89,9 @@ export default async function PanelPage({
})}
</div>
<LeadsView leads={leadsView} />
<div data-tour="leads-tabla">
<LeadsView leads={leadsView} />
</div>
</div>
);
}

View File

@@ -93,6 +93,19 @@ export async function actualizarExtras(formData: FormData) {
revalidatePath('/panel/precios');
}
export async function actualizarBaremo(formData: FormData) {
const tenantId = await getTenantId();
const raw = formData.get('baremoMinimo');
const txt = typeof raw === 'string' ? raw.trim() : '';
// Vacío = sin baremo (null). Con valor = euros → céntimos.
const baremoMinimo = txt === '' ? null : eurosToCents(raw, 'baremo de rentabilidad');
await db
.update(pricingConfig)
.set({ baremoMinimo, updatedAt: new Date() })
.where(eq(pricingConfig.tenantId, tenantId));
revalidatePath('/panel/precios');
}
export async function actualizarEnvio(formData: FormData) {
const tenantId = await getTenantId();
const modo = formData.get('modo');

View File

@@ -5,6 +5,7 @@ import {
borrarMaterial,
actualizarConfig,
actualizarExtras,
actualizarBaremo,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
@@ -81,7 +82,7 @@ export default async function PreciosPage() {
</section>
{/* Config general */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-config">
<h2 className="font-bold text-black mb-4">Configuración general</h2>
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
<label className="text-sm">
@@ -120,6 +121,34 @@ export default async function PreciosPage() {
</form>
</section>
{/* Baremo de rentabilidad */}
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-baremo">
<h2 className="font-bold text-black mb-1">Baremo de rentabilidad</h2>
<p className="text-sm text-gray-500 mb-4">
Importe mínimo de un trabajo para que te resulte rentable. Es solo orientativo para ti: en la
ficha de cada lead verás marcados en otro color los presupuestos que no lleguen a este valor.
No afecta a lo que ve el cliente ni a la conversación de los agentes. Déjalo vacío para no usar
baremo.
</p>
<form action={actualizarBaremo} className="flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Baremo mínimo ()</span>
<input
name="baremoMinimo"
type="number"
step="1"
min="0"
defaultValue={config.baremoMinimo != null ? config.baremoMinimo / 100 : ''}
placeholder="Sin baremo"
className="w-40 border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
<button className="bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar baremo
</button>
</form>
</section>
{/* Extras fijos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
@@ -256,7 +285,7 @@ export default async function PreciosPage() {
})}
{/* Import CSV */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-catalogo">
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
<p className="text-xs text-gray-500 mb-3">
Cabecera: <code className="break-all">categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El

View File

@@ -1,82 +1,295 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
import TenantBrand from '@/components/funnel/TenantBrand';
export const dynamic = 'force-dynamic';
const BRAND = 'var(--brand, #0a0a0a)';
const BRAND_CONTRAST = 'var(--brand-contrast, #ffffff)';
function IconLlamada() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconWhatsapp() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconFormulario() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 2v6h6M16 13H8M16 17H8M10 9H8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
const CANALES = [
{
slug: 'llamada',
icon: '📞',
icon: <IconLlamada />,
titulo: 'Que te llamemos',
descripcion:
'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',
},
{
slug: 'whatsapp',
icon: '💬',
icon: <IconWhatsapp />,
titulo: 'Por WhatsApp',
descripcion:
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
descripcion: 'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
cta: 'Seguir por WhatsApp',
badge: null,
},
{
slug: 'formulario',
icon: '📝',
icon: <IconFormulario />,
titulo: 'Rellenar un formulario',
descripcion:
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
cta: 'Rellenar el formulario',
badge: null,
},
] as const;
const PASOS_DESPUES = [
{
titulo: '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.',
},
{
titulo: 'Render + presupuesto en minutos',
body: 'Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.',
},
// El tercer paso interpola el nombre del reformista; se monta en el componente.
] as const;
function Stepper() {
const conectores = 'h-px flex-1 min-w-4';
return (
<ol className="flex items-center gap-2.5 sm:gap-3" aria-label="Progreso de tu solicitud">
<li className="flex items-center gap-2 shrink-0">
<span
className="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
>
<svg width="12" height="12" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M6 16l7 7L26 9"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="hidden sm:inline text-xs font-semibold text-gray-600">Tus datos</span>
</li>
<li aria-hidden="true" className={conectores} style={{ backgroundColor: BRAND }} />
<li className="flex items-center gap-2 shrink-0" aria-current="step">
<span
className="w-6 h-6 rounded-full text-[11px] font-black flex items-center justify-center shrink-0"
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
>
2
</span>
<span className="text-xs font-bold text-black">Tu reforma</span>
</li>
<li aria-hidden="true" className={`${conectores} bg-gray-200`} />
<li className="flex items-center gap-2 shrink-0">
<span className="w-6 h-6 rounded-full bg-white border border-gray-300 text-[11px] font-bold text-gray-400 flex items-center justify-center shrink-0">
3
</span>
<span className="hidden sm:inline text-xs font-semibold text-gray-400">
Render + presupuesto
</span>
</li>
</ol>
);
}
export default async function ChooserPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, tenant } = data;
const theme = resolveTheme(tenant?.themePreset, tenant?.themeColor);
const nombrePila = lead.nombre.split(' ')[0];
const nombreReformista = tenant?.nombreEmpresa ?? 'el reformista';
const pasosDespues = [
...PASOS_DESPUES,
{
titulo: 'Visita gratuita para el presupuesto final',
body: `Si te convence, acuerdas una visita con ${nombreReformista}: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.`,
},
];
return (
<>
<div
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
style={themeStyle(tenant?.themePreset, tenant?.themeColor)}
>
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
<div className="container py-10 max-w-2xl flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Elige cómo seguir
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
y tu presupuesto.
</p>
</div>
<div className="flex flex-col gap-3">
{CANALES.map((c) => (
<Link
key={c.slug}
href={`/solicitud/${id}/${c.slug}`}
className="group bg-white border border-gray-200 rounded-xl p-5 shadow-sm flex items-center gap-4 transition-all hover:border-black hover:shadow-md"
>
<span className="text-3xl shrink-0" aria-hidden="true">
{c.icon}
</span>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-base font-bold text-black">{c.titulo}</span>
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
{c.cta}
<div className="relative overflow-hidden">
{/* Halo sutil con el color de marca del reformista */}
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-80 pointer-events-none"
style={{
background: `radial-gradient(60% 100% at 50% 0%, color-mix(in srgb, ${BRAND} 8%, transparent), transparent 70%)`,
}}
/>
<div className="container relative max-w-4xl py-10 md:py-14 flex flex-col gap-8 md:gap-10">
<div className="motion-safe:animate-fade-up">
<Stepper />
</div>
<header
className="flex flex-col gap-3 max-w-2xl motion-safe:animate-fade-up"
style={{ animationDelay: '80ms' }}
>
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Elige cómo seguir
</span>
<h1 className="text-3xl md:text-4xl font-black tracking-tight text-black leading-[1.1] text-balance">
¿Cómo prefieres contarnos tu reforma, {nombrePila}?
</h1>
<p className="text-sm md:text-base text-gray-500 leading-relaxed">
eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu
render y tu presupuesto.
</p>
</header>
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
{CANALES.map((c, i) => (
<Link
key={c.slug}
href={`/solicitud/${id}/${c.slug}`}
className="group relative flex items-start gap-4 md:flex-col md:gap-5 bg-white border border-gray-200 rounded-2xl p-5 md:p-6 shadow-sm transition-all duration-250 hover:-translate-y-0.5 hover:border-[color:var(--brand,#0a0a0a)] hover:shadow-[0_16px_40px_-12px_rgba(0,0,0,0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--brand,#0a0a0a)] focus-visible:ring-offset-2 motion-safe:animate-fade-up"
style={{ animationDelay: `${160 + i * 80}ms` }}
>
{c.badge && (
<span
className="absolute top-0 right-5 -translate-y-1/2 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest"
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
>
{c.badge}
</span>
)}
<span
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-transform duration-250 group-hover:scale-105"
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
aria-hidden="true"
>
{c.icon}
</span>
</div>
</Link>
))}
<span className="flex flex-col gap-1.5 min-w-0 flex-1">
<h2 className="text-lg font-black tracking-tight text-black leading-snug">
{c.titulo}
</h2>
<span className="text-sm text-gray-500 leading-relaxed">{c.descripcion}</span>
<span
className="flex items-center gap-1.5 text-sm font-bold mt-2 md:mt-auto md:pt-4"
style={{ color: BRAND }}
>
{c.cta}
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="transition-transform duration-250 group-hover:translate-x-1"
>
<path
d="M2 8h12M10 4l4 4-4 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</span>
</Link>
))}
</div>
<section
className="bg-white border border-gray-200 rounded-2xl p-6 md:p-8 shadow-sm motion-safe:animate-fade-up"
style={{ animationDelay: '420ms' }}
aria-labelledby="que-pasa-despues"
>
<h2
id="que-pasa-despues"
className="text-base md:text-lg font-black tracking-tight text-black"
>
Elijas lo que elijas, esto es lo que pasa después
</h2>
<ol className="relative mt-6 grid gap-6 md:grid-cols-3 md:gap-8">
{/* Línea que conecta los pasos en desktop */}
<div
aria-hidden="true"
className="hidden md:block absolute top-[15px] left-8 right-8 h-px bg-gray-200"
/>
{pasosDespues.map((paso, i) => (
<li key={paso.titulo} className="relative flex items-start gap-4 md:flex-col">
<span
className="relative w-8 h-8 rounded-full text-[13px] font-black flex items-center justify-center shrink-0 ring-4 ring-white"
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
aria-hidden="true"
>
{i + 1}
</span>
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-sm font-bold text-black leading-snug">{paso.titulo}</h3>
<p className="text-sm text-gray-500 leading-relaxed">{paso.body}</p>
</div>
</li>
))}
</ol>
</section>
</div>
</div>
</>
</div>
);
}

View File

@@ -46,6 +46,7 @@ export interface PricingConfig {
factorZona: Record<string, number>; // provincia -> multiplicador
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
extras?: ExtrasFijos; // importes fijos en céntimos
baremoMinimo?: number | null; // céntimos; trabajo mínimo rentable (informativo, no lo usan los agentes)
}
export interface BudgetInputs {

View File

@@ -116,6 +116,7 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
<Link
key={l.href}
href={l.href}
data-tour={`nav-${l.icon}`}
className={active === l.href ? 'text-primary-700 font-semibold' : 'text-gray-500 hover:text-primary-700'}
>
{l.label}

View File

@@ -1,3 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { PublicGaleriaFoto } from '@/lib/funnel/public-queries';
type GaleriaTrabajosProps = {
@@ -5,11 +8,37 @@ type GaleriaTrabajosProps = {
nombreEmpresa: string;
};
// Galería de trabajos del reformista en su landing pública. Solo se muestra si
// el reformista ha subido fotos desde su panel.
// Galería de trabajos del reformista en su landing pública. Solo se muestra si el reformista ha
// subido fotos desde su panel. Formato apaisado y, al pulsar una foto, se amplía en un lightbox
// con navegación entre todas las imágenes.
export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajosProps) {
const [idx, setIdx] = useState<number | null>(null);
const cerrar = useCallback(() => setIdx(null), []);
const mover = useCallback(
(d: number) => setIdx((cur) => (cur === null ? cur : (cur + d + fotos.length) % fotos.length)),
[fotos.length],
);
useEffect(() => {
if (idx === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') cerrar();
else if (e.key === 'ArrowRight') mover(1);
else if (e.key === 'ArrowLeft') mover(-1);
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [idx, cerrar, mover]);
if (fotos.length === 0) return null;
const actual = idx !== null ? fotos[idx] : null;
return (
<section id="galeria" className="bg-gray-50 section" aria-label="Galería de trabajos">
<div className="container">
@@ -24,24 +53,31 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
Reformas que ya hemos hecho
</h2>
<p className="text-gray-500 mt-3 leading-relaxed">
Una muestra real del trabajo de {nombreEmpresa}. Calidad de acabados, plazos cumplidos.
Una muestra real del trabajo de {nombreEmpresa}. Toca cualquier imagen para verla en grande.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
{fotos.map((f) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
{fotos.map((f, i) => (
<figure
key={f.id}
className="group relative aspect-[4/3] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
className="group relative aspect-[3/2] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<button
type="button"
onClick={() => setIdx(i)}
className="block h-full w-full cursor-zoom-in"
aria-label={`Ampliar ${f.titulo ?? `reforma de ${nombreEmpresa}`}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</button>
{f.titulo && (
<figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<figcaption className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{f.titulo}
</figcaption>
)}
@@ -49,6 +85,67 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
))}
</div>
</div>
{actual && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 p-4 sm:p-8"
onClick={cerrar}
role="dialog"
aria-modal="true"
aria-label="Imagen ampliada"
>
<button
type="button"
onClick={cerrar}
aria-label="Cerrar"
className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-2xl leading-none text-white hover:bg-white/30"
>
×
</button>
{fotos.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
mover(-1);
}}
aria-label="Anterior"
className="absolute left-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-3xl leading-none text-white hover:bg-white/30 sm:left-6"
>
</button>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={actual.url}
alt={actual.titulo ?? `Reforma de ${nombreEmpresa}`}
onClick={(e) => e.stopPropagation()}
className="max-h-[86vh] max-w-[94vw] w-auto rounded-lg shadow-2xl"
/>
{fotos.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
mover(1);
}}
aria-label="Siguiente"
className="absolute right-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-3xl leading-none text-white hover:bg-white/30 sm:right-6"
>
</button>
)}
{actual.titulo && (
<div className="pointer-events-none absolute inset-x-0 bottom-5 text-center text-sm font-medium text-white/85">
{actual.titulo}
</div>
)}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { driver, type DriveStep } from 'driver.js';
import 'driver.js/dist/driver.css';
import { tourForPath } from '@/lib/onboarding/panel-tour';
const SEEN_PREFIX = 'reformix_tour_v1_';
// Onboarding del panel con driver.js. Lanza el tour de la pestaña actual la primera vez que se
// visita (flag por pestaña en localStorage) y deja un botón flotante para repetirlo. Los pasos
// cuyo elemento no exista o esté oculto (p. ej. la nav de escritorio en móvil) se descartan.
export default function PanelTour() {
const pathname = usePathname();
const [hayTour, setHayTour] = useState(false);
useEffect(() => {
const tour = tourForPath(pathname);
setHayTour(Boolean(tour));
if (!tour) return;
if (localStorage.getItem(SEEN_PREFIX + tour.key) === '1') return;
// Espera a que el contenido de la página esté montado antes de resaltar.
const t = setTimeout(() => {
localStorage.setItem(SEEN_PREFIX + tour.key, '1');
lanzar(tour.steps);
}, 700);
return () => clearTimeout(t);
}, [pathname]);
function visibles(steps: DriveStep[]): DriveStep[] {
return steps.filter((s) => {
const sel = s.element;
if (!sel || typeof sel !== 'string') return true; // paso centrado (intro)
const el = document.querySelector(sel) as HTMLElement | null;
return !!el && el.offsetParent !== null;
});
}
function lanzar(steps: DriveStep[]) {
const pasos = visibles(steps);
if (pasos.length === 0) return;
driver({
showProgress: true,
overlayColor: '#0b1220',
nextBtnText: 'Siguiente',
prevBtnText: 'Atrás',
doneBtnText: 'Listo',
progressText: '{{current}} de {{total}}',
steps: pasos,
}).drive();
}
function repetir() {
const tour = tourForPath(pathname);
if (tour) lanzar(tour.steps);
}
if (!hayTour) return null;
return (
<button
type="button"
onClick={repetir}
className="fixed right-4 bottom-20 sm:bottom-4 z-40 inline-flex items-center gap-1.5 rounded-full bg-primary-700 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-primary-900"
aria-label="Ver el tour de esta sección"
>
<span aria-hidden="true"></span> Tour
</button>
);
}

View File

@@ -42,6 +42,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
factorZona: {},
manoObra: { ...MANO_OBRA_DEFAULT },
extras: { ...EXTRAS_DEFAULT },
baremoMinimo: null,
};
}
return {
@@ -49,6 +50,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
factorZona: row.factorZona,
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
baremoMinimo: row.baremoMinimo ?? null,
};
}

View File

@@ -396,6 +396,10 @@ export const pricingConfig = pgTable('pricing_config', {
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
.notNull()
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
// Baremo de rentabilidad (céntimos): importe mínimo que el reformista considera rentable. Solo
// informativo en el panel (marca en otro color los leads por debajo); los agentes NO lo usan para
// decidir nada. Null = sin baremo configurado.
baremoMinimo: integer('baremo_minimo'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -291,13 +291,13 @@ const SEED_LEADS: SeedLead[] = [
const STAGE_ORDER = schema.pipelineStage.enumValues;
async function main() {
const [existing] = await db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
.limit(1);
// Guard de seguridad: solo sembramos si la base de datos está VACÍA (sin ningún tenant). Antes se
// comprobaba un slug concreto ("reformas-ejemplo"); si ese tenant no estaba pero había otros
// (p. ej. una empresa creada por el reformista), el seed los TRUNCABA en cada deploy → pérdida de
// datos. Ahora cualquier tenant existente protege toda la DB. SEED_FORCE=1 fuerza el reseed (BORRA TODO).
const [existing] = await db.select({ id: schema.tenants.id }).from(schema.tenants).limit(1);
if (existing && !process.env.SEED_FORCE) {
console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.');
console.log('La base de datos ya tiene datos (existe al menos un tenant). Saltando seed para no borrar nada. Usa SEED_FORCE=1 para forzar (¡BORRA TODO!).');
await client.end();
return;
}

View File

@@ -0,0 +1,41 @@
import { env } from '@/lib/env';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export function openrouterConfigurado(): boolean {
return Boolean(env.OPENROUTER_API_KEY);
}
// Llamada de chat que espera una respuesta JSON. Parseo robusto (tolera fences ```json).
export async function chatJSON(system: string, user: string, model?: string): Promise<unknown> {
const res = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix App',
},
body: JSON.stringify({
model: model || env.OPENROUTER_MODEL_ANALISIS || 'anthropic/claude-haiku-4.5',
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
temperature: 0.1,
max_tokens: 700,
}),
});
if (!res.ok) {
throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const data = await res.json();
const content: string = data?.choices?.[0]?.message?.content ?? '';
try {
return JSON.parse(content);
} catch {
const m = content.match(/\{[\s\S]*\}/);
if (m) return JSON.parse(m[0]);
throw new Error('La respuesta del modelo no es JSON');
}
}

View File

@@ -26,8 +26,13 @@ const schema = z.object({
PERFIL_WEBHOOK_URL: opcional,
WHATSAPP_WEBHOOK_URL: opcional,
WHATSAPP_START_WEBHOOK_URL: opcional,
// Cross-canal: tras una llamada, pedir al lead las fotos por WhatsApp.
WHATSAPP_FOTOS_WEBHOOK_URL: opcional,
// Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email).
APP_URL: opcional,
// LLM (OpenRouter) para el post-análisis de la conversación de WhatsApp.
OPENROUTER_API_KEY: opcional,
OPENROUTER_MODEL_ANALISIS: opcional,
});
export const env = schema.parse({
@@ -44,7 +49,10 @@ export const env = schema.parse({
PERFIL_WEBHOOK_URL: process.env.PERFIL_WEBHOOK_URL,
WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL,
WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_WEBHOOK_URL,
WHATSAPP_FOTOS_WEBHOOK_URL: process.env.WHATSAPP_FOTOS_WEBHOOK_URL,
APP_URL: process.env.APP_URL,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
OPENROUTER_MODEL_ANALISIS: process.env.OPENROUTER_MODEL_ANALISIS,
});
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
@@ -71,3 +79,7 @@ export function whatsappWebhookConfigurado(): boolean {
export function whatsappStartConfigurado(): boolean {
return Boolean(env.WHATSAPP_START_WEBHOOK_URL);
}
export function whatsappFotosConfigurado(): boolean {
return Boolean(env.WHATSAPP_FOTOS_WEBHOOK_URL);
}

View File

@@ -0,0 +1,111 @@
import { asc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, conversacionWhatsapp, leadPipelineEventos } from '@/db/schema';
import { chatJSON, openrouterConfigurado } from '@/lib/ai/openrouter';
import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto';
export interface AnalisisResultado {
ok: boolean;
perfil?: Record<string, unknown>;
turnos?: number;
presupuesto?: number;
error?: string;
}
export type OrigenAnalisis = 'whatsapp' | 'llamada' | 'formulario';
const SYSTEM = `Eres un analista que extrae datos estructurados de una conversación de cualificación de
una reforma entre una agente y un CLIENTE (puede ser un chat de WhatsApp o la transcripción de una
llamada). Lee toda la conversación y devuelve SOLO un objeto JSON válido (sin texto alrededor, sin
markdown) con estas claves; usa null si el dato no aparece:
{
"tipoReforma": "cocina|bano|salon|comedor|integral|otro",
"m2Suelo": número en m² (si el cliente da un rango, usa el punto medio: "menos de 10"→8,
"entre 10 y 20"→15, "entre 20 y 40"→30, "más de 40"→50),
"calidadGlobal": "basica|media|premium" (funcional/básico→basica, cuidado/buenos materiales→media,
exclusivo/lujo/premium→premium; "moderno pero barato"→basica),
"urgencia": "alta|media|baja" (cuanto antes/pronto→alta, sin prisa/explorando→baja),
"presupuestoTarget": número en EUROS que declara el cliente (no céntimos), o null,
"viable": booleano (false si el presupuesto declarado es claramente insuficiente para la reforma),
"espacio": el espacio en crudo tal cual lo dijo el cliente,
"rangoM2": el tamaño en crudo tal cual lo dijo,
"estilo": el estilo/acabado en crudo tal cual lo dijo,
"presupuestoDeclarado": el presupuesto en crudo tal cual lo dijo,
"resumen": una frase con el resumen del lead
}`;
// Núcleo agnóstico del canal: dada una transcripción (de WhatsApp, llamada o formulario), extrae los
// campos clave con un LLM y los persiste en el lead. Idempotente.
export async function analizarTranscripcion(
leadId: string,
transcript: string,
origen: OrigenAnalisis,
): Promise<AnalisisResultado> {
if (!openrouterConfigurado()) return { ok: false, error: 'OPENROUTER_API_KEY no configurada.' };
if (!transcript || !transcript.trim()) return { ok: false, error: 'Transcripción vacía.' };
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return { ok: false, error: 'Lead no encontrado.' };
let ex: Record<string, unknown>;
try {
ex = (await chatJSON(SYSTEM, transcript)) as Record<string, unknown>;
} catch (err) {
return { ok: false, error: `Extracción falló: ${(err as Error).message}` };
}
const enumOk = (v: unknown, allowed: string[]) =>
typeof v === 'string' && allowed.includes(v) ? v : undefined;
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
const set: Record<string, unknown> = { updatedAt: new Date() };
const tipoReforma = enumOk(ex.tipoReforma, ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro']);
const calidadGlobal = enumOk(ex.calidadGlobal, ['basica', 'media', 'premium']);
const urgencia = enumOk(ex.urgencia, ['alta', 'media', 'baja']);
if (tipoReforma) set.tipoReforma = tipoReforma;
if (calidadGlobal) set.calidadGlobal = calidadGlobal;
if (urgencia) set.urgencia = urgencia;
if (typeof ex.m2Suelo === 'number' && ex.m2Suelo > 0) set.m2Suelo = ex.m2Suelo;
if (typeof ex.presupuestoTarget === 'number' && ex.presupuestoTarget >= 0) {
set.presupuestoTarget = Math.round(ex.presupuestoTarget * 100); // euros → céntimos
}
if (typeof ex.viable === 'boolean') set.viable = ex.viable;
if (str(ex.espacio)) set.espacio = str(ex.espacio);
if (str(ex.rangoM2)) set.rangoM2 = str(ex.rangoM2);
if (str(ex.estilo)) set.estilo = str(ex.estilo);
if (str(ex.presupuestoDeclarado)) set.presupuestoDeclarado = str(ex.presupuestoDeclarado);
if (str(ex.resumen)) set.tasteText = str(ex.resumen);
// El paso del bot solo aplica al canal conversacional de WhatsApp.
if (origen === 'whatsapp') set.botStep = 'presupuesto';
await db.update(leads).set(set).where(eq(leads.id, leadId));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'llamada_completada',
metadata: { origen: `analisis_${origen}`, campos: Object.keys(set) },
});
// Con los datos capturados, calcula ya el presupuesto orientativo (para que el PDF y el panel lo
// muestren). Best-effort: si faltan tipoReforma/m2 no se calcula, pero el resto sí queda guardado.
const presupuesto = await calcularPresupuestoLead(leadId);
return { ok: true, perfil: set, presupuesto: presupuesto.ok ? presupuesto.total : undefined };
}
// Entrada para WhatsApp: arma la transcripción desde conversacion_whatsapp y delega en el núcleo.
export async function analizarConversacion(leadId: string): Promise<AnalisisResultado> {
const turnos = await db
.select({ rol: conversacionWhatsapp.rol, mensaje: conversacionWhatsapp.mensaje })
.from(conversacionWhatsapp)
.where(eq(conversacionWhatsapp.leadId, leadId))
.orderBy(asc(conversacionWhatsapp.createdAt));
if (turnos.length === 0) return { ok: false, error: 'El lead no tiene conversación.' };
const transcript = turnos
.map((t) => `${t.rol === 'user' ? 'CLIENTE' : 'LUISA'}: ${t.mensaje}`)
.join('\n');
const r = await analizarTranscripcion(leadId, transcript, 'whatsapp');
return { ...r, turnos: turnos.length };
}

View File

@@ -0,0 +1,62 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads } from '@/db/schema';
import { getPricingConfigFor, getCatalogFor } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import { deterministicExtractor } from '@/lib/voice/extractor';
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
import type { RawCallData } from '@/lib/voice/preferences';
export interface CalculoResultado {
ok: boolean;
total?: number;
error?: string;
}
// Calcula el presupuesto orientativo del lead con su catálogo (mismo motor que el orquestador) y lo
// persiste en presupuestoEstimado + desgloseSnapshot. Reutilizable desde el post-análisis (WhatsApp/
// llamada) y desde finalizar (antes de construir el PDF). Requiere al menos tipoReforma + m2Suelo.
export async function calcularPresupuestoLead(leadId: string): Promise<CalculoResultado> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) return { ok: false, error: 'Lead no encontrado.' };
if (!lead.tipoReforma || !lead.m2Suelo) {
return { ok: false, error: 'Faltan tipoReforma o m2Suelo para calcular el presupuesto.' };
}
const [config, catalog] = await Promise.all([
getPricingConfigFor(lead.tenantId),
getCatalogFor(lead.tenantId),
]);
const raw: RawCallData = {
tipoReforma: lead.tipoReforma,
m2Suelo: lead.m2Suelo ?? null,
calidad: lead.calidadGlobal ?? null,
estructural: lead.estructural,
urgencia: lead.urgencia ?? null,
presupuestoTarget: lead.presupuestoTarget ?? null,
tasteText: lead.tasteText ?? '',
};
const prefs = deterministicExtractor.extract(raw, catalog);
const inputs = mergeIntoBudgetInputs(prefs, {
tipoReforma: lead.tipoReforma,
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
provincia: lead.provincia ?? null,
anteriorA2000: lead.anteriorA2000,
cambioDistribucion: lead.cambioDistribucion,
});
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
await db
.update(leads)
.set({
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: 'presupuesto_generado', result },
preferencesSnapshot: prefs,
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
return { ok: true, total: result.total };
}

View File

@@ -6,6 +6,7 @@ import { enviarPresupuestoEmail } from '@/lib/email/mailer';
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
import { resolveTheme } from '@/lib/funnel/themes';
import { normalizarTelefonoEs } from '@/lib/voice/retell';
import { calcularPresupuestoLead } from '@/lib/funnel/calcular-presupuesto';
export type ResultadoFinalizar = {
ok: boolean;
@@ -18,6 +19,10 @@ export type ResultadoFinalizar = {
// flujo externo. Entrega real (la rama simulada de orchestrator.ts:Paso 7 es solo el estado
// intermedio del funnel). Best-effort en email/WhatsApp: el lead avanza igualmente.
export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinalizar> {
// Asegura el presupuesto orientativo ANTES de construir el PDF (si los datos lo permiten), para
// que el documento incluya la cifra. Best-effort: sin tipoReforma/m2 el PDF sale sin importe.
await calcularPresupuestoLead(leadId);
const pdf = await construirPresupuestoPdf(leadId);
if (!pdf) return { ok: false, emailEnviado: false, whatsappSenal: false };

View File

@@ -52,6 +52,13 @@ export async function señalarPerfilCompleto(leadId: string): Promise<boolean> {
urgencia: lead.urgencia,
presupuestoTarget: lead.presupuestoTarget,
},
// Gustos estéticos del cliente (estilo + resumen en texto libre de lo que pidió hablando con
// Luisa / en la llamada): se mandan al generador para que el render los represente. Se omiten
// las claves vacías (JSON.stringify descarta undefined).
preferencias: {
estilo: lead.estilo || undefined,
gustos: lead.tasteText || undefined,
},
empresa: { tenantId: lead.tenantId, nombre: tenant.nombreEmpresa },
zonas: Array.from(zonas, ([zona, d]) => ({
zona,

View File

@@ -0,0 +1,147 @@
import type { DriveStep } from 'driver.js';
// Pasos del onboarding del panel, por pestaña. El copy vive también en copy/COPY-GUIDE.md
// (sección "Onboarding del panel"). Los pasos cuyo elemento no exista o no esté visible se
// descartan en PanelTour (degrada con naturalidad en móvil o si una sección no aparece).
const PASOS_PANEL: DriveStep[] = [
{
popover: {
title: 'Tu panel de Reformix',
description:
'Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X.',
},
},
{
element: '[data-tour="nav-leads"]',
popover: {
title: 'Leads',
description:
'Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día.',
side: 'bottom',
},
},
{
element: '[data-tour="nav-precios"]',
popover: {
title: 'Precios y baremo',
description:
'Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos.',
side: 'bottom',
},
},
{
element: '[data-tour="nav-galeria"]',
popover: { title: 'Galería', description: 'Tus fotos de trabajos para enseñar en la web.', side: 'bottom' },
},
{
element: '[data-tour="nav-opiniones"]',
popover: {
title: 'Opiniones',
description: 'Reseñas de tus clientes; las apruebas tú antes de publicarlas.',
side: 'bottom',
},
},
{
element: '[data-tour="nav-empresa"]',
popover: { title: 'Empresa', description: 'Tu marca, logo y datos de contacto.', side: 'bottom' },
},
{
element: '[data-tour="leads-filtros"]',
popover: {
title: 'Filtra por estado',
description: 'Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora.',
side: 'bottom',
},
},
{
element: '[data-tour="leads-tabla"]',
popover: {
title: 'Tus leads',
description: 'Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo.',
side: 'top',
},
},
];
const PASOS_FICHA: DriveStep[] = [
{
element: '[data-tour="ficha-presupuesto"]',
popover: {
title: 'Presupuesto estimado',
description:
'Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo.',
side: 'bottom',
},
},
{
element: '[data-tour="ficha-estado"]',
popover: {
title: 'Estado del lead',
description: 'Avanza el lead por el funnel: contactado, presupuestado, ganado…',
side: 'bottom',
},
},
{
element: '[data-tour="ficha-render"]',
popover: {
title: 'Render de la reforma',
description:
'La imagen del “después” que ve tu cliente, generada a partir de su foto y sus gustos.',
side: 'top',
},
},
{
element: '[data-tour="ficha-desglose"]',
popover: {
title: 'Presupuesto desglosado',
description:
'Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp.',
side: 'top',
},
},
];
const PASOS_PRECIOS: DriveStep[] = [
{
element: '[data-tour="precios-baremo"]',
popover: {
title: 'Baremo de rentabilidad',
description:
'El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo.',
side: 'bottom',
},
},
{
element: '[data-tour="precios-config"]',
popover: {
title: 'Mano de obra',
description: 'Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto.',
side: 'top',
},
},
{
element: '[data-tour="precios-catalogo"]',
popover: {
title: 'Tu catálogo',
description: 'Materiales y precios por calidad. Puedes importarlos en bloque por CSV.',
side: 'top',
},
},
];
export interface PanelTour {
key: string;
steps: DriveStep[];
}
// Devuelve el tour que corresponde a la ruta actual del panel, o null si esa ruta no tiene tour.
export function tourForPath(pathname: string): PanelTour | null {
if (pathname === '/panel') return { key: 'panel', steps: PASOS_PANEL };
if (pathname === '/panel/precios') return { key: 'precios', steps: PASOS_PRECIOS };
const m = pathname.match(/^\/panel\/([^/]+)\/?$/);
if (m && !['precios', 'galeria', 'opiniones', 'empresa'].includes(m[1])) {
return { key: 'ficha', steps: PASOS_FICHA };
}
return null;
}

View File

@@ -3,6 +3,7 @@ import {
perfilWebhookConfigurado,
whatsappWebhookConfigurado,
whatsappStartConfigurado,
whatsappFotosConfigurado,
} from '@/lib/env';
// POST JSON best-effort: nunca lanza. Devuelve true solo si el destino respondió 2xx.
@@ -55,3 +56,16 @@ export async function iniciarConversacionWhatsapp(payload: {
if (!whatsappStartConfigurado()) return false;
return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload);
}
// Cross-canal: tras una llamada, que Luisa escriba al lead por WhatsApp, referencie lo hablado
// (contexto) y le pida las fotos para completar el render + presupuesto.
export async function pedirFotosWhatsapp(payload: {
leadId: string;
telefono: string;
nombre: string;
empresa: string;
contexto?: string;
}): Promise<boolean> {
if (!whatsappFotosConfigurado()) return false;
return postWebhook(env.WHATSAPP_FOTOS_WEBHOOK_URL!, payload);
}

6
mvp/image-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
coverage/

View File

@@ -1,28 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AppModule = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const webhook_module_1 = require("./webhook/webhook.module");
const pipeline_module_1 = require("./pipeline/pipeline.module");
const reformix_module_1 = require("./reformix/reformix.module");
let AppModule = class AppModule {
};
exports.AppModule = AppModule;
exports.AppModule = AppModule = __decorate([
(0, common_1.Module)({
imports: [
config_1.ConfigModule.forRoot({ isGlobal: true }),
webhook_module_1.WebhookModule,
pipeline_module_1.PipelineModule,
reformix_module_1.ReformixModule,
],
})
], AppModule);
//# sourceMappingURL=app.module.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"app.module.js","sourceRoot":"","sources":["../src/app.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2CAA8C;AAC9C,6DAAyD;AACzD,gEAA4D;AAC5D,gEAA4D;AAUrD,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,8BAAS;oBAAT,SAAS;IARrB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE;YACP,qBAAY,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YACxC,8BAAa;YACb,gCAAc;YACd,gCAAc;SACf;KACF,CAAC;GACW,SAAS,CAAG"}

View File

@@ -1,19 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
require("reflect-metadata");
const core_1 = require("@nestjs/core");
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const app_module_1 = require("./app.module");
async function bootstrap() {
const app = await core_1.NestFactory.create(app_module_1.AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
app.useGlobalPipes(new common_1.ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
const config = app.get(config_1.ConfigService);
const port = config.get('PORT', 3001);
await app.listen(port);
console.log(`[Reformix Image Worker] corriendo en puerto ${port}`);
}
bootstrap();
//# sourceMappingURL=main.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";;AAAA,4BAA0B;AAC1B,uCAA2C;AAC3C,2CAAgD;AAChD,2CAA+C;AAC/C,6CAAyC;AAEzC,KAAK,UAAU,SAAS;IACtB,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,sBAAS,EAAE;QAC9C,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC;KAC1C,CAAC,CAAC;IAEH,GAAG,CAAC,cAAc,CAAC,IAAI,uBAAc,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,oBAAoB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAEzG,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,sBAAa,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAC;AACrE,CAAC;AAED,SAAS,EAAE,CAAC"}

View File

@@ -1,94 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var ImageGeneratorService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ImageGeneratorService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const axios_1 = require("axios");
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
let ImageGeneratorService = ImageGeneratorService_1 = class ImageGeneratorService {
constructor(config) {
this.config = config;
this.logger = new common_1.Logger(ImageGeneratorService_1.name);
}
async generarRender(prompt, fotoAntesDataUri) {
const apiKey = this.config.get('OPENROUTER_API_KEY');
const model = this.config.get('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
const intentosRateLimit = 1;
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
try {
const response = await axios_1.default.post(OPENROUTER_URL, {
model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
],
},
],
}, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
timeout: 60000,
});
const content = response.data.choices?.[0]?.message?.content;
if (!content)
throw new Error('OpenRouter no devolvio contenido');
const imagen = this.extraerImagenDeRespuesta(content, response.data);
if (!imagen)
throw new Error('No se pudo extraer imagen de la respuesta');
return imagen;
}
catch (err) {
if (err.response?.status === 429 && attempt < intentosRateLimit) {
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
await new Promise((r) => setTimeout(r, 5000));
continue;
}
throw err;
}
}
throw new Error('Fallaron todos los intentos de generacion de imagen');
}
extraerImagenDeRespuesta(content, rawResponse) {
if (content.startsWith('data:image'))
return content;
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
if (dataUriMatch)
return dataUriMatch[0];
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
if (urlMatch)
return urlMatch[0];
const parts = rawResponse?.choices?.[0]?.message?.content;
if (Array.isArray(parts)) {
for (const part of parts) {
if (part.type === 'image_url' && part.image_url?.url)
return part.image_url.url;
if (part.image_url?.url?.startsWith('data:image'))
return part.image_url.url;
}
}
return null;
}
};
exports.ImageGeneratorService = ImageGeneratorService;
exports.ImageGeneratorService = ImageGeneratorService = ImageGeneratorService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService])
], ImageGeneratorService);
//# sourceMappingURL=image-generator.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"image-generator.service.js","sourceRoot":"","sources":["../../src/pipeline/image-generator.service.ts"],"names":[],"mappings":"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"}

View File

@@ -1,26 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PipelineModule = void 0;
const common_1 = require("@nestjs/common");
const pipeline_service_1 = require("./pipeline.service");
const prompt_builder_service_1 = require("./prompt-builder.service");
const image_generator_service_1 = require("./image-generator.service");
const supervisor_service_1 = require("./supervisor.service");
const reformix_module_1 = require("../reformix/reformix.module");
let PipelineModule = class PipelineModule {
};
exports.PipelineModule = PipelineModule;
exports.PipelineModule = PipelineModule = __decorate([
(0, common_1.Module)({
imports: [reformix_module_1.ReformixModule],
providers: [pipeline_service_1.PipelineService, prompt_builder_service_1.PromptBuilderService, image_generator_service_1.ImageGeneratorService, supervisor_service_1.SupervisorService],
exports: [pipeline_service_1.PipelineService],
})
], PipelineModule);
//# sourceMappingURL=pipeline.module.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"pipeline.module.js","sourceRoot":"","sources":["../../src/pipeline/pipeline.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,yDAAqD;AACrD,qEAAgE;AAChE,uEAAkE;AAClE,6DAAyD;AACzD,iEAA6D;AAOtD,IAAM,cAAc,GAApB,MAAM,cAAc;CAAG,CAAA;AAAjB,wCAAc;yBAAd,cAAc;IAL1B,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,gCAAc,CAAC;QACzB,SAAS,EAAE,CAAC,kCAAe,EAAE,6CAAoB,EAAE,+CAAqB,EAAE,sCAAiB,CAAC;QAC5F,OAAO,EAAE,CAAC,kCAAe,CAAC;KAC3B,CAAC;GACW,cAAc,CAAG"}

View File

@@ -1,96 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var PipelineService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PipelineService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const prompt_builder_service_1 = require("./prompt-builder.service");
const image_generator_service_1 = require("./image-generator.service");
const supervisor_service_1 = require("./supervisor.service");
const reformix_service_1 = require("../reformix/reformix.service");
let PipelineService = PipelineService_1 = class PipelineService {
constructor(config, promptBuilder, imageGenerator, supervisor, reformix) {
this.config = config;
this.promptBuilder = promptBuilder;
this.imageGenerator = imageGenerator;
this.supervisor = supervisor;
this.reformix = reformix;
this.logger = new common_1.Logger(PipelineService_1.name);
this.maxRetries = this.config.get('MAX_RETRIES', 2);
this.minScore = this.config.get('SUPERVISOR_MIN_SCORE', 70);
}
async procesarLead(dto) {
const { leadId, reforma, zonas } = dto;
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
this.logger.log(`[${leadId}] Iniciando pipeline para ${zonasConFotos.length} zonas`);
for (const z of zonasSaltadas) {
this.logger.log(`[${leadId}] Zona ${z.zona}: sin fotos "antes", saltando`);
}
const renders = [];
for (const zona of zonasConFotos) {
try {
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
renders.push(render);
}
catch (err) {
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
}
}
if (renders.length === 0) {
this.logger.warn(`[${leadId}] No se generaron renders para ninguna zona`);
return;
}
const items = renders.map((r) => ({
zona: r.zona,
imagen: r.imagen,
}));
const ok = await this.reformix.entregarRenders(leadId, items);
if (ok) {
this.logger.log(`[${leadId}] Renders entregados correctamente (${renders.length} zonas)`);
}
else {
this.logger.error(`[${leadId}] Error entregando renders a la app principal`);
}
}
async procesarZona(leadId, zona, reforma, notas, fotoAntes) {
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
let ultimaImagen = null;
for (let intento = 0; intento <= this.maxRetries; intento++) {
if (intento > 0) {
this.logger.log(`[${leadId}] Zona ${zona}: reintento ${intento} de ${this.maxRetries}`);
}
const imagen = await this.imageGenerator.generarRender(prompt, fotoAntes);
ultimaImagen = imagen;
this.logger.log(`[${leadId}] Zona ${zona}: imagen generada`);
const resultado = await this.supervisor.supervisar(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas, fotoAntes, imagen);
const aprobada = resultado.aprobado && resultado.score >= this.minScore;
this.logger.log(`[${leadId}] Zona ${zona}: ${aprobada ? 'aprobada' : 'rechazada'} (score: ${resultado.score}) - ${resultado.motivo}`);
if (aprobada) {
return { zona, imagen, score: resultado.score, aprobada: true };
}
}
this.logger.warn(`[${leadId}] Zona ${zona}: usando ultimo render pese a no superar validacion`);
return { zona, imagen: ultimaImagen, score: 0, aprobada: false };
}
};
exports.PipelineService = PipelineService;
exports.PipelineService = PipelineService = PipelineService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService,
prompt_builder_service_1.PromptBuilderService,
image_generator_service_1.ImageGeneratorService,
supervisor_service_1.SupervisorService,
reformix_service_1.ReformixService])
], PipelineService);
//# sourceMappingURL=pipeline.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"pipeline.service.js","sourceRoot":"","sources":["../../src/pipeline/pipeline.service.ts"],"names":[],"mappings":"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"}

View File

@@ -1,74 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var PromptBuilderService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PromptBuilderService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const fs = require("fs");
const path = require("path");
const axios_1 = require("axios");
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
let PromptBuilderService = PromptBuilderService_1 = class PromptBuilderService {
constructor(config) {
this.config = config;
this.logger = new common_1.Logger(PromptBuilderService_1.name);
this.systemPrompt = '';
const ruta = path.join(process.cwd(), 'prompts', 'prompt-builder.txt');
if (fs.existsSync(ruta)) {
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
}
else {
this.logger.warn('prompts/prompt-builder.txt no encontrado, usando default');
}
}
async generarPrompt(tipoReforma, m2Suelo, calidad, notas) {
const apiKey = this.config.get('OPENROUTER_API_KEY');
const model = this.config.get('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
- Area: ${m2Suelo ?? 'unknown'}
- Quality level: ${calidad}
- Client notes: ${notas.join('; ') || 'none'}
- Style: modern ${tipoReforma} renovation`;
try {
const response = await axios_1.default.post(OPENROUTER_URL, {
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'user', content: userContent },
],
max_tokens: 512,
temperature: 0.5,
}, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
});
const prompt = response.data.choices?.[0]?.message?.content?.trim();
if (!prompt)
throw new Error('OpenRouter devolvio respuesta vacia');
return prompt;
}
catch (err) {
this.logger.error(`Error generando prompt: ${err.message}`);
throw err;
}
}
};
exports.PromptBuilderService = PromptBuilderService;
exports.PromptBuilderService = PromptBuilderService = PromptBuilderService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService])
], PromptBuilderService);
//# sourceMappingURL=prompt-builder.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"prompt-builder.service.js","sourceRoot":"","sources":["../../src/pipeline/prompt-builder.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;AAAA,2CAAoD;AACpD,2CAA+C;AAC/C,yBAAyB;AACzB,6BAA6B;AAC7B,iCAA0B;AAE1B,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAGhE,IAAM,oBAAoB,4BAA1B,MAAM,oBAAoB;IAI/B,YAA6B,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;QAHjC,WAAM,GAAG,IAAI,eAAM,CAAC,sBAAoB,CAAC,IAAI,CAAC,CAAC;QACxD,iBAAY,GAAG,EAAE,CAAC;QAGxB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;QACvE,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACxB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,0DAA0D,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;IAED,KAAK,CAAC,aAAa,CACjB,WAAmB,EACnB,OAAsB,EACtB,OAAe,EACf,KAAe;QAEf,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,oBAAoB,CAAC,CAAC;QAC7D,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAS,wBAAwB,EAAE,qCAAqC,CAAC,CAAC;QAEvG,MAAM,WAAW,GAAG,kCAAkC,WAAW;UAC3D,OAAO,IAAI,SAAS;mBACX,OAAO;kBACR,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,MAAM;kBAC1B,WAAW,aAAa,CAAC;QAEvC,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,eAAK,CAAC,IAAI,CAC/B,cAAc,EACd;gBACE,KAAK;gBACL,QAAQ,EAAE;oBACR,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE;oBAC9C,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE;iBACvC;gBACD,UAAU,EAAE,GAAG;gBACf,WAAW,EAAE,GAAG;aACjB,EACD;gBACE,OAAO,EAAE;oBACP,aAAa,EAAE,UAAU,MAAM,EAAE;oBACjC,cAAc,EAAE,kBAAkB;oBAClC,cAAc,EAAE,qBAAqB;oBACrC,SAAS,EAAE,uBAAuB;iBACnC;aACF,CACF,CAAC;YAEF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YACpE,IAAI,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;YACpE,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,2BAA2B,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAC5D,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;CACF,CAAA;AA1DY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,mBAAU,GAAE;qCAK0B,sBAAa;GAJvC,oBAAoB,CA0DhC"}

View File

@@ -1,104 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var SupervisorService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SupervisorService = void 0;
const common_1 = require("@nestjs/common");
const config_1 = require("@nestjs/config");
const fs = require("fs");
const path = require("path");
const axios_1 = require("axios");
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
let SupervisorService = SupervisorService_1 = class SupervisorService {
constructor(config) {
this.config = config;
this.logger = new common_1.Logger(SupervisorService_1.name);
this.systemPrompt = '';
const ruta = path.join(process.cwd(), 'prompts', 'supervisor.txt');
if (fs.existsSync(ruta)) {
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
}
else {
this.logger.warn('prompts/supervisor.txt no encontrado, usando default');
}
}
async supervisar(tipoReforma, m2Suelo, calidad, notas, fotoAntes, renderDespues) {
const apiKey = this.config.get('OPENROUTER_API_KEY');
const model = this.config.get('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const notasTexto = notas.join('; ') || 'sin notas';
try {
const response = await axios_1.default.post(OPENROUTER_URL, {
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{
role: 'user',
content: [
{
type: 'text',
text: `Reforma tipo: ${tipoReforma}\nMetros: ${m2Suelo ?? 'desconocido'}\nCalidad: ${calidad}\nNotas del cliente: ${notasTexto}`,
},
{
type: 'image_url',
image_url: { url: fotoAntes },
},
{
type: 'image_url',
image_url: { url: renderDespues },
},
],
},
],
max_tokens: 256,
temperature: 0.2,
}, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
});
const textContent = response.data.choices?.[0]?.message?.content?.trim();
if (!textContent) {
return { aprobado: false, score: 0, motivo: 'Modelo devolvio respuesta vacia' };
}
return this.parsearRespuesta(textContent);
}
catch (err) {
this.logger.error(`Error en supervisor: ${err.message}`);
return { aprobado: false, score: 0, motivo: `Error del supervisor: ${err.message}` };
}
}
parsearRespuesta(texto) {
const jsonMatch = texto.match(/\{[^{}]*"aprobado"[^{}]*\}/i);
if (!jsonMatch) {
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
}
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
aprobado: Boolean(parsed.aprobado),
score: Math.min(100, Math.max(0, Number(parsed.score) || 0)),
motivo: String(parsed.motivo || 'Sin motivo'),
};
}
catch {
return { aprobado: false, score: 0, motivo: 'Error parseando respuesta del supervisor' };
}
}
};
exports.SupervisorService = SupervisorService;
exports.SupervisorService = SupervisorService = SupervisorService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_1.ConfigService])
], SupervisorService);
//# sourceMappingURL=supervisor.service.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"supervisor.service.js","sourceRoot":"","sources":["../../src/pipeline/supervisor.service.ts"],"names":[],"mappings":"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"}

View File

@@ -1,47 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var WebhookController_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookController = void 0;
const common_1 = require("@nestjs/common");
const webhook_dto_1 = require("./webhook.dto");
const pipeline_service_1 = require("../pipeline/pipeline.service");
let WebhookController = WebhookController_1 = class WebhookController {
constructor(pipelineService) {
this.pipelineService = pipelineService;
this.logger = new common_1.Logger(WebhookController_1.name);
}
recibirPerfil(dto) {
this.logger.log(`[${dto.leadId}] Webhook recibido: ${dto.zonas.length} zonas`);
setImmediate(() => {
this.pipelineService.procesarLead(dto).catch((err) => {
this.logger.error(`[${dto.leadId}] Pipeline fallo: ${err.message}`, err.stack);
});
});
return { ok: true, message: 'Procesando renders en background...' };
}
};
exports.WebhookController = WebhookController;
__decorate([
(0, common_1.Post)('perfil-completo'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [webhook_dto_1.PerfilCompletoDto]),
__metadata("design:returntype", void 0)
], WebhookController.prototype, "recibirPerfil", null);
exports.WebhookController = WebhookController = WebhookController_1 = __decorate([
(0, common_1.Controller)(),
__metadata("design:paramtypes", [pipeline_service_1.PipelineService])
], WebhookController);
//# sourceMappingURL=webhook.controller.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"webhook.controller.js","sourceRoot":"","sources":["../../src/webhook/webhook.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2CAAgE;AAChE,+CAAkD;AAClD,mEAA+D;AAGxD,IAAM,iBAAiB,yBAAvB,MAAM,iBAAiB;IAG5B,YAA6B,eAAgC;QAAhC,oBAAe,GAAf,eAAe,CAAiB;QAF5C,WAAM,GAAG,IAAI,eAAM,CAAC,mBAAiB,CAAC,IAAI,CAAC,CAAC;IAEG,CAAC;IAGjE,aAAa,CAAS,GAAsB;QAC1C,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,uBAAuB,GAAG,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;QAE/E,YAAY,CAAC,GAAG,EAAE;YAChB,IAAI,CAAC,eAAe,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;gBACnD,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,qBAAqB,GAAG,CAAC,OAAO,EAAE,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;YACjF,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,qCAAqC,EAAE,CAAC;IACtE,CAAC;CACF,CAAA;AAjBY,8CAAiB;AAM5B;IADC,IAAA,aAAI,EAAC,iBAAiB,CAAC;IACT,WAAA,IAAA,aAAI,GAAE,CAAA;;qCAAM,+BAAiB;;sDAU3C;4BAhBU,iBAAiB;IAD7B,IAAA,mBAAU,GAAE;qCAImC,kCAAe;GAHlD,iBAAiB,CAiB7B"}

View File

@@ -1,22 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookModule = void 0;
const common_1 = require("@nestjs/common");
const webhook_controller_1 = require("./webhook.controller");
const pipeline_module_1 = require("../pipeline/pipeline.module");
let WebhookModule = class WebhookModule {
};
exports.WebhookModule = WebhookModule;
exports.WebhookModule = WebhookModule = __decorate([
(0, common_1.Module)({
imports: [pipeline_module_1.PipelineModule],
controllers: [webhook_controller_1.WebhookController],
})
], WebhookModule);
//# sourceMappingURL=webhook.module.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"webhook.module.js","sourceRoot":"","sources":["../../src/webhook/webhook.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,6DAAyD;AACzD,iEAA6D;AAMtD,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IAJzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,gCAAc,CAAC;QACzB,WAAW,EAAE,CAAC,sCAAiB,CAAC;KACjC,CAAC;GACW,aAAa,CAAG"}

View File

@@ -4,10 +4,16 @@ Your task is to generate a detailed, technical prompt in English for an image-to
The prompt must include:
- Specific materials and finishes (tile type, countertop material, flooring)
- Lighting style (natural, warm artificial, accent)
- Color palette aligned with the quality level
- Style and atmosphere based on client notes
- A color palette and style
- Technical rendering keywords: photorealistic, 8k, architectural visualization, professional interior photography, high detail
Honoring the client's wishes is the top priority. When the input provides the client's desired
style or stated tastes (specific colors, materials, finishes or must-haves), you MUST reflect them
faithfully in the prompt: use the exact colors and materials they asked for and the style they want,
even if it differs from a generic "modern" look. Only when no style or tastes are given should you
infer a tasteful design. The color palette should follow the client's stated colors when provided,
otherwise align it with the quality level. Keep the existing layout/structure of the real photo.
Quality level guide:
- basica: standard materials, functional design, clean finishes
- media: mid-range materials, modern design, quality finishes

View File

@@ -3,13 +3,17 @@ import { ConfigModule } from '@nestjs/config';
import { WebhookModule } from './webhook/webhook.module';
import { PipelineModule } from './pipeline/pipeline.module';
import { ReformixModule } from './reformix/reformix.module';
import { SettingsModule } from './settings/settings.module';
import { SandboxModule } from './sandbox/sandbox.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
SettingsModule,
WebhookModule,
PipelineModule,
ReformixModule,
SandboxModule,
],
})
export class AppModule {}

View File

@@ -2,6 +2,7 @@ import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
@@ -9,6 +10,10 @@ async function bootstrap() {
logger: ['error', 'warn', 'log', 'debug'],
});
// Las fotos viajan como data URI (base64) → subir el límite por defecto de Express (100kb).
app.use(json({ limit: '30mb' }));
app.use(urlencoded({ limit: '30mb', extended: true }));
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
const config = app.get(ConfigService);

View File

@@ -1,84 +1,130 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { SettingsService } from '../settings/settings.service';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface GenerarRenderResultado {
imagen: string | null;
error?: string;
debug: Record<string, unknown>;
}
@Injectable()
export class ImageGeneratorService {
private readonly logger = new Logger(ImageGeneratorService.name);
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async generarRender(prompt: string, fotoAntesDataUri: string): Promise<string> {
// Usado por el pipeline real: devuelve la imagen o lanza.
async generarRender(prompt: string, fotoAntesDataUri: string, opts?: { model?: string }): Promise<string> {
const r = await this.generarConDebug(prompt, fotoAntesDataUri, opts);
if (!r.imagen) throw new Error(r.error || 'No se pudo extraer imagen de la respuesta');
return r.imagen;
}
// Usado por el sandbox: nunca lanza, devuelve imagen|null + info de depuración (sin volcar base64).
async generarConDebug(
prompt: string,
fotoAntesDataUri: string,
opts?: { model?: string },
): Promise<GenerarRenderResultado> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = this.config.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.0-flash-exp-image-generation');
const model = opts?.model?.trim() || this.settings.getModeloImagen();
const intentosRateLimit = 1;
for (let attempt = 0; attempt <= intentosRateLimit; attempt++) {
try {
const response = await axios.post(
OPENROUTER_URL,
{
model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
],
},
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
try {
const response = await axios.post(
OPENROUTER_URL,
{
model,
// Necesario para que OpenRouter devuelva imagen además de texto.
modalities: ['image', 'text'],
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: fotoAntesDataUri } },
],
},
timeout: 60000,
],
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://reformix.es',
'X-Title': 'Reformix Image Worker',
},
);
timeout: 90000,
},
);
const content = response.data.choices?.[0]?.message?.content;
if (!content) throw new Error('OpenRouter no devolvio contenido');
const message = response.data?.choices?.[0]?.message;
const imagen = this.extraerImagen(message);
return {
imagen,
error: imagen ? undefined : 'No se encontró imagen en la respuesta de OpenRouter',
debug: this.resumenDebug(response.data, model, imagen),
};
} catch (err: any) {
const status = err.response?.status;
const msg = err.response?.data?.error?.message || err.message;
this.logger.error(`Error generando imagen (${status ?? 'sin status'}): ${msg}`);
return {
imagen: null,
error: msg,
debug: { model, status, error: msg },
};
}
}
const imagen = this.extraerImagenDeRespuesta(content, response.data);
if (!imagen) throw new Error('No se pudo extraer imagen de la respuesta');
private extraerImagen(message: any): string | null {
if (!message) return null;
return imagen;
} catch (err: any) {
if (err.response?.status === 429 && attempt < intentosRateLimit) {
this.logger.warn('Rate limit (429), esperando 5s y reintentando...');
await new Promise((r) => setTimeout(r, 5000));
continue;
}
throw err;
// 1) Forma de OpenRouter para image-gen: message.images[].image_url.url
if (Array.isArray(message.images)) {
for (const img of message.images) {
const url = img?.image_url?.url ?? img?.url ?? (typeof img === 'string' ? img : null);
if (typeof url === 'string' && url) return url;
}
}
throw new Error('Fallaron todos los intentos de generacion de imagen');
}
private extraerImagenDeRespuesta(content: string, rawResponse?: any): string | null {
if (content.startsWith('data:image')) return content;
const dataUriMatch = content.match(/data:image\/[a-zA-Z]+;base64,[^\s"']+/);
if (dataUriMatch) return dataUriMatch[0];
const urlMatch = content.match(/https?:\/\/[^\s"'()]+\.(png|jpg|jpeg|webp)/i);
if (urlMatch) return urlMatch[0];
const parts = rawResponse?.choices?.[0]?.message?.content;
if (Array.isArray(parts)) {
for (const part of parts) {
if (part.type === 'image_url' && part.image_url?.url) return part.image_url.url;
if (part.image_url?.url?.startsWith('data:image')) return part.image_url.url;
const content = message.content;
if (typeof content === 'string') {
if (content.startsWith('data:image')) return content;
const dataUri = content.match(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+/);
if (dataUri) return dataUri[0];
const url = content.match(/https?:\/\/[^\s"'()]+\.(?:png|jpg|jpeg|webp)/i);
if (url) return url[0];
} else if (Array.isArray(content)) {
for (const part of content) {
const url = part?.image_url?.url;
if (typeof url === 'string' && url) return url;
}
}
return null;
}
// Resumen compacto para depurar el formato real sin meter el base64 (enorme) en la respuesta.
private resumenDebug(data: any, model: string, imagen: string | null): Record<string, unknown> {
const msg = data?.choices?.[0]?.message;
return {
model,
modelDevuelto: data?.model,
finishReason: data?.choices?.[0]?.finish_reason,
messageKeys: msg ? Object.keys(msg) : [],
imagesCount: Array.isArray(msg?.images) ? msg.images.length : 0,
contentType: Array.isArray(msg?.content) ? 'array' : typeof msg?.content,
contentPreview: typeof msg?.content === 'string' ? msg.content.slice(0, 200) : undefined,
imagenEncontrada: !!imagen,
imagenTipo: imagen ? (imagen.startsWith('data:') ? 'data-uri' : 'url') : null,
usage: data?.usage,
};
}
}

View File

@@ -8,6 +8,6 @@ import { ReformixModule } from '../reformix/reformix.module';
@Module({
imports: [ReformixModule],
providers: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
exports: [PipelineService],
exports: [PipelineService, PromptBuilderService, ImageGeneratorService, SupervisorService],
})
export class PipelineModule {}

View File

@@ -31,7 +31,7 @@ export class PipelineService {
}
async procesarLead(dto: PerfilCompletoDto): Promise<void> {
const { leadId, reforma, zonas } = dto;
const { leadId, reforma, zonas, preferencias } = dto;
const zonasConFotos = zonas.filter((z) => z.fotos.antes.length > 0);
const zonasSaltadas = zonas.filter((z) => z.fotos.antes.length === 0);
@@ -45,7 +45,7 @@ export class PipelineService {
for (const zona of zonasConFotos) {
try {
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0]);
const render = await this.procesarZona(leadId, zona.zona, reforma, zona.notas, zona.fotos.antes[0], preferencias);
renders.push(render);
} catch (err: any) {
this.logger.error(`[${leadId}] Zona ${zona.zona}: error fatal: ${err.message}`);
@@ -76,8 +76,9 @@ export class PipelineService {
reforma: PerfilCompletoDto['reforma'],
notas: string[],
fotoAntes: string,
preferencias?: PerfilCompletoDto['preferencias'],
): Promise<ZonaRender> {
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas);
const prompt = await this.promptBuilder.generarPrompt(reforma.tipo, reforma.m2Suelo, reforma.calidad, notas, preferencias);
this.logger.log(`[${leadId}] Zona ${zona}: prompt generado`);
let ultimaImagen: string | null = null;

View File

@@ -1,39 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { SettingsService } from '../settings/settings.service';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface PromptBuilderOpts {
systemPrompt?: string;
model?: string;
}
export interface PreferenciasCliente {
estilo?: string;
gustos?: string;
}
// Arma el mensaje de usuario para el LLM constructor de prompts. Función pura (sin red) para poder
// testearla. Si el cliente expresó estilo o gustos (color/material/acabados), se incluyen como bloque
// dedicado y se omite el "modern" por defecto, para que el render represente lo que pidió.
export function construirUserContent(
tipoReforma: string,
m2Suelo: number | null,
calidad: string,
notas: string[],
preferencias?: PreferenciasCliente,
): string {
const lineas = [
`Generate a render prompt for a ${tipoReforma} renovation.`,
`- Area: ${m2Suelo ?? 'unknown'}`,
`- Quality level: ${calidad}`,
`- Client notes: ${notas.join('; ') || 'none'}`,
];
const estilo = preferencias?.estilo?.trim();
const gustos = preferencias?.gustos?.trim();
if (estilo) lineas.push(`- Client's desired style: ${estilo}`);
if (gustos) {
lineas.push(
`- Client's stated tastes (colors, materials, finishes, must-haves) — honor these in the render: ${gustos}`,
);
}
if (!estilo && !gustos) lineas.push(`- Style: modern ${tipoReforma} renovation`);
return lineas.join('\n');
}
@Injectable()
export class PromptBuilderService {
private readonly logger = new Logger(PromptBuilderService.name);
private systemPrompt = '';
constructor(private readonly config: ConfigService) {
const ruta = path.join(process.cwd(), 'prompts', 'prompt-builder.txt');
if (fs.existsSync(ruta)) {
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
} else {
this.logger.warn('prompts/prompt-builder.txt no encontrado, usando default');
}
}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async generarPrompt(
tipoReforma: string,
m2Suelo: number | null,
calidad: string,
notas: string[],
preferencias?: PreferenciasCliente,
opts?: PromptBuilderOpts,
): Promise<string> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const model = opts?.model?.trim() || this.settings.getModeloTexto();
const systemPrompt = opts?.systemPrompt ?? this.settings.getPromptBuilder();
const userContent = `Generate a render prompt for a ${tipoReforma} renovation.
- Area: ${m2Suelo ?? 'unknown'}
- Quality level: ${calidad}
- Client notes: ${notas.join('; ') || 'none'}
- Style: modern ${tipoReforma} renovation`;
const userContent = construirUserContent(tipoReforma, m2Suelo, calidad, notas, preferencias);
try {
const response = await axios.post(
@@ -41,7 +72,7 @@ export class PromptBuilderService {
{
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
max_tokens: 512,

View File

@@ -1,8 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import axios from 'axios';
import { SettingsService } from '../settings/settings.service';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
@@ -12,19 +11,19 @@ export interface SupervisarResultado {
motivo: string;
}
export interface SupervisorOpts {
systemPrompt?: string;
model?: string;
}
@Injectable()
export class SupervisorService {
private readonly logger = new Logger(SupervisorService.name);
private systemPrompt = '';
constructor(private readonly config: ConfigService) {
const ruta = path.join(process.cwd(), 'prompts', 'supervisor.txt');
if (fs.existsSync(ruta)) {
this.systemPrompt = fs.readFileSync(ruta, 'utf-8');
} else {
this.logger.warn('prompts/supervisor.txt no encontrado, usando default');
}
}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async supervisar(
tipoReforma: string,
@@ -33,10 +32,11 @@ export class SupervisorService {
notas: string[],
fotoAntes: string,
renderDespues: string,
opts?: SupervisorOpts,
): Promise<SupervisarResultado> {
const apiKey = this.config.get<string>('OPENROUTER_API_KEY');
const model = this.config.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022');
const model = opts?.model?.trim() || this.settings.getModeloTexto();
const systemPrompt = opts?.systemPrompt ?? this.settings.getSupervisor();
const notasTexto = notas.join('; ') || 'sin notas';
try {
@@ -45,7 +45,7 @@ export class SupervisorService {
{
model,
messages: [
{ role: 'system', content: this.systemPrompt },
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
@@ -53,14 +53,8 @@ export class SupervisorService {
type: 'text',
text: `Reforma tipo: ${tipoReforma}\nMetros: ${m2Suelo ?? 'desconocido'}\nCalidad: ${calidad}\nNotas del cliente: ${notasTexto}`,
},
{
type: 'image_url',
image_url: { url: fotoAntes },
},
{
type: 'image_url',
image_url: { url: renderDespues },
},
{ type: 'image_url', image_url: { url: fotoAntes } },
{ type: 'image_url', image_url: { url: renderDespues } },
],
},
],

View File

@@ -0,0 +1,137 @@
import {
Controller,
Get,
Post,
Body,
Req,
Header,
UnauthorizedException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SettingsService } from '../settings/settings.service';
import { PromptBuilderService } from '../pipeline/prompt-builder.service';
import { ImageGeneratorService } from '../pipeline/image-generator.service';
import { SupervisorService } from '../pipeline/supervisor.service';
import { SANDBOX_HTML } from './sandbox.page';
@Controller('sandbox')
export class SandboxController {
private readonly logger = new Logger(SandboxController.name);
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
private readonly promptBuilder: PromptBuilderService,
private readonly imageGenerator: ImageGeneratorService,
private readonly supervisor: SupervisorService,
) {}
private autorizado(req: any): boolean {
const key = this.config.get<string>('FUNNEL_API_KEY');
if (!key) return false;
const auth = (req.headers?.authorization as string) || '';
return auth === `Bearer ${key}`;
}
private soloTexto(v: unknown): string | undefined {
return typeof v === 'string' && v.trim() ? v : undefined;
}
@Get()
@Header('Content-Type', 'text/html; charset=utf-8')
page(): string {
return SANDBOX_HTML;
}
@Get('config')
getConfig(@Req() req: any) {
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
return {
...this.settings.getAll(),
maxRetries: Number(this.config.get('MAX_RETRIES', 2)),
minScore: Number(this.config.get('SUPERVISOR_MIN_SCORE', 70)),
};
}
@Post('save')
save(@Req() req: any, @Body() body: Record<string, any>) {
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
const config = this.settings.guardar({
promptBuilder: body.promptBuilder,
supervisor: body.supervisor,
modeloTexto: body.modeloTexto,
modeloImagen: body.modeloImagen,
});
this.logger.log('Config guardada desde el sandbox');
return { ok: true, config };
}
@Post('render')
async render(@Req() req: any, @Body() body: Record<string, any>) {
if (!this.autorizado(req)) throw new UnauthorizedException('No autorizado');
const imagenAntes = body.imagenAntes;
if (typeof imagenAntes !== 'string' || !imagenAntes) {
throw new BadRequestException('Falta imagenAntes (data URI).');
}
const tipo = this.soloTexto(body.tipo) || 'otro';
const calidad = this.soloTexto(body.calidad) || 'media';
const m2 = typeof body.m2 === 'number' ? body.m2 : null;
const notas: string[] = Array.isArray(body.notas)
? body.notas.map((n: unknown) => String(n)).filter(Boolean)
: String(body.notas ?? '')
.split(';')
.map((s) => s.trim())
.filter(Boolean);
const modeloTexto = this.soloTexto(body.modeloTexto);
const modeloImagen = this.soloTexto(body.modeloImagen);
const systemPromptBuilder = this.soloTexto(body.systemPromptBuilder);
const supervisorPrompt = this.soloTexto(body.supervisorPrompt);
const estiloPref = this.soloTexto(body.estilo);
const gustosPref = this.soloTexto(body.gustos);
const supervisar = body.supervisar !== false;
const n = Math.min(4, Math.max(1, Number(body.nVariaciones) || 1));
const minScore = Number(this.config.get('SUPERVISOR_MIN_SCORE', 70));
// Prompt de imagen: directo (si lo pasan) o vía Prompt Builder.
const promptDirecto = this.soloTexto(body.promptDirecto);
let promptUsado: string;
if (promptDirecto) {
promptUsado = promptDirecto;
} else {
promptUsado = await this.promptBuilder.generarPrompt(
tipo,
m2,
calidad,
notas,
{ estilo: estiloPref, gustos: gustosPref },
{ systemPrompt: systemPromptBuilder, model: modeloTexto },
);
}
const variaciones: Array<Record<string, unknown>> = [];
for (let i = 0; i < n; i++) {
const gen = await this.imageGenerator.generarConDebug(promptUsado, imagenAntes, { model: modeloImagen });
const v: Record<string, unknown> = {
imagen: gen.imagen,
error: gen.error,
debug: gen.debug,
};
if (supervisar && gen.imagen) {
const sup = await this.supervisor.supervisar(tipo, m2, calidad, notas, imagenAntes, gen.imagen, {
systemPrompt: supervisorPrompt,
model: modeloTexto,
});
v.score = sup.score;
v.motivo = sup.motivo;
v.aprobado = sup.aprobado && sup.score >= minScore;
}
variaciones.push(v);
}
return { promptUsado, variaciones };
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SandboxController } from './sandbox.controller';
import { PipelineModule } from '../pipeline/pipeline.module';
@Module({
imports: [PipelineModule],
controllers: [SandboxController],
})
export class SandboxModule {}

View File

@@ -0,0 +1,225 @@
// Página del sandbox (HTML autocontenido). El <script> evita backticks y la secuencia dólar+llave
// para no colisionar con el template literal que lo envuelve.
export const SANDBOX_HTML = `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reformix · Sandbox de renders</title>
<style>
:root { --bg:#0f1115; --panel:#181b22; --border:#2a2f3a; --fg:#e6e8ec; --muted:#9aa3b2; --accent:#4f8cff; --ok:#2ecc71; --bad:#ff5555; }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); }
header { padding:14px 20px; border-bottom:1px solid var(--border); display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
header h1 { font-size:16px; margin:0; font-weight:700; }
header .key { margin-left:auto; display:flex; gap:8px; align-items:center; }
.layout { display:grid; grid-template-columns:380px 1fr; gap:16px; padding:16px; align-items:start; }
@media (max-width:900px){ .layout { grid-template-columns:1fr; } }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; }
label { display:block; font-size:12px; color:var(--muted); margin:10px 0 4px; font-weight:600; }
input, select, textarea, button { font:inherit; color:var(--fg); background:#11141a; border:1px solid var(--border); border-radius:7px; padding:8px 10px; width:100%; }
textarea { resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; line-height:1.4; }
.row { display:flex; gap:8px; } .row > * { flex:1; }
button { cursor:pointer; width:auto; }
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:700; }
.btn-ghost { background:transparent; }
.actions { display:flex; gap:8px; margin-top:14px; }
.preview { margin-top:8px; max-width:100%; border-radius:8px; border:1px solid var(--border); display:none; }
details { margin-top:10px; } summary { cursor:pointer; color:var(--muted); font-size:12px; }
.status { margin-top:10px; font-size:13px; color:var(--muted); min-height:18px; }
.promptUsado { white-space:pre-wrap; background:#11141a; border:1px solid var(--border); border-radius:8px; padding:10px; font-family:ui-monospace,monospace; font-size:12px; color:#cdd3dd; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; margin-top:14px; }
.card { background:#11141a; border:1px solid var(--border); border-radius:10px; overflow:hidden; }
.card img { width:100%; display:block; background:#000; }
.card .meta { padding:10px; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:700; }
.badge.ok { background:rgba(46,204,113,.15); color:var(--ok); } .badge.bad { background:rgba(255,85,85,.15); color:var(--bad); }
.motivo { font-size:12px; color:var(--muted); margin-top:6px; }
pre { white-space:pre-wrap; word-break:break-word; font-size:11px; color:#9aa3b2; background:#0c0e12; padding:8px; border-radius:6px; max-height:220px; overflow:auto; }
.hint { font-size:11px; color:var(--muted); margin-top:4px; }
</style>
</head>
<body>
<header>
<h1>🎨 Reformix · Sandbox de renders</h1>
<div class="key">
<input id="apiKey" type="password" placeholder="FUNNEL_API_KEY" style="width:240px" />
<button class="btn-ghost" onclick="guardarKey()">Recordar</button>
</div>
</header>
<div class="layout">
<div class="panel">
<label>Foto "antes"</label>
<input id="foto" type="file" accept="image/*" onchange="cargarFoto(event)" />
<img id="fotoPreview" class="preview" />
<div class="row">
<div>
<label>Tipo</label>
<select id="tipo">
<option>cocina</option><option>bano</option><option>salon</option>
<option>comedor</option><option>integral</option><option>otro</option>
</select>
</div>
<div>
<label>Calidad</label>
<select id="calidad"><option>basica</option><option selected>media</option><option>premium</option></select>
</div>
</div>
<div class="row">
<div><label>m²</label><input id="m2" type="number" value="12" /></div>
<div><label>Variaciones</label><input id="n" type="number" value="1" min="1" max="4" /></div>
</div>
<label>Notas del cliente (separa con ;)</label>
<input id="notas" placeholder="encimera de cuarzo; suelo porcelánico claro" />
<label>Estilo del cliente (opcional)</label>
<input id="estilo" placeholder="nórdico, industrial, clásico..." />
<label>Gustos del cliente · color, materiales, must-haves (opcional)</label>
<input id="gustos" placeholder="tonos azules, muebles de madera, encimera clara" />
<div class="hint">Si los rellenas, el render los prioriza sobre un estilo genérico.</div>
<div class="row">
<div><label>Modelo texto</label><input id="modeloTexto" /></div>
<div><label>Modelo imagen</label><input id="modeloImagen" /></div>
</div>
<div class="hint">Imagen sugerida: google/gemini-2.5-flash-image-preview</div>
<label><input type="checkbox" id="supervisar" checked style="width:auto;margin-right:6px" />Pasar por el supervisor (puntúa)</label>
<label>Prompt de imagen directo (opcional — si lo rellenas, se salta el Prompt Builder)</label>
<textarea id="promptDirecto" rows="3" placeholder="Photorealistic render of a modern kitchen..."></textarea>
<details open>
<summary>System prompt · Prompt Builder</summary>
<textarea id="systemPromptBuilder" rows="10"></textarea>
</details>
<details>
<summary>System prompt · Supervisor (avanzado)</summary>
<textarea id="supervisorPrompt" rows="8"></textarea>
</details>
<div class="actions">
<button class="btn-primary" onclick="generar()">Generar</button>
<button class="btn-ghost" onclick="guardar()">Guardar config en el worker</button>
</div>
<div class="status" id="status"></div>
</div>
<div class="panel">
<label>Prompt usado</label>
<div class="promptUsado" id="promptUsado">—</div>
<div class="grid" id="resultados"></div>
</div>
</div>
<script>
function key(){ return document.getElementById('apiKey').value.trim(); }
function guardarKey(){ localStorage.setItem('reformix_funnel_key', key()); setStatus('Key recordada en este navegador.'); }
function setStatus(t){ document.getElementById('status').textContent = t || ''; }
function headers(){ return { 'Content-Type':'application/json', 'Authorization':'Bearer ' + key() }; }
function val(id){ return document.getElementById(id).value; }
var fotoDataUri = '';
function cargarFoto(e){
var f = e.target.files[0]; if(!f) return;
var r = new FileReader();
r.onload = function(){ fotoDataUri = r.result; var img = document.getElementById('fotoPreview'); img.src = fotoDataUri; img.style.display='block'; };
r.readAsDataURL(f);
}
function notasArray(){ return val('notas').split(';').map(function(s){return s.trim();}).filter(Boolean); }
async function cargarConfig(){
if(!key()) return;
try {
var res = await fetch('/sandbox/config', { headers: headers() });
if(!res.ok) return;
var c = await res.json();
if(!val('systemPromptBuilder')) document.getElementById('systemPromptBuilder').value = c.promptBuilder || '';
if(!val('supervisorPrompt')) document.getElementById('supervisorPrompt').value = c.supervisor || '';
if(!val('modeloTexto')) document.getElementById('modeloTexto').value = c.modeloTexto || '';
if(!val('modeloImagen')) document.getElementById('modeloImagen').value = c.modeloImagen || '';
setStatus('Config actual cargada (maxRetries=' + c.maxRetries + ', minScore=' + c.minScore + ').');
} catch(e){}
}
async function generar(){
if(!key()) return setStatus('Pon la FUNNEL_API_KEY arriba.');
if(!fotoDataUri) return setStatus('Sube una foto "antes".');
setStatus('Generando... (puede tardar bastantes segundos por variación)');
document.getElementById('resultados').innerHTML = '';
document.getElementById('promptUsado').textContent = '—';
var body = {
imagenAntes: fotoDataUri,
tipo: val('tipo'), calidad: val('calidad'),
m2: Number(val('m2')) || null, notas: notasArray(),
estilo: val('estilo').trim() || undefined,
gustos: val('gustos').trim() || undefined,
promptDirecto: val('promptDirecto').trim() || null,
systemPromptBuilder: val('systemPromptBuilder'),
supervisorPrompt: val('supervisorPrompt'),
modeloTexto: val('modeloTexto').trim() || null,
modeloImagen: val('modeloImagen').trim() || null,
nVariaciones: Number(val('n')) || 1,
supervisar: document.getElementById('supervisar').checked
};
try {
var res = await fetch('/sandbox/render', { method:'POST', headers: headers(), body: JSON.stringify(body) });
var data = await res.json();
if(!res.ok){ setStatus('Error: ' + (data.error || res.status)); return; }
document.getElementById('promptUsado').textContent = data.promptUsado || '(prompt directo)';
pintar(data.variaciones || []);
setStatus('Listo: ' + (data.variaciones||[]).length + ' variación(es).');
} catch(e){ setStatus('Fallo de red: ' + e.message); }
}
// Construido con DOM seguro (sin innerHTML de contenido externo): src/textContent no inyectan.
function el(tag, props){ var e = document.createElement(tag); if(props) Object.keys(props).forEach(function(k){ e[k] = props[k]; }); return e; }
function pintar(vars){
var cont = document.getElementById('resultados');
cont.replaceChildren();
vars.forEach(function(v, i){
var card = el('div', { className:'card' });
if(v.imagen){ card.appendChild(el('img', { src: v.imagen, alt: 'render ' + (i+1) })); }
else { card.appendChild(el('div', { textContent:'Sin imagen', style:'padding:20px;color:#ff5555;font-size:13px' })); }
var meta = el('div', { className:'meta' });
if(typeof v.score === 'number'){
meta.appendChild(el('span', { className:'badge ' + (v.aprobado?'ok':'bad'), textContent:'score ' + v.score + (v.aprobado?' · aprobada':' · rechazada') }));
}
if(v.motivo){ meta.appendChild(el('div', { className:'motivo', textContent: v.motivo })); }
if(v.error){ var er = el('div', { className:'motivo', textContent: v.error }); er.style.color = '#ff5555'; meta.appendChild(er); }
var det = el('details'); det.appendChild(el('summary', { textContent:'debug' }));
det.appendChild(el('pre', { textContent: JSON.stringify(v.debug||{}, null, 2) }));
meta.appendChild(det);
card.appendChild(meta);
cont.appendChild(card);
});
}
async function guardar(){
if(!key()) return setStatus('Pon la FUNNEL_API_KEY arriba.');
var body = {
promptBuilder: val('systemPromptBuilder'),
supervisor: val('supervisorPrompt'),
modeloTexto: val('modeloTexto').trim() || undefined,
modeloImagen: val('modeloImagen').trim() || undefined
};
if(!confirm('Guardar estos system prompts y modelos como la config del worker? Afecta a los renders reales de inmediato.')) return;
setStatus('Guardando...');
try {
var res = await fetch('/sandbox/save', { method:'POST', headers: headers(), body: JSON.stringify(body) });
var data = await res.json();
if(!res.ok){ setStatus('Error guardando: ' + (data.error || res.status)); return; }
setStatus('Guardado. El worker ya usa esta config (sin redeploy).');
} catch(e){ setStatus('Fallo de red: ' + e.message); }
}
(function init(){
var k = localStorage.getItem('reformix_funnel_key');
if(k){ document.getElementById('apiKey').value = k; cargarConfig(); }
})();
</script>
</body>
</html>`;

View File

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

View File

@@ -0,0 +1,98 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
export interface SandboxConfig {
promptBuilder: string;
supervisor: string;
modeloTexto: string;
modeloImagen: string;
}
const DATA_DIR = process.env.SANDBOX_DATA_DIR || path.join(process.cwd(), 'data');
const CONFIG_FILE = path.join(DATA_DIR, 'sandbox-config.json');
const CAMPOS = ['promptBuilder', 'supervisor', 'modeloTexto', 'modeloImagen'] as const;
// Config efectiva del pipeline (system prompts + modelos). Arranca de los defaults (ficheros
// prompts/*.txt + env) y se superpone lo guardado desde el sandbox, persistido en un volumen
// (CONFIG_FILE) para que sobreviva a redeploys. El pipeline real lee de aquí en cada llamada,
// así que "guardar" en el sandbox aplica al worker al instante sin redeploy.
@Injectable()
export class SettingsService {
private readonly logger = new Logger(SettingsService.name);
private config: SandboxConfig;
constructor(private readonly env: ConfigService) {
this.config = this.cargarDefaults();
this.overlayPersistido();
}
private leerPrompt(archivo: string): string {
try {
const ruta = path.join(process.cwd(), 'prompts', archivo);
if (fs.existsSync(ruta)) return fs.readFileSync(ruta, 'utf-8');
} catch {
/* defaults vacíos si no hay fichero */
}
return '';
}
private cargarDefaults(): SandboxConfig {
return {
promptBuilder: this.leerPrompt('prompt-builder.txt'),
supervisor: this.leerPrompt('supervisor.txt'),
modeloTexto: this.env.get<string>('OPENROUTER_MODEL_TEXTO', 'anthropic/claude-3.5-haiku-20241022'),
modeloImagen: this.env.get<string>('OPENROUTER_MODEL_IMAGEN', 'google/gemini-2.5-flash-image-preview'),
};
}
private overlayPersistido(): void {
try {
if (fs.existsSync(CONFIG_FILE)) {
const saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
this.config = { ...this.config, ...this.limpiar(saved) };
this.logger.log(`Config de sandbox cargada de ${CONFIG_FILE}`);
}
} catch (err: any) {
this.logger.warn(`No se pudo leer ${CONFIG_FILE}: ${err.message}`);
}
}
private limpiar(o: any): Partial<SandboxConfig> {
const out: Partial<SandboxConfig> = {};
for (const k of CAMPOS) {
if (typeof o?.[k] === 'string' && o[k].trim()) out[k] = o[k];
}
return out;
}
getAll(): SandboxConfig {
return { ...this.config };
}
getPromptBuilder(): string {
return this.config.promptBuilder;
}
getSupervisor(): string {
return this.config.supervisor;
}
getModeloTexto(): string {
return this.config.modeloTexto;
}
getModeloImagen(): string {
return this.config.modeloImagen;
}
guardar(partial: Partial<SandboxConfig>): SandboxConfig {
this.config = { ...this.config, ...this.limpiar(partial) };
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf-8');
this.logger.log(`Config de sandbox guardada en ${CONFIG_FILE}`);
} catch (err: any) {
this.logger.error(`No se pudo guardar la config: ${err.message}`);
throw err;
}
return this.getAll();
}
}

View File

@@ -43,6 +43,16 @@ class ReformaDto {
presupuestoTarget?: number;
}
class PreferenciasDto {
@IsOptional()
@IsString()
estilo?: string;
@IsOptional()
@IsString()
gustos?: string;
}
class EmpresaDto {
@IsUUID()
tenantId: string;
@@ -86,6 +96,11 @@ export class PerfilCompletoDto {
@Type(() => ReformaDto)
reforma: ReformaDto;
@IsOptional()
@ValidateNested()
@Type(() => PreferenciasDto)
preferencias?: PreferenciasDto;
@ValidateNested()
@Type(() => EmpresaDto)
empresa: EmpresaDto;

File diff suppressed because one or more lines are too long