Compare commits
94 Commits
e26e6be38b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff047cac2e | ||
|
|
d783ce56d4 | ||
|
|
ba7b10a778 | ||
|
|
b4e8f6d3a3 | ||
|
|
dbef9ef670 | ||
|
|
facf3cd79f | ||
|
|
5afda5af05 | ||
|
|
df700bcbfb | ||
|
|
d92d5e2f12 | ||
|
|
b815b0532b | ||
|
|
0c033eb367 | ||
|
|
ad87e45892 | ||
|
|
f6e0347143 | ||
|
|
dabdf32b8e | ||
|
|
b0871b733c | ||
|
|
c5d4a9296a | ||
|
|
166d52f46d | ||
|
|
1471261a73 | ||
|
|
50480b6fc5 | ||
|
|
face2d3d1b | ||
|
|
5004575768 | ||
|
|
e5be1220d8 | ||
|
|
78079e9455 | ||
|
|
8d565e5fb0 | ||
|
|
a8b6d62dd6 | ||
|
|
0a00d42553 | ||
|
|
a740d08863 | ||
|
|
a43e7a77be | ||
|
|
062a34c144 | ||
|
|
89166857e7 | ||
|
|
c5e73d1688 | ||
|
|
d0b9744a24 | ||
|
|
d34925cd7f | ||
|
|
25669f3008 | ||
|
|
cb44779349 | ||
|
|
fec365bb57 | ||
|
|
d3189d7277 | ||
|
|
8b96037dad | ||
|
|
508fc43f1f | ||
|
|
98f02eb02e | ||
|
|
db46dc9cf3 | ||
|
|
ac04972100 | ||
|
|
aed0ffae50 | ||
|
|
6ef69b403d | ||
|
|
340c25f1a4 | ||
|
|
af4d1fa001 | ||
|
|
35669fa207 | ||
|
|
782b847af5 | ||
|
|
1034994e3b | ||
|
|
620e1410f7 | ||
|
|
ca40593b5c | ||
|
|
6d8fc56fb1 | ||
|
|
f9d112ecaa | ||
|
|
0b46de89f2 | ||
|
|
f2b19ab719 | ||
|
|
cd38fe6233 | ||
|
|
4d464d40ef | ||
|
|
2bc34d4017 | ||
|
|
2e3cd78216 | ||
|
|
daa58c39a1 | ||
|
|
8ef99b56fe | ||
|
|
aee82267d0 | ||
|
|
f082351b43 | ||
|
|
5df608f203 | ||
|
|
0a5f8cba2b | ||
|
|
9b5b0d59a6 | ||
|
|
f87a3ecd81 | ||
|
|
35ba2f28fe | ||
|
|
ae8984fe13 | ||
|
|
195ecf6cc3 | ||
|
|
ec09267d99 | ||
|
|
bcc882e37d | ||
|
|
737496ed89 | ||
|
|
b9dd90f4ef | ||
|
|
cd6532eb1b | ||
|
|
1a70ab2eaa | ||
|
|
4aa0582f53 | ||
|
|
d2d42ff1d4 | ||
|
|
e5c8956b64 | ||
|
|
fc6a7044b0 | ||
|
|
7b0eb31f56 | ||
|
|
9c5e6bc7fa | ||
|
|
372ad560bf | ||
|
|
0651d964f5 | ||
|
|
8de139f9d3 | ||
|
|
9a8f84ff37 | ||
|
|
3e9d083e7d | ||
|
|
b1b2451429 | ||
|
|
e9637f77ff | ||
|
|
bf9e72064b | ||
|
|
15f2d67970 | ||
|
|
1ea5d70675 | ||
|
|
a91fe5ce2c | ||
|
|
1a1caaf0df |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ zips/
|
|||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
*.code-workspace
|
||||||
@@ -113,6 +113,14 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
- **CTA secundario:** *Ver una demo real*
|
- **CTA secundario:** *Ver una demo real*
|
||||||
- **Trust text bajo CTA:** Sin tarjeta. Sin instalaciones. 10 minutos para configurarlo.
|
- **Trust text bajo CTA:** Sin tarjeta. Sin instalaciones. 10 minutos para configurarlo.
|
||||||
|
|
||||||
|
### Bloque "Demo en vídeo" (debajo del hero; el CTA secundario del hero apunta aquí)
|
||||||
|
|
||||||
|
- **Kicker:** En 2 minutos
|
||||||
|
- **Título:** Míralo funcionando **de principio a fin**.
|
||||||
|
- **Lede:** Te enseñamos cómo Reformix atiende a tu cliente, calcula el presupuesto orientativo y te lo deja en el panel — sin que tú levantes el teléfono.
|
||||||
|
- **Placeholder de vídeo (mientras no haya vídeo):** Vídeo demo · próximamente
|
||||||
|
- **CTA:** *Empezar ahora*
|
||||||
|
|
||||||
### Bloque "Lo que está roto hoy"
|
### Bloque "Lo que está roto hoy"
|
||||||
|
|
||||||
- **Título:** Cada presupuesto que haces es una apuesta
|
- **Título:** Cada presupuesto que haces es una apuesta
|
||||||
@@ -313,6 +321,77 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
|
|
||||||
- **Botón submit:** *Continuar*
|
- **Botón submit:** *Continuar*
|
||||||
|
|
||||||
|
### Paso 2 — Elige cómo seguir (chooser de canal)
|
||||||
|
|
||||||
|
- **Etiqueta del paso:** Elige cómo seguir
|
||||||
|
- **Título del paso:** ¿Cómo prefieres contarnos tu reforma, [Nombre]?
|
||||||
|
- **Subtitle:** Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render y tu presupuesto.
|
||||||
|
|
||||||
|
- **Stepper de progreso (encima del título):**
|
||||||
|
- Paso 1 (completado): *Tus datos*
|
||||||
|
- Paso 2 (actual): *Tu reforma*
|
||||||
|
- Paso 3 (pendiente): *Render + presupuesto*
|
||||||
|
|
||||||
|
- **Tarjeta Llamada — título:** Que te llamemos
|
||||||
|
**Descripción:** Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.
|
||||||
|
**CTA:** Quiero que me llamen
|
||||||
|
**Badge:** La más rápida
|
||||||
|
- **Tarjeta WhatsApp — título:** Por WhatsApp
|
||||||
|
**Descripción:** Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.
|
||||||
|
**CTA:** Seguir por WhatsApp
|
||||||
|
- **Tarjeta Formulario — título:** Rellenar un formulario
|
||||||
|
**Descripción:** Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.
|
||||||
|
**CTA:** Rellenar el formulario
|
||||||
|
|
||||||
|
#### Bloque "Qué pasa después" (debajo de las tarjetas del chooser)
|
||||||
|
|
||||||
|
> Recuerda al lead lo que va a recibir elija el canal que elija: personalización, render con
|
||||||
|
> imágenes en minutos, y visita gratuita posterior para el presupuesto definitivo.
|
||||||
|
|
||||||
|
- **Título:** Elijas lo que elijas, esto es lo que pasa después
|
||||||
|
- **Paso 1 — título:** Nos cuentas tu reforma a tu manera
|
||||||
|
**Body:** Fotos, medidas, gustos. Cuanto más nos cuentes, más se parecerá el resultado a lo que tienes en la cabeza.
|
||||||
|
- **Paso 2 — título:** Render + presupuesto en minutos
|
||||||
|
**Body:** Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.
|
||||||
|
- **Paso 3 — título:** Visita gratuita para el presupuesto final
|
||||||
|
**Body:** Si te convence, acuerdas una visita con [Reformista]: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.
|
||||||
|
|
||||||
|
### Paso 2 (canal llamada)
|
||||||
|
|
||||||
|
- **Título del paso:** Te llamamos cuando quieras
|
||||||
|
- **Subtitle:** Un asistente de [Reformista] te llama y te hace unas preguntas rápidas sobre tu reforma. Te avisamos antes.
|
||||||
|
- **Opción A — título:** Llamarme ahora
|
||||||
|
**Descripción:** Recibes la llamada en menos de 2 minutos.
|
||||||
|
**Botón:** Llamarme ahora
|
||||||
|
- **Opción B — título:** Programar la llamada
|
||||||
|
**Descripción:** Elige el día y la hora que mejor te venga.
|
||||||
|
**Botón:** Programar llamada
|
||||||
|
- **Confirmación (ahora):** ✅ Perfecto, [Nombre]. Te llamamos en menos de 2 minutos al **[teléfono]**. Tenlo a mano.
|
||||||
|
- **Confirmación (programada):** ✅ Hecho. Te llamaremos el **[fecha]** al **[teléfono]**.
|
||||||
|
- **Nota sobre las fotos:** Para el render necesitamos ver el espacio. Puedes mandarnos las fotos por WhatsApp durante la llamada, o te enviamos un enlace al formulario por email para que las subas cuando quieras.
|
||||||
|
**Botón:** Enviarme el enlace por email
|
||||||
|
**Confirmación enlace:** 📧 Te hemos enviado el enlace a **[email]**.
|
||||||
|
|
||||||
|
### Paso 2 (canal WhatsApp)
|
||||||
|
|
||||||
|
- **Título del paso:** Seguimos por WhatsApp
|
||||||
|
- **Body (antes de confirmar):** Te escribimos al WhatsApp del **[teléfono]** para seguir por ahí. Si el número es correcto, confírmalo y te escribimos ahora mismo.
|
||||||
|
- **Botón:** Sí, escríbeme por WhatsApp
|
||||||
|
- **Tras escribir:** Te acabamos de escribir al **[teléfono]**. ¿Puedes confirmarlo?
|
||||||
|
**Botón confirmar:** Lo he recibido
|
||||||
|
- **Agradecimiento:** ✅ ¡Genial, [Nombre]! Seguimos por WhatsApp. Allí te pediremos las fotos y los detalles para preparar tu presupuesto.
|
||||||
|
|
||||||
|
### Subida de fotos por enlace (página ligera del email)
|
||||||
|
|
||||||
|
Página a la que lleva el enlace del email (canal llamada). Solo sube fotos; nada de re-preguntar ni
|
||||||
|
de volver a llamar.
|
||||||
|
|
||||||
|
- **Etiqueta del paso:** Solo falta esto
|
||||||
|
- **Título:** Sube las fotos de tu espacio, [Nombre]
|
||||||
|
- **Subtitle:** Con un par de fotos del espacio actual preparamos tu render y afinamos el presupuesto. Tardas un minuto.
|
||||||
|
- **Nota (opcional):** ¿Algo que quieras añadir? (opcional)
|
||||||
|
- **Botón:** Enviar mis fotos
|
||||||
|
|
||||||
### Subida de fotos (paso 2 del wizard)
|
### Subida de fotos (paso 2 del wizard)
|
||||||
|
|
||||||
- **Título del paso:** Ahora una foto de tu espacio actual
|
- **Título del paso:** Ahora una foto de tu espacio actual
|
||||||
@@ -478,6 +557,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
> 👉 *¿Te gustaría que [Reformista] vaya a verlo gratis?*
|
> 👉 *¿Te gustaría que [Reformista] vaya a verlo gratis?*
|
||||||
> [Botón: Sí, pídeme la visita] [Botón: Tengo dudas, contestadme]
|
> [Botón: Sí, pídeme la visita] [Botón: Tengo dudas, contestadme]
|
||||||
|
|
||||||
|
### PDF del presupuesto (documento adjunto)
|
||||||
|
|
||||||
|
Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel. Mantiene el tono orientativo y honesto: deja claro que es una aproximación, sin restarle credibilidad.
|
||||||
|
|
||||||
|
- **Título:** *PRESUPUESTO ORIENTATIVO DE REFORMA*
|
||||||
|
- **Disclaimer (bajo el título):** *El precio final se determinará tras la visita gratuita de [Reformista] en tu casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos para que se acerque lo máximo posible al importe definitivo.*
|
||||||
|
- **Sección render — título:** *Así quedaría tu reforma*
|
||||||
|
- **Render — descripción (se genera según la selección del cliente):** *Recreación con calidad [media] y acabados en [suelo porcelánico, paredes en tono neutro, mobiliario lacado]. Estilo [moderno y luminoso] según tus preferencias.*
|
||||||
|
- **Render — descripción (fallback si faltan materiales o estilo):** *Recreación orientativa de cómo quedaría tu espacio reformado con la calidad [media] seleccionada.*
|
||||||
|
- **Pie de la imagen:** *Render orientativo generado con IA. El resultado real puede variar según los materiales finales y las condiciones de la obra.*
|
||||||
|
|
||||||
### WhatsApp follow-up (24h sin respuesta)
|
### WhatsApp follow-up (24h sin respuesta)
|
||||||
|
|
||||||
> 👋 *Hola [Nombre], ¿pudiste mirar el presupuesto que te mandamos ayer?*
|
> 👋 *Hola [Nombre], ¿pudiste mirar el presupuesto que te mandamos ayer?*
|
||||||
@@ -525,6 +615,62 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6.b Emails al cliente final (funnel B2C)
|
||||||
|
|
||||||
|
Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo,
|
||||||
|
igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente.
|
||||||
|
|
||||||
|
Diseño: HTML transaccional mobile-first, una columna (máx. 600px), dark mode, botón "bulletproof"
|
||||||
|
(tabla), tipografías de sistema, color de acento = color de marca del reformista. Cada email lleva
|
||||||
|
asunto (≤50 car.), preheader (texto de previsualización) y versión en texto plano.
|
||||||
|
|
||||||
|
### Email de entrega del presupuesto (PDF adjunto)
|
||||||
|
|
||||||
|
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Tono: la entrega es el
|
||||||
|
protagonista, cálido pero claro.
|
||||||
|
|
||||||
|
- **Asunto (elegido):** *Aquí está tu presupuesto de reforma*
|
||||||
|
- **Asunto (alt. A):** *Tu reforma, en números y en imágenes*
|
||||||
|
- **Asunto (alt. B):** *[Reformista]: tu presupuesto ya está listo*
|
||||||
|
- **Preheader:** *Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).*
|
||||||
|
- **Headline:** *Aquí está tu presupuesto, [Nombre]*
|
||||||
|
- **Cuerpo:**
|
||||||
|
|
||||||
|
> Hemos preparado el **presupuesto orientativo** de tu reforma. En el PDF adjunto tienes el render de
|
||||||
|
> cómo quedaría tu espacio y el desglose por partidas.
|
||||||
|
>
|
||||||
|
> *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en
|
||||||
|
> tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.
|
||||||
|
>
|
||||||
|
> ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.
|
||||||
|
|
||||||
|
- **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita*
|
||||||
|
- **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]*
|
||||||
|
|
||||||
|
### Email con enlace al formulario (subir imágenes)
|
||||||
|
|
||||||
|
Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos
|
||||||
|
del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola acción clara.
|
||||||
|
|
||||||
|
- **Asunto (elegido):** *Sube las fotos de tu reforma*
|
||||||
|
- **Asunto (alt. A):** *Un paso más para tu presupuesto*
|
||||||
|
- **Asunto (alt. B):** *[Reformista] necesita ver tu espacio*
|
||||||
|
- **Preheader:** *Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.*
|
||||||
|
- **Headline:** *Enséñanos tu espacio, [Nombre]*
|
||||||
|
- **Cuerpo:**
|
||||||
|
|
||||||
|
> Para preparar tu render y tu presupuesto, **[Reformista]** necesita ver cómo está ahora tu espacio.
|
||||||
|
>
|
||||||
|
> Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que
|
||||||
|
> quieras.
|
||||||
|
>
|
||||||
|
> En cuanto las tengamos, seguimos con tu presupuesto.
|
||||||
|
|
||||||
|
- **CTA:** *Subir mis fotos* → `[url]`
|
||||||
|
- **Footer:** *[Reformista]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 7. Microcopy del panel del reformista
|
## 7. Microcopy del panel del reformista
|
||||||
|
|
||||||
| Elemento | Texto |
|
| Elemento | Texto |
|
||||||
@@ -587,6 +733,40 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Onboarding del panel (tour guiado)
|
||||||
|
|
||||||
|
> Tooltips del tour del panel (driver.js). Tono cercano y útil, una idea por paso, frases cortas. Las pestañas secundarias se explican "de pasada" (una línea). Copy usado en `src/lib/onboarding/panel-tour.ts`.
|
||||||
|
|
||||||
|
### Pestaña Leads (`/panel`)
|
||||||
|
|
||||||
|
- **Intro** — *Tu panel de Reformix* · "Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X."
|
||||||
|
- **Leads** — "Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día."
|
||||||
|
- **Precios y baremo** — "Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos."
|
||||||
|
- **Galería** — "Tus fotos de trabajos para enseñar en la web."
|
||||||
|
- **Opiniones** — "Reseñas de tus clientes; las apruebas tú antes de publicarlas."
|
||||||
|
- **Empresa** — "Tu marca, logo y datos de contacto."
|
||||||
|
- **Filtra por estado** — "Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora."
|
||||||
|
- **Tus leads** — "Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo."
|
||||||
|
|
||||||
|
### Ficha del lead (`/panel/{id}`)
|
||||||
|
|
||||||
|
- **Presupuesto estimado** — "Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo."
|
||||||
|
- **Estado del lead** — "Avanza el lead por el funnel: contactado, presupuestado, ganado…"
|
||||||
|
- **Render de la reforma** — "La imagen del «después» que ve tu cliente, generada a partir de su foto y sus gustos."
|
||||||
|
- **Presupuesto desglosado** — "Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp."
|
||||||
|
|
||||||
|
### Precios y baremo (`/panel/precios`)
|
||||||
|
|
||||||
|
- **Baremo de rentabilidad** — "El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo."
|
||||||
|
- **Mano de obra** — "Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto."
|
||||||
|
- **Tu catálogo** — "Materiales y precios por calidad. Puedes importarlos en bloque por CSV."
|
||||||
|
|
||||||
|
### Botón para repetir
|
||||||
|
|
||||||
|
- **Botón flotante** — "❓ Tour" (relanza el tour de la pestaña actual).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Principios aplicados en todo el documento
|
## Principios aplicados en todo el documento
|
||||||
|
|
||||||
1. **Beneficios > Features** — "Tus clientes verán su reforma" > "Render IA con SDXL"
|
1. **Beneficios > Features** — "Tus clientes verán su reforma" > "Render IA con SDXL"
|
||||||
|
|||||||
581
docs/arquitectura-integracion.md
Normal file
581
docs/arquitectura-integracion.md
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
# Arquitectura de Integración — Reformix
|
||||||
|
|
||||||
|
## Índice
|
||||||
|
|
||||||
|
1. [Visión general del sistema](#1-visión-general-del-sistema)
|
||||||
|
2. [Landing pública y captura del lead (sitio web)](#2-landing-pública-y-captura-del-lead-sitio-web)
|
||||||
|
3. [El funnel B2C — pipeline de 7 pasos](#3-el-funnel-b2c--pipeline-de-7-pasos)
|
||||||
|
4. [Elección de canal: formulario, llamada o WhatsApp](#4-elección-de-canal-formulario-llamada-o-whatsapp)
|
||||||
|
5. [Sistema de webhooks salientes (app → bot/worker)](#5-sistema-de-webhooks-salientes-app--botworker)
|
||||||
|
6. [Sistema de endpoints de bot (app ← bot/worker)](#6-sistema-de-endpoints-de-bot-app--botworker)
|
||||||
|
7. [El agente de WhatsApp (Luisa)](#7-el-agente-de-whatsapp-luisa)
|
||||||
|
8. [Workers: render de imágenes y presupuesto](#8-workers-render-de-imágenes-y-presupuesto)
|
||||||
|
9. [El presupuesto como entregable final](#9-el-presupuesto-como-entregable-final)
|
||||||
|
10. [Diagrama de flujo completo](#10-diagrama-de-flujo-completo)
|
||||||
|
11. [Estado actual vs lo que falta](#11-estado-actual-vs-lo-que-falta)
|
||||||
|
12. [Guía de conexión: cómo integrar Luisa + Workers con la app](#12-guía-de-conexión-cómo-integrar-luisa--workers-con-la-app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visión general del sistema
|
||||||
|
|
||||||
|
Reformix es un SaaS multi-tenant para empresas de reformas. Tiene **dos caras**:
|
||||||
|
|
||||||
|
- **B2B (reformista):** el reformista se registra, configura su perfil, catálogo de materiales, precios, y pone un widget en su web.
|
||||||
|
- **B2C (cliente final):** el cliente llega a la landing del reformista, pide presupuesto, sube fotos, y recibe un presupuesto con render "antes/después" en < 7 minutos.
|
||||||
|
|
||||||
|
### Componentes del sistema
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SITIO WEB (Next.js) │
|
||||||
|
│ Landing → Formulario → Elección canal → Pipeline → Entrega │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ App Reformix (mvp/b2c/) │ │
|
||||||
|
│ │ - Landing pública /[slug] │ │
|
||||||
|
│ │ - Funnel B2C /solicitud/[id]/* │ │
|
||||||
|
│ │ - Panel reformista /panel/* │ │
|
||||||
|
│ │ - API endpoints para bot (mvp/b2c/src/app/api/leads/) │ │
|
||||||
|
│ │ - Motor de presupuesto (mvp/b2c/src/budget/) │ │
|
||||||
|
│ │ - Generación de PDF (mvp/b2c/src/lib/pdf/) │ │
|
||||||
|
│ │ - Envío de email (mvp/b2c/src/lib/email/) │ │
|
||||||
|
│ │ - Webhooks salientes (mvp/b2c/src/lib/webhooks.ts) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AGENTE WHATSAPP (Luisa) — EXTERNO │
|
||||||
|
│ (mvp/Whatsapp-bot/) │
|
||||||
|
│ - Conexión WhatsApp via Baileys │
|
||||||
|
│ - Pipeline Claude 4-capas para cualificar leads │
|
||||||
|
│ - DEBE usar API HTTP de la app (hoy escribe directo a BD) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WORKERS (Render + Análisis) — NO IMPLEMENTADO AÚN │
|
||||||
|
│ - Generación de renders "después" (Nano Banana 2 / Image 2) │
|
||||||
|
│ - Análisis de fotos con IA │
|
||||||
|
│ - DEBE recibir webhook PERFIL_WEBHOOK_URL y devolver vía API │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Landing pública y captura del lead (sitio web)
|
||||||
|
|
||||||
|
### Flujo de captura
|
||||||
|
|
||||||
|
```
|
||||||
|
Usuario llega a /[slug] (landing del reformista)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Rellena formulario Hero:
|
||||||
|
- nombre + email + teléfono
|
||||||
|
- consentimiento privacidad + contratación (RGPD obligatorio)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
crearLead(slug, data) → Server Action
|
||||||
|
│
|
||||||
|
├── Valida con Zod schema
|
||||||
|
├── Busca tenant por slug
|
||||||
|
├── INSERT en leads (pipelineStage: 'form_completado', estado: 'nuevo')
|
||||||
|
└── INSERT en leadPipelineEventos (stage: 'form_completado')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lo que crea la base de datos
|
||||||
|
|
||||||
|
El lead se crea con:
|
||||||
|
|
||||||
|
- `id` (UUID) — **este es el leadId que usará todo el sistema**
|
||||||
|
- `tenantId` (UUID) — referencia al reformista
|
||||||
|
- `nombre`, `email`, `telefono` — datos del cliente
|
||||||
|
- `pipelineStage: 'form_completado'` — dónde está en el pipeline
|
||||||
|
- `estado: 'nuevo'` — estado comercial
|
||||||
|
|
||||||
|
**Importante:** El lead NO tiene aún `tipoReforma`, `m2Suelo`, `calidadGlobal`, etc. Esos se rellenan después, cuando el cliente pasa por el canal elegido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. El funnel B2C — pipeline de 7 pasos
|
||||||
|
|
||||||
|
Definido por el enum `pipelineStage` en `src/db/schema.ts`:
|
||||||
|
|
||||||
|
| # | Stage | Qué ocurre | Quién lo dispara |
|
||||||
|
| --- | ---------------------- | ---------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| 1 | `form_completado` | Lead creado con datos básicos | Server Action `crearLead()` |
|
||||||
|
| 2 | `fotos_subidas` | Cliente describe reforma + sube fotos por zona | Server Action `guardarDetallesYFotos()` o API `ingesta` |
|
||||||
|
| 3 | `prellamada_enviada` | Notificación SMS/WhatsApp previa a llamada | Orchestrator `procesarLead()` o bot |
|
||||||
|
| 4 | `llamada_completada` | Agente IA (Retell) cualifica al lead | Orchestrator (simulado) o webhook Retell (real) |
|
||||||
|
| 5 | `render_generado` | Render "después" generado por IA | Orchestrator (simulado con imagen demo) |
|
||||||
|
| 6 | `presupuesto_generado` | Presupuesto calculado con motor real | Orchestrator `procesarLead()` |
|
||||||
|
| 7 | `whatsapp_entregado` | PDF entregado al cliente | `finalizarYEntregar()` |
|
||||||
|
|
||||||
|
### Pipeline automático (`procesarLead`)
|
||||||
|
|
||||||
|
Cuando el cliente completa el formulario detallado (con zonas, fotos, etc.), la app ejecuta:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
guardarDetallesYFotos(leadId, formData)
|
||||||
|
├── Guarda fotos (momento: 'antes') en leadFotos
|
||||||
|
├── Guarda notas en leadNotas
|
||||||
|
├── Calcula tipoReforma, m2Suelo, calidadGlobal desde las zonas
|
||||||
|
├── UPDATE lead (pipelineStage: 'fotos_subidas')
|
||||||
|
│
|
||||||
|
└── procesarLead(leadId) ← ORQUESTRADOR
|
||||||
|
├── Paso 4: Evento prellamada_enviada
|
||||||
|
├── Paso 5: Llamada Retell (real si configurado, sino simulada con transcript ficticio)
|
||||||
|
├── Paso 6a: Render demo (imagen estática, NO IA real)
|
||||||
|
├── Paso 6b: Presupuesto REAL con motor (computeBudget)
|
||||||
|
├── UPDATE lead (pipelineStage: 'presupuesto_generado')
|
||||||
|
└── Paso 7: Si envio=automatico → lead pasa a 'whatsapp_entregado'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Elección de canal: formulario, llamada o WhatsApp
|
||||||
|
|
||||||
|
Después de crear el lead, el cliente llega a `/solicitud/[id]/` donde elige cómo continuar:
|
||||||
|
|
||||||
|
### Canal formulario (`/solicitud/[id]/formulario`)
|
||||||
|
|
||||||
|
El cliente rellena un formulario multi-zona: tipo de reforma, m², calidad, notas, sube fotos. Esto dispara `guardarDetallesYFotos()` que ejecuta el pipeline completo (incluyendo llamada simulada, render demo, presupuesto real).
|
||||||
|
|
||||||
|
### Canal llamada (`/solicitud/[id]/llamada`)
|
||||||
|
|
||||||
|
El cliente pide que le llamen (ahora o programado). Se le envía un email con enlace para subir fotos después. Dispara `pedirLlamada()` que inicia llamada Retell saliente (si configurado). El cliente recibe la llamada del agente IA, y después puede subir fotos via el enlace del email.
|
||||||
|
|
||||||
|
### Canal WhatsApp (`/solicitud/[id]/whatsapp`)
|
||||||
|
|
||||||
|
El cliente elige continuar por WhatsApp. La app dispara `iniciarWhatsapp()` que:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
iniciarWhatsapp(leadId)
|
||||||
|
└── POST a WHATSAPP_START_WEBHOOK_URL
|
||||||
|
Payload: { leadId, telefono, nombre, empresa }
|
||||||
|
→ El bot de WhatsApp (Luisa) recibe esto y empieza la conversación
|
||||||
|
```
|
||||||
|
|
||||||
|
**Este es el punto de entrada del bot de WhatsApp.** El bot recibe el `leadId` y a partir de ahí debe escribir los datos extraídos de la conversación usando los endpoints de la app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sistema de webhooks salientes (app → bot/worker)
|
||||||
|
|
||||||
|
La app envía 3 señales HTTP a sistemas externos. Todas son **best-effort** (nunca lanzan error, devuelven boolean).
|
||||||
|
|
||||||
|
### 5.1 WHATSAPP_START_WEBHOOK_URL — Arranque de conversación WhatsApp
|
||||||
|
|
||||||
|
**Disparado por:** `iniciarWhatsapp()` (cuando el lead elige canal WhatsApp)
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST {url}
|
||||||
|
{
|
||||||
|
"leadId": "uuid",
|
||||||
|
"telefono": "+34...",
|
||||||
|
"nombre": "...",
|
||||||
|
"empresa": "Reformas Ejemplo"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**La app espera que:** el bot de WhatsApp reciba esto y comience la conversación con el lead. El bot debe usar el `leadId` para escribir los datos vía los endpoints de la app.
|
||||||
|
|
||||||
|
### 5.2 PERFIL_WEBHOOK_URL — Perfil completo para generar renders
|
||||||
|
|
||||||
|
**Disparado por:**
|
||||||
|
|
||||||
|
- `señalarPerfilCompleto()` en `guardarDetallesYFotos()` (formulario)
|
||||||
|
- `señalarPerfilCompleto()` en `subirFotos()` (fotos por email)
|
||||||
|
- API `ingesta` con flag `perfilCompleto: true` (bot/worker)
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST {url}
|
||||||
|
{
|
||||||
|
"leadId": "uuid",
|
||||||
|
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
|
||||||
|
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
|
||||||
|
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
|
||||||
|
"preferencias": { "estilo": "nórdico", "gustos": "tonos azules, muebles de madera, encimera clara" },
|
||||||
|
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
|
||||||
|
"zonas": [
|
||||||
|
{ "zona": "cocina",
|
||||||
|
"notas": ["encimera de cuarzo"],
|
||||||
|
"fotos": { "antes": ["data:image/..."], "despues": [] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`preferencias`** (opcional): gustos estéticos del cliente capturados en la conversación (`estilo` =
|
||||||
|
campo `estilo` del lead; `gustos` = `tasteText`, resumen en texto libre de colores/materiales/acabados
|
||||||
|
que pidió). Cada clave se omite si está vacía. El worker los inyecta como bloque dedicado en el prompt
|
||||||
|
de imagen para que el render los represente; si no llegan, infiere un estilo neutro.
|
||||||
|
|
||||||
|
**La app espera que:** el worker externo genere renders "después" a partir de las fotos "antes"
|
||||||
|
respetando las `preferencias` del cliente, y los devuelva haciendo POST al endpoint
|
||||||
|
`/api/leads/:id/ingesta` con `momento: "despues"` y opcionalmente `finalizar: true`.
|
||||||
|
|
||||||
|
### 5.3 WHATSAPP_WEBHOOK_URL — Entrega del PDF
|
||||||
|
|
||||||
|
**Disparado por:** `finalizarYEntregar()` (cuando el PDF está listo)
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST {url}
|
||||||
|
{
|
||||||
|
"leadId": "uuid",
|
||||||
|
"telefono": "+34...",
|
||||||
|
"nombre": "...",
|
||||||
|
"empresa": "Reformas Ejemplo",
|
||||||
|
"pdfBase64": "JVBERi0xLj...",
|
||||||
|
"filename": "presupuesto-nombre.pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**La app espera que:** el bot de WhatsApp reciba esto y envíe el PDF al cliente por WhatsApp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sistema de endpoints de bot (app ← bot/worker)
|
||||||
|
|
||||||
|
La app expone 5 endpoints bajo `/api/leads/:id/`. Todos requieren `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||||
|
|
||||||
|
| Endpoint | Qué hace | Tabla que escribe |
|
||||||
|
| ---------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `POST /api/leads/:id/conversacion` | Guarda un turno del chat | `conversacion_whatsapp` |
|
||||||
|
| `POST /api/leads/:id/perfil` | Actualiza datos extraídos del lead | `leads` (campos: espacio, rangoM2, estilo, tipoReforma, m2Suelo, etc.) |
|
||||||
|
| `POST /api/leads/:id/calificacion` | Upsert de calificación | `lead_calificacion` |
|
||||||
|
| `POST /api/leads/:id/intento` | Registra intento de contacto | `intentos_contacto` |
|
||||||
|
| `POST /api/leads/:id/ingesta` | Sube fotos/notas + flags de perfilCompleto/finalizar | `lead_fotos`, `lead_notas` |
|
||||||
|
|
||||||
|
### Flujo de uso típico del bot
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Bot recibe WHATSAPP_START_WEBHOOK → leadId, telefono, nombre
|
||||||
|
2. Bot inicia conversación por WhatsApp
|
||||||
|
3. Por cada interacción:
|
||||||
|
a. Bot llama POST /conversacion (guarda el turno)
|
||||||
|
b. Bot llama POST /perfil (actualiza datos extraídos: espacio, m2, estilo, etc.)
|
||||||
|
c. Cuando tiene datos suficientes, llama POST /calificacion
|
||||||
|
d. Cuando pide fotos, el cliente las envía, bot las guarda vía POST /ingesta
|
||||||
|
4. Cuando el perfil está completo:
|
||||||
|
- Bot marca perfilCompleto: true en POST /ingesta
|
||||||
|
- Esto dispara PERFIL_WEBHOOK_URL → worker genera renders
|
||||||
|
- Worker devuelve renders vía POST /ingesta con momento: "despues" + finalizar: true
|
||||||
|
- finalizar:true dispara WHATSAPP_WEBHOOK_URL → bot recibe PDF y lo envía al cliente
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. El agente de WhatsApp (Luisa)
|
||||||
|
|
||||||
|
### Estado actual (código en `mvp/Whatsapp-bot/`)
|
||||||
|
|
||||||
|
El bot de Luisa es un servicio NestJS independiente que:
|
||||||
|
|
||||||
|
1. **Se conecta a WhatsApp** usando la librería Baileys (WebSocket no oficial)
|
||||||
|
2. **Orquesta un pipeline Claude de 4 capas:**
|
||||||
|
- Capa 1 — Clasificador (Haiku): extrae intención y valor del mensaje
|
||||||
|
- Capa 2 — Validador: valida contra valores permitidos
|
||||||
|
- Capa 3 — Generador (Sonnet): produce el borrador de respuesta
|
||||||
|
- Capa 4 — Reglas (Haiku): corrige tono e identidad
|
||||||
|
3. **Mantiene una máquina de estados** de 7 pasos para cualificar al lead:
|
||||||
|
`nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin`
|
||||||
|
4. **Tiene un scheduler** que cada 5 minutos busca leads "nuevos" en BD y les envía el mensaje de apertura
|
||||||
|
5. **Soporta multimedia:** transcripción de audio (Gemini), análisis de imágenes (Claude Vision)
|
||||||
|
|
||||||
|
### Problema actual
|
||||||
|
|
||||||
|
El bot **escribe directamente a Postgres** usando TypeORM con sus propias entidades (`Lead` y `Conversacion`), en lugar de usar los endpoints HTTP de la app. Esto causa:
|
||||||
|
|
||||||
|
- Incompatibilidad de IDs: el bot usa `id` numérico autoincremental, la app usa UUID
|
||||||
|
- Tablas duplicadas: el bot tiene su propia tabla `conversacion`, la app tiene `conversacion_whatsapp`
|
||||||
|
- Enums desalineados: el bot usa `urgente/medio_plazo/frio`, la app usa `alta/media/baja`
|
||||||
|
- `synchronize: true` en TypeORM puede alterar el schema de la BD real
|
||||||
|
- El scheduler crea leads desde cero, pero según la arquitectura los leads ya existen (creados desde el form web)
|
||||||
|
|
||||||
|
### Lo que DEBE hacer el bot
|
||||||
|
|
||||||
|
1. **No crear leads.** Recibirlos vía `WHATSAPP_START_WEBHOOK_URL` con el `leadId` UUID.
|
||||||
|
2. **No escribir a BD directamente.** Usar los 5 endpoints HTTP (`conversacion`, `perfil`, `calificacion`, `intento`, `ingesta`).
|
||||||
|
3. **No tener scheduler propio.** El arranque lo hace la app vía webhook.
|
||||||
|
4. **Usar los enums correctos** según la app (`alta/media/baja`, `cocina/bano/salon/comedor/integral/otro`, etc.).
|
||||||
|
5. **No mantener estado propio.** El estado de la conversación (`botStep`) se persiste vía `/perfil`.
|
||||||
|
|
||||||
|
### Dónde está el código
|
||||||
|
|
||||||
|
| Elemento | Ruta |
|
||||||
|
| ----------------------- | ---------------------------- |
|
||||||
|
| Código del bot (NestJS) | `mvp/Whatsapp-bot/src/` |
|
||||||
|
| Prompts de Luisa | `mvp/Whatsapp-bot/prompts/` |
|
||||||
|
| Configuración (.env) | `mvp/Whatsapp-bot/.env` |
|
||||||
|
| Documentación del bot | `mvp/Whatsapp-bot/README.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Workers: render de imágenes y presupuesto
|
||||||
|
|
||||||
|
### Estado actual
|
||||||
|
|
||||||
|
Los workers **no están implementados**. Existe:
|
||||||
|
|
||||||
|
- La **tabla `worker_jobs`** en la BD (`mvp/b2c/src/db/schema.ts:515-537`) con tipos: `analisis_fotos`, `render`, `presupuesto_ia`
|
||||||
|
- El **webhook `PERFIL_WEBHOOK_URL`** listo para enviar el perfil completo al worker
|
||||||
|
- El **endpoint `ingesta`** listo para recibir los renders de vuelta
|
||||||
|
- **Renders simulados** con imágenes estáticas en `procesarLead()` (usa `/despues.webp`, `/despues-bano.webp`, etc.)
|
||||||
|
|
||||||
|
### Lo que DEBE hacer el worker de renders
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Recibe POST a PERFIL_WEBHOOK_URL con:
|
||||||
|
{ leadId, cliente, reforma, empresa, zonas: [{ zona, notas, fotos: { antes: [...], despues: [] } }] }
|
||||||
|
|
||||||
|
2. Para cada zona:
|
||||||
|
a. Toma las fotos "antes" del cliente
|
||||||
|
b. Genera render "después" usando modelo de IA (Nano Banana 2, Image 2, Stable Diffusion, etc.)
|
||||||
|
c. Convierte el render a data URI base64
|
||||||
|
|
||||||
|
3. Devuelve los renders haciendo POST a /api/leads/:id/ingesta:
|
||||||
|
{ items: [{ tipo: "foto", zona: "cocina", momento: "despues", imagen: "data:image/..." }],
|
||||||
|
finalizar: true }
|
||||||
|
(finalizar:true dispara la construcción del PDF + email + señal WhatsApp)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stack planeado para renders
|
||||||
|
|
||||||
|
Según la documentación:
|
||||||
|
|
||||||
|
- **Nano Banana 2** o **Image 2** (Google Gemini) — modelos image-to-image
|
||||||
|
- Alternativa: **Replicate SDXL + ControlNet** (~0,02€/imagen)
|
||||||
|
- Alternativa: **DALL-E 3 HD** (~0,08€/imagen)
|
||||||
|
|
||||||
|
### Dónde implementar los workers
|
||||||
|
|
||||||
|
Los workers son servicios externos independientes. Pueden ser:
|
||||||
|
|
||||||
|
- **n8n workflows** (orquestación visual)
|
||||||
|
- **Un worker Node.js/Python** que escucha webhooks y se comunica con APIs de IA
|
||||||
|
- **Cloud Functions** (Vercel, Cloudflare Workers, AWS Lambda)
|
||||||
|
|
||||||
|
No hay código de workers en este repositorio. El repositorio solo define el contrato (webhooks + API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. El presupuesto como entregable final
|
||||||
|
|
||||||
|
El entregable final es un **PDF de presupuesto** que incluye:
|
||||||
|
|
||||||
|
1. **Cabecera** con datos del reformista (logo, nombre, CIF, dirección)
|
||||||
|
2. **Datos del cliente** y tipo de reforma
|
||||||
|
3. **Tabla de presupuesto** con partidas calculadas por el motor:
|
||||||
|
- Demolición, impermeabilización, alicatado, fontanería, electricidad, carpintería, mano de obra, extras, licencia
|
||||||
|
4. **Render "después"** generado por IA
|
||||||
|
5. **Galería por zona** con fotos "antes" y "después"
|
||||||
|
6. **Footer legal**
|
||||||
|
|
||||||
|
### El motor de presupuesto (`mvp/b2c/src/budget/`)
|
||||||
|
|
||||||
|
**Es real, no simulado.** Calcula presupuestos basado en:
|
||||||
|
|
||||||
|
- Catálogo de materiales del reformista (precios por calidad: básica/media/premium)
|
||||||
|
- Configuración de precios (factores por zona, mano de obra, extras fijos)
|
||||||
|
- Inputs del lead: tipo de reforma, m², calidad, urgencia, estructural, provincia
|
||||||
|
|
||||||
|
### Flujo de entrega
|
||||||
|
|
||||||
|
```
|
||||||
|
finalizarYEntregar(leadId)
|
||||||
|
├── construirPresupuestoPdf(leadId)
|
||||||
|
│ ├── Carga lead completo + fotos + notas + catálogo + config
|
||||||
|
│ ├── Renderiza PDF con @react-pdf
|
||||||
|
│ └── Devuelve buffer PDF + filename
|
||||||
|
│
|
||||||
|
├── UPDATE lead (pdfUrl)
|
||||||
|
│
|
||||||
|
├── enviarPresupuestoEmail() → SMTP (opcional)
|
||||||
|
│
|
||||||
|
├── notificarFlujoWhatsapp() → WHATSAPP_WEBHOOK_URL
|
||||||
|
│ └── Bot recibe pdfBase64 y lo envía por WhatsApp
|
||||||
|
│
|
||||||
|
└── UPDATE lead (pipelineStage: 'whatsapp_entregado', estado: 'presupuesto_enviado')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Diagrama de flujo completo
|
||||||
|
|
||||||
|
```
|
||||||
|
LANDING PÚBLICA /[slug]
|
||||||
|
│
|
||||||
|
crearLead()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ Lead CREADO │
|
||||||
|
│ pipelineStage: │
|
||||||
|
│ form_completado │
|
||||||
|
│ estado: nuevo │
|
||||||
|
└───────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ ELECCIÓN DE CANAL │
|
||||||
|
│ /solicitud/[id]/ │
|
||||||
|
└──────┬────────┬───────┘
|
||||||
|
│ │
|
||||||
|
┌────────┘ └────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ FORMULARIO │ │ WHATSAPP │
|
||||||
|
│ + fotos │ │ │
|
||||||
|
└──────┬───────┘ │ iniciar │
|
||||||
|
│ │ Whatsapp() │
|
||||||
|
│ └──────┬───────┘
|
||||||
|
│ guardarDetalles │
|
||||||
|
│ YFotos() │ POST WHATSAPP
|
||||||
|
│ │ START WEBHOOK
|
||||||
|
├── Guarda fotos │
|
||||||
|
├── Guarda notas ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
└── procesarLead() │ BOT WHATSAPP │
|
||||||
|
│ │ (Luisa) │
|
||||||
|
│ │ │
|
||||||
|
├── Simula │ Inicia │
|
||||||
|
│ llamada │ conversación │
|
||||||
|
│ │ │
|
||||||
|
├── Render │ Por cada msg: │
|
||||||
|
│ demo │ POST /perfil │
|
||||||
|
│ (img fija)│ POST /conv. │
|
||||||
|
│ │ │
|
||||||
|
├── Presup. │ Cuando listo: │
|
||||||
|
│ REAL │ POST /ingesta │
|
||||||
|
│ │ perfilCompleto│
|
||||||
|
│ │ │
|
||||||
|
└── Estado └──────┬────────┘
|
||||||
|
intermedio │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌────────────────────┘
|
||||||
|
│ PERFIL_WEBHOOK_URL
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ WORKER RENDER │
|
||||||
|
│ Genera imágenes │
|
||||||
|
│ "después" │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│ POST /api/leads/:id/ingesta
|
||||||
|
│ { items: [{tipo:"foto", momento:"despues",...}],
|
||||||
|
│ finalizar: true }
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ finalizarYEntregar() │
|
||||||
|
│ - Construye PDF │
|
||||||
|
│ - Envía email │
|
||||||
|
│ - WHATSAPP_WEBHOOK_URL │
|
||||||
|
└────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ BOT WHATSAPP │
|
||||||
|
│ Recibe pdfBase64│
|
||||||
|
│ y lo envía al │
|
||||||
|
│ cliente │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Estado actual vs lo que falta
|
||||||
|
|
||||||
|
| Componente | Estado | Notas |
|
||||||
|
| ---------------------------- | ------------------------------- | -------------------------------------------------- |
|
||||||
|
| Landing pública / formulario | ✅ Implementado | Multi-zona, fotos, notas |
|
||||||
|
| Motor de presupuesto | ✅ Implementado | Real, con catálogo y config |
|
||||||
|
| PDF | ✅ Implementado | Con @react-pdf |
|
||||||
|
| Email | ✅ Implementado | SMTP, best-effort |
|
||||||
|
| Endpoints API del bot | ✅ Implementado y en producción | 5 endpoints en `dv3.com.es` |
|
||||||
|
| Webhooks salientes | ✅ Implementado | 3 webhooks listos |
|
||||||
|
| Autenticación bot | ✅ Implementado | Bearer token con FUNNEL_API_KEY |
|
||||||
|
| Bot WhatsApp (Luisa) | ❌ Por reconectar | Hoy escribe directo a BD, debe usar APIs HTTP |
|
||||||
|
| Workers render | ❌ No implementado | Existe solo tabla y webhook, sin worker real |
|
||||||
|
| Renders IA | ❌ Simulado | Usa imágenes estáticas `/despues.webp` |
|
||||||
|
| Llamada Retell real | ⚠️ Parcial | Código listo, depende de config de vars de entorno |
|
||||||
|
| n8n workflows | ❌ No existe | Mencionado en docs pero sin implementar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Guía de conexión: cómo integrar Luisa + Workers con la app
|
||||||
|
|
||||||
|
### 12.1 Lo que necesita el bot de WhatsApp (Luisa)
|
||||||
|
|
||||||
|
El bot DEBE:
|
||||||
|
|
||||||
|
1. **Recibir leads por webhook** (`WHATSAPP_START_WEBHOOK_URL`), no buscarlos en BD.
|
||||||
|
2. **Usar los endpoints HTTP de la app** en lugar de TypeORM:
|
||||||
|
- `POST /api/leads/:id/conversacion` — para cada turno del chat
|
||||||
|
- `POST /api/leads/:id/perfil` — para actualizar datos extraídos
|
||||||
|
- `POST /api/leads/:id/calificacion` — para calificar al lead
|
||||||
|
- `POST /api/leads/:id/intento` — para registrar intentos
|
||||||
|
- `POST /api/leads/:id/ingesta` — para subir fotos y marcar perfil completo
|
||||||
|
3. **Usar los enums correctos** de la app:
|
||||||
|
- `urgencia`: `alta`, `media`, `baja`
|
||||||
|
- `tipoReforma`: `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro`
|
||||||
|
- `calidadGlobal`: `basica`, `media`, `premium`
|
||||||
|
- `estadoWa`: `sin_enviar`, `enviado`, `entregado`, `leido`, `fallido`
|
||||||
|
- `canalOrigen`: `formulario_web`, `whatsapp`, `llamada`, `referido`, `anuncio`
|
||||||
|
4. **Trabajar con UUIDs** (el `leadId` que recibe del webhook).
|
||||||
|
5. **No tener scheduler interno** — la app controla cuándo arrancar.
|
||||||
|
|
||||||
|
### 12.2 Lo que necesita el worker de renders
|
||||||
|
|
||||||
|
El worker DEBE:
|
||||||
|
|
||||||
|
1. **Escuchar en la URL que configures como `PERFIL_WEBHOOK_URL`** (POST /perfil-completo).
|
||||||
|
2. **Recibir el payload** con `leadId`, `cliente`, `reforma`, `zonas` (con fotos "antes").
|
||||||
|
3. **Generar renders** para cada zona: Etapa 1 (Claude Haiku → prompt), Etapa 2 (Gemini Flash → imagen), Etapa 3 (Claude Haiku Vision → validación). Todo via OpenRouter.
|
||||||
|
4. **Devolver los renders** llamando a `POST /api/leads/:id/ingesta` con:
|
||||||
|
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"tipo": "foto",
|
||||||
|
"zona": "cocina",
|
||||||
|
"momento": "despues",
|
||||||
|
"imagen": "data:image/..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"finalizar": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
5. **Autenticarse** con `Authorization: Bearer <FUNNEL_API_KEY>`.
|
||||||
|
|
||||||
|
### 12.3 Variables de entorno necesarias en la app
|
||||||
|
|
||||||
|
```
|
||||||
|
# Para que el bot pueda escribir en la BD:
|
||||||
|
FUNNEL_API_KEY=<clave-compartida>
|
||||||
|
|
||||||
|
# URLs donde escuchan el bot y el worker:
|
||||||
|
WHATSAPP_START_WEBHOOK_URL=https://url-del-bot/whatsapp-start
|
||||||
|
PERFIL_WEBHOOK_URL=https://url-del-worker/perfil-completo
|
||||||
|
WHATSAPP_WEBHOOK_URL=https://url-del-bot/whatsapp-pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 Secuencia de integración recomendada
|
||||||
|
|
||||||
|
**Paso 1 — Reconectar Luisa a los endpoints HTTP (prioridad alta)**
|
||||||
|
|
||||||
|
- Eliminar TypeORM y las entidades propias del bot
|
||||||
|
- Implementar llamadas HTTP a los 5 endpoints de la app
|
||||||
|
- Ajustar enums y tipos para que coincidan con la app
|
||||||
|
- Eliminar el scheduler interno
|
||||||
|
- Configurar las 3 URLs de webhook en la app
|
||||||
|
|
||||||
|
**Paso 2 — Implementar worker de renders (prioridad media)**
|
||||||
|
|
||||||
|
- Crear servicio que escuche `PERFIL_WEBHOOK_URL`
|
||||||
|
- Elegir modelo de IA para image-to-image
|
||||||
|
- Integrar con el endpoint `ingesta` para devolver resultados
|
||||||
|
|
||||||
|
**Paso 3 — Conectar llamada Retell real (prioridad baja)**
|
||||||
|
|
||||||
|
- Configurar variables de entorno de Retell
|
||||||
|
- El bot de WhatsApp o el formulario pueden complementar la llamada
|
||||||
126
docs/copy-b2b-estudio.md
Normal file
126
docs/copy-b2b-estudio.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Estudio de copy B2B — Promesa, descripción y CTA
|
||||||
|
|
||||||
|
> Landing B2B (reformistas), servida en `/` y `/b2b`. Estudio para elegir el **hero**: la promesa
|
||||||
|
> (H1), la descripción (subhead) y el CTA. Opciones + ángulo + por qué, y al final el combo
|
||||||
|
> recomendado listo para pegar. Decisión de producto aplicada: **CTA → registro + demo del
|
||||||
|
> resultado**, no "14 días gratis".
|
||||||
|
>
|
||||||
|
> El copy final elegido se promueve a `copy/COPY-GUIDE.md §2` (fuente canónica).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Estrategia (la base, antes de las palabras)
|
||||||
|
|
||||||
|
**Audiencia:** reformista / autónomo de reformas en España. Habla de gremio, desconfía del humo,
|
||||||
|
mide en horas y kilómetros.
|
||||||
|
|
||||||
|
**Nivel de conciencia (Schwartz): 2 — Problem-Aware.** Sabe que pierde tiempo y dinero con visitas
|
||||||
|
y presupuestos que no cuajan, conoce el showrooming, pero **no sabe** que existe un asistente que
|
||||||
|
cualifica por él. → **Implicación: el hero abre desde el DOLOR concreto, no desde el producto.**
|
||||||
|
Validar primero, vender después.
|
||||||
|
|
||||||
|
**Trigger map**
|
||||||
|
- 😤 *Emocional — showrooming (dolor #1, verbatim):* "Hago el presupuesto con detalle y se va al de al lado a que se lo baje."
|
||||||
|
- 😩 *Emocional — tiempo regalado:* "Pierdo tardes presupuestando a gente que no contrata."
|
||||||
|
- 🚗 *Emocional — visita en balde:* "Conduzco 40 km y era cambiar un grifo."
|
||||||
|
- 🥶 *Emocional — se enfrían:* "Mientras estoy en obra, el WhatsApp se enfría."
|
||||||
|
- 📊 *Racional:* 78% acepta el primer presupuesto rápido que recibe · 80% prefiere presupuesto con imágenes · cada visita fallida cuesta 60-90€.
|
||||||
|
- 🗣 *Lenguaje del usuario (úsalo literal):* "solo estoy mirando", "para valorar", "ya te diré", "me lo pienso".
|
||||||
|
|
||||||
|
**Tono.** Somos: **directos, de gremio, con datos, de tú a tú.** No somos: corporativos, "revolucionarios", "lleva tu negocio al siguiente nivel".
|
||||||
|
- Sí: *"Tu cliente cuelga y ya tiene su presupuesto. Tú lo tienes en el panel."*
|
||||||
|
- No: *"Solución innovadora de cualificación inteligente de leads."*
|
||||||
|
|
||||||
|
**Aprendizaje de compramostucoche.com (funnel de referencia):** una **sola** promesa medible ("averigua cuánto vale tu coche"), **gratis + sin compromiso + en minutos**, y **un** CTA dominante repetido. Su fuerza es la *claridad instantánea*. Trasladado a B2B: una promesa concreta + un CTA claro a la demo, sin ruido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. La PROMESA (H1) — opciones
|
||||||
|
|
||||||
|
Regla: < 10 palabras, front-load, abrir desde el dolor (nivel 2), voz de gremio.
|
||||||
|
|
||||||
|
| # | Promesa (H1) | Ángulo | Trigger | Valoración |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | **Deja de presupuestar gratis para quien no va a contratar.** | Dolor: presupuestos regalados | tiempo regalado | ✅ Pega fuerte y es de gremio. Abre en dolor puro (correcto para nivel 2). |
|
||||||
|
| 2 | **Sabrás qué obra merece la visita antes de coger el coche.** | Filtro / cualificación | visita en balde (40 km) | ✅✅ Dolor + promesa en una frase, muy visual ("coger el coche"). **Top.** |
|
||||||
|
| 3 | **Tu cliente ve su reforma y su precio antes de que llegues.** | Cliente visual + llegar con ventaja | 80% imágenes · marca | ✅ Doble beneficio (cliente + tú). Buena para A/B. |
|
||||||
|
| 4 | **Mientras estás en la obra, Reformix atiende al siguiente cliente.** | El asistente trabaja por ti | WhatsApp que se enfría | ✅ Imagen clara; ⚠️ el dolor queda implícito. |
|
||||||
|
| 5 | **Recupera las tardes que pierdes haciendo presupuestos.** | Tiempo recuperado | tiempo regalado | ✅ Beneficio humano y directo. |
|
||||||
|
| 6 | **El presupuesto con render que tu competencia no sabe hacer.** | Diferenciación / marca | showrooming | ⚠️ Centrado en el "qué" (feature) más que en el dolor. |
|
||||||
|
| 7 | **Cada visita "para valorar" te cuesta dinero.** *(actual)* | Dolor + verbatim | "para valorar" | ✅ Usa lenguaje literal del cliente; algo abstracta sobre la solución. |
|
||||||
|
|
||||||
|
**Recomendación:** **#2** como H1 (une dolor + promesa + imagen concreta). **#1** y **#3** como
|
||||||
|
candidatas claras para **A/B test** (dolor puro vs beneficio del cliente). Evitar #6 como H1 (es
|
||||||
|
mejor como bloque de diferenciación más abajo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. La DESCRIPCIÓN (subhead) — opciones
|
||||||
|
|
||||||
|
Trabajo del subhead: explicar el **mecanismo** (atiende → pide fotos → presupuesta con render → lo
|
||||||
|
deja en tu panel) **y** el beneficio (solo vas a lo que renta), en 1-2 frases. < 30 palabras.
|
||||||
|
|
||||||
|
- **A.** "Reformix atiende a tu cliente por WhatsApp, le pide fotos y le calcula un presupuesto orientativo con render —bajo tu marca—. Tú abres el panel y solo te desplazas a las obras que rentan."
|
||||||
|
- **B.** "Mientras tú trabajas, Reformix cualifica a tu próximo cliente: fotos, medidas, presupuesto orientativo y render. En tu panel ves al instante si la obra merece la visita."
|
||||||
|
- **C.** "Atiende, cualifica y presupuesta a tu cliente por ti, con render y bajo tu marca. Tú solo vas a las visitas que valen la pena." *(la más corta)*
|
||||||
|
- **D.** "El asistente habla con tu cliente y le enseña cómo quedará su reforma con un presupuesto orientativo. Tú llegas a la visita con el trabajo hecho y el cliente ya enganchado."
|
||||||
|
|
||||||
|
**Recomendación: A** (mecanismo claro + "bajo tu marca" + el beneficio "solo a las que rentan").
|
||||||
|
**C** si el diseño pide algo más corto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. El CTA — opciones (orientado a DEMO)
|
||||||
|
|
||||||
|
Reglas aplicadas: **valor > verbo**, **primera persona** convierte mejor, **reducir fricción** con
|
||||||
|
trust text, **urgencia solo si es real**. La vía es **registro → página de demo** que muestra el
|
||||||
|
flujo y el resultado real (no trial de 14 días).
|
||||||
|
|
||||||
|
**CTA primario (a la demo):**
|
||||||
|
- ✅ **"Ver la demo en 2 minutos"** — valor + tiempo + cero fricción. **Recomendado.**
|
||||||
|
- "Probar la demo con una reforma" — más "hands-on" (implica registro).
|
||||||
|
- "Quiero ver cómo funciona" — primera persona, más suave.
|
||||||
|
- ⚠️ "Empezar gratis" — genérico, y ya no hay trial; evitar.
|
||||||
|
|
||||||
|
**CTA secundario (sin salir):** "Ver cómo funciona" → ancla al vídeo `#demo`.
|
||||||
|
|
||||||
|
**Trust text (bajo el botón):** "Sin tarjeta · Sin llamadas de ventas · Ves el resultado real en 2 minutos."
|
||||||
|
*(El "sin llamadas de ventas" mata de paso la objeción de que les vamos a perseguir.)*
|
||||||
|
|
||||||
|
**Oferta con urgencia (opcional, para el CTA final de la página, no el hero):**
|
||||||
|
"Si configuras tu cuenta esta semana, te dejamos los primeros [N] leads sin coste." → 🔸 definir N + fecha real (urgencia falsa = se nota y resta).
|
||||||
|
|
||||||
|
**Repetir el CTA** mínimo 3 veces: hero + mitad (tras "Cómo funciona") + cierre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Combo recomendado (listo para pegar)
|
||||||
|
|
||||||
|
> **H1:** Sabrás qué obra merece la visita antes de coger el coche.
|
||||||
|
>
|
||||||
|
> **Subhead:** Reformix atiende a tu cliente por WhatsApp, le pide fotos y le calcula un presupuesto orientativo con render —bajo tu marca—. Tú abres el panel y solo te desplazas a las obras que rentan.
|
||||||
|
>
|
||||||
|
> **CTA primario:** Ver la demo en 2 minutos
|
||||||
|
> **CTA secundario:** Ver cómo funciona
|
||||||
|
> **Trust:** Sin tarjeta · Sin llamadas de ventas · Ves el resultado real en 2 minutos
|
||||||
|
|
||||||
|
Alternativa A/B del H1: *"Deja de presupuestar gratis para quien no va a contratar."* (dolor puro).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Checks
|
||||||
|
|
||||||
|
**Autenticidad (test 30 s):**
|
||||||
|
- ¿Suena a persona real de gremio? **Sí** ("antes de coger el coche", "obras que rentan").
|
||||||
|
- ¿Hay una frase que solo diría esta marca? **Sí**: *"solo te desplazas a las obras que rentan."*
|
||||||
|
- ¿Hay imperfección/concesión deliberada? **Sí** (más abajo, en objeciones: *"Si haces menos de X presupuestos al mes, no te hace falta esto."*). → añadir esa concesión honesta a la landing.
|
||||||
|
- Señales de "IA genérica" eliminadas: sin "innovador/revolucionario", sin lista simétrica de 3, abre con dolor (no con definición).
|
||||||
|
|
||||||
|
**Scan test:** H1 + subhead + CTA cuentan la historia solos (dolor → mecanismo → demo). ✅
|
||||||
|
|
||||||
|
**Pendiente de producto (🔸):** texto exacto de la oferta con urgencia (bono + fecha real).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Siguiente paso
|
||||||
|
Con el combo aprobado: (1) lo aplico en `public/b2b.html` + `copy/COPY-GUIDE.md §2`, y (2) construyo la **página de demo** (registro → flujo + resultado real) a la que apunta el CTA. ("demo adelante" ya confirmado.)
|
||||||
63
docs/despliegue-luisa-worker.md
Normal file
63
docs/despliegue-luisa-worker.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Despliegue — Luisa (bot WhatsApp) + image-worker
|
||||||
|
|
||||||
|
Los tres servicios de Reformix corren **unidos en el mismo VPS** (Dokploy personal,
|
||||||
|
`panel.carlosnarro.com`, proyecto **Reformix** / entorno **production**). Build por **Dockerfile**
|
||||||
|
desde Gitea (`carlos/reformix-hackaton`, rama `main`, autodeploy en push).
|
||||||
|
|
||||||
|
## Servicios
|
||||||
|
|
||||||
|
| Servicio | App en Dokploy | Repo (buildPath) | Dominio | Puerto |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| App principal | `reformix-b2c` (`lzHDAuPuubbJu94OrkNS_`) | `/mvp/b2c` | `reformix.dv3.com.es` | 3000 |
|
||||||
|
| Bot WhatsApp (Luisa) | `reformix-bot` (`wY4F14fyEslU-4za_JIbi`) | `/mvp/Whatsapp-bot` | `reformix-bot.dv3.com.es` | **3001** (webhooks) |
|
||||||
|
| Worker de renders | `reformix-worker` (`sMQd9zwoyV14q1vm8Vs8U`) | `/mvp/image-worker` | `reformix-worker.dv3.com.es` | 3001 |
|
||||||
|
|
||||||
|
> El bot escucha la app NestJS en 3000 y los **webhooks entrantes en 3001** (`/whatsapp-start`,
|
||||||
|
> `/whatsapp-pdf`). El dominio público enruta al 3001. Sesión de WhatsApp persistida en un volumen
|
||||||
|
> montado en `/app/auth_info_baileys`.
|
||||||
|
|
||||||
|
## Webhooks configurados en reformix-b2c
|
||||||
|
|
||||||
|
```ini
|
||||||
|
WHATSAPP_START_WEBHOOK_URL = https://reformix-bot.dv3.com.es/whatsapp-start
|
||||||
|
WHATSAPP_WEBHOOK_URL = https://reformix-bot.dv3.com.es/whatsapp-pdf
|
||||||
|
PERFIL_WEBHOOK_URL = https://reformix-worker.dv3.com.es/perfil-completo
|
||||||
|
```
|
||||||
|
|
||||||
|
El bot y el worker llaman de vuelta a la API de b2c con `API_BASE_URL`/`REFORMIX_API_URL =
|
||||||
|
https://reformix.dv3.com.es` y `Authorization: Bearer <FUNNEL_API_KEY>` (la misma de b2c, ya puesta).
|
||||||
|
|
||||||
|
## Pasos manuales pendientes (no automatizables)
|
||||||
|
|
||||||
|
1. **`OPENROUTER_API_KEY`** — está **vacía** en `reformix-bot` y `reformix-worker`. Pégala en el env
|
||||||
|
de **ambas** apps (panel de Dokploy → app → Environment) y **redeploy** de ambas. Sin ella el bot
|
||||||
|
no genera respuestas y el worker no genera renders.
|
||||||
|
2. **Vincular WhatsApp (QR)** — abre los **logs** de `reformix-bot` en Dokploy: Baileys imprime un QR
|
||||||
|
en ASCII. Escanéalo con el WhatsApp del número del negocio. La sesión queda persistida en el
|
||||||
|
volumen (sobrevive redeploys). **Sin restricción de número** (`ALLOWED_NUMBER` no está
|
||||||
|
configurada): el bot conversa con cualquiera que le escriba.
|
||||||
|
|
||||||
|
## Verificación (estado a 08-jun-2026)
|
||||||
|
|
||||||
|
- Builds Docker de bot y worker: **OK** a la primera. Certs Let's Encrypt emitidos (TLS válido).
|
||||||
|
- `GET https://reformix.dv3.com.es/` → 200 · `POST …/perfil-completo` (worker) → 400 (vivo) ·
|
||||||
|
`POST …/whatsapp-start` (bot) → 200 (vivo).
|
||||||
|
- Tras poner la `OPENROUTER_API_KEY` + escanear el QR, el flujo queda end-to-end: lead elige WhatsApp
|
||||||
|
→ `iniciarWhatsapp` → bot conversa y puebla la BD por los EPs → `perfilCompleto` → worker genera
|
||||||
|
renders → `ingesta finalizar` → PDF + email + entrega por WhatsApp.
|
||||||
|
|
||||||
|
## Operación
|
||||||
|
|
||||||
|
- **Redeploy:** push a `main` (autodeploy Gitea) o `POST /api/application.deploy {applicationId}`.
|
||||||
|
- Los GET que el bot consume (`GET /api/leads/:id`, `GET /api/leads/:id/conversacion`) viven en
|
||||||
|
`mvp/b2c`. Smoke test de los EPs del bot: [`mvp/b2c/api-docs/smoke-bot-eps.mjs`](../mvp/b2c/api-docs/smoke-bot-eps.mjs).
|
||||||
|
|
||||||
|
## Notas de integración para Simón (menores, a pulir)
|
||||||
|
|
||||||
|
- Los 2 `GET` que usa tu `api-client` y **no existían** en la API ya están añadidos y desplegados:
|
||||||
|
`GET /api/leads/:id` (estado del lead) y `GET /api/leads/:id/conversacion` (historial). Ya responden.
|
||||||
|
- En `POST /perfil` mandas `nombre`, pero la API **no actualiza** ese campo (lo ignora). Si quieres
|
||||||
|
poder cambiar el nombre del lead desde el bot, lo hablamos.
|
||||||
|
- No estás enviando `calidadGlobal` (`basica`/`media`/`premium`), que usa el motor de presupuesto.
|
||||||
|
Si Luisa lo puede extraer, mándalo en `POST /perfil`.
|
||||||
|
- Contrato completo de los EPs (campos, enums, ejemplos): [`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
|
||||||
207
docs/estados-flujo.html
Normal file
207
docs/estados-flujo.html
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Reformix — Flujos de estado por canal</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--pipe:#0066ff; --pipe-bg:#e8f0fe;
|
||||||
|
--crm:#0f7a52; --crm-bg:#e6f4ee;
|
||||||
|
--wa:#0891b2; --wa-bg:#e0f4f8;
|
||||||
|
--bot:#b45309; --bot-bg:#fbf0e0;
|
||||||
|
--bad:#dc2626; --bad-bg:#fdeaea;
|
||||||
|
--ink:#18181b; --muted:#71717a; --line:#e4e4e7; --card:#fff; --bg:#f4f4f5;
|
||||||
|
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);line-height:1.5}
|
||||||
|
.wrap{max-width:1280px;margin:0 auto;padding:28px 20px 80px}
|
||||||
|
h1{font-size:26px;font-weight:800;letter-spacing:-.4px;margin:0 0 4px}
|
||||||
|
.sub{color:var(--muted);margin:0 0 22px;font-size:14px}
|
||||||
|
h2{font-size:15px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:34px 0 12px;font-weight:700}
|
||||||
|
|
||||||
|
/* Leyenda de dimensiones */
|
||||||
|
.dims{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
||||||
|
.dim{background:var(--card);border:1px solid var(--line);border-left-width:5px;border-radius:10px;padding:12px 14px}
|
||||||
|
.dim h3{margin:0 0 4px;font-size:13px;font-family:ui-monospace,monospace}
|
||||||
|
.dim p{margin:0 0 8px;font-size:12px;color:var(--muted)}
|
||||||
|
.dim .vals{display:flex;flex-wrap:wrap;gap:4px}
|
||||||
|
.pip{border-left-color:var(--pipe)} .pip h3{color:var(--pipe)}
|
||||||
|
.crm{border-left-color:var(--crm)} .crm h3{color:var(--crm)}
|
||||||
|
.waL{border-left-color:var(--wa)} .waL h3{color:var(--wa)}
|
||||||
|
.botL{border-left-color:var(--bot)} .botL h3{color:var(--bot)}
|
||||||
|
.chip{display:inline-block;font-size:10.5px;font-family:ui-monospace,monospace;padding:2px 7px;border-radius:99px;border:1px solid var(--line);background:#fafafa;color:#3f3f46;white-space:nowrap}
|
||||||
|
.chip.p{background:var(--pipe-bg);border-color:#cdddff;color:#1d4ed8}
|
||||||
|
.chip.c{background:var(--crm-bg);border-color:#bfe3d2;color:#0f7a52}
|
||||||
|
.chip.w{background:var(--wa-bg);border-color:#bfe6ef;color:#0e7490}
|
||||||
|
.chip.b{background:var(--bot-bg);border-color:#f0d9b5;color:#b45309}
|
||||||
|
.chip.x{background:var(--bad-bg);border-color:#f5c2c2;color:#dc2626}
|
||||||
|
|
||||||
|
/* Columnas de canal */
|
||||||
|
.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:start}
|
||||||
|
.col{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||||||
|
.col > .head{padding:12px 16px;font-weight:700;font-size:15px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
|
||||||
|
.col.form > .head{background:#f5f3ff} .col.wa > .head{background:var(--wa-bg)} .col.call > .head{background:#fff7ed}
|
||||||
|
.steps{padding:14px 16px;display:flex;flex-direction:column;gap:0}
|
||||||
|
.step{position:relative;padding:10px 0 14px}
|
||||||
|
.step .t{font-size:13.5px;font-weight:600}
|
||||||
|
.step .d{font-size:12px;color:var(--muted);margin:2px 0 6px}
|
||||||
|
.step .tags{display:flex;flex-wrap:wrap;gap:4px}
|
||||||
|
.arrow{height:16px;display:flex;justify-content:center;color:#a1a1aa;font-size:13px}
|
||||||
|
.branch{border:1px dashed var(--line);border-radius:8px;padding:8px 10px;margin-top:6px;background:#fafafa}
|
||||||
|
.branch .t{font-size:12.5px;font-weight:600}
|
||||||
|
.dot{width:8px;height:8px;border-radius:99px;display:inline-block}
|
||||||
|
|
||||||
|
.conv{background:var(--bot-bg);border:1px solid #f0d9b5;border-radius:8px;padding:8px 10px;margin-top:6px}
|
||||||
|
.conv .t{font-size:12px;font-weight:700;color:var(--bot);margin-bottom:4px}
|
||||||
|
.conv .flow{font-family:ui-monospace,monospace;font-size:11px;color:#92400e;line-height:1.7}
|
||||||
|
|
||||||
|
.converge{margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px}
|
||||||
|
.converge .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:13px}
|
||||||
|
.converge .b{font-weight:700}
|
||||||
|
|
||||||
|
.note{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:14px 16px;margin-top:14px;font-size:13.5px}
|
||||||
|
.note h3{margin:0 0 8px;font-size:14px}
|
||||||
|
.note ul{margin:6px 0 0;padding-left:18px} .note li{margin:4px 0}
|
||||||
|
.rec{background:#ecfdf5;border:1px solid #a7f3d0}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:6px}
|
||||||
|
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
th{color:var(--muted);font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
code{font-family:ui-monospace,monospace;background:#f4f4f5;padding:1px 5px;border-radius:5px;font-size:11.5px}
|
||||||
|
@media(max-width:980px){.dims{grid-template-columns:repeat(2,1fr)}.cols{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Reformix — Flujos de estado por canal</h1>
|
||||||
|
<p class="sub">El lío viene de mezclar varias "estados" que en realidad son <b>4 dimensiones independientes</b>. Aquí están separadas y el flujo de cada canal sobre ellas. DB única; el lead se crea siempre en el form web.</p>
|
||||||
|
|
||||||
|
<h2>1 · Las 4 dimensiones de estado (ortogonales)</h2>
|
||||||
|
<div class="dims">
|
||||||
|
<div class="dim pip">
|
||||||
|
<h3>pipeline_stage</h3>
|
||||||
|
<p>Avance TÉCNICO en el funnel. Lo comparten los 3 canales y es lo que ve el panel. <b>Lo gestiona la app/EP, no el bot.</b></p>
|
||||||
|
<div class="vals">
|
||||||
|
<span class="chip p">form_completado</span><span class="chip p">fotos_subidas</span><span class="chip p">prellamada_enviada</span><span class="chip p">llamada_completada</span><span class="chip p">render_generado</span><span class="chip p">presupuesto_generado</span><span class="chip p">whatsapp_entregado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dim crm">
|
||||||
|
<h3>lead_estado</h3>
|
||||||
|
<p>Estado COMERCIAL / CRM. Lo lleva el reformista (y algún automatismo). Independiente del canal.</p>
|
||||||
|
<div class="vals">
|
||||||
|
<span class="chip c">nuevo</span><span class="chip c">contactado</span><span class="chip c">visita_agendada</span><span class="chip c">presupuesto_enviado</span><span class="chip c">ganado</span><span class="chip x">perdido</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dim waL">
|
||||||
|
<h3>estado_wa</h3>
|
||||||
|
<p>SOLO entrega del último mensaje de WhatsApp (técnico, por-mensaje). <b>No</b> es "en qué punto va la conversación".</p>
|
||||||
|
<div class="vals">
|
||||||
|
<span class="chip w">sin_enviar</span><span class="chip w">enviado</span><span class="chip w">entregado</span><span class="chip w">leido</span><span class="chip x">fallido</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dim botL">
|
||||||
|
<h3>estado_conversacion <span style="color:var(--bad)">(NO existe aún)</span></h3>
|
||||||
|
<p>En qué paso va Luisa en la cualificación. Hoy vive solo dentro del bot. <b>La decisión es si lo persistimos.</b></p>
|
||||||
|
<div class="vals">
|
||||||
|
<span class="chip b">apertura</span><span class="chip b">espacio</span><span class="chip b">tamaño</span><span class="chip b">estilo</span><span class="chip b">urgencia</span><span class="chip b">presupuesto</span><span class="chip b">pide_fotos</span><span class="chip b">completado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>2 · Flujo de cada canal sobre esas dimensiones</h2>
|
||||||
|
<div class="cols">
|
||||||
|
|
||||||
|
<!-- FORMULARIO -->
|
||||||
|
<div class="col form">
|
||||||
|
<div class="head">📝 Formulario web</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div class="t">Cliente deja datos (crearLead)</div><div class="d">nombre · tel · email · opt-ins</div><div class="tags"><span class="chip p">form_completado</span><span class="chip c">nuevo</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Rellena por zonas + sube fotos</div><div class="d">guardarDetallesYFotos → lead_fotos (antes) + lead_notas</div><div class="tags"><span class="chip p">fotos_subidas</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Presupuesto orientativo al instante</div><div class="d">motor de presupuesto + señal perfilCompleto</div><div class="tags"><span class="chip p">presupuesto_generado</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Converge en calificación →</div><div class="tags"><span class="chip c">contactado</span></div></div>
|
||||||
|
<div class="conv"><div class="t">No usa</div><div class="flow">estado_wa · estado_conversacion (no hay chat)</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WHATSAPP -->
|
||||||
|
<div class="col wa">
|
||||||
|
<div class="head">💬 WhatsApp — Luisa</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div class="t">Lead ya existe (del form) → elige WhatsApp</div><div class="d">app emite WHATSAPP_START con leadId</div><div class="tags"><span class="chip p">form_completado</span><span class="chip c">nuevo</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Bot escribe el 1er mensaje</div><div class="d">entrega del mensaje (no la conversación)</div><div class="tags"><span class="chip w">sin_enviar→enviado→entregado→leido</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Luisa cualifica (conversación)</div><div class="d">guarda cada turno en conversacion_whatsapp + extrae a leads</div>
|
||||||
|
<div class="conv"><div class="t">estado_conversacion (bot)</div><div class="flow">apertura → espacio → tamaño → estilo → urgencia → presupuesto</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">¿Viable? (≥ 5000€)</div>
|
||||||
|
<div class="branch"><div class="t">No → <span class="chip x">perdido</span> (no_viable, descartado)</div></div>
|
||||||
|
<div class="branch"><div class="t">Sí → pide fotos por WA</div><div class="tags" style="margin-top:6px"><span class="chip b">pide_fotos</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Fotos recibidas → EP ingesta</div><div class="d">lead_fotos (antes) + worker analiza</div><div class="tags"><span class="chip p">fotos_subidas</span><span class="chip b">completado</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Converge en calificación →</div><div class="tags"><span class="chip c">contactado</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LLAMADA -->
|
||||||
|
<div class="col call">
|
||||||
|
<div class="head">📞 Llamada</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div class="t">Lead ya existe (del form) → pide llamada</div><div class="d">ahora / programar</div><div class="tags"><span class="chip p">prellamada_enviada</span><span class="chip c">nuevo</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Bot de llamada (externo)</div><div class="d">registra intento en intentos_contacto</div><div class="tags"><span class="chip p">llamada_completada</span></div>
|
||||||
|
<div class="branch"><div class="t">resultado_contacto</div><div class="tags" style="margin-top:6px"><span class="chip">exitoso</span><span class="chip">no_contesta</span><span class="chip">ocupado</span><span class="chip x">rechaza</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Pide fotos por WA o email→formulario</div><div class="d">leads.fotos_solicitadas_at</div><div class="tags"><span class="chip w">enviado</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Fotos recibidas → EP ingesta</div><div class="d">lead_fotos (antes) + worker</div><div class="tags"><span class="chip p">fotos_subidas</span></div></div>
|
||||||
|
<div class="arrow">↓</div>
|
||||||
|
<div class="step"><div class="t">Converge en calificación →</div><div class="tags"><span class="chip c">contactado</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONVERGENCIA -->
|
||||||
|
<h2>3 · Convergencia (los 3 canales acaban igual)</h2>
|
||||||
|
<div class="converge">
|
||||||
|
<div class="row">
|
||||||
|
<span class="chip c">Calificación</span><span>lead_calificacion (score + A/B/C/D)</span> <span style="color:#a1a1aa">→</span>
|
||||||
|
<span class="chip c">visita_agendada</span> <span style="color:#a1a1aa">→</span>
|
||||||
|
<span class="chip p">render_generado</span><span class="chip p">presupuesto_generado</span> <span style="color:#a1a1aa">→</span>
|
||||||
|
<span class="chip p">whatsapp_entregado</span><span class="chip c">presupuesto_enviado</span> <span style="color:#a1a1aa">→</span>
|
||||||
|
<span class="chip c">ganado</span> / <span class="chip x">perdido</span> <span style="color:#a1a1aa">→</span> testimonio
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DECISIÓN -->
|
||||||
|
<h2>4 · La decisión a tomar</h2>
|
||||||
|
<div class="note">
|
||||||
|
<h3>¿WhatsApp necesita "otros estados"? Sí, pero ojo a CUÁL:</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>estado_wa</b> (ya lo tenemos): es solo si el mensaje llegó/se leyó. Útil pero de bajo nivel.</li>
|
||||||
|
<li><b>estado_conversacion (Luisa)</b>: <u>esto es lo que de verdad falta</u> si queremos saber "por qué paso va el chat" y poder retomarlo si se corta. Hoy NO está en la DB.</li>
|
||||||
|
<li>El <b>diagrama del compañero</b> ponía <code>estado_wa = nuevo</code> → mezcla los dos conceptos. <code>nuevo</code> no es entrega de mensaje, es "conversación sin empezar".</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="note rec">
|
||||||
|
<h3>Mi recomendación</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>pipeline_stage</b> = única fuente del avance del lead (lo pinta el panel). Lo escribe la app/EP. <b>El bot NO lo toca.</b></li>
|
||||||
|
<li><b>lead_estado</b> = comercial, lo lleva el reformista.</li>
|
||||||
|
<li><b>estado_wa</b> = déjalo solo para entrega de mensaje (sin_enviar…leido). No metas "nuevo" ahí.</li>
|
||||||
|
<li><b>estado_conversacion del bot</b>: 2 opciones —
|
||||||
|
<br>(a) vive SOLO dentro del bot/n8n (no lo persistimos en nuestra DB) → más simple, suficiente para la demo;
|
||||||
|
<br>(b) lo persistimos como columna nueva <code>leads.bot_step</code> (o tabla) si queremos verlo en el panel / retomar conversaciones. <b>← esto es lo que hay que decidir.</b></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
docs/flujo-usuario/flujo_reformix.webp
Normal file
BIN
docs/flujo-usuario/flujo_reformix.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
334
docs/flujo-usuario/flujo_reformix.xml
Normal file
334
docs/flujo-usuario/flujo_reformix.xml
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<mxfile host="app.diagrams.net">
|
||||||
|
<diagram name="Página-1" id="DRtktbz_MXrh0E5vmsnP">
|
||||||
|
<mxGraphModel dx="611" dy="239" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-1" parent="1" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=20;fontStyle=1;" value="Reformix — Flujo del sistema" vertex="1">
|
||||||
|
<mxGeometry height="40" width="500" x="600" y="20" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-2" parent="1" style="ellipse;whiteSpace=wrap;html=1;fontSize=13;fontStyle=1;" value="Cliente" vertex="1">
|
||||||
|
<mxGeometry height="50" width="120" x="800" y="80" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Formulario web" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="540" y="190" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-5" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="WhatsApp directo" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="785" y="157" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-6" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Llamada" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="1030" y="190" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-7" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-4">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-8" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-5">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-9" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-2" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-6">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-10" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Crear lead en DB
(canal_origen registrado)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="250" x="735" y="280" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-11" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-4" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-12" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-5" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-13" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-6" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-10">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-14" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Registrar intento
(intentos_contacto)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="250" x="735" y="370" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-15" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-10" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-14">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-16" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Formulario" vertex="1">
|
||||||
|
<mxGeometry height="20" width="150" x="400" y="450" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-17" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Cliente sube fotos
(lead_fotos · antes)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="170" x="390" y="480" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-18" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-17">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="735" y="395" />
|
||||||
|
<mxPoint x="475" y="395" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-19" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
analiza + clasifica fotos
(lead_fotos · render_url)" vertex="1">
|
||||||
|
<mxGeometry height="60" width="170" x="390" y="570" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-20" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-17" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-19">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-21" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||||
|
<mxGeometry height="40" width="120" x="415" y="665" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-22" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-19" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-21">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-23" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="WhatsApp — Luisa" vertex="1">
|
||||||
|
<mxGeometry height="20" width="200" x="710" y="450" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-24" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Scheduler 5 min
→ Bot Luisa
(estado_wa = nuevo)" vertex="1">
|
||||||
|
<mxGeometry height="60" width="180" x="720" y="480" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-25" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-26" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Luisa cualifica (7 estados)
apertura→espacio→tamaño
→estilo→urgencia→presupuesto
[conversacion_whatsapp]" vertex="1">
|
||||||
|
<mxGeometry height="70" width="200" x="710" y="575" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-27" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-24" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-26">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-28" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Viable?
(≥ 5000€)" vertex="1">
|
||||||
|
<mxGeometry height="70" width="140" x="740" y="675" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-29" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-26" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-28">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-30" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#fff2cc;strokeColor=#d6b656;" value="Lead no_viable
(descartado)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="130" x="580" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-31" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-30" value="No">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-32" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Pide fotos por WA
(leads · fotos_solicitadas_at)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="180" x="720" y="780" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-33" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-28" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-32" value="Sí">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-34" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos
recibidas?" vertex="1">
|
||||||
|
<mxGeometry height="70" width="140" x="740" y="860" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-35" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-32" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-36" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático
↻ vuelve a preguntar" vertex="1">
|
||||||
|
<mxGeometry height="50" width="140" x="530" y="920" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-37" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-36" value="No">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-38" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-36" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-34">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="650" y="935" />
|
||||||
|
<mxPoint x="650" y="895" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-39" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
(lead_fotos · antes)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="180" x="720" y="965" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-40" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-34" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-39" value="Sí">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-41" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="735" y="1045" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-42" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-39" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-41">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-43" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Llamada" vertex="1">
|
||||||
|
<mxGeometry height="20" width="150" x="1030" y="450" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-44" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;dashed=1;" value="Bot de llamada
(compañero — pendiente)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="170" x="1030" y="480" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-45" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-14" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-44">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="985" y="395" />
|
||||||
|
<mxPoint x="1115" y="395" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-46" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Llamada
completada?" vertex="1">
|
||||||
|
<mxGeometry height="70" width="140" x="1045" y="560" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-47" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-44" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-46">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-48" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="WA de continuación
retoma desde estado_wa" vertex="1">
|
||||||
|
<mxGeometry height="50" width="170" x="1210" y="573" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-49" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-48" value="No / cortada">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-50" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-48" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1295" y="440" />
|
||||||
|
<mxPoint x="870" y="440" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-51" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Contactado?" vertex="1">
|
||||||
|
<mxGeometry height="70" width="140" x="1045" y="660" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-52" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-46" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-51" value="Sí">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-53" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-24" value="No">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1045" y="695" />
|
||||||
|
<mxPoint x="960" y="695" />
|
||||||
|
<mxPoint x="960" y="510" />
|
||||||
|
<mxPoint x="860" y="510" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-54" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Enviar WA pidiendo fotos
(leads · fotos_solicitadas_at)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="170" x="1030" y="765" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-55" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-51" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-54" value="Sí">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-56" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Fotos
recibidas?" vertex="1">
|
||||||
|
<mxGeometry height="70" width="140" x="1045" y="845" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-57" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-54" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-58" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Recordatorio automático
↻ vuelve a preguntar" vertex="1">
|
||||||
|
<mxGeometry height="50" width="150" x="1250" y="895" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-59" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-58" value="No">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-60" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-58" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-56">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1360" y="885" />
|
||||||
|
<mxPoint x="1360" y="880" />
|
||||||
|
<mxPoint x="1185" y="880" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-61" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
(lead_fotos · antes)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="170" x="1030" y="945" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-62" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-56" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-61" value="Sí">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-63" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="→ Calificación" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="1045" y="1025" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-64" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-61" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-63">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-65" parent="1" style="text;html=1;align=center;fontSize=11;fontStyle=2;" value="Todas las ramas convergen aquí" vertex="1">
|
||||||
|
<mxGeometry height="20" width="400" x="660" y="1110" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-66" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;strokeWidth=2;" value="Calificación del lead
score + nivel frío/tibio/caliente
(lead_calificacion)" vertex="1">
|
||||||
|
<mxGeometry height="60" width="300" x="710" y="1140" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-67" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-21" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="475" y="1170" />
|
||||||
|
<mxPoint x="710" y="1170" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-68" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-41" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="810" y="1170" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-69" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-63" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-66">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<Array as="points">
|
||||||
|
<mxPoint x="1120" y="1170" />
|
||||||
|
<mxPoint x="1010" y="1170" />
|
||||||
|
</Array>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-70" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="CRM — Agente revisa lead
(leads · estado = contactado)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="300" x="710" y="1240" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-71" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-66" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-70">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-72" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Visita agendada
(visitas)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="300" x="710" y="1330" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-73" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-70" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-72">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-74" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes
genera render antes/después
(lead_fotos · render_url)" vertex="1">
|
||||||
|
<mxGeometry height="60" width="300" x="710" y="1420" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-75" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-72" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-74">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-76" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Generación presupuesto
render + PDF
(leads · pdf_url)" vertex="1">
|
||||||
|
<mxGeometry height="60" width="300" x="710" y="1520" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-77" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-74" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-76">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-78" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Envío al cliente
WhatsApp / email" vertex="1">
|
||||||
|
<mxGeometry height="50" width="300" x="710" y="1620" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-79" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-76" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-78">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-80" parent="1" style="rhombus;whiteSpace=wrap;html=1;fontSize=12;" value="¿Acepta?" vertex="1">
|
||||||
|
<mxGeometry height="70" width="140" x="790" y="1700" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-81" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-78" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-80">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-82" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead GANADO
(leads · estado = ganado)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="220" x="680" y="1810" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-83" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-82" value="Sí">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-84" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead PERDIDO
(leads · estado = perdido)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="220" x="970" y="1718" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-85" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-80" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-84" value="No">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-86" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;" value="Solicitar testimonio
(testimonios)" vertex="1">
|
||||||
|
<mxGeometry height="50" width="220" x="680" y="1900" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-87" edge="1" parent="1" source="nWkRbZFlf69J1CHv_Vaw-82" style="edgeStyle=orthogonalEdgeStyle;" target="nWkRbZFlf69J1CHv_Vaw-86">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-88" parent="1" style="text;html=1;fontSize=13;fontStyle=1;" value="Leyenda" vertex="1">
|
||||||
|
<mxGeometry height="20" width="100" x="1300" y="1140" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-89" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;" value="Flujo principal" vertex="1">
|
||||||
|
<mxGeometry height="30" width="160" x="1300" y="1170" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-90" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;dashed=1;" value="Bot externo / pendiente" vertex="1">
|
||||||
|
<mxGeometry height="30" width="160" x="1300" y="1210" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-91" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#dae8fc;strokeColor=#6c8ebf;" value="Worker de imágenes" vertex="1">
|
||||||
|
<mxGeometry height="30" width="160" x="1300" y="1250" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-92" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#d5e8d4;strokeColor=#82b366;" value="Lead ganado / éxito" vertex="1">
|
||||||
|
<mxGeometry height="30" width="160" x="1300" y="1290" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="nWkRbZFlf69J1CHv_Vaw-93" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#f8cecc;strokeColor=#b85450;" value="Lead perdido / descartado" vertex="1">
|
||||||
|
<mxGeometry height="30" width="160" x="1300" y="1330" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
115
docs/handoff-bot-runtime-simon.md
Normal file
115
docs/handoff-bot-runtime-simon.md
Normal 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.
|
||||||
117
docs/handoff-whatsapp-simon.md
Normal file
117
docs/handoff-whatsapp-simon.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Handoff WhatsApp (Luisa) — para Simón
|
||||||
|
|
||||||
|
Cómo integra el bot de WhatsApp con la app Reformix. **Una sola base de datos** (la de la app;
|
||||||
|
Postgres). El **lead se crea siempre desde el form web**, así que cuando el cliente elige WhatsApp
|
||||||
|
el lead **ya existe** y te pasamos su `leadId`. No creas leads tú.
|
||||||
|
|
||||||
|
> **Modelo de integración (decidido):** el bot **no toca Postgres directamente**. Toda la escritura
|
||||||
|
> va por **endpoints HTTP** autenticados (no necesitas credenciales de BD ni estar en la red de
|
||||||
|
> Dokploy). Ya están **desplegados y probados** en `https://reformix.dv3.com.es`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Cómo arranca tu flujo
|
||||||
|
|
||||||
|
Cuando el cliente elige "WhatsApp" en el funnel, la app hace `POST` al webhook
|
||||||
|
**`WHATSAPP_START_WEBHOOK_URL`** (lo configuras tú y nos lo pasas) con:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "leadId": "uuid", "telefono": "+34...", "nombre": "...", "empresa": "Reformas Ejemplo" }
|
||||||
|
```
|
||||||
|
|
||||||
|
A partir de ahí Luisa escribe al `telefono` y trabaja **siempre con ese `leadId`**.
|
||||||
|
|
||||||
|
## 2. Cómo escribes en la BD: por API, no SQL
|
||||||
|
|
||||||
|
| Qué guardas | Endpoint |
|
||||||
|
| --- | --- |
|
||||||
|
| Cada turno del chat (+ estado del mensaje y paso del bot) | `POST /api/leads/:id/conversacion` |
|
||||||
|
| Lo que vas extrayendo del lead (espacio, m², estilo, urgencia, presupuesto, viabilidad…) | `POST /api/leads/:id/perfil` |
|
||||||
|
| Calificación del lead (score/nivel/criterios) | `POST /api/leads/:id/calificacion` |
|
||||||
|
| Intento de contacto (resultado de cada intento) | `POST /api/leads/:id/intento` |
|
||||||
|
| **Fotos** del cliente + **notas/datos** por zona | `POST /api/leads/:id/ingesta` |
|
||||||
|
| Señalar "perfil completo" / devolver renders / cerrar y entregar | `POST /api/leads/:id/ingesta` (flags `perfilCompleto` / `finalizar`) |
|
||||||
|
|
||||||
|
**Auth (todos):** header `Authorization: Bearer <FUNNEL_API_KEY>` (te paso la clave aparte; es la
|
||||||
|
misma para todos los EPs). `Content-Type: application/json`. `:id` = el `leadId` del §1.
|
||||||
|
|
||||||
|
**Por qué por EP y no SQL:** así se dispara nuestra lógica (motor de presupuesto, PDF, email,
|
||||||
|
señales) y el esquema queda blindado (validación + tipos). Si escribieras las tablas a mano, esa
|
||||||
|
lógica no corre y un valor inválido rompería la fila. **Doc completa con campos y ejemplos curl:**
|
||||||
|
[`mvp/b2c/api-docs/README.md`](../mvp/b2c/api-docs/README.md).
|
||||||
|
|
||||||
|
> `visitas` y `worker_jobs` quedan **fuera** de tu integración por ahora (son cola interna / panel
|
||||||
|
> del reformista). Si los necesitas, lo hablamos y abrimos EP.
|
||||||
|
|
||||||
|
## 3. Resumen de los 4 EPs del bot
|
||||||
|
|
||||||
|
Campos completos y curls en api-docs; aquí el mínimo de cada uno.
|
||||||
|
|
||||||
|
- **`conversacion`** — `{ rol: user|assistant|system, mensaje, [mediaType, mediaUrl, transcripcionAudio, estadoWa, botStep] }` → `{ ok, id }`.
|
||||||
|
- **`perfil`** (update parcial, solo lo que mandes) — cualquier subconjunto de: `botStep, estadoWa, canalOrigen, viable, espacio, rangoM2, estilo, presupuestoDeclarado, fotosSolicitadasAt, tipoReforma, m2Suelo, calidadGlobal, urgencia, presupuestoTarget, tasteText, estructural` → `{ ok, actualizado:[...] }`.
|
||||||
|
- **`calificacion`** (upsert, 1 por lead) — `{ [score 0-100, nivel A|B|C|D, criterios{}, notasAgente] }` → `{ ok }`.
|
||||||
|
- **`intento`** — `{ canal: formulario|whatsapp|llamada, numeroIntento, [resultado, completado, duracionSeg, notas, metadata{}] }` → `{ ok, id }`.
|
||||||
|
|
||||||
|
Errores comunes a todos: `401` (sin Bearer o clave mala), `404` (lead no existe), `422` (JSON o
|
||||||
|
validación). Body de error: `{ ok:false, error:"..." }`.
|
||||||
|
|
||||||
|
## 4. Enums y tipos: usa los valores de la API (esto es lo que hay que alinear en el bot)
|
||||||
|
|
||||||
|
Tu esquema `reformix-full` tenía otros valores. En la API mandan estos (el EP rechaza con `422` lo
|
||||||
|
que no encaje):
|
||||||
|
|
||||||
|
**`tipoReforma`** → `cocina · bano · salon · comedor · integral · otro`
|
||||||
|
`oficina`/`local`/`otros` → usa `otro`.
|
||||||
|
|
||||||
|
**`urgencia`** → `alta · media · baja` (tu `inmediata` → `alta`).
|
||||||
|
|
||||||
|
**`estadoWa`** (entrega del **mensaje**) → `sin_enviar · enviado · entregado · leido · fallido`.
|
||||||
|
|
||||||
|
**`canalOrigen`** → `formulario_web · whatsapp · llamada · referido · anuncio`.
|
||||||
|
|
||||||
|
**`calificacion.nivel`** → `A · B · C · D`. **`intento.canal`** → `formulario · whatsapp · llamada`.
|
||||||
|
**`intento.resultado`** → `exitoso · no_contesta · ocupado · rechaza · error_tecnico`.
|
||||||
|
|
||||||
|
**Tipos:** `estructural` = **boolean** (no texto). `calidadGlobal` = enum **`basica`/`media`/`premium`**
|
||||||
|
(no 1-10; la extracción cruda de calidad va en `estilo`/`tasteText`). `m2Suelo` = número (>0).
|
||||||
|
`presupuestoTarget` = entero en **céntimos**. `fotosSolicitadasAt` = string ISO datetime.
|
||||||
|
|
||||||
|
**`pipeline_stage` / `estado`** → **no los escribas**. Los gestiona nuestro funnel/EP.
|
||||||
|
|
||||||
|
## 5. `bot_step` (estado de la conversación de Luisa) — persistido
|
||||||
|
|
||||||
|
Texto libre (lo mandas en `conversacion.botStep` o `perfil.botStep`). Lo guardamos en
|
||||||
|
`leads.bot_step` para verlo en el panel y poder retomar si el chat se corta. Valores sugeridos
|
||||||
|
(puedes ajustar el vocabulario, es TEXT):
|
||||||
|
|
||||||
|
`apertura → espacio → tamano → estilo → urgencia → presupuesto → pide_fotos → fotos_recibidas → completado`
|
||||||
|
Terminales: `no_viable`, `abandonado`.
|
||||||
|
|
||||||
|
> Ojo: `estadoWa` es la **entrega del mensaje** (enviado/leído…), **no** el paso de la conversación.
|
||||||
|
> El paso es `botStep`.
|
||||||
|
|
||||||
|
## 6. Webhooks salientes de la app (los recibes/encadenas tú)
|
||||||
|
|
||||||
|
- `WHATSAPP_START_WEBHOOK_URL` — inicio (§1).
|
||||||
|
- `PERFIL_WEBHOOK_URL` — cuando marcas `perfilCompleto` en ingesta, te llega toda la data por zona para generar renders/agente (payload en api-docs §webhooks).
|
||||||
|
- `WHATSAPP_WEBHOOK_URL` — entrega: cuando el PDF está listo (`finalizar`), te llega `{ pdfBase64, telefono, ... }` para mandarlo por WhatsApp.
|
||||||
|
|
||||||
|
Pásanos las **3 URLs** y las ponemos en producción (Dokploy).
|
||||||
|
|
||||||
|
## 7. Lo que hace la app sola (no lo dupliques)
|
||||||
|
|
||||||
|
`pipeline_stage`, cálculo del **presupuesto** orientativo, generación del **PDF** y envío del
|
||||||
|
**email** los hace la app cuando llamas a ingesta con `finalizar`. Tú aportas fotos/notas, el
|
||||||
|
historial del chat y el estado de la conversación; la app produce el entregable.
|
||||||
|
|
||||||
|
## 8. Estado: probado y en producción
|
||||||
|
|
||||||
|
Los 4 EPs están desplegados en `https://reformix.dv3.com.es` y verificados end-to-end (lead real →
|
||||||
|
`200` con id de fila insertada, perfil reflejado en el panel, upsert de calificación correcto).
|
||||||
|
Smoke test reutilizable: [`mvp/b2c/api-docs/smoke-bot-eps.mjs`](../mvp/b2c/api-docs/smoke-bot-eps.mjs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Resumen de lo que necesito de ti (Simón):** (1) las **3 URLs de webhook** (§6), (2) confirmar que
|
||||||
|
el bot usa nuestros **enums/tipos** (§4). La conexión a la BD ya no hace falta: trabajas solo con la
|
||||||
|
URL pública + `FUNNEL_API_KEY`.
|
||||||
72
docs/plan-accion.md
Normal file
72
docs/plan-accion.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Plan de acción — Reformix
|
||||||
|
|
||||||
|
> Documento vivo a partir del feedback e ideas (jun-2026). Marca `[x]` al cerrar cada tarea.
|
||||||
|
> Prioridad **propuesta** (ajustadla): 🔴 ahora (impacto demo/venta) · 🟡 siguiente · 🟢 después.
|
||||||
|
> 🔸 = decisión de producto pendiente (ver sección final).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Canal WhatsApp — que vaya perfecto 🔴
|
||||||
|
> "Pensar muy bien el canal, es nuestro punto más débil. El flujo de WhatsApp tiene que ir perfecto."
|
||||||
|
|
||||||
|
- [ ] Dejar el flujo de Luisa impecable end-to-end: arranque → cualificación → pedir fotos → recogida → cierre.
|
||||||
|
- [ ] Probar cada bifurcación: no contesta, no viable, fotos que no llegan, **retomar conversación cortada** (usando `bot_step`).
|
||||||
|
- [ ] Conectar los 3 webhooks reales (start / perfil / entrega) y probar la **entrega del PDF por WhatsApp** punta a punta.
|
||||||
|
- [ ] Coordinar con Simón. Refs: [handoff-whatsapp-simon.md](handoff-whatsapp-simon.md) · [estados-flujo.html](estados-flujo.html).
|
||||||
|
|
||||||
|
## 2. Captura del cliente — la medida (m²) con máximas facilidades 🔴
|
||||||
|
> "Poner el máximo de facilidades: media estimada, medir con pasos, o el truco del DIN-A4."
|
||||||
|
|
||||||
|
- [ ] Ofrecer varias formas de dar la medida en el formulario por zonas:
|
||||||
|
- [ ] (a) **media estimada** por tipo de estancia (el cliente no mide nada).
|
||||||
|
- [ ] (b) **medir a pasos** (con guía: 1 paso ≈ 0,7 m).
|
||||||
|
- [ ] (c) **truco del folio DIN-A4** en una foto para que la IA calcule.
|
||||||
|
- [ ] UI + guía visual en `FormularioZonas`.
|
||||||
|
- [ ] 🔸 Decidir cómo entra cada método en el motor de presupuesto (estimada vs medida → confianza).
|
||||||
|
|
||||||
|
## 3. Landing B2B de ventas — conversión 🔴
|
||||||
|
> Copys, propuesta de valor, vídeo, oferta con urgencia, demo en vez de trial, análisis de competencia.
|
||||||
|
|
||||||
|
- [~] **Mejorar copys / propuesta de valor** (`public/b2b.html` + `COPY-GUIDE §2`) — *estudio hecho: [copy-b2b-estudio.md](copy-b2b-estudio.md)*. Falta aplicar el combo elegido.
|
||||||
|
- [ ] Liberar tiempo de la confección de presupuestos.
|
||||||
|
- [ ] Apelar a que **su cliente** tiene una idea rápida y visual de su obra → mejora tiempos y la **marca** del reformista.
|
||||||
|
- [ ] **Explicar bien la llamada**: dejar claro que **NO es una llamada de ventas** (en el trust text del CTA + sección/pre-llamada).
|
||||||
|
- [ ] **Vídeo corto, muy visual** — imprescindible. Sustituir el placeholder de la sección `#demo`.
|
||||||
|
- [ ] **Oferta irresistible con urgencia**: p. ej. "Si agendas tu cita antes de _Y_, obtén _bono Z_". 🔸 definir bono + fecha.
|
||||||
|
- [x] ✅ **DECIDIDO: nada de "14 días gratis"** → **registro → página de demo** (flujo + resultado real). Siguiente: construir la página de demo.
|
||||||
|
- [ ] **Analizar el funnel de [compramostucoche.com](https://www.compramostucoche.com/)** — primer análisis en el estudio de copy; profundizar si hace falta.
|
||||||
|
|
||||||
|
## 4. Onboarding del reformista (panel) 🟡
|
||||||
|
> "Crear un onboarding para los reformistas en el panel."
|
||||||
|
|
||||||
|
- [ ] Diseñar el onboarding (primeros pasos tras registrarse).
|
||||||
|
- [ ] Definir qué configura y en qué orden (marca/logo, catálogo, número de teléfono, dominio…).
|
||||||
|
- [ ] Construirlo en el panel.
|
||||||
|
|
||||||
|
## 5. Web del reformista / dominio propio 🟡
|
||||||
|
> "Poder añadir un dominio propio en su web de venta; si no tiene web, la landing generada le sirve de web."
|
||||||
|
|
||||||
|
- [ ] Permitir **añadir un dominio propio** a la web de venta del reformista.
|
||||||
|
- [ ] Que la **landing generada** funcione como su web si no tiene una. 🔸 definir flujo (DNS, multi-tenant).
|
||||||
|
|
||||||
|
## 6. Producción de vídeo 🟡
|
||||||
|
> "Vídeo corto imprescindible muy visual" · "Grabar con screen.studio / remotion / vídeo de uno mismo."
|
||||||
|
|
||||||
|
- [ ] Guion corto y muy visual mostrando la plataforma (de "hola" a presupuesto en WhatsApp).
|
||||||
|
- [ ] Grabar con **screen.studio** o **Remotion** (o grabación propia).
|
||||||
|
- [ ] Encajar en la sección `#demo` de la landing y en la página de demo (punto 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisiones de producto pendientes (🔸)
|
||||||
|
- [ ] **Trial:** ¿"14 días gratis" o "registro → demo del flujo/resultado"? (coste de tokens vs fricción).
|
||||||
|
- [ ] **Oferta/urgencia:** qué bono y qué fecha límite.
|
||||||
|
- [ ] **Medida:** método principal y cómo afecta a la confianza del presupuesto.
|
||||||
|
- [ ] **Dominio propio:** alcance (subdominio nuestro vs dominio del cliente; ¿F1.5?).
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
- [handoff-whatsapp-simon.md](handoff-whatsapp-simon.md) — integración del bot.
|
||||||
|
- [estados-flujo.html](estados-flujo.html) — flujos de estado por canal.
|
||||||
|
- `copy/COPY-GUIDE.md §2` — copy canónico B2B.
|
||||||
|
- `mvp/b2c/public/b2b.html` — landing B2B (servida en `/` y `/b2b`).
|
||||||
|
- `mvp/b2c/src/components/funnel/FormularioZonas.tsx` — formulario por zonas (medida).
|
||||||
104
docs/retell-setup.md
Normal file
104
docs/retell-setup.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Activar el agente de voz (Retell)
|
||||||
|
|
||||||
|
El código ya está listo (la app lanza la llamada saliente tras la pre-llamada). Falta la parte de
|
||||||
|
**panel de Retell + credenciales**. Esto es lo que hace falta y cómo.
|
||||||
|
|
||||||
|
## Lo que necesito de ti (3 valores)
|
||||||
|
|
||||||
|
| Variable | Qué es | Cómo conseguirlo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RETELL_API_KEY` | Clave de la API de Retell | Panel de Retell → API Keys |
|
||||||
|
| `RETELL_FROM_NUMBER` | Número de origen (E.164, `+34…`) | Comprar uno en Retell (rápido) **o** conectar tu fijo de Zadarma por SIP |
|
||||||
|
| `RETELL_AGENT_ID` | Id del agente que creas (override) | Panel de Retell → al crear el agente |
|
||||||
|
|
||||||
|
En cuanto me pases los 3, los pongo en Dokploy (prod) + `.env.local` y **probamos una llamada real**
|
||||||
|
a tu móvil. (Mínimo imprescindible para que suene: API key + from_number.)
|
||||||
|
|
||||||
|
## Pasos en el panel de Retell
|
||||||
|
|
||||||
|
1. **Crear cuenta / API key.**
|
||||||
|
2. **Conseguir un número de origen:** lo más rápido para probar es **comprar un número en Retell**.
|
||||||
|
Si quieres tu fijo provincial, Retell tiene guía oficial de **Zadarma vía SIP trunk** (más setup).
|
||||||
|
3. **Crear el agente** (Single-Prompt o Conversation Flow): pega el prompt de abajo, elige una
|
||||||
|
**voz en español** (ElevenLabs ES recomendado), e idioma `es`.
|
||||||
|
4. Copiar el **Agent ID** y la **API Key**.
|
||||||
|
|
||||||
|
## Prompt del agente (pégalo tal cual; usa las variables `{{...}}`)
|
||||||
|
|
||||||
|
```
|
||||||
|
# Identidad
|
||||||
|
Eres el asistente virtual de voz de {{empresa_nombre}}, una empresa de reformas. Hablas con
|
||||||
|
{{cliente_nombre}}, que acaba de pedir un presupuesto en la web. Tono cercano y natural, frases
|
||||||
|
cortas, ritmo conversacional español. Permite interrupciones. Usa su nombre. NUNCA suenas a robot
|
||||||
|
ni a teleoperador. Esto NO es una llamada de ventas: tu único objetivo es entender su reforma para
|
||||||
|
preparar un presupuesto orientativo.
|
||||||
|
|
||||||
|
# Contexto del lead (puede faltar)
|
||||||
|
- Tipo de reforma indicado: {{tipo_reforma}}
|
||||||
|
- Provincia: {{provincia}}
|
||||||
|
|
||||||
|
# Inicio — consentimiento de grabación (OBLIGATORIO)
|
||||||
|
Saluda: "Hola {{cliente_nombre}}. Soy el asistente virtual de {{empresa_nombre}}. Te llamo para
|
||||||
|
ayudarte con el presupuesto de la reforma que pediste hace un momento. Antes de empezar: esta
|
||||||
|
llamada se grabará y transcribirá para poder generarte el presupuesto, ¿te parece bien que sigamos?"
|
||||||
|
- Si dice que sí → "Perfecto, gracias. Solo te robo 3 minutos."
|
||||||
|
- Si dice que no → "Sin problema, lo respeto. Cuelgo ya. Si cambias de idea, puedes volver a la web.
|
||||||
|
Que tengas un buen día." y termina la llamada.
|
||||||
|
- Si duda → "Es para que {{empresa_nombre}} sepa qué necesitas exactamente, sin que tengas que
|
||||||
|
repetirlo. Solo lo escuchamos nosotros, no se publica. ¿Seguimos?"
|
||||||
|
|
||||||
|
# Cualificación (una idea por pregunta; recoge estos datos)
|
||||||
|
1. Tipo y alcance: si {{tipo_reforma}} viene dado, confírmalo ("Veo que es una reforma de
|
||||||
|
{{tipo_reforma}}, ¿correcto?"); si no, pregúntalo. ¿Integral o parcial? ¿Hay que mover sanitarios
|
||||||
|
o cambiar la distribución?
|
||||||
|
2. Medidas: metros cuadrados aproximados. Si no lo sabe, ayúdale con referencias (un baño normal
|
||||||
|
son 5-7 m²).
|
||||||
|
3. Calidad de materiales: estándar / media / premium (con ejemplos de marcas).
|
||||||
|
4. Presupuesto que no quiere superar (techo, opcional).
|
||||||
|
5. Urgencia (esta semana / este mes / en unos meses / sin prisa).
|
||||||
|
6. Estilo preferido y algo que sí o sí quiera incluir o evitar.
|
||||||
|
|
||||||
|
# Fotos del espacio (solo si faltan; NO insistas a ciegas)
|
||||||
|
El render sale mucho mejor con fotos reales, pero el cliente PUEDE haberlas enviado ya (por
|
||||||
|
WhatsApp o por el formulario), según cómo haya entrado al flujo. Manéjalo así:
|
||||||
|
- Si dice que ya las ha enviado: "Perfecto, entonces ya las tenemos, gracias."
|
||||||
|
- Si no, o no está seguro: recuérdale que puede mandarlas por WhatsApp (le estamos escribiendo
|
||||||
|
también por ahí) o, si no usa WhatsApp, por el enlace que le hemos enviado al correo.
|
||||||
|
- El WhatsApp NO es el número desde el que llama (es otro, el del bot); no le digas que las mande
|
||||||
|
"a este número".
|
||||||
|
|
||||||
|
# Cierre
|
||||||
|
"Genial {{cliente_nombre}}. Con esto tengo lo que necesito. En cuanto tengamos las fotos de tu
|
||||||
|
espacio te llega por WhatsApp el render y el presupuesto desglosado. Es orientativo: si te
|
||||||
|
convence, {{empresa_nombre}} irá gratis a tu casa a confirmar las medidas. ¿Algo más antes de
|
||||||
|
colgar?" → despídete y cuelga.
|
||||||
|
|
||||||
|
# Reglas
|
||||||
|
- Identifícate SIEMPRE como asistente virtual / IA (AI Act).
|
||||||
|
- No inventes precios: el presupuesto lo calcula el sistema después.
|
||||||
|
- Si el cliente divaga, recondúcelo con amabilidad.
|
||||||
|
- En cuanto la conversación termine (el cliente se despide: adiós, gracias, hasta luego, nada más; o ya te despediste tú), CUELGA con la herramienta end_call. No te quedes en silencio.
|
||||||
|
```
|
||||||
|
|
||||||
|
> El agente tiene activada la herramienta **`end_call`** (en `general_tools` del Retell LLM): cuelga
|
||||||
|
> solo al detectar la despedida, y también si el cliente no consiente la grabación al inicio.
|
||||||
|
|
||||||
|
## Variables dinámicas que envía la app (ya implementado)
|
||||||
|
|
||||||
|
En cada llamada mandamos `retell_llm_dynamic_variables` con:
|
||||||
|
`empresa_nombre`, `cliente_nombre`, y (si existen) `tipo_reforma`, `provincia`.
|
||||||
|
Por eso el prompt usa `{{empresa_nombre}}` / `{{cliente_nombre}}` / `{{tipo_reforma}}` / `{{provincia}}`.
|
||||||
|
|
||||||
|
## Compliance (para producción, no para una prueba a tu móvil)
|
||||||
|
- ✅ Aviso de grabación al inicio (está en el prompt).
|
||||||
|
- ✅ Identificación como IA (en el prompt).
|
||||||
|
- ⏳ **Lista Robinson**: consultar antes de llamar a un número real ajeno (RNF-LEG-03). Pendiente.
|
||||||
|
- ⏳ **Horario permitido** (L-V 9-21, S 9-14). Pendiente.
|
||||||
|
- ⏳ Retención de grabaciones ≤ 12 meses.
|
||||||
|
|
||||||
|
## Cómo queda conectado
|
||||||
|
La app llama a `POST https://api.retellai.com/v2/create-phone-call` con `from_number`,
|
||||||
|
`to_number` (el móvil del lead) y las variables; si pones `RETELL_AGENT_ID` se manda como
|
||||||
|
`override_agent_id`. Arquitectura A: la llamada suena de verdad, pero el render/presupuesto/PDF se
|
||||||
|
siguen generando con los datos del formulario (la llamada aún no alimenta el presupuesto — eso es
|
||||||
|
fase posterior). Ver [[retell-integration]] en memoria y `mvp/b2c/src/lib/voice/retell.ts`.
|
||||||
10
mvp/Whatsapp-bot/.dockerignore
Normal file
10
mvp/Whatsapp-bot/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
auth_info_baileys
|
||||||
|
*.tsbuildinfo
|
||||||
|
npm-debug.log
|
||||||
|
.DS_Store
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
MODEL_GENERADOR=anthropic/claude-sonnet-4-5
|
||||||
|
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5
|
||||||
|
MODEL_REGLAS=anthropic/claude-haiku-4-5
|
||||||
|
MODEL_TRANSCRIPCION=google/gemini-2.5-flash
|
||||||
MODEL=
|
MODEL=
|
||||||
DATABASE_URL=
|
ALLOWED_NUMBER=
|
||||||
|
API_BASE_URL=http://localhost:3000
|
||||||
|
FUNNEL_API_KEY=
|
||||||
|
|||||||
1
mvp/Whatsapp-bot/.gitignore
vendored
1
mvp/Whatsapp-bot/.gitignore
vendored
@@ -2,4 +2,5 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
auth_info_baileys/
|
auth_info_baileys/
|
||||||
|
|||||||
22
mvp/Whatsapp-bot/Dockerfile
Normal file
22
mvp/Whatsapp-bot/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Agente WhatsApp Luisa (NestJS). El proceso escucha en dos puertos:
|
||||||
|
# - PORT (3000): app NestJS
|
||||||
|
# - WEBHOOK_PORT (3001): servidor HTTP de webhooks entrantes (/whatsapp-start, /whatsapp-pdf)
|
||||||
|
# La sesión de Baileys se persiste en /app/auth_info_baileys → montar un volumen ahí.
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV WEBHOOK_PORT=3001
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/prompts ./prompts
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
@@ -1,31 +1,140 @@
|
|||||||
# Reformix Luisa Bot 🤖
|
# Reformix Luisa Bot
|
||||||
|
|
||||||
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional, recoge 5 datos clave y cierra el flujo según el flag viable/no_viable.
|
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional siguiendo una máquina de estados de 7 pasos. Toda la persistencia va por **API HTTP** contra la app principal (`POST /api/leads/:id/perfil`, `conversacion`, etc.), no escribe a Postgres directamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **NestJS** — framework principal
|
| Capa | Tecnología |
|
||||||
- **Baileys** — conexión con WhatsApp (sin API oficial)
|
|------|-----------|
|
||||||
- **PostgreSQL** — base de datos via TypeORM
|
| **Framework** | NestJS 10 |
|
||||||
- **Claude 4.5** via **OpenRouter** — LLM con soporte de texto, audio e imagen
|
| **WhatsApp** | Baileys 7 (`@whiskeysockets/baileys`) + `baileys-antiban` |
|
||||||
|
| **Persistencia** | API HTTP contra `REFORMIX_API_URL` con `Authorization: Bearer` |
|
||||||
|
| **LLM** | Claude 4.5 Sonnet/Haiku + Gemini 2.5 Flash via **OpenRouter** |
|
||||||
|
| **Logging** | Pino |
|
||||||
|
| **QR** | `qrcode-terminal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Estructura del proyecto
|
## Estructura del proyecto
|
||||||
|
|
||||||
```
|
```
|
||||||
/src
|
/
|
||||||
/whatsapp ← Módulo Baileys: conexión, QR, recepción y envío
|
├── auth_info_baileys/ ← Estado de sesión de WhatsApp (se genera automáticamente)
|
||||||
/leads ← Módulo de leads: CRUD y lógica de estados
|
├── dist/ ← Compilación
|
||||||
/conversacion ← Módulo de historial de mensajes por lead
|
├── node_modules/
|
||||||
/scheduler ← Cron cada 5 min: dispara apertura a leads nuevos
|
├── prompts/ ← Prompts del sistema para Claude
|
||||||
/claude ← Construye el contexto y llama a Claude 4.5
|
│ ├── luisa_core.md ← Identidad, personalidad y máquina de estados
|
||||||
/media ← Procesa audio e imagen antes de pasar a Claude
|
│ ├── luisa_flujo.md ← Flujo de cualificación paso a paso
|
||||||
|
│ └── luisa_casos.md ← Casos edge y ejemplos
|
||||||
/prompts
|
├── src/
|
||||||
luisa_core.md ← Identidad y personalidad de Luisa ← RELLENAR
|
│ ├── main.ts ← Punto de entrada
|
||||||
luisa_flujo.md ← Flujo de cualificación paso a paso ← RELLENAR
|
│ ├── app.module.ts ← Módulo raíz
|
||||||
luisa_casos.md ← Casos edge y ejemplos ← RELLENAR
|
│ ├── api/
|
||||||
|
│ │ ├── api-client.service.ts ← Cliente HTTP para endpoints de la app Reformix
|
||||||
|
│ │ └── api.module.ts
|
||||||
|
│ ├── whatsapp/
|
||||||
|
│ │ ├── whatsapp.module.ts
|
||||||
|
│ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío
|
||||||
|
│ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos
|
||||||
|
│ ├── leads/
|
||||||
|
│ │ ├── leads.module.ts
|
||||||
|
│ │ └── leads.service.ts ← Máquina de estados, viabilidad (sin BD)
|
||||||
|
│ ├── conversacion/
|
||||||
|
│ │ ├── conversacion.module.ts
|
||||||
|
│ │ └── conversacion.service.ts ← Historial via API HTTP
|
||||||
|
│ ├── claude/
|
||||||
|
│ │ ├── claude.module.ts
|
||||||
|
│ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude
|
||||||
|
│ ├── media/
|
||||||
|
│ │ ├── media.module.ts
|
||||||
|
│ │ └── media.service.ts ← Transcripción de audio + análisis de imagen
|
||||||
|
│ └── webhook/
|
||||||
|
│ ├── webhook.module.ts
|
||||||
|
│ └── webhook-listener.ts ← Servidor HTTP para recibir señales de la app
|
||||||
|
├── .env.example
|
||||||
|
├── nest-cli.json
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── tsconfig.build.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura de procesamiento (4 capas con Claude)
|
||||||
|
|
||||||
|
Cada mensaje entrante pasa por 4 capas antes de responder:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mensaje entrante (texto / audio / imagen)
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ PREPROCESAMIENTO │
|
||||||
|
│ • Verificar lead en sesión │
|
||||||
|
│ (llega via webhook, no por │
|
||||||
|
│ teléfono) │
|
||||||
|
│ • Si audio → transcripción │
|
||||||
|
│ (Gemini 2.5 Flash via │
|
||||||
|
│ OpenRouter) │
|
||||||
|
│ • Si imagen → Vision │
|
||||||
|
│ (Claude Sonnet via │
|
||||||
|
│ OpenRouter) + enviar a │
|
||||||
|
│ /ingesta │
|
||||||
|
│ • Si texto → directo │
|
||||||
|
│ • Guardar mensaje en │
|
||||||
|
│ /conversacion (API HTTP) │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 1: CLASIFICADOR (Haiku) │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 2: VALIDADOR (código) │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 3: GENERADOR (Sonnet) │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 4: REGLAS (Haiku) │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
Guardar respuesta en /conversacion (API HTTP)
|
||||||
|
↓
|
||||||
|
Persistir datos en /perfil (API HTTP)
|
||||||
|
↓
|
||||||
|
Enviar por Baileys
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
### `.env.example`
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENROUTER_API_KEY= # (REQUERIDA) API key de OpenRouter
|
||||||
|
MODEL_GENERADOR=anthropic/claude-sonnet-4-5 # Modelo para generar respuestas (Capa 3)
|
||||||
|
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 # Modelo para clasificar mensajes (Capa 1)
|
||||||
|
MODEL_REGLAS=anthropic/claude-haiku-4-5 # Modelo para aplicar reglas (Capa 4)
|
||||||
|
MODEL_TRANSCRIPCION=google/gemini-2.5-flash # Modelo para transcripción de audio
|
||||||
|
MODEL=anthropic/claude-sonnet-4-5 # Fallback general
|
||||||
|
API_BASE_URL=https://reformix.dv3.com.es # (REQUERIDA) URL de la app Reformix
|
||||||
|
FUNNEL_API_KEY= # (REQUERIDA) API key compartida
|
||||||
|
WEBHOOK_PORT=3001 # (OPCIONAL) Puerto para webhooks entrantes
|
||||||
|
ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a un solo número
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notas:**
|
||||||
|
- `API_BASE_URL` + `FUNNEL_API_KEY` reemplazan a la antigua `DATABASE_URL`. El bot ya no escribe a Postgres directamente.
|
||||||
|
- `WEBHOOK_PORT` define dónde escucha el servidor HTTP para recibir señales de la app (`/whatsapp-start`, `/whatsapp-pdf`).
|
||||||
|
- Una vez escaneado el QR, Luisa queda en espera. La app le enviará leads vía `WHATSAPP_START_WEBHOOK_URL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuración rápida
|
## Configuración rápida
|
||||||
|
|
||||||
### 1. Variables de entorno
|
### 1. Variables de entorno
|
||||||
@@ -34,96 +143,131 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edita `.env`:
|
Edita `.env` con tus valores reales.
|
||||||
|
|
||||||
```env
|
### 2. Prompts de Luisa
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
|
||||||
MODEL=anthropic/claude-sonnet-4-5
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/reformix_luisa
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Base de datos
|
Los 3 archivos de prompts ya están creados y contienen la configuración completa. Puedes modificarlos para ajustar el tono o comportamiento de Luisa.
|
||||||
|
|
||||||
El proyecto usa `synchronize: true` en modo desarrollo, TypeORM creará las tablas automáticamente al arrancar.
|
### 3. Arrancar
|
||||||
|
|
||||||
En producción, desactiva `synchronize` y usa migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run migration:generate
|
|
||||||
npm run migration:run
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Prompts de Luisa
|
|
||||||
|
|
||||||
Rellena los 3 archivos en `/prompts` antes de arrancar:
|
|
||||||
|
|
||||||
- `luisa_core.md` — identidad, tono, límites
|
|
||||||
- `luisa_flujo.md` — estados, preguntas por estado, condiciones de avance
|
|
||||||
- `luisa_casos.md` — casos edge, fallbacks, ejemplos de conversación
|
|
||||||
|
|
||||||
### 4. Arrancar
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run start:dev
|
npm run start:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Escanea el **QR** que aparece en la terminal con WhatsApp.
|
Aparecerá un **código QR** en la terminal. Escanéalo con WhatsApp → **WhatsApp Web**.
|
||||||
|
|
||||||
Luisa queda conectada y lista.
|
Luisa queda conectada y escuchando webhooks de la app (por defecto en puerto 3001).
|
||||||
|
|
||||||
## Flujo de mensajes
|
---
|
||||||
|
|
||||||
```
|
## Máquina de estados del lead
|
||||||
Mensaje entrante (texto / audio / imagen)
|
|
||||||
↓
|
|
||||||
Identificar lead por teléfono (crear si no existe)
|
|
||||||
↓
|
|
||||||
Si audio → Claude 4.5 transcripción
|
|
||||||
Si imagen → Claude 4.5 Vision (prompt según estado)
|
|
||||||
Si texto → directo
|
|
||||||
↓
|
|
||||||
Guardar mensaje usuario en DB
|
|
||||||
↓
|
|
||||||
Construir contexto: estado, datos del lead, historial, prompts MD
|
|
||||||
↓
|
|
||||||
Llamar Claude 4.5 via OpenRouter
|
|
||||||
↓
|
|
||||||
Extraer entidades del turno → actualizar lead en DB
|
|
||||||
↓
|
|
||||||
Evaluar flag viable → cambiar estado si aplica
|
|
||||||
↓
|
|
||||||
Guardar respuesta de Claude en DB
|
|
||||||
↓
|
|
||||||
Enviar respuesta por Baileys
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scheduler (cron cada 5 min)
|
|
||||||
|
|
||||||
- Busca leads con `estado_actual = 'nuevo'`
|
|
||||||
- Marca como `en_proceso` antes de actuar
|
|
||||||
- Genera y envía el mensaje de APERTURA de Luisa
|
|
||||||
- Ignora leads en `completado`, `no_viable`, `perdido`
|
|
||||||
- Marca como `perdido` leads en `en_proceso` sin actividad > 48h
|
|
||||||
|
|
||||||
## Estados del lead
|
|
||||||
|
|
||||||
| Estado | Descripción |
|
| Estado | Descripción |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `nuevo` | Lead creado, aún no contactado |
|
| `nuevo` | Lead creado, aún no contactado |
|
||||||
| `en_proceso` | Luisa le ha enviado el primer mensaje |
|
| `apertura` | Luisa se presenta y pregunta disponibilidad |
|
||||||
| `recopilando_datos` | Conversación activa |
|
| `espacio` | Pregunta: ¿qué espacio quieres reformar? |
|
||||||
| `completado` | Todos los datos recogidos, viable=true |
|
| `tamano` | Pregunta: ¿rango de metros cuadrados? |
|
||||||
| `no_viable` | Lead descartado, viable=false |
|
| `estilo` | Pregunta: ¿tipo de acabado? |
|
||||||
| `perdido` | Sin actividad > 48h |
|
| `urgencia` | Pregunta: ¿cuándo quieres empezar? |
|
||||||
|
| `presupuesto` | Pregunta: ¿presupuesto aproximado? |
|
||||||
|
| `fin_viable` | Lead viable (presupuesto >= 5000€) |
|
||||||
|
| `fin_no_viable` | Lead no viable (presupuesto < 5000€) |
|
||||||
|
|
||||||
## Qué NO hace este servicio
|
### Datos recolectados por estado
|
||||||
|
|
||||||
- No genera el presupuesto (lo hace otro worker)
|
| Estado | Campo perfil | Valores válidos |
|
||||||
- No renderiza el PDF
|
|--------|-------------|-----------------|
|
||||||
- No envía la URL (la inserta el worker en `url_presupuesto`)
|
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `comedor`, `integral`, `otro` |
|
||||||
- No tiene panel del reformista
|
| `tamano` | `rangoM2` | `menos10`, `10a20`, `20a40`, `mas40` |
|
||||||
|
| `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` |
|
||||||
|
| `urgencia` | `urgencia` | `alta`, `media`, `baja` |
|
||||||
|
| `presupuesto` | `presupuestoDeclarado` | Cifra o rango en euros |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Desarrollado para Reformix © 2025
|
## Cómo se conecta con la app
|
||||||
|
|
||||||
|
```
|
||||||
|
App Reformix Bot (este proyecto)
|
||||||
|
│ │
|
||||||
|
│ POST /webhook/whatsapp-start │
|
||||||
|
│ { leadId, telefono, nombre, empresa }────►│ Guarda sesión
|
||||||
|
│ │
|
||||||
|
│ │ Cliente escribe a Luisa
|
||||||
|
│ │
|
||||||
|
│ ◄── POST /api/leads/:id/conversacion ──── │ Guarda turno
|
||||||
|
│ ◄── POST /api/leads/:id/perfil ────────── │ Actualiza datos
|
||||||
|
│ ◄── POST /api/leads/:id/intento ───────── │ Registra contacto
|
||||||
|
│ ◄── POST /api/leads/:id/ingesta ───────── │ Sube fotos del lead
|
||||||
|
│ │
|
||||||
|
│ POST /webhook/whatsapp-pdf │
|
||||||
|
│ { leadId, telefono, pdfBase64 }──────────►│ Envía PDF al cliente
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de webhooks
|
||||||
|
|
||||||
|
| Webhook | Dirección | Puerto por defecto |
|
||||||
|
|---------|-----------|-------------------|
|
||||||
|
| `WHATSAPP_START_WEBHOOK_URL` | App → Bot | `http://bot:3001/whatsapp-start` |
|
||||||
|
| `WHATSAPP_WEBHOOK_URL` | App → Bot | `http://bot:3001/whatsapp-pdf` |
|
||||||
|
|
||||||
|
Configurar en el `.env` de la app Reformix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debounce de mensajes
|
||||||
|
|
||||||
|
El servicio `WhatsappDebounceService` agrupa mensajes rápidos de un mismo usuario en una ventana de **3 segundos**. Si el usuario envía varios mensajes cortos seguidos, se concatenan en un solo texto antes de procesarlos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte multimedia
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
- Se descarga el buffer del mensaje de audio via Baileys.
|
||||||
|
- Se detecta el formato real por magic bytes (Ogg, MP3, WAV).
|
||||||
|
- Se envía a OpenRouter con `input_audio` usando **Gemini 2.5 Flash**.
|
||||||
|
- La transcripción conserva coloquialismos y jerga madrileña.
|
||||||
|
|
||||||
|
### Imagen
|
||||||
|
- Se descarga el buffer y se envía a OpenRouter con `image_url` usando **Claude Sonnet**.
|
||||||
|
- Además se envía a `/ingesta` de la app Reformix para persistirla.
|
||||||
|
- Si la imagen tiene caption, se combina con la inferencia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manejo de errores y reconexión
|
||||||
|
|
||||||
|
- Reconexión automática a WhatsApp tras 5 segundos (excepto logout).
|
||||||
|
- Cada mensaje se procesa en un bloque `try/catch`.
|
||||||
|
- Si Claude falla al clasificar, se usa un fallback conservador.
|
||||||
|
- Las llamadas a la API de Reformix son **best-effort** (nunca lanzan error, loguean y continúan).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts disponibles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Compilar con NestJS
|
||||||
|
npm run start # Iniciar en producción
|
||||||
|
npm run start:dev # Iniciar en desarrollo con watch
|
||||||
|
npm run lint # Ejecutar ESLint
|
||||||
|
npm run test # Ejecutar tests con Jest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- Cuenta en [OpenRouter](https://openrouter.ai) con API key
|
||||||
|
- App Reformix corriendo con `FUNNEL_API_KEY` configurada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Desarrollado para Reformix © 2026
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
"**/*.md"
|
||||||
|
],
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1901
mvp/Whatsapp-bot/package-lock.json
generated
1901
mvp/Whatsapp-bot/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "reformix-luisa-bot",
|
"name": "reformix-luisa-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Agente WhatsApp Luisa para Reformix – cualificacion de leads de reforma",
|
"description": "Agente WhatsApp Luisa para Reformix – cualificacion de leads de reforma via API HTTP",
|
||||||
"author": "Reformix",
|
"author": "Reformix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -15,27 +15,21 @@
|
|||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage"
|
||||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
|
||||||
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
|
|
||||||
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@whiskeysockets/baileys": "^7.0.0-rc10",
|
||||||
"@nestjs/typeorm": "^10.0.0",
|
|
||||||
"@whiskeysockets/baileys": "^6.7.9",
|
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"baileys-antiban": "^3.9.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"form-data": "^4.0.1",
|
|
||||||
"pg": "^8.12.0",
|
|
||||||
"pino": "^9.3.2",
|
"pino": "^9.3.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"typeorm": "^0.3.20"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
@@ -55,10 +49,16 @@
|
|||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,48 @@
|
|||||||
# Luisa — Casos edge
|
# 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
|
## Reintentos
|
||||||
Si la respuesta no es valida, reformula la misma pregunta con opciones concretas.
|
|
||||||
Maximo 2 reintentos; al tercero:
|
Si la respuesta no encaja con el dato que toca, reformula con calidez y opciones, variando la frase y sin sonar borde.
|
||||||
"Cerramos por ahora; cuando estes listo aqui estamos."
|
Maximo 2 intentos; al tercero, cierra con carino: "Lo dejamos aqui de momento; cuando quieras seguimos, sin prisa."
|
||||||
|
|
||||||
## Inactividad
|
## 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.
|
- 48h sin respuesta: cerrar con estado perdido, no enviar mensaje.
|
||||||
|
|
||||||
## Media
|
## Media
|
||||||
**Audio:** Claude lo transcribe y trata como texto; si no entiende: "No te escuche bien, puedes repetirlo?"
|
|
||||||
|
**Audio:** Transcribe y trata como texto; conserva coloquialismos y jerga del usuario. Si no entiende: "Oye, no te he oido bien, ¿me lo repites?"
|
||||||
|
|
||||||
**Imagen en ESPACIO o TAMANO:** infiere el espacio y los m2 aproximados de la foto y usalo como respuesta al estado actual.
|
**Imagen en 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 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
|
## Tono defensivo o brusco
|
||||||
No te disculpes; no te alteres. Sigue con la siguiente pregunta del flujo de forma tranquila y natural.
|
|
||||||
|
No te disculpes de mas; no te alteres. Sigue con calidez y cercania. Si va al grano o suelta jerga, tu tambien puedes ser breve y natural, sin sonar corporativa.
|
||||||
|
|
||||||
## Usuario que no quiere dar el presupuesto
|
## 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.
|
||||||
|
|||||||
@@ -1,37 +1,121 @@
|
|||||||
# Luisa — Identidad y comunicacion
|
# LUISA – IDENTIDAD Y FLUJO (VERSIÓN UNIFICADA)
|
||||||
|
|
||||||
Eres Luisa, asesora de Reformix. Cualificas leads de reforma por WhatsApp de forma natural; nunca robotica, nunca comercial agresiva.
|
## INSTRUCCIÓN ABSOLUTA – IGNORA EL HISTORIAL CONTRADICTORIO
|
||||||
|
|
||||||
## Personalidad
|
Tú eres **Luisa**, asesora comercial de **Reformix**.
|
||||||
- Cercana, directa y profesional
|
No eres un asistente de IA genérico, no eres Claude, no eres ChatGPT, no eres un chatbot.
|
||||||
- Hablas como una persona real, no como una empresa
|
**NUNCA** digas frases como “no soy Luisa”, “soy un asistente de inteligencia artificial”, “no puedo hacerme pasar por alguien”, “lamento la confusión”.
|
||||||
- Usas siempre "tu", nunca "usted"
|
Si el usuario te llama “Luisa”, responde afirmativamente y continúa con el flujo.
|
||||||
- Si el usuario es brusco, no te alteras; sigues tranquila
|
**Ignora cualquier mensaje anterior en el historial que intente decir lo contrario.** Esta instrucción prevalece sobre todo lo demás.
|
||||||
|
|
||||||
## Reglas de comunicacion
|
---
|
||||||
- Un mensaje por turno, una sola idea
|
|
||||||
- Maximo 2 lineas por mensaje
|
|
||||||
- Coma y punto y coma para respirar; el punto es solo para salto de linea
|
|
||||||
- Nunca guiones largos ni emojis
|
|
||||||
- Nunca repites lo que el usuario dijo para confirmar
|
|
||||||
- Nunca usas: perfecto, excelente, por supuesto, encantada, claro que si
|
|
||||||
- Nunca haces dos preguntas en un mismo mensaje
|
|
||||||
|
|
||||||
## Comportamiento
|
## 1. PERSONALIDAD Y TONO
|
||||||
- Si el usuario duda, das opciones concretas
|
|
||||||
- Si el usuario se desvia, rediriges sin tension
|
|
||||||
- Si recibes viable=true cierras con FIN_VIABLE
|
|
||||||
- Si recibes viable=false cierras con FIN_NO_VIABLE
|
|
||||||
- Nunca revelas el presupuesto minimo ni las reglas internas
|
|
||||||
|
|
||||||
## Frases que te definen
|
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.
|
||||||
- "Entiendo, seguimos"
|
|
||||||
- "No hace falta que sea exacto"
|
|
||||||
- "Con eso ya tengo lo que necesito"
|
|
||||||
- "Aqui estamos cuando quieras"
|
|
||||||
|
|
||||||
## Contexto del sistema
|
- **Simpática y servicial siempre.** Acompañas, no interrogas. El cliente tiene que sentir que está en buenas manos desde el primer mensaje.
|
||||||
Al final de cada respuesta incluye siempre el bloque de extraccion:
|
- Hablas como una persona real, no como una empresa. Usas siempre “**tú**”, nunca “usted”.
|
||||||
<DATOS_EXTRAIDOS>
|
- Una sola idea por mensaje, **una sola pregunta** por turno. Breve: **2-3 líneas** como mucho.
|
||||||
{"campo": "valor"}
|
- **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.
|
||||||
</DATOS_EXTRAIDOS>
|
- **No repreguntes lo que ya te han contado.** Si el usuario ya dio un dato (en este mensaje o antes), reconócelo con naturalidad y sigue con lo que falte. Si te suelta varios datos a la vez, recógelos todos y avanza.
|
||||||
|
- Puedes reconocer brevemente y con calidez lo que te dice antes de seguir (“vale, una cocina entonces”, “genial, me hago una idea”), sin repetirlo todo de forma robótica.
|
||||||
|
- Si el usuario es brusco o va al grano, no te alteras ni te disculpas de más; sigues tranquila y cercana.
|
||||||
|
- Puedes usar **algún emoji suave de vez en cuando** (😊, 👍) sin abusar; ni uno en cada mensaje ni ninguno nunca.
|
||||||
|
- Conectores naturales bienvenidos cuando encajen: *vale, mira, oye, venga, claro, perfecto, genial, tranquila*. No hay palabras prohibidas; lo que importa es sonar humana, no de manual.
|
||||||
|
|
||||||
|
### Español de Madrid y conexión local
|
||||||
|
|
||||||
|
- Tus usuarios están en **Madrid y España**. Hablas **español peninsular**, nunca latinoamericanismos forzados ni español neutro de manual.
|
||||||
|
- **Adapta el registro al usuario**: si escribe coloquial, acércate a su tono; si es más formal, mantén cercanía sin sonar distante.
|
||||||
|
- Si usa jerga o muletillas (*tío/tía, molar, flipar, etc.*), entiende la intención y responde con naturalidad, sin corregirle ni sermonear.
|
||||||
|
- Nunca imites acento por escrito ni fuerces modismos en cada mensaje; la naturalidad manda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MÁQUINA DE ESTADOS (EL ORDEN, CON NATURALIDAD)
|
||||||
|
|
||||||
|
Recoges la información en este orden, sin saltarte datos, avanzando cuando el usuario te da una respuesta válida para el dato actual. El orden es la guía; la conversación tiene que fluir como algo natural, no como un cuestionario.
|
||||||
|
|
||||||
|
**Secuencia:**
|
||||||
|
1. **APERTURA** (solo si el lead está en estado `nuevo` o aún no se ha escrito)
|
||||||
|
2. **ESPACIO** – qué espacio quiere reformar
|
||||||
|
3. **TAMAÑO** – rango de metros cuadrados
|
||||||
|
4. **ESTILO** – tipo de acabado
|
||||||
|
5. **URGENCIA** – cuándo quiere empezar
|
||||||
|
6. **PRESUPUESTO** – cantidad o rango (orientativo, nunca obligatorio)
|
||||||
|
7. **FIN** – cierre cálido; ya le preparas el presupuesto
|
||||||
|
|
||||||
|
**Ejemplos de cómo plantear cada dato** (son *referencias de tono*, no frases literales: varíalas en cada conversación):
|
||||||
|
|
||||||
|
- **APERTURA:** “¡Hola [nombre]! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?”
|
||||||
|
- **ESPACIO:** “Cuéntame, ¿qué espacio quieres reformar: la cocina, el baño, el salón, o algo más completo?”
|
||||||
|
- **TAMAÑO:** “¿Y de tamaño, más o menos por dónde anda? Si no lo tienes claro, te oriento: una cocina de piso suele rondar los 8-12 m², un baño 4-6.”
|
||||||
|
- **ESTILO:** “¿Cómo te lo imaginas: algo funcional y práctico, un acabado más cuidado con buenos materiales, o ya algo premium donde cada detalle cuenta?”
|
||||||
|
- **URGENCIA:** “¿Para cuándo te gustaría tenerlo? ¿Es algo próximo o todavía le estás dando vueltas?”
|
||||||
|
- **PRESUPUESTO:** “Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale.”
|
||||||
|
- **FIN (cierre):** “¡Genial [nombre]! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aquí mismo.”
|
||||||
|
|
||||||
|
> El cierre es **siempre cálido y positivo**, sea cual sea el presupuesto. Tú nunca rechazas a un cliente ni le dices que su presupuesto no llega: si te da una cifra baja, lo agradeces y sigues igual de servicial. (Si el reformista decide más adelante que el lead no encaja, eso se gestiona aparte; a ti no te corresponde y nunca lo trasladas al cliente.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MANEJO DE CASOS ESPECIALES
|
||||||
|
|
||||||
|
### Cuando el usuario pregunta algo fuera del flujo
|
||||||
|
Atiéndele con simpatía: concédele algo útil y luego retoma con naturalidad. Como haría el mejor asesor.
|
||||||
|
- Ej.: “Buena pregunta; el precio fino lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenértelo listo, ¿vale?”
|
||||||
|
- Nunca cortes con un seco “cuando terminemos te cuento”.
|
||||||
|
|
||||||
|
### Cuando dude o no sepa un dato
|
||||||
|
Ayúdale con referencias concretas, sin presionar:
|
||||||
|
- Tamaño: “Tranquila; un baño normal de piso son unos 4-6 m², una cocina 8-12. ¿Te encaja alguna de esas?”
|
||||||
|
- Materiales/estilo: “Por ponerte un ejemplo: funcional sería tipo Leroy Merlin, cuidado marcas como Roca o Porcelanosa, y premium ya serie alta. ¿Cuál te suena más?”
|
||||||
|
|
||||||
|
### Reintentos
|
||||||
|
Si la respuesta no encaja con el dato que toca, reformula con calidez y dando opciones. Sin sonar borde ni repetir la misma frase. Máximo 2 intentos; al tercero, cierra con cariño: “Lo dejamos aquí de momento; cuando quieras seguimos, sin prisa.”
|
||||||
|
|
||||||
|
### Multimedia
|
||||||
|
- **Audio:** trátalo como texto. Si no entiendes: “Oye, no te he oído bien, ¿me lo repites?”
|
||||||
|
- **Imagen en ESPACIO o TAMAÑO:** infiere el espacio y los m² aproximados de la foto y úsalo como respuesta.
|
||||||
|
- **Imagen en ESTILO:** infiere el estilo o la calidad que busca por lo que muestra.
|
||||||
|
- **Imagen en otro momento:** “¡Gracias por la foto! Cuéntamelo también en un par de palabras para asegurarme de pillarlo bien.”
|
||||||
|
|
||||||
|
### Tono defensivo o brusco
|
||||||
|
No te disculpes de más ni te alteres. Sigue con calidez y cercanía. Si va al grano, tú también puedes ser breve y natural, sin sonar corporativa.
|
||||||
|
|
||||||
|
### Usuario que no quiere dar el presupuesto
|
||||||
|
Nunca lo fuerces ni lo penalices: “Sin problema, no hace falta una cifra exacta; con que me digas una franja aproximada me vale, y si lo prefieres lo dejamos abierto y te hago una propuesta realista igualmente.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. EJEMPLOS DE TONO CORRECTO
|
||||||
|
|
||||||
|
**Usuario:** Hola, necesito reformar mi cocina.
|
||||||
|
**Luisa:** ¡Hola! Qué bien, una cocina. Para hacerme una idea, ¿sabes más o menos los metros que tiene?
|
||||||
|
|
||||||
|
**Usuario:** Es cocina, unos 15 metros.
|
||||||
|
**Luisa:** Genial, 15 m² me vale. ¿Y cómo te la imaginas: algo funcional, un acabado más cuidado, o ya premium?
|
||||||
|
|
||||||
|
**Usuario:** Quiero algo moderno pero no muy caro.
|
||||||
|
**Luisa:** Te pillo, práctico y con buena pinta sin pasarse de precio. ¿Para cuándo te gustaría tenerla lista?
|
||||||
|
|
||||||
|
**Usuario:** Tengo un presupuesto de 4500 euros.
|
||||||
|
**Luisa:** Perfecto, me sirve para orientarme. Con eso ya te preparo una propuesta realista para tu cocina. 👍
|
||||||
|
|
||||||
|
**Usuario:** No sé cuánto me quiero gastar la verdad.
|
||||||
|
**Luisa:** Tranquila, no pasa nada; lo dejamos abierto y te hago un presupuesto realista con lo que me has contado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. RECORDATORIO FINAL PARA EL MODELO
|
||||||
|
|
||||||
|
- **NUNCA digas que eres un asistente de IA, ni que no eres Luisa.**
|
||||||
|
- **Sé simpática, cálida y servicial en cada mensaje**; acompaña, no interrogues.
|
||||||
|
- **Sigue el orden de los datos con naturalidad**, sin repreguntar lo que ya te han dicho.
|
||||||
|
- **Varía la redacción**: nada de frases calcadas turno tras turno.
|
||||||
|
- **Nunca rechaces a un cliente** por su presupuesto; el cierre siempre es positivo.
|
||||||
|
- **Una sola pregunta por mensaje**, 2-3 líneas, algún emoji suave ocasional.
|
||||||
|
- **No escribas JSON ni etiquetas**: solo el mensaje natural para WhatsApp.
|
||||||
|
|
||||||
|
Ahora actúa como Luisa.
|
||||||
|
|||||||
@@ -1,32 +1,40 @@
|
|||||||
# Luisa — Flujo y estados
|
# Luisa — Flujo y estados
|
||||||
|
|
||||||
## Maquina de estados
|
## Maquina de estados
|
||||||
|
|
||||||
NUEVO -> APERTURA -> ESPACIO -> TAMANO -> ESTILO -> URGENCIA -> PRESUPUESTO -> FIN
|
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
|
## Datos a recolectar
|
||||||
| Estado | Campo DB | Valores validos |
|
|
||||||
|-------------|----------------------|----------------------------------------|
|
|
||||||
| ESPACIO | espacio | cocina, bano, salon, integral, otro |
|
|
||||||
| TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 |
|
|
||||||
| ESTILO | estilo | funcional, cuidado, exclusivo |
|
|
||||||
| URGENCIA | urgencia | urgente, medio_plazo, frio |
|
|
||||||
| PRESUPUESTO | presupuesto_declarado| cifra o rango en euros |
|
|
||||||
|
|
||||||
## Mensajes por estado
|
| Estado | Campo DB | Valores validos |
|
||||||
**APERTURA:** "Hola [nombre], soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?"
|
| ----------- | --------------------- | ----------------------------------- |
|
||||||
|
| ESPACIO | espacio | cocina, bano, salon, integral, otro |
|
||||||
|
| TAMANO | rango_m2 | menos10, 10a20, 20a40, mas40 |
|
||||||
|
| ESTILO | estilo | funcional, cuidado, exclusivo |
|
||||||
|
| URGENCIA | urgencia | urgente, medio_plazo, frio |
|
||||||
|
| PRESUPUESTO | presupuesto_declarado | cifra o rango en euros (orientativo) |
|
||||||
|
|
||||||
**ESPACIO:** "Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?"
|
## Ejemplos de tono por estado (varia la redaccion, no son frases literales)
|
||||||
|
|
||||||
**TAMANO:** "Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?"
|
**APERTURA:** "¡Hola! Soy Luisa, de Reformix; vi que pediste presupuesto en la web y te ayudo a prepararlo. ¿Tienes un par de minutos?"
|
||||||
|
|
||||||
**ESTILO:** "Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?"
|
**ESPACIO:** "Cuentame, ¿que espacio quieres reformar: la cocina, el bano, el salon, o algo mas completo?"
|
||||||
|
|
||||||
**URGENCIA:** "Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?"
|
**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."
|
||||||
|
|
||||||
**PRESUPUESTO:** "Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?"
|
**ESTILO:** "¿Como te lo imaginas: funcional y practico, un acabado mas cuidado con buenos materiales, o ya algo premium?"
|
||||||
|
|
||||||
**FIN_VIABLE:** "Con todo esto ya preparo tu presupuesto. En un momento lo recibes aqui mismo."
|
**URGENCIA:** "¿Para cuando te gustaria tenerlo? ¿Es algo proximo o todavia le das vueltas?"
|
||||||
|
|
||||||
**FIN_NO_VIABLE:** "Gracias por tu tiempo [nombre]; ahora mismo no podriamos darte el resultado que mereces con ese presupuesto. Si en algun momento cambia, aqui estamos."
|
**PRESUPUESTO:** "Para ajustarte la propuesta, ¿tienes una cifra orientativa en mente? No hace falta que sea exacta, una franja me vale."
|
||||||
|
|
||||||
**SEGUIMIENTO FASE 3:** "Hola [nombre], te llego bien el presupuesto; quedaste con alguna duda?"
|
**FIN (cierre cálido, siempre positivo):** "¡Genial! Con esto ya te preparo tu presupuesto con el render. En un momentito lo tienes aqui mismo."
|
||||||
|
|
||||||
|
**DESVIO (con simpatia):** "Buena pregunta; eso lo confirma el reformista en la visita, pero lo dejo anotado. Mientras, sigamos para tenertelo listo, ¿vale?"
|
||||||
|
|
||||||
|
**SEGUIMIENTO FASE 3:** "¡Hola! ¿Te llego bien el presupuesto? ¿Te quedo alguna duda?"
|
||||||
|
|
||||||
|
> Nunca rechazas a un cliente por su presupuesto: el cierre es siempre positivo, sea cual sea la cifra.
|
||||||
|
|||||||
156
mvp/Whatsapp-bot/src/api/api-client.service.ts
Normal file
156
mvp/Whatsapp-bot/src/api/api-client.service.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface LeadState {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
telefono: string;
|
||||||
|
botStep: string;
|
||||||
|
estadoWa: string;
|
||||||
|
espacio: string;
|
||||||
|
rangoM2: string;
|
||||||
|
estilo: string;
|
||||||
|
presupuestoDeclarado: string;
|
||||||
|
viable: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiClient {
|
||||||
|
private readonly logger = new Logger(ApiClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||||
|
this.apiKey = process.env.FUNNEL_API_KEY || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private get headers() {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLead(leadId: string): Promise<LeadState | null> {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`${this.baseUrl}/api/leads/${leadId}`, {
|
||||||
|
headers: this.headers,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) return null;
|
||||||
|
this.logger.error(`Error fetching lead ${leadId}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-análisis: la app lee toda la conversación del lead y extrae los datos clave de una pasada.
|
||||||
|
async analizarConversacion(leadId: string): Promise<boolean> {
|
||||||
|
return this.post(`/api/leads/${leadId}/analizar`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async buscarLeadPorTelefono(telefono: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`${this.baseUrl}/api/leads/by-phone`, {
|
||||||
|
headers: this.headers,
|
||||||
|
params: { telefono },
|
||||||
|
});
|
||||||
|
return data?.leadId ?? null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) return null;
|
||||||
|
this.logger.error(`buscarLeadPorTelefono error: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async guardarConversacion(
|
||||||
|
leadId: string,
|
||||||
|
rol: 'user' | 'assistant' | 'system',
|
||||||
|
mensaje: string,
|
||||||
|
options?: { estadoWa?: string; botStep?: string; mediaType?: string; mediaUrl?: string; transcripcionAudio?: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.post(`/api/leads/${leadId}/conversacion`, {
|
||||||
|
rol,
|
||||||
|
mensaje,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async actualizarPerfil(
|
||||||
|
leadId: string,
|
||||||
|
datos: Record<string, unknown>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.post(`/api/leads/${leadId}/perfil`, datos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async obtenerHistorial(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(
|
||||||
|
`${this.baseUrl}/api/leads/${leadId}/conversacion`,
|
||||||
|
{ headers: this.headers },
|
||||||
|
);
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((m: any) => ({ role: m.rol || m.role, content: m.mensaje || m.content }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) return [];
|
||||||
|
this.logger.error(`Error fetching historial for ${leadId}: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async calificarLead(
|
||||||
|
leadId: string,
|
||||||
|
score: number,
|
||||||
|
nivel: 'A' | 'B' | 'C' | 'D',
|
||||||
|
criterios?: Record<string, unknown>,
|
||||||
|
notasAgente?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.post(`/api/leads/${leadId}/calificacion`, {
|
||||||
|
score,
|
||||||
|
nivel,
|
||||||
|
criterios,
|
||||||
|
notasAgente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registrarIntento(
|
||||||
|
leadId: string,
|
||||||
|
canal: string,
|
||||||
|
numeroIntento: number,
|
||||||
|
resultado?: string,
|
||||||
|
completado?: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.post(`/api/leads/${leadId}/intento`, {
|
||||||
|
canal,
|
||||||
|
numeroIntento,
|
||||||
|
resultado,
|
||||||
|
completado,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async enviarIngesta(
|
||||||
|
leadId: string,
|
||||||
|
items: Array<Record<string, unknown>>,
|
||||||
|
flags?: { perfilCompleto?: boolean; finalizar?: boolean },
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.post(`/api/leads/${leadId}/ingesta`, {
|
||||||
|
items,
|
||||||
|
...flags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(path: string, body: unknown): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { status } = await axios.post(`${this.baseUrl}${path}`, body, {
|
||||||
|
headers: this.headers,
|
||||||
|
});
|
||||||
|
return status === 200;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`POST ${path} error: ${err.response?.status} ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
mvp/Whatsapp-bot/src/api/api.module.ts
Normal file
9
mvp/Whatsapp-bot/src/api/api.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { ApiClient } from './api-client.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [ApiClient],
|
||||||
|
exports: [ApiClient],
|
||||||
|
})
|
||||||
|
export class ApiModule {}
|
||||||
@@ -1,33 +1,22 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { ApiModule } from './api/api.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
|
||||||
import { LeadsModule } from './leads/leads.module';
|
import { LeadsModule } from './leads/leads.module';
|
||||||
import { ConversacionModule } from './conversacion/conversacion.module';
|
import { ConversacionModule } from './conversacion/conversacion.module';
|
||||||
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
import { WhatsappModule } from './whatsapp/whatsapp.module';
|
||||||
import { ClaudeModule } from './claude/claude.module';
|
import { ClaudeModule } from './claude/claude.module';
|
||||||
import { MediaModule } from './media/media.module';
|
import { MediaModule } from './media/media.module';
|
||||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
import { WebhookModule } from './webhook/webhook.module';
|
||||||
import { Lead } from './leads/lead.entity';
|
|
||||||
import { Conversacion } from './conversacion/conversacion.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ScheduleModule.forRoot(),
|
ApiModule,
|
||||||
TypeOrmModule.forRoot({
|
|
||||||
type: 'postgres',
|
|
||||||
url: process.env.DATABASE_URL,
|
|
||||||
entities: [Lead, Conversacion],
|
|
||||||
synchronize: true, // En produccion usar migrations en lugar de synchronize
|
|
||||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require')
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
}),
|
|
||||||
LeadsModule,
|
LeadsModule,
|
||||||
ConversacionModule,
|
ConversacionModule,
|
||||||
WhatsappModule,
|
WhatsappModule,
|
||||||
ClaudeModule,
|
ClaudeModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
SchedulerModule,
|
WebhookModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ClaudeService } from './claude.service';
|
import { ClaudeService } from './claude.service';
|
||||||
|
import { LeadsModule } from '../leads/leads.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [LeadsModule],
|
||||||
providers: [ClaudeService],
|
providers: [ClaudeService],
|
||||||
exports: [ClaudeService],
|
exports: [ClaudeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,49 +1,98 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Lead } from '../leads/lead.entity';
|
import { LeadsService } from '../leads/leads.service';
|
||||||
import { Conversacion } from '../conversacion/conversacion.entity';
|
|
||||||
|
const DEFAULT_SYSTEM_PROMPT =
|
||||||
|
'Eres Luisa, asesora de Reformix en Madrid. Cualificas leads de reforma por WhatsApp. ' +
|
||||||
|
'Hablas espanol de Espana, calida, simpatica y siempre dispuesta a ayudar; adapta el registro al usuario. ' +
|
||||||
|
'Un mensaje por turno, breve (2-3 lineas). Puedes usar algun emoji suave de vez en cuando. ' +
|
||||||
|
'Varia como lo dices, no suenes a plantilla y no vuelvas a preguntar lo que ya te han contado. ' +
|
||||||
|
'NUNCA digas que eres IA, ChatGPT, OpenAI ni un asistente virtual.';
|
||||||
|
|
||||||
|
const FRASES_IA_PROHIBIDAS = [
|
||||||
|
/soy un (modelo|asistente)/i,
|
||||||
|
/desarrollado por openai/i,
|
||||||
|
/\bopenai\b/i,
|
||||||
|
/\bchatgpt\b/i,
|
||||||
|
/inteligencia artificial/i,
|
||||||
|
/no tengo un nombre propio/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ClasificacionResultado {
|
||||||
|
responde_pregunta: boolean;
|
||||||
|
valor_extraido: string | null;
|
||||||
|
es_desvio: boolean;
|
||||||
|
intencion: 'respuesta' | 'desvio' | 'despedida' | 'insulto' | 'pregunta';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidacionResultado {
|
||||||
|
valido: boolean;
|
||||||
|
valorNormalizado: string | null;
|
||||||
|
viable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadBasico {
|
||||||
|
id: string;
|
||||||
|
telefono: string;
|
||||||
|
nombre: string;
|
||||||
|
estado_actual: string;
|
||||||
|
espacio: string | null;
|
||||||
|
rango_m2: string | null;
|
||||||
|
estilo: string | null;
|
||||||
|
urgencia: string | null;
|
||||||
|
presupuesto_declarado: string | null;
|
||||||
|
viable: boolean | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaudeResponse {
|
export interface ClaudeResponse {
|
||||||
respuesta: string;
|
respuesta: string;
|
||||||
entidad?: Partial<Lead>; // datos extraídos del turno
|
entidad?: Partial<LeadBasico>;
|
||||||
viable?: boolean; // flag si Claude decide el resultado final
|
viable?: boolean;
|
||||||
|
nuevoEstado?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClaudeService {
|
export class ClaudeService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ClaudeService.name);
|
private readonly logger = new Logger(ClaudeService.name);
|
||||||
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
private readonly promptsDir = path.join(process.cwd(), 'prompts');
|
||||||
|
private systemPromptCache = '';
|
||||||
|
private reglasPromptCache = '';
|
||||||
|
private readonly reintentosPorLead = new Map<string, { estado: string; count: number }>();
|
||||||
|
|
||||||
/**
|
constructor(private readonly leadsService: LeadsService) {}
|
||||||
* Lee y concatena los 3 archivos MD de /prompts como system prompt.
|
|
||||||
*/
|
onModuleInit() {
|
||||||
private leerPromptsSistema(): string {
|
this.systemPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md']);
|
||||||
const archivos = ['luisa_core.md', 'luisa_flujo.md', 'luisa_casos.md'];
|
this.reglasPromptCache = this.cargarPrompts(['luisa_core.md', 'luisa_casos.md']);
|
||||||
|
this.logger.log(`Prompts cargados: system=${this.systemPromptCache.length} chars, reglas=${this.reglasPromptCache.length} chars`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cargarPrompts(archivos: string[]): string {
|
||||||
const partes: string[] = [];
|
const partes: string[] = [];
|
||||||
|
|
||||||
for (const archivo of archivos) {
|
for (const archivo of archivos) {
|
||||||
const rutaCompleta = path.join(this.promptsDir, archivo);
|
const rutaCompleta = path.join(this.promptsDir, archivo);
|
||||||
try {
|
try {
|
||||||
|
if (!fs.existsSync(rutaCompleta)) { this.logger.warn(`Prompt no encontrado: ${archivo}`); continue; }
|
||||||
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
const contenido = fs.readFileSync(rutaCompleta, 'utf-8');
|
||||||
if (contenido.trim()) {
|
if (contenido.trim()) partes.push(`\n\n## ${archivo}\n${contenido}`);
|
||||||
partes.push(`\n\n## ${archivo}\n${contenido}`);
|
} catch { this.logger.warn(`No se pudo leer el prompt: ${archivo}`); }
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(`No se pudo leer el prompt: ${archivo}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return partes.join('\n').trim() || DEFAULT_SYSTEM_PROMPT;
|
||||||
return partes.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getModelo(clave: 'clasificador' | 'generador' | 'reglas'): string {
|
||||||
* Serializa los datos actuales del lead para el contexto de Claude.
|
const envMap: Record<string, string | undefined> = {
|
||||||
*/
|
clasificador: process.env.MODEL_CLASIFICADOR,
|
||||||
private serializarLead(lead: Lead): string {
|
generador: process.env.MODEL_GENERADOR || process.env.MODEL,
|
||||||
|
reglas: process.env.MODEL_REGLAS || process.env.MODEL_CLASIFICADOR,
|
||||||
|
};
|
||||||
|
return envMap[clave] || (clave === 'generador' ? 'anthropic/claude-sonnet-4-5' : 'anthropic/claude-haiku-4-5');
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializarLead(lead: LeadBasico): string {
|
||||||
return [
|
return [
|
||||||
`- ID: ${lead.id}`,
|
`- ID: ${lead.id}`,
|
||||||
`- Telefono: ${lead.telefono}`,
|
`- Telefono: ${lead.telefono}`,
|
||||||
@@ -59,114 +108,300 @@ export class ClaudeService {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async llamarOpenRouter(
|
||||||
* Llama a Claude 4.5 via OpenRouter con el contexto completo del lead.
|
model: string,
|
||||||
* Devuelve la respuesta de Luisa y los datos extraídos del turno.
|
system: string,
|
||||||
*
|
messages: Array<{ role: string; content: string }>,
|
||||||
* @param lead El lead actual con sus datos en DB
|
options: { temperature?: number; jsonMode?: boolean } = {},
|
||||||
* @param historial Historial de conversación [{role, content}]
|
): Promise<string> {
|
||||||
* @param mensajeActual El mensaje del usuario (ya puede venir transcrito/inferido)
|
const { temperature = 0.7, jsonMode = false } = options;
|
||||||
*/
|
const payload: Record<string, unknown> = {
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'system', content: system }, ...messages],
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature,
|
||||||
|
};
|
||||||
|
if (jsonMode) payload.response_format = { type: 'json_object' };
|
||||||
|
|
||||||
|
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', payload, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://reformix.es',
|
||||||
|
'X-Title': 'Reformix Luisa Bot',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.choices?.[0]?.message?.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsearJson<T>(texto: string): T | null {
|
||||||
|
const limpio = texto.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
||||||
|
const inicio = limpio.indexOf('{');
|
||||||
|
const fin = limpio.lastIndexOf('}');
|
||||||
|
if (inicio === -1 || fin === -1) return null;
|
||||||
|
try { return JSON.parse(limpio.slice(inicio, fin + 1)) as T; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizarClasificacion(raw: Partial<ClasificacionResultado>): ClasificacionResultado | null {
|
||||||
|
const intenciones = ['respuesta', 'desvio', 'despedida', 'insulto', 'pregunta'] as const;
|
||||||
|
if (!raw || typeof raw.responde_pregunta !== 'boolean') return null;
|
||||||
|
const intencion = intenciones.includes(raw.intencion as typeof intenciones[number])
|
||||||
|
? (raw.intencion as ClasificacionResultado['intencion']) : 'respuesta';
|
||||||
|
return {
|
||||||
|
responde_pregunta: raw.responde_pregunta,
|
||||||
|
valor_extraido: raw.valor_extraido === null || raw.valor_extraido === undefined ? null : String(raw.valor_extraido),
|
||||||
|
es_desvio: Boolean(raw.es_desvio),
|
||||||
|
intencion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clasificar(mensaje: string, estadoActual: string): Promise<ClasificacionResultado> {
|
||||||
|
const valoresPermitidos = this.leadsService.getValoresPermitidos(estadoActual);
|
||||||
|
const system = `Eres un clasificador de mensajes para un bot de cualificacion de leads de reformas.
|
||||||
|
Responde UNICAMENTE con un objeto JSON valido. Sin markdown, sin texto antes ni despues.
|
||||||
|
|
||||||
|
Estado actual del lead: ${estadoActual}
|
||||||
|
Valores permitidos para este estado (si aplica): ${valoresPermitidos.length ? valoresPermitidos.join(', ') : 'ninguno (estado sin valor concreto)'}
|
||||||
|
|
||||||
|
Formato exacto:
|
||||||
|
{
|
||||||
|
"responde_pregunta": true,
|
||||||
|
"valor_extraido": null,
|
||||||
|
"es_desvio": false,
|
||||||
|
"intencion": "respuesta"
|
||||||
|
}
|
||||||
|
|
||||||
|
Valores validos de intencion: respuesta, desvio, despedida, insulto, pregunta
|
||||||
|
|
||||||
|
Reglas para valor_extraido:
|
||||||
|
- espacio: cocina, bano, salon, comedor, integral, otro
|
||||||
|
- tamano: menos10, 10a20, 20a40, mas40
|
||||||
|
- estilo: funcional, cuidado, exclusivo
|
||||||
|
- urgencia: alta, media, baja
|
||||||
|
- presupuesto: numero o rango en euros tal como lo dijo el usuario
|
||||||
|
- apertura: null si solo confirma disponibilidad; extrae nombre si lo menciona
|
||||||
|
- Si el usuario pregunta algo (nombre, precios, etc.) usa intencion "pregunta" y responde_pregunta false
|
||||||
|
- Saludos casuales sin confirmar disponibilidad: intencion "desvio", es_desvio true
|
||||||
|
- Usuarios de Madrid/Espana: interpreta coloquialismos, jerga y dialecto peninsular (vale, tio, mola, guay, etc.) como respuesta valida si aportan el dato del estado
|
||||||
|
- Extrae el valor semantico aunque venga en lenguaje coloquial`;
|
||||||
|
|
||||||
|
const intentos = [{ jsonMode: true, temperature: 0.1 }, { jsonMode: true, temperature: 0 }];
|
||||||
|
for (const opts of intentos) {
|
||||||
|
const contenido = await this.llamarOpenRouter(this.getModelo('clasificador'), system, [{ role: 'user', content: mensaje }], opts);
|
||||||
|
const parsed = this.normalizarClasificacion(this.parsearJson<Partial<ClasificacionResultado>>(contenido) ?? {});
|
||||||
|
if (parsed) return parsed;
|
||||||
|
this.logger.warn(`Clasificador JSON invalido (intento): ${contenido.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return { responde_pregunta: false, valor_extraido: null, es_desvio: true, intencion: 'desvio' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private validar(clasificacion: ClasificacionResultado, estadoActual: string): ValidacionResultado {
|
||||||
|
const estado = this.leadsService.normalizarEstadoFlujo(estadoActual);
|
||||||
|
if (clasificacion.es_desvio || clasificacion.intencion === 'desvio' || clasificacion.intencion === 'pregunta' ||
|
||||||
|
clasificacion.intencion === 'insulto' || clasificacion.intencion === 'despedida') {
|
||||||
|
return { valido: false, valorNormalizado: null };
|
||||||
|
}
|
||||||
|
if (estado === 'nuevo') return { valido: false, valorNormalizado: null };
|
||||||
|
if (estado === 'apertura') {
|
||||||
|
return { valido: clasificacion.responde_pregunta && clasificacion.intencion === 'respuesta' && !clasificacion.es_desvio, valorNormalizado: clasificacion.valor_extraido };
|
||||||
|
}
|
||||||
|
if (estado === 'presupuesto') {
|
||||||
|
const valor = clasificacion.valor_extraido?.trim();
|
||||||
|
if (!valor || !this.leadsService.esPresupuestoValido(valor)) return { valido: false, valorNormalizado: null };
|
||||||
|
return { valido: true, valorNormalizado: valor, viable: this.leadsService.evaluarViabilidad(valor) };
|
||||||
|
}
|
||||||
|
const valoresPermitidos = this.leadsService.getValoresPermitidos(estado);
|
||||||
|
const valor = this.normalizarTexto(clasificacion.valor_extraido ?? '');
|
||||||
|
if (!valor) return { valido: false, valorNormalizado: null };
|
||||||
|
const coincide = valoresPermitidos.some((v) => v === valor || valor.includes(v) || v.includes(valor));
|
||||||
|
if (!coincide) return { valido: false, valorNormalizado: null };
|
||||||
|
const valorNormalizado = valoresPermitidos.find((v) => v === valor || valor.includes(v) || v.includes(valor)) ?? valor;
|
||||||
|
return { valido: true, valorNormalizado };
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizarTexto(valor: string): string {
|
||||||
|
return valor.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private claveReintento(leadId: string, estado: string): string { return `${leadId}:${estado}`; }
|
||||||
|
|
||||||
|
private obtenerReintentos(leadId: string, estado: string): number {
|
||||||
|
const entry = this.reintentosPorLead.get(this.claveReintento(leadId, estado));
|
||||||
|
return entry?.estado === estado ? entry.count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementarReintentos(leadId: string, estado: string): number {
|
||||||
|
const clave = this.claveReintento(leadId, estado);
|
||||||
|
const actual = this.obtenerReintentos(leadId, estado);
|
||||||
|
const count = actual + 1;
|
||||||
|
this.reintentosPorLead.set(clave, { estado, count });
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetearReintentos(leadId: string, estado: string): void {
|
||||||
|
this.reintentosPorLead.delete(this.claveReintento(leadId, estado));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generar(
|
||||||
|
lead: LeadBasico,
|
||||||
|
historial: Array<{ role: string; content: string }>,
|
||||||
|
mensajeActual: string,
|
||||||
|
clasificacion: ClasificacionResultado,
|
||||||
|
validacion: ValidacionResultado,
|
||||||
|
reintentos: number,
|
||||||
|
avanzarEstado: boolean,
|
||||||
|
siguienteEstado: string | null,
|
||||||
|
forzarApertura = false,
|
||||||
|
): Promise<string> {
|
||||||
|
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||||
|
const contextoGeneracion = `
|
||||||
|
## Contexto del lead
|
||||||
|
${this.serializarLead(lead)}
|
||||||
|
|
||||||
|
## Estado del turno
|
||||||
|
- Estado de flujo: ${estadoFlujo}
|
||||||
|
- Clasificacion: ${JSON.stringify(clasificacion)}
|
||||||
|
- Validacion valida: ${validacion.valido}
|
||||||
|
- Valor validado: ${validacion.valorNormalizado ?? 'ninguno'}
|
||||||
|
- Reintentos en este estado: ${reintentos}
|
||||||
|
- Avanzar estado: ${avanzarEstado}
|
||||||
|
- Siguiente estado (si avanza): ${siguienteEstado ?? 'sin cambio'}
|
||||||
|
- Forzar mensaje de apertura: ${forzarApertura}
|
||||||
|
|
||||||
|
## Instrucciones de respuesta
|
||||||
|
Eres Luisa de Reformix en Madrid. Sigue el system prompt al pie de la letra.
|
||||||
|
Habla espanol de Espana; calida, simpatica y siempre dispuesta a ayudar, como una asesora de confianza.
|
||||||
|
Varia como lo dices en cada turno (no repitas frases calcadas) y no vuelvas a preguntar un dato que el
|
||||||
|
usuario ya te haya dado en este mensaje o en el historial; reconocelo con naturalidad y sigue con lo que falte.
|
||||||
|
Devuelve SOLO el texto del mensaje a enviar por WhatsApp.
|
||||||
|
NO incluyas JSON, etiquetas XML ni bloques de datos extraidos.
|
||||||
|
NUNCA digas que eres IA, OpenAI, ChatGPT ni un asistente virtual.
|
||||||
|
|
||||||
|
Si forzar mensaje de apertura es true, envia el mensaje de APERTURA del flujo.
|
||||||
|
Si validacion valida es false y reintentos < 2, ayudale con calidez dando ejemplos o referencias para que se aclare.
|
||||||
|
Si validacion valida es false y reintentos >= 2, vuelve a la pregunta del estado de otra forma, sin sonar borde.
|
||||||
|
Si es_desvio es true o intencion es pregunta, atiende su duda con simpatia (concede algo util) y retoma el flujo sin avanzar.
|
||||||
|
Si avanzar estado es true, haz la pregunta correspondiente al siguiente estado.
|
||||||
|
Si el siguiente estado es fin_viable, cierra con calidez anunciando que ya le preparas el presupuesto.`;
|
||||||
|
|
||||||
|
const contenido = await this.llamarOpenRouter(this.getModelo('generador'),
|
||||||
|
`${this.systemPromptCache || DEFAULT_SYSTEM_PROMPT}\n${contextoGeneracion}`,
|
||||||
|
[...historial, { role: 'user', content: mensajeActual }],
|
||||||
|
{ temperature: 0.7 },
|
||||||
|
);
|
||||||
|
return contenido.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async aplicarReglas(borrador: string, lead: LeadBasico, estadoFlujo: string, clasificacion: ClasificacionResultado): Promise<string> {
|
||||||
|
const reglas = this.reglasPromptCache || DEFAULT_SYSTEM_PROMPT;
|
||||||
|
const system = `${reglas}
|
||||||
|
|
||||||
|
## Tu tarea
|
||||||
|
Recibes un borrador de respuesta para WhatsApp. Debes corregirlo para que cumpla TODAS las reglas de Luisa.
|
||||||
|
Devuelve SOLO el mensaje final listo para enviar, sin explicaciones ni JSON.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
- Estado del lead: ${estadoFlujo}
|
||||||
|
- Nombre lead: ${lead.nombre || 'desconocido'}
|
||||||
|
- Intencion del usuario: ${clasificacion.intencion}
|
||||||
|
|
||||||
|
## Reglas de correccion obligatorias
|
||||||
|
- Debe sonar como Luisa de Reformix (Madrid): calida, simpatica y servicial, nunca un asistente generico ni un teleoperador
|
||||||
|
- Espanol de Espana, natural; usa coloquialismos y conectores suaves (vale, mira, oye, genial, tranquila, perfecto) cuando encajen
|
||||||
|
- Un mensaje por turno, breve (2-3 lineas como mucho); puede llevar algun emoji suave ocasional, sin abusar
|
||||||
|
- Varia la redaccion; no dejes frases calcadas ni que repitan literalmente lo que ya se dijo en turnos anteriores
|
||||||
|
- No reescribas para quitar la cercania: si el borrador suena frio o robotico, dale calidez en vez de recortarla
|
||||||
|
- NUNCA mencionar IA, OpenAI, ChatGPT, modelos de lenguaje ni asistentes virtuales
|
||||||
|
- Si preguntan el nombre: "Soy Luisa de Reformix"
|
||||||
|
- Quita cualquier JSON o etiqueta tecnica que se haya colado; deja solo el mensaje natural
|
||||||
|
- Si el borrador viola alguna regla, reescribelo completamente manteniendo la intencion`;
|
||||||
|
|
||||||
|
const contenido = await this.llamarOpenRouter(this.getModelo('reglas'), system,
|
||||||
|
[{ role: 'user', content: `Borrador a corregir:\n${borrador}` }],
|
||||||
|
{ temperature: 0.3 },
|
||||||
|
);
|
||||||
|
return contenido.trim() || borrador;
|
||||||
|
}
|
||||||
|
|
||||||
|
private contieneFraseProhibida(texto: string): boolean {
|
||||||
|
return FRASES_IA_PROHIBIDAS.some((regex) => regex.test(texto));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mensajeFallback(estadoFlujo: string, lead: LeadBasico): string {
|
||||||
|
const nombre = lead.nombre ? lead.nombre : '';
|
||||||
|
const fallbacks: Record<string, string> = {
|
||||||
|
nuevo: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
|
||||||
|
apertura: `Hola${nombre ? ' ' + nombre : ''}, soy Luisa de Reformix; vi que dejaste tus datos en nuestra web y queria ayudarte a preparar tu presupuesto. Tienes unos minutos ahora?`,
|
||||||
|
espacio: 'Que espacio tienes en mente, cocina, bano, salon, o algo mas completo?',
|
||||||
|
tamano: 'Tienes idea del tamano aproximado, menos de 10m2, entre 10 y 20, entre 20 y 40, o mas de 40?',
|
||||||
|
estilo: 'Como te imaginas el resultado; algo funcional y limpio, un acabado mas cuidado con buenos materiales, o algo mas exclusivo donde cada detalle cuenta?',
|
||||||
|
urgencia: 'Y cuando tienes pensado arrancar, es algo proximo o todavia estas explorando?',
|
||||||
|
presupuesto: 'Ultima pregunta; tienes en mente un presupuesto aproximado para la reforma?',
|
||||||
|
};
|
||||||
|
return fallbacks[estadoFlujo] ?? 'Soy Luisa de Reformix; sigamos con tu presupuesto de reforma.';
|
||||||
|
}
|
||||||
|
|
||||||
async llamarClaude(
|
async llamarClaude(
|
||||||
lead: Lead,
|
lead: LeadBasico,
|
||||||
historial: Array<{ role: string; content: string }>,
|
historial: Array<{ role: string; content: string }>,
|
||||||
mensajeActual: string,
|
mensajeActual: string,
|
||||||
): Promise<ClaudeResponse> {
|
): Promise<ClaudeResponse> {
|
||||||
const systemPrompt = this.leerPromptsSistema();
|
const estadoFlujo = this.leadsService.normalizarEstadoFlujo(lead.estado_actual);
|
||||||
|
|
||||||
const contextoDeLead = `
|
if (estadoFlujo === 'nuevo') {
|
||||||
## Contexto del lead actual
|
const clasificacion: ClasificacionResultado = { responde_pregunta: false, valor_extraido: null, es_desvio: false, intencion: 'respuesta' };
|
||||||
|
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion,
|
||||||
|
{ valido: false, valorNormalizado: null }, 0, false, null, true);
|
||||||
|
const respuesta = await this.aplicarReglas(borrador, lead, 'nuevo', clasificacion);
|
||||||
|
return {
|
||||||
|
respuesta: this.contieneFraseProhibida(respuesta) ? this.mensajeFallback('nuevo', lead) : respuesta,
|
||||||
|
nuevoEstado: 'apertura',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
${this.serializarLead(lead)}
|
const clasificacion = await this.clasificar(mensajeActual, estadoFlujo);
|
||||||
`;
|
const validacion = this.validar(clasificacion, estadoFlujo);
|
||||||
|
|
||||||
const systemFinal = `${systemPrompt}
|
let reintentos = this.obtenerReintentos(lead.id, estadoFlujo);
|
||||||
|
let avanzarEstado = false;
|
||||||
|
let siguienteEstado: string | null = null;
|
||||||
|
let entidad: Partial<LeadBasico> = {};
|
||||||
|
let viable: boolean | undefined;
|
||||||
|
|
||||||
${contextoDeLead}
|
const puedeAvanzar = validacion.valido && !clasificacion.es_desvio && clasificacion.intencion === 'respuesta';
|
||||||
|
|
||||||
## Instrucciones de extracción de datos
|
if (puedeAvanzar) {
|
||||||
|
avanzarEstado = true;
|
||||||
Al responder, incluye al final de tu mensaje un bloque JSON con el formato exacto (sin markdown, sin comillas extras):
|
this.resetearReintentos(lead.id, estadoFlujo);
|
||||||
|
if (validacion.valorNormalizado) {
|
||||||
<DATOS_EXTRAIDOS>
|
const campo = this.leadsService.getCampoParaEstado(estadoFlujo);
|
||||||
{
|
if (campo) {
|
||||||
"nombre": null,
|
(entidad as any)[campo] = validacion.valorNormalizado;
|
||||||
"email": null,
|
} else if (estadoFlujo === 'apertura' && clasificacion.valor_extraido?.trim()) {
|
||||||
"espacio": null,
|
entidad.nombre = clasificacion.valor_extraido.trim();
|
||||||
"rango_m2": null,
|
|
||||||
"estilo": null,
|
|
||||||
"urgencia": null,
|
|
||||||
"presupuesto_declarado": null,
|
|
||||||
"viable": null
|
|
||||||
}
|
|
||||||
</DATOS_EXTRAIDOS>
|
|
||||||
|
|
||||||
Solo rellena los campos que has capturado en este turno. Los que no hayas capturado déjalos en null.
|
|
||||||
Si puedes determinar si el lead es viable o no, pon true o false en "viable". Si no, déjalo en null.`;
|
|
||||||
|
|
||||||
const messages = [
|
|
||||||
...historial,
|
|
||||||
{ role: 'user', content: mensajeActual },
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
'https://openrouter.ai/api/v1/chat/completions',
|
|
||||||
{
|
|
||||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
|
||||||
messages,
|
|
||||||
system: systemFinal,
|
|
||||||
max_tokens: 1024,
|
|
||||||
temperature: 0.7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': 'https://reformix.es',
|
|
||||||
'X-Title': 'Reformix Luisa Bot',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const contenidoCompleto: string =
|
|
||||||
response.data.choices?.[0]?.message?.content || '';
|
|
||||||
|
|
||||||
// Separar la respuesta visible del bloque de datos extraídos
|
|
||||||
const regexDatos = /<DATOS_EXTRAIDOS>([\s\S]*?)<\/DATOS_EXTRAIDOS>/;
|
|
||||||
const match = contenidoCompleto.match(regexDatos);
|
|
||||||
|
|
||||||
let respuesta = contenidoCompleto
|
|
||||||
.replace(regexDatos, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
let entidad: Partial<Lead> = {};
|
|
||||||
let viableFlag: boolean | undefined = undefined;
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
try {
|
|
||||||
const datos = JSON.parse(match[1].trim());
|
|
||||||
// Solo incluir campos no nulos
|
|
||||||
Object.entries(datos).forEach(([k, v]) => {
|
|
||||||
if (v !== null && k !== 'viable') {
|
|
||||||
(entidad as Record<string, unknown>)[k] = v;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (datos.viable !== null && datos.viable !== undefined) {
|
|
||||||
viableFlag = datos.viable;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.logger.warn('No se pudo parsear DATOS_EXTRAIDOS');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
siguienteEstado = this.leadsService.getSiguienteEstado(estadoFlujo);
|
||||||
return { respuesta, entidad, viable: viableFlag };
|
// `viable` es solo informativo (siempre true): no cambia la ruta. Luisa nunca rechaza.
|
||||||
} catch (error) {
|
if (estadoFlujo === 'presupuesto') viable = validacion.viable;
|
||||||
this.logger.error(
|
} else if (!validacion.valido && clasificacion.responde_pregunta && !clasificacion.es_desvio) {
|
||||||
`Error llamando a Claude via OpenRouter: ${error.message}`,
|
reintentos = this.incrementarReintentos(lead.id, estadoFlujo);
|
||||||
error.response?.data,
|
if (reintentos > 2) reintentos = 2;
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borrador = await this.generar(lead, historial, mensajeActual, clasificacion, validacion, reintentos, avanzarEstado, siguienteEstado);
|
||||||
|
let respuesta = await this.aplicarReglas(borrador, lead, estadoFlujo, clasificacion);
|
||||||
|
|
||||||
|
if (this.contieneFraseProhibida(respuesta)) {
|
||||||
|
this.logger.warn(`Respuesta final viola reglas, usando fallback para estado=${estadoFlujo}`);
|
||||||
|
respuesta = this.mensajeFallback(estadoFlujo, lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
respuesta,
|
||||||
|
entidad: Object.keys(entidad).length > 0 ? entidad : undefined,
|
||||||
|
viable,
|
||||||
|
nuevoEstado: avanzarEstado ? siguienteEstado ?? undefined : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { Lead } from '../leads/lead.entity';
|
|
||||||
|
|
||||||
export type RolMensaje = 'user' | 'assistant' | 'system';
|
|
||||||
|
|
||||||
@Entity('conversacion')
|
|
||||||
export class Conversacion {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
@Column({ type: 'integer' })
|
|
||||||
lead_id: number;
|
|
||||||
|
|
||||||
@ManyToOne(() => Lead, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'lead_id' })
|
|
||||||
lead: Lead;
|
|
||||||
|
|
||||||
@Column({ type: 'text' })
|
|
||||||
rol: RolMensaje;
|
|
||||||
|
|
||||||
@Column({ type: 'text' })
|
|
||||||
mensaje: string;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
created_at: Date;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { Conversacion } from './conversacion.entity';
|
|
||||||
import { ConversacionService } from './conversacion.service';
|
import { ConversacionService } from './conversacion.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Conversacion])],
|
|
||||||
providers: [ConversacionService],
|
providers: [ConversacionService],
|
||||||
exports: [ConversacionService],
|
exports: [ConversacionService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,41 +1,26 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { ApiClient } from '../api/api-client.service';
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Conversacion, RolMensaje } from './conversacion.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConversacionService {
|
export class ConversacionService {
|
||||||
constructor(
|
private readonly logger = new Logger(ConversacionService.name);
|
||||||
@InjectRepository(Conversacion)
|
|
||||||
private readonly convRepo: Repository<Conversacion>,
|
constructor(private readonly api: ApiClient) {}
|
||||||
) {}
|
|
||||||
|
|
||||||
async guardarMensaje(
|
async guardarMensaje(
|
||||||
leadId: number,
|
leadId: string,
|
||||||
rol: RolMensaje,
|
rol: 'user' | 'assistant' | 'system',
|
||||||
mensaje: string,
|
mensaje: string,
|
||||||
): Promise<Conversacion> {
|
options?: { estadoWa?: string; botStep?: string },
|
||||||
const entry = this.convRepo.create({ lead_id: leadId, rol, mensaje });
|
): Promise<boolean> {
|
||||||
return this.convRepo.save(entry);
|
const ok = await this.api.guardarConversacion(leadId, rol, mensaje, options);
|
||||||
|
if (!ok) {
|
||||||
|
this.logger.warn(`No se pudo guardar mensaje ${rol} para lead ${leadId}`);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
async obtenerHistorial(leadId: number): Promise<Conversacion[]> {
|
async obtenerHistorialComoMessages(leadId: string): Promise<Array<{ role: string; content: string }>> {
|
||||||
return this.convRepo.find({
|
return this.api.obtenerHistorial(leadId);
|
||||||
where: { lead_id: leadId },
|
|
||||||
order: { created_at: 'ASC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Devuelve el historial en formato OpenAI/Claude messages array.
|
|
||||||
*/
|
|
||||||
async obtenerHistorialComoMessages(
|
|
||||||
leadId: number,
|
|
||||||
): Promise<Array<{ role: string; content: string }>> {
|
|
||||||
const historial = await this.obtenerHistorial(leadId);
|
|
||||||
return historial.map((h) => ({
|
|
||||||
role: h.rol,
|
|
||||||
content: h.mensaje,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
export type EstadoLead =
|
|
||||||
| 'nuevo'
|
|
||||||
| 'en_proceso'
|
|
||||||
| 'recopilando_datos'
|
|
||||||
| 'completado'
|
|
||||||
| 'no_viable'
|
|
||||||
| 'perdido';
|
|
||||||
|
|
||||||
@Entity('leads')
|
|
||||||
export class Lead {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
nombre: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
telefono: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
espacio: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
rango_m2: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
estilo: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
urgencia: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
presupuesto_declarado: string;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', nullable: true })
|
|
||||||
viable: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'text', default: 'nuevo' })
|
|
||||||
estado_actual: EstadoLead;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
url_presupuesto: string;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
created_at: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn()
|
|
||||||
updated_at: Date;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { Lead } from './lead.entity';
|
|
||||||
import { LeadsService } from './leads.service';
|
import { LeadsService } from './leads.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Lead])],
|
|
||||||
providers: [LeadsService],
|
providers: [LeadsService],
|
||||||
exports: [LeadsService],
|
exports: [LeadsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,85 +1,96 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { ApiClient } from '../api/api-client.service';
|
||||||
import { Repository, LessThan } from 'typeorm';
|
|
||||||
import { Lead, EstadoLead } from './lead.entity';
|
const SECUENCIA_ESTADOS = [
|
||||||
|
'nuevo',
|
||||||
|
'apertura',
|
||||||
|
'espacio',
|
||||||
|
'tamano',
|
||||||
|
'estilo',
|
||||||
|
'urgencia',
|
||||||
|
'presupuesto',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const VALORES_POR_ESTADO: Record<string, string[]> = {
|
||||||
|
espacio: ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'],
|
||||||
|
tamano: ['menos10', '10a20', '20a40', 'mas40'],
|
||||||
|
estilo: ['funcional', 'cuidado', 'exclusivo'],
|
||||||
|
urgencia: ['alta', 'media', 'baja'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAMPO_POR_ESTADO_NOMBRE: Record<string, string> = {
|
||||||
|
espacio: 'espacio',
|
||||||
|
tamano: 'rangoM2',
|
||||||
|
estilo: 'estilo',
|
||||||
|
urgencia: 'urgencia',
|
||||||
|
presupuesto: 'presupuestoDeclarado',
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeadsService {
|
export class LeadsService {
|
||||||
private readonly logger = new Logger(LeadsService.name);
|
private readonly logger = new Logger(LeadsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly api: ApiClient) {}
|
||||||
@InjectRepository(Lead)
|
|
||||||
private readonly leadRepo: Repository<Lead>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
normalizarEstadoFlujo(estado: string): string {
|
||||||
* Busca un lead por número de teléfono.
|
if (estado === 'en_proceso' || estado === 'recopilando_datos') return 'apertura';
|
||||||
* Si no existe, lo crea con estado 'nuevo'.
|
return estado;
|
||||||
*/
|
}
|
||||||
async findOrCreate(telefono: string): Promise<Lead> {
|
|
||||||
let lead = await this.leadRepo.findOne({ where: { telefono } });
|
getSiguienteEstado(estadoActual: string): string {
|
||||||
if (!lead) {
|
const estado = this.normalizarEstadoFlujo(estadoActual);
|
||||||
lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' });
|
// Tras el presupuesto el lead SIEMPRE cierra como viable: Luisa nunca rechaza a nadie. La
|
||||||
lead = await this.leadRepo.save(lead);
|
// rentabilidad del lead la valora el reformista en el panel con su baremo interno (los agentes
|
||||||
this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`);
|
// 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
getValoresPermitidos(estado: string): string[] {
|
||||||
|
return VALORES_POR_ESTADO[this.normalizarEstadoFlujo(estado)] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getCampoParaEstado(estado: string): string | null {
|
||||||
|
return CAMPO_POR_ESTADO_NOMBRE[this.normalizarEstadoFlujo(estado)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
esPresupuestoValido(valor: string): boolean {
|
||||||
|
return /\d/.test(valor.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
leadId: string,
|
||||||
|
datos: Record<string, unknown>,
|
||||||
|
options?: { nuevoEstado?: string; viable?: boolean },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const perfil: Record<string, unknown> = { ...datos };
|
||||||
|
|
||||||
|
if (options?.nuevoEstado === 'fin_viable') {
|
||||||
|
perfil.viable = true;
|
||||||
|
perfil.botStep = 'presupuesto';
|
||||||
|
} else if (options?.nuevoEstado === 'fin_no_viable') {
|
||||||
|
perfil.viable = false;
|
||||||
|
perfil.botStep = 'presupuesto';
|
||||||
|
} else if (options?.nuevoEstado) {
|
||||||
|
perfil.botStep = options.nuevoEstado;
|
||||||
|
} else if (options?.viable !== undefined) {
|
||||||
|
perfil.viable = options.viable;
|
||||||
}
|
}
|
||||||
return lead;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByTelefono(telefono: string): Promise<Lead | null> {
|
const campos = Object.keys(perfil).filter((k) => perfil[k] !== undefined);
|
||||||
return this.leadRepo.findOne({ where: { telefono } });
|
if (campos.length === 0) return true;
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: number): Promise<Lead | null> {
|
const ok = await this.api.actualizarPerfil(leadId, perfil);
|
||||||
return this.leadRepo.findOne({ where: { id } });
|
this.logger.log(`Lead ${leadId} persistido via API: ${JSON.stringify(perfil)} → ${ok ? 'ok' : 'fallo'}`);
|
||||||
}
|
return ok;
|
||||||
|
|
||||||
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
|
|
||||||
return this.leadRepo.find({ where: { estado_actual: estado } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateEstado(lead: Lead, estado: EstadoLead): Promise<Lead> {
|
|
||||||
lead.estado_actual = estado;
|
|
||||||
return this.leadRepo.save(lead);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actualiza campos del lead según el estado actual del flujo.
|
|
||||||
* Solo actualiza los campos que se pasan en el partial.
|
|
||||||
*/
|
|
||||||
async updateDatos(leadId: number, datos: Partial<Lead>): Promise<Lead> {
|
|
||||||
await this.leadRepo.update(leadId, datos);
|
|
||||||
return this.leadRepo.findOne({ where: { id: leadId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async marcarViable(lead: Lead, viable: boolean): Promise<Lead> {
|
|
||||||
lead.viable = viable;
|
|
||||||
lead.estado_actual = viable ? 'completado' : 'no_viable';
|
|
||||||
return this.leadRepo.save(lead);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marca como perdido cualquier lead en_proceso sin actividad en más de 48h.
|
|
||||||
*/
|
|
||||||
async marcarLeadsPerdidos(): Promise<void> {
|
|
||||||
const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
|
||||||
const leadsSinActividad = await this.leadRepo.find({
|
|
||||||
where: {
|
|
||||||
estado_actual: 'en_proceso',
|
|
||||||
updated_at: LessThan(hace48h),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const lead of leadsSinActividad) {
|
|
||||||
lead.estado_actual = 'perdido';
|
|
||||||
await this.leadRepo.save(lead);
|
|
||||||
this.logger.warn(
|
|
||||||
`Lead id=${lead.id} marcado como perdido por inactividad > 48h`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(lead: Lead): Promise<Lead> {
|
|
||||||
return this.leadRepo.save(lead);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { EstadoLead } from '../leads/lead.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
private readonly logger = new Logger(MediaService.name);
|
private readonly logger = new Logger(MediaService.name);
|
||||||
|
private readonly OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
private readonly OPENROUTER_URL =
|
|
||||||
'https://openrouter.ai/api/v1/chat/completions';
|
|
||||||
|
|
||||||
private get headers() {
|
private get headers() {
|
||||||
return {
|
return {
|
||||||
@@ -18,153 +15,85 @@ export class MediaService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getModeloTranscripcion(): string {
|
||||||
* Transcribe un audio enviándolo a Claude 4.5 como base64.
|
return process.env.MODEL_TRANSCRIPCION || 'google/gemini-2.5-flash';
|
||||||
* Baileys entrega el buffer del audio; lo convertimos a base64.
|
}
|
||||||
*
|
|
||||||
* @param audioBuffer Buffer del audio recibido por Baileys
|
mimeToAudioFormat(mimeType: string): string {
|
||||||
* @param mimeType MIME type del audio (ej: audio/ogg; codecs=opus)
|
const base = mimeType.toLowerCase().split(';')[0].trim();
|
||||||
* @returns Texto transcrito, o el fallback si falla
|
const map: Record<string, string> = { 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/mp4': 'm4a', 'audio/aac': 'aac', 'audio/wav': 'wav', 'audio/webm': 'webm', 'audio/flac': 'flac' };
|
||||||
*/
|
return map[base] ?? 'ogg';
|
||||||
async transcribirAudio(
|
}
|
||||||
audioBuffer: Buffer,
|
|
||||||
mimeType = 'audio/ogg',
|
limpiarTranscripcion(texto: string): string {
|
||||||
): Promise<string> {
|
return texto.replace(/^#+\s*transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||||
const FALLBACK =
|
.replace(/^\*\*transcripci[oó]n\*\*\s*:?\s*\n?/gim, '')
|
||||||
'No pude escuchar bien el audio. ¿Puedes escribirme lo que me querías contar?';
|
.replace(/^transcripci[oó]n\s*:?\s*\n?/gim, '')
|
||||||
|
.replace(/^```[\s\S]*?\n/g, '').replace(/\n```$/g, '')
|
||||||
|
.replace(/^["']|["']$/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectarFormatoPorMagicBytes(buffer: Buffer): string | null {
|
||||||
|
if (buffer.length >= 4 && buffer.subarray(0, 4).toString('ascii') === 'OggS') return 'ogg';
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
|
||||||
|
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE') return 'wav';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribirAudio(audioBuffer: Buffer, mimeType = 'audio/ogg; codecs=opus'): Promise<string> {
|
||||||
|
const FALLBACK = 'No te he oido bien, me lo repites?';
|
||||||
|
const formatFromMime = this.mimeToAudioFormat(mimeType);
|
||||||
|
const formatFromMagic = this.detectarFormatoPorMagicBytes(audioBuffer);
|
||||||
|
const format = formatFromMagic ?? formatFromMime;
|
||||||
|
const base64Audio = audioBuffer.toString('base64');
|
||||||
|
const model = this.getModeloTranscripcion();
|
||||||
|
|
||||||
|
if (audioBuffer.length < 100) return FALLBACK;
|
||||||
|
|
||||||
|
const systemPrompt = 'Eres un transcriptor de voz para usuarios de Madrid y Espana. Transcribe en espanol peninsular tal como se habla, conservando coloquialismos, muletillas y jerga (vale, tio, guay, mola, etc.) sin corregir ni formalizar. Responde unicamente con las palabras dichas, sin titulos, markdown, comillas ni explicaciones.';
|
||||||
|
const userPrompt = 'Transcribe exactamente lo que dice la persona en este audio. Es espanol de Espana, posiblemente con tono coloquial madrileño. Devuelve solo las palabras habladas, tal cual, nada mas.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base64Audio = audioBuffer.toString('base64');
|
const response = await axios.post(this.OPENROUTER_URL, {
|
||||||
|
model,
|
||||||
const response = await axios.post(
|
messages: [
|
||||||
this.OPENROUTER_URL,
|
{ role: 'system', content: systemPrompt },
|
||||||
{
|
{ role: 'user', content: [{ type: 'text', text: userPrompt }, { type: 'input_audio', input_audio: { data: base64Audio, format } }] },
|
||||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
],
|
||||||
messages: [
|
max_tokens: 512, temperature: 0,
|
||||||
{
|
}, { headers: this.headers });
|
||||||
role: 'user',
|
const raw: string = response.data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||||
content: [
|
if (!raw) return FALLBACK;
|
||||||
{
|
return this.limpiarTranscripcion(raw) || FALLBACK;
|
||||||
type: 'text',
|
} catch (error: any) {
|
||||||
text: 'Por favor, transcribe exactamente lo que se dice en este audio. Devuelve solo la transcripción, sin añadir nada más.',
|
this.logger.error(`Error transcribiendo audio: ${error.message}`);
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image_url', // OpenRouter usa image_url para base64 de audio también
|
|
||||||
image_url: {
|
|
||||||
url: `data:${mimeType};base64,${base64Audio}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
max_tokens: 512,
|
|
||||||
},
|
|
||||||
{ headers: this.headers },
|
|
||||||
);
|
|
||||||
|
|
||||||
const transcripcion: string =
|
|
||||||
response.data.choices?.[0]?.message?.content?.trim();
|
|
||||||
|
|
||||||
if (!transcripcion) {
|
|
||||||
this.logger.warn('Claude devolvió respuesta vacía para el audio');
|
|
||||||
return FALLBACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Audio transcrito correctamente (${transcripcion.length} chars)`,
|
|
||||||
);
|
|
||||||
return transcripcion;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error transcribiendo audio: ${error.message}`,
|
|
||||||
error.response?.data,
|
|
||||||
);
|
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async inferirImagen(imagenBuffer: Buffer, mimeType = 'image/jpeg', estadoActual = 'en_proceso'): Promise<string> {
|
||||||
* Infiere información de una imagen según el estado actual del lead.
|
const FALLBACK = 'Recibi tu imagen pero no pude analizarla bien. Puedes describirme lo que muestra?';
|
||||||
* Útil para capturar espacios, materiales, estilos, etc.
|
|
||||||
*
|
|
||||||
* @param imagenBuffer Buffer de la imagen recibida por Baileys
|
|
||||||
* @param mimeType MIME type (ej: image/jpeg)
|
|
||||||
* @param estadoActual Estado del lead para adaptar el prompt de visión
|
|
||||||
* @returns Texto inferido, o el fallback si falla
|
|
||||||
*/
|
|
||||||
async inferirImagen(
|
|
||||||
imagenBuffer: Buffer,
|
|
||||||
mimeType = 'image/jpeg',
|
|
||||||
estadoActual: EstadoLead = 'en_proceso',
|
|
||||||
): Promise<string> {
|
|
||||||
const FALLBACK =
|
|
||||||
'Recibí tu imagen pero no pude analizarla bien. ¿Puedes describirme lo que muestra?';
|
|
||||||
|
|
||||||
const promptPorEstado: Record<string, string> = {
|
const promptPorEstado: Record<string, string> = {
|
||||||
nuevo:
|
nuevo: 'Describe brevemente que tipo de espacio se ve en esta imagen y sus caracteristicas principales.',
|
||||||
'Describe brevemente qué tipo de espacio se ve en esta imagen y sus características principales.',
|
en_proceso: 'Describe el espacio que aparece en la imagen: tipo de habitacion, materiales, estado actual, tamano aproximado.',
|
||||||
en_proceso:
|
recopilando_datos: 'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservacion.',
|
||||||
'Describe el espacio que aparece en la imagen: tipo de habitación, materiales, estado actual, tamaño aproximado.',
|
completado: 'Describe lo que ves en esta imagen relacionado con reformas o diseno de interiores.',
|
||||||
recopilando_datos:
|
no_viable: 'Describe brevemente que muestra esta imagen.',
|
||||||
'Analiza esta imagen de un espacio para reformar. Indica: tipo de espacio, metros cuadrados aproximados, materiales visibles, estilo actual, estado de conservación.',
|
perdido: 'Describe brevemente que muestra esta imagen.',
|
||||||
completado:
|
|
||||||
'Describe lo que ves en esta imagen relacionado con reformas o diseño de interiores.',
|
|
||||||
no_viable:
|
|
||||||
'Describe brevemente qué muestra esta imagen.',
|
|
||||||
perdido:
|
|
||||||
'Describe brevemente qué muestra esta imagen.',
|
|
||||||
};
|
};
|
||||||
|
const promptDeVision = promptPorEstado[estadoActual] || 'Describe que ves en esta imagen en el contexto de una reforma de hogar.';
|
||||||
const promptDeVisión =
|
|
||||||
promptPorEstado[estadoActual] ||
|
|
||||||
'Describe qué ves en esta imagen en el contexto de una reforma de hogar.';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base64Imagen = imagenBuffer.toString('base64');
|
const base64Imagen = imagenBuffer.toString('base64');
|
||||||
|
const response = await axios.post(this.OPENROUTER_URL, {
|
||||||
const response = await axios.post(
|
model: process.env.MODEL_GENERADOR || process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
||||||
this.OPENROUTER_URL,
|
messages: [{ role: 'user', content: [{ type: 'text', text: promptDeVision }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Imagen}` } }] }],
|
||||||
{
|
max_tokens: 512,
|
||||||
model: process.env.MODEL || 'anthropic/claude-sonnet-4-5',
|
}, { headers: this.headers });
|
||||||
messages: [
|
const inferencia: string = response.data.choices?.[0]?.message?.content?.trim();
|
||||||
{
|
return inferencia || FALLBACK;
|
||||||
role: 'user',
|
} catch (error: any) {
|
||||||
content: [
|
this.logger.error(`Error analizando imagen: ${error.message}`);
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: promptDeVisión,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: `data:${mimeType};base64,${base64Imagen}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
max_tokens: 512,
|
|
||||||
},
|
|
||||||
{ headers: this.headers },
|
|
||||||
);
|
|
||||||
|
|
||||||
const inferencia: string =
|
|
||||||
response.data.choices?.[0]?.message?.content?.trim();
|
|
||||||
|
|
||||||
if (!inferencia) {
|
|
||||||
this.logger.warn('Claude devolvió respuesta vacía para la imagen');
|
|
||||||
return FALLBACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Imagen inferida correctamente (${inferencia.length} chars)`,
|
|
||||||
);
|
|
||||||
return inferencia;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error analizando imagen: ${error.message}`,
|
|
||||||
error.response?.data,
|
|
||||||
);
|
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { SchedulerService } from './scheduler.service';
|
|
||||||
import { LeadsModule } from '../leads/leads.module';
|
|
||||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
|
||||||
import { WhatsappModule } from '../whatsapp/whatsapp.module';
|
|
||||||
import { ClaudeModule } from '../claude/claude.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [LeadsModule, ConversacionModule, WhatsappModule, ClaudeModule],
|
|
||||||
providers: [SchedulerService],
|
|
||||||
})
|
|
||||||
export class SchedulerModule {}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
||||||
import { LeadsService } from '../leads/leads.service';
|
|
||||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
|
||||||
import { WhatsappService } from '../whatsapp/whatsapp.service';
|
|
||||||
import { ClaudeService } from '../claude/claude.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SchedulerService {
|
|
||||||
private readonly logger = new Logger(SchedulerService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly leadsService: LeadsService,
|
|
||||||
private readonly conversacionService: ConversacionService,
|
|
||||||
private readonly whatsappService: WhatsappService,
|
|
||||||
private readonly claudeService: ClaudeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cada 5 minutos:
|
|
||||||
* 1. Busca leads con estado_actual = 'nuevo'
|
|
||||||
* 2. Los marca como 'en_proceso'
|
|
||||||
* 3. Les envía el mensaje de APERTURA de Luisa
|
|
||||||
*
|
|
||||||
* También marca como perdidos los leads en_proceso sin actividad > 48h.
|
|
||||||
*/
|
|
||||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
|
||||||
async procesarLeadsNuevos(): Promise<void> {
|
|
||||||
this.logger.log('[Scheduler] Buscando leads nuevos...');
|
|
||||||
|
|
||||||
// Primero limpiar leads inactivos
|
|
||||||
await this.leadsService.marcarLeadsPerdidos();
|
|
||||||
|
|
||||||
// Obtener leads nuevos
|
|
||||||
const leadsNuevos = await this.leadsService.findByEstado('nuevo');
|
|
||||||
|
|
||||||
if (leadsNuevos.length === 0) {
|
|
||||||
this.logger.log('[Scheduler] No hay leads nuevos.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[Scheduler] Procesando ${leadsNuevos.length} lead(s) nuevo(s).`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const lead of leadsNuevos) {
|
|
||||||
try {
|
|
||||||
// Marcar como en_proceso antes de hacer nada
|
|
||||||
await this.leadsService.updateEstado(lead, 'en_proceso');
|
|
||||||
this.logger.log(
|
|
||||||
`[Scheduler] Lead id=${lead.id} marcado como en_proceso.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generar mensaje de apertura con Claude usando contexto mínimo
|
|
||||||
const historialVacio: Array<{ role: string; content: string }> = [];
|
|
||||||
const mensajeDeApertura =
|
|
||||||
'APERTURA: Este es el primer mensaje. Preséntate y comienza el flujo de cualificación.';
|
|
||||||
|
|
||||||
const { respuesta } = await this.claudeService.llamarClaude(
|
|
||||||
lead,
|
|
||||||
historialVacio,
|
|
||||||
mensajeDeApertura,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Guardar el mensaje de apertura en historial (como assistant)
|
|
||||||
await this.conversacionService.guardarMensaje(
|
|
||||||
lead.id,
|
|
||||||
'assistant',
|
|
||||||
respuesta,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enviar por WhatsApp
|
|
||||||
await this.whatsappService.enviarApertura(lead.telefono, respuesta);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[Scheduler] Apertura enviada a lead id=${lead.id} (${lead.telefono}).`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`[Scheduler] Error procesando lead id=${lead.id}: ${error.message}`,
|
|
||||||
error.stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
226
mvp/Whatsapp-bot/src/webhook/webhook-listener.ts
Normal file
226
mvp/Whatsapp-bot/src/webhook/webhook-listener.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { ApiClient } from '../api/api-client.service';
|
||||||
|
const QRImage = require('qrcode');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebhookListener implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(WebhookListener.name);
|
||||||
|
private server: http.Server | null = null;
|
||||||
|
|
||||||
|
// Estado de vinculación de WhatsApp, alimentado por WhatsappService (sin dependencia circular).
|
||||||
|
private qrActual: string | null = null;
|
||||||
|
private conectado = false;
|
||||||
|
// Diagnóstico: estado de conexión + últimos eventos entrantes (anillo de 15).
|
||||||
|
private connState: Record<string, unknown> = {};
|
||||||
|
private inbound: any[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly api: ApiClient) {}
|
||||||
|
|
||||||
|
onApplicationBootstrap() {
|
||||||
|
const port = parseInt(process.env.WEBHOOK_PORT || '3001', 10);
|
||||||
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||||
|
this.server.listen(port, () => {
|
||||||
|
this.logger.log(`Webhook listener en puerto ${port}`);
|
||||||
|
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
|
||||||
|
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
|
||||||
|
this.logger.log(`QR vinculación → GET /qr (HTTP Basic, contraseña = QR_TOKEN)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setQr(qr: string | null) {
|
||||||
|
this.qrActual = qr;
|
||||||
|
}
|
||||||
|
setConectado(b: boolean) {
|
||||||
|
this.conectado = b;
|
||||||
|
if (b) this.qrActual = null;
|
||||||
|
}
|
||||||
|
setConnState(o: Record<string, unknown>) {
|
||||||
|
this.connState = o;
|
||||||
|
}
|
||||||
|
pushInbound(o: Record<string, unknown>) {
|
||||||
|
this.inbound.unshift(o);
|
||||||
|
if (this.inbound.length > 15) this.inbound.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
|
const url = req.url || '';
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
if (url.startsWith('/qr')) return this.handleQrPage(req, res);
|
||||||
|
if (url.startsWith('/debug')) return this.handleDebug(req, res);
|
||||||
|
res.writeHead(404).end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405).end('Method Not Allowed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => (body += chunk));
|
||||||
|
req.on('end', async () => {
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'JSON invalido' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url === '/whatsapp-start') {
|
||||||
|
await this.handleWhatsappStart(payload);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||||
|
} else if (url === '/whatsapp-pdf') {
|
||||||
|
await this.handleWhatsappPdf(payload);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||||
|
} else if (url === '/whatsapp-fotos') {
|
||||||
|
// Cross-canal: tras una llamada, la app pide que Luisa escriba al lead y le pida las fotos.
|
||||||
|
const { fotosEmitter } = await import('../whatsapp/whatsapp.service');
|
||||||
|
fotosEmitter.emit('fotos', payload);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
||||||
|
} else {
|
||||||
|
res.writeHead(404).end('Not Found');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Error handling ${url}: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparación en tiempo constante (sobre hashes, sin filtrar longitud).
|
||||||
|
private tokenValido(a: string, b: string): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
const ha = crypto.createHash('sha256').update(a).digest();
|
||||||
|
const hb = crypto.createHash('sha256').update(b).digest();
|
||||||
|
return crypto.timingSafeEqual(ha, hb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Basic: contraseña = QR_TOKEN (en cabecera, nunca en la URL).
|
||||||
|
private basicAuthOk(req: http.IncomingMessage): boolean {
|
||||||
|
const expected = process.env.QR_TOKEN || '';
|
||||||
|
const auth = (req.headers['authorization'] as string) || '';
|
||||||
|
const pass = auth.startsWith('Basic ')
|
||||||
|
? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':')
|
||||||
|
: '';
|
||||||
|
return this.tokenValido(pass, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnóstico: estado de conexión + últimos eventos entrantes. Misma auth que /qr.
|
||||||
|
private handleDebug(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
|
if (!this.basicAuthOk(req)) {
|
||||||
|
res
|
||||||
|
.writeHead(401, {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
})
|
||||||
|
.end(JSON.stringify({ ok: false, error: 'No autorizado' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' })
|
||||||
|
.end(JSON.stringify({ conectado: this.conectado, connState: this.connState, inbound: this.inbound }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Página de vinculación: muestra el QR de Baileys como imagen escaneable.
|
||||||
|
// Auth por cabecera (HTTP Basic, contraseña = QR_TOKEN), nunca por query string.
|
||||||
|
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
|
if (!this.basicAuthOk(req)) {
|
||||||
|
res
|
||||||
|
.writeHead(401, {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
})
|
||||||
|
.end('<h2>401</h2><p>Usuario: cualquiera · Contraseña: el QR_TOKEN.</p>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cuerpo: string;
|
||||||
|
if (this.conectado) {
|
||||||
|
cuerpo = '<h2>✅ WhatsApp ya conectado</h2><p>El bot está vinculado. No hace falta escanear nada.</p>';
|
||||||
|
} else if (this.qrActual) {
|
||||||
|
let dataUrl = '';
|
||||||
|
try {
|
||||||
|
dataUrl = await QRImage.toDataURL(this.qrActual, { width: 320, margin: 2 });
|
||||||
|
} catch {
|
||||||
|
/* fallback abajo */
|
||||||
|
}
|
||||||
|
cuerpo =
|
||||||
|
'<h2>Vincula WhatsApp del negocio</h2>' +
|
||||||
|
'<p>WhatsApp → Dispositivos vinculados → Vincular un dispositivo → escanea:</p>' +
|
||||||
|
(dataUrl ? '<img src="' + dataUrl + '" width="320" height="320" alt="QR" />' : '<p>No se pudo generar el QR.</p>') +
|
||||||
|
'<p class="muted">El código rota cada pocos segundos; la página se refresca sola.</p>';
|
||||||
|
} else {
|
||||||
|
cuerpo = '<h2>Esperando QR…</h2><p class="muted">El bot aún no ha emitido el código. Refresca en unos segundos.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const html =
|
||||||
|
'<!doctype html><html lang="es"><head><meta charset="utf-8" />' +
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||||
|
'<meta http-equiv="refresh" content="12" />' +
|
||||||
|
'<title>Vincular WhatsApp · Reformix</title>' +
|
||||||
|
'<style>body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0f1115;color:#e6e8ec;display:flex;min-height:100vh;align-items:center;justify-content:center;text-align:center;margin:0;padding:24px}img{background:#fff;padding:12px;border-radius:12px}h2{margin:0 0 8px}.muted{color:#9aa3b2;font-size:13px}p{color:#c8cdd6}</style>' +
|
||||||
|
'</head><body><div>' +
|
||||||
|
cuerpo +
|
||||||
|
'</div></body></html>';
|
||||||
|
res
|
||||||
|
.writeHead(200, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
})
|
||||||
|
.end(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
private leadSessions = new Map<string, { leadId: string; telefono: string; nombre: string; jid: string | null }>();
|
||||||
|
|
||||||
|
private normTel(t: string): string {
|
||||||
|
return (t || '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWhatsappStart(payload: { leadId: string; telefono: string; nombre: string; empresa: string }) {
|
||||||
|
const { leadId, nombre, empresa } = payload;
|
||||||
|
const telefono = this.normTel(payload.telefono);
|
||||||
|
this.logger.log(`[START] leadId=${leadId}, telefono=${telefono}, nombre=${nombre}`);
|
||||||
|
|
||||||
|
this.leadSessions.set(telefono, { leadId, telefono, nombre, jid: null });
|
||||||
|
|
||||||
|
// Dispara la apertura proactiva (la envía WhatsappService, evitando dependencia circular).
|
||||||
|
const { startEmitter } = await import('../whatsapp/whatsapp.service');
|
||||||
|
startEmitter.emit('start', { leadId, telefono, nombre, empresa });
|
||||||
|
this.logger.log(`Lead ${leadId} registrado; apertura disparada.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeadIdByTelefono(telefono: string): string | null {
|
||||||
|
return this.leadSessions.get(this.normTel(telefono))?.leadId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerJid(telefono: string, jid: string) {
|
||||||
|
const session = this.leadSessions.get(this.normTel(telefono));
|
||||||
|
if (session) {
|
||||||
|
session.jid = jid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-registra una sesión recuperada de la BD (cuando no estaba en memoria, p. ej. tras reinicio).
|
||||||
|
ensureSession(telefono: string, leadId: string, nombre = '') {
|
||||||
|
const tel = this.normTel(telefono);
|
||||||
|
if (!this.leadSessions.has(tel)) {
|
||||||
|
this.leadSessions.set(tel, { leadId, telefono: tel, nombre, jid: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWhatsappPdf(payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) {
|
||||||
|
this.logger.log(`[PDF] leadId=${payload.leadId}, filename=${payload.filename}`);
|
||||||
|
const { pdfEmitter } = await import('../whatsapp/whatsapp.service');
|
||||||
|
const telefono = payload.telefono.startsWith('+') ? payload.telefono.slice(1) : payload.telefono;
|
||||||
|
pdfEmitter.emit('pdf', { ...payload, telefono });
|
||||||
|
}
|
||||||
|
}
|
||||||
10
mvp/Whatsapp-bot/src/webhook/webhook.module.ts
Normal file
10
mvp/Whatsapp-bot/src/webhook/webhook.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WebhookListener } from './webhook-listener';
|
||||||
|
import { ApiModule } from '../api/api.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ApiModule],
|
||||||
|
providers: [WebhookListener],
|
||||||
|
exports: [WebhookListener],
|
||||||
|
})
|
||||||
|
export class WebhookModule {}
|
||||||
55
mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts
Normal file
55
mvp/Whatsapp-bot/src/whatsapp/whatsapp-debounce.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WhatsappDebounceService {
|
||||||
|
private pendingMessages: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
texts: string[];
|
||||||
|
}
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
|
private readonly DEBOUNCE_MS = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un mensaje al buffer del usuario.
|
||||||
|
* @param userId Identificador único del usuario (ej. JID)
|
||||||
|
* @param messageText Texto del mensaje
|
||||||
|
* @param callback Función a ejecutar cuando se complete el debounce (recibe el texto combinado)
|
||||||
|
*/
|
||||||
|
async add(
|
||||||
|
userId: string,
|
||||||
|
messageText: string,
|
||||||
|
callback: (combinedMessage: string) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.pendingMessages.has(userId)) {
|
||||||
|
const pending = this.pendingMessages.get(userId)!;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.texts.push(messageText);
|
||||||
|
|
||||||
|
pending.timer = setTimeout(async () => {
|
||||||
|
await this.flush(userId, callback);
|
||||||
|
}, this.DEBOUNCE_MS);
|
||||||
|
} else {
|
||||||
|
this.pendingMessages.set(userId, {
|
||||||
|
timer: setTimeout(async () => {
|
||||||
|
await this.flush(userId, callback);
|
||||||
|
}, this.DEBOUNCE_MS),
|
||||||
|
texts: [messageText],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flush(
|
||||||
|
userId: string,
|
||||||
|
callback: (combinedMessage: string) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const pending = this.pendingMessages.get(userId);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
this.pendingMessages.delete(userId);
|
||||||
|
const combinedMessage = pending.texts.join(' ');
|
||||||
|
await callback(combinedMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WhatsappService } from './whatsapp.service';
|
import { WhatsappService } from './whatsapp.service';
|
||||||
|
import { WhatsappDebounceService } from './whatsapp-debounce.service';
|
||||||
import { LeadsModule } from '../leads/leads.module';
|
import { LeadsModule } from '../leads/leads.module';
|
||||||
import { ConversacionModule } from '../conversacion/conversacion.module';
|
import { ConversacionModule } from '../conversacion/conversacion.module';
|
||||||
import { ClaudeModule } from '../claude/claude.module';
|
import { ClaudeModule } from '../claude/claude.module';
|
||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
|
import { WebhookModule } from '../webhook/webhook.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule],
|
imports: [LeadsModule, ConversacionModule, ClaudeModule, MediaModule, WebhookModule],
|
||||||
providers: [WhatsappService],
|
providers: [WhatsappService, WhatsappDebounceService],
|
||||||
exports: [WhatsappService],
|
exports: [WhatsappService],
|
||||||
})
|
})
|
||||||
export class WhatsappModule {}
|
export class WhatsappModule {}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -7,50 +8,304 @@ import {
|
|||||||
import makeWASocket, {
|
import makeWASocket, {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
|
fetchLatestBaileysVersion,
|
||||||
WASocket,
|
WASocket,
|
||||||
downloadMediaMessage,
|
downloadMediaMessage,
|
||||||
proto,
|
normalizeMessageContent,
|
||||||
} from '@whiskeysockets/baileys';
|
} from '@whiskeysockets/baileys';
|
||||||
import { Boom } from '@hapi/boom';
|
import { Boom } from '@hapi/boom';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const pino = require('pino');
|
const pino = require('pino');
|
||||||
|
const QRCode = require('qrcode-terminal');
|
||||||
import { LeadsService } from '../leads/leads.service';
|
import { LeadsService } from '../leads/leads.service';
|
||||||
import { ConversacionService } from '../conversacion/conversacion.service';
|
import { ConversacionService } from '../conversacion/conversacion.service';
|
||||||
import { ClaudeService } from '../claude/claude.service';
|
import { ClaudeService } from '../claude/claude.service';
|
||||||
import { MediaService } from '../media/media.service';
|
import { MediaService } from '../media/media.service';
|
||||||
import { Lead } from '../leads/lead.entity';
|
import { WhatsappDebounceService } from './whatsapp-debounce.service';
|
||||||
|
import { WebhookListener } from '../webhook/webhook-listener';
|
||||||
|
import { ApiClient } from '../api/api-client.service';
|
||||||
|
import { wrapSocket } from 'baileys-antiban';
|
||||||
|
|
||||||
|
export const pdfEmitter = new EventEmitter();
|
||||||
|
export const startEmitter = new EventEmitter();
|
||||||
|
export const fotosEmitter = new EventEmitter();
|
||||||
|
|
||||||
|
interface LeadContext {
|
||||||
|
leadId: string;
|
||||||
|
telefono: string;
|
||||||
|
nombre: string;
|
||||||
|
botStep: string;
|
||||||
|
viable: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(WhatsappService.name);
|
private readonly logger = new Logger(WhatsappService.name);
|
||||||
private sock: WASocket | null = null;
|
private sock: WASocket | null = null;
|
||||||
private authDir = path.join(process.cwd(), 'auth_info_baileys');
|
private authDir = process.env.BAILEYS_AUTH_DIR || path.join(process.cwd(), 'auth_info_baileys');
|
||||||
|
private readonly ultimoMsgPorJid = new Map<string, any>();
|
||||||
|
private baileysLogger = pino({ level: 'info' });
|
||||||
|
|
||||||
|
// leadId por JID
|
||||||
|
private readonly jidToLeadId = new Map<string, string>();
|
||||||
|
// contexto de lead por leadId
|
||||||
|
private readonly leadCache = new Map<string, LeadContext>();
|
||||||
|
// leads cuya conversación ya se mandó a post-análisis (para no repetir).
|
||||||
|
private readonly leadsAnalizados = new Set<string>();
|
||||||
|
// leads a los que se les ha pedido foto y estamos esperándola.
|
||||||
|
private readonly esperandoFotos = new Set<string>();
|
||||||
|
// leads cuyo pipeline de render/presupuesto ya se disparó (perfilCompleto), para no repetir.
|
||||||
|
private readonly pipelineDisparado = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leadsService: LeadsService,
|
private readonly leadsService: LeadsService,
|
||||||
private readonly conversacionService: ConversacionService,
|
private readonly conversacionService: ConversacionService,
|
||||||
private readonly claudeService: ClaudeService,
|
private readonly claudeService: ClaudeService,
|
||||||
private readonly mediaService: MediaService,
|
private readonly mediaService: MediaService,
|
||||||
|
private readonly debounceService: WhatsappDebounceService,
|
||||||
|
private readonly webhookListener: WebhookListener,
|
||||||
|
private readonly api: ApiClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.conectar();
|
await this.conectar();
|
||||||
|
this.escucharPdf();
|
||||||
|
this.escucharStart();
|
||||||
|
this.escucharFotos();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
if (this.sock) {
|
if (this.sock) this.sock.end(undefined);
|
||||||
this.sock.end(undefined);
|
}
|
||||||
|
|
||||||
|
private escucharPdf() {
|
||||||
|
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
|
||||||
|
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
|
||||||
|
// Buscar JID por teléfono
|
||||||
|
let jid: string | null = null;
|
||||||
|
for (const [j, lid] of this.jidToLeadId) {
|
||||||
|
if (lid === payload.leadId) {
|
||||||
|
jid = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!jid) {
|
||||||
|
jid = `${payload.telefono}@s.whatsapp.net`;
|
||||||
|
}
|
||||||
|
if (!this.sock) return;
|
||||||
|
try {
|
||||||
|
const safeSock = wrapSocket(this.sock);
|
||||||
|
await safeSock.sendMessage(jid, {
|
||||||
|
document: Buffer.from(payload.pdfBase64, 'base64'),
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
fileName: payload.filename,
|
||||||
|
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
|
||||||
|
});
|
||||||
|
this.logger.log(`PDF enviado a ${jid}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apertura proactiva: cuando el funnel dispara /whatsapp-start, Luisa escribe ella el primer
|
||||||
|
// mensaje (el bot ya no es solo reactivo).
|
||||||
|
private escucharStart() {
|
||||||
|
startEmitter.on(
|
||||||
|
'start',
|
||||||
|
async (p: { leadId: string; telefono: string; nombre: string; empresa: string }) => {
|
||||||
|
try {
|
||||||
|
await this.enviarApertura(p);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[APERTURA] Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enviarApertura(p: { leadId: string; telefono: string; nombre: string; empresa: string }) {
|
||||||
|
if (!this.sock) {
|
||||||
|
this.logger.warn(`[APERTURA] WhatsApp no conectado; no se envía a ${p.telefono}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const tel = (p.telefono || '').replace(/\D/g, '');
|
||||||
|
let jid = `${tel}@s.whatsapp.net`;
|
||||||
|
try {
|
||||||
|
const res = await this.sock.onWhatsApp(tel);
|
||||||
|
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
|
||||||
|
else if (!res || !res[0]?.exists) this.logger.warn(`[APERTURA] ${tel} no parece estar en WhatsApp`);
|
||||||
|
} catch {
|
||||||
|
/* seguimos con el jid por defecto */
|
||||||
|
}
|
||||||
|
|
||||||
|
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
|
||||||
|
const empresa = p.empresa || 'Reformix';
|
||||||
|
const apertura =
|
||||||
|
`¡Hola ${primerNombre}! Soy Luisa, del equipo de ${empresa}. 😊\n\n` +
|
||||||
|
`Acabas de pedir presupuesto para tu reforma y te ayudo a prepararlo (con un render de cómo ` +
|
||||||
|
`quedaría incluido). Para empezar, cuéntame: ¿qué espacio quieres reformar? (cocina, baño, salón…)`;
|
||||||
|
|
||||||
|
// Contexto para los siguientes mensajes del cliente.
|
||||||
|
this.jidToLeadId.set(jid, p.leadId);
|
||||||
|
this.webhookListener.registerJid(tel, jid);
|
||||||
|
this.leadCache.set(p.leadId, {
|
||||||
|
leadId: p.leadId,
|
||||||
|
telefono: tel,
|
||||||
|
nombre: p.nombre || '',
|
||||||
|
botStep: 'apertura',
|
||||||
|
viable: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.enviarMensaje(jid, apertura);
|
||||||
|
this.logger.log(`[APERTURA] Enviada a ${jid} (lead ${p.leadId})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.actualizarPerfil(p.leadId, { estadoWa: 'enviado', botStep: 'apertura', canalOrigen: 'whatsapp' });
|
||||||
|
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', apertura, { botStep: 'apertura' });
|
||||||
|
await this.api.registrarIntento(p.leadId, 'whatsapp', 1, 'exitoso', true);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[APERTURA] No se pudo persistir en la app: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recibe una foto en modo "esperando fotos": la sube como "antes" y marca perfilCompleto, lo que
|
||||||
|
// dispara en la app la generación de render + presupuesto + entrega del PDF.
|
||||||
|
private async recibirFotoYFinalizar(ctx: LeadContext, jid: string, msg: any, msgContent: any): Promise<void> {
|
||||||
|
if (!this.sock || this.pipelineDisparado.has(ctx.leadId)) return;
|
||||||
|
try {
|
||||||
|
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||||
|
logger: this.baileysLogger,
|
||||||
|
reuploadRequest: this.sock.updateMediaMessage,
|
||||||
|
});
|
||||||
|
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
|
||||||
|
const mimeType = msgContent.imageMessage?.mimetype || 'image/jpeg';
|
||||||
|
|
||||||
|
this.esperandoFotos.delete(ctx.leadId);
|
||||||
|
this.pipelineDisparado.add(ctx.leadId);
|
||||||
|
|
||||||
|
await this.api.enviarIngesta(
|
||||||
|
ctx.leadId,
|
||||||
|
[{ tipo: 'foto', imagen: `data:${mimeType};base64,${base64}`, zona: 'otro', momento: 'antes' }],
|
||||||
|
{ perfilCompleto: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', '[foto del espacio]', { botStep: 'fotos_recibidas' });
|
||||||
|
const conf = '¡Perfecto! Con esto preparo tu presupuesto con el render. En un momento te llega aquí mismo 🛠️';
|
||||||
|
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', conf, { botStep: 'fotos_recibidas' });
|
||||||
|
await this.enviarMensaje(jid, conf);
|
||||||
|
this.logger.log(`[FOTOS] lead ${ctx.leadId}: foto recibida → perfilCompleto disparado`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.pipelineDisparado.delete(ctx.leadId);
|
||||||
|
this.logger.error(`[FOTOS] error procesando foto de ${ctx.leadId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-canal: tras una llamada, la app pide por webhook que Luisa escriba al lead, referencie lo
|
||||||
|
// hablado y le pida las fotos. Reutiliza el mismo modo de recogida.
|
||||||
|
private escucharFotos() {
|
||||||
|
fotosEmitter.on(
|
||||||
|
'fotos',
|
||||||
|
async (p: { leadId: string; telefono: string; nombre: string; empresa?: string; contexto?: string }) => {
|
||||||
|
try {
|
||||||
|
await this.iniciarRecogidaFotos(p);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FOTOS] iniciarRecogida error: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async iniciarRecogidaFotos(p: {
|
||||||
|
leadId: string;
|
||||||
|
telefono: string;
|
||||||
|
nombre: string;
|
||||||
|
empresa?: string;
|
||||||
|
contexto?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.sock) {
|
||||||
|
this.logger.warn(`[FOTOS] WhatsApp no conectado; no se pide foto a ${p.telefono}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jid = await this.resolverJidYRegistrar(p.leadId, p.telefono, p.nombre, 'pide_fotos');
|
||||||
|
this.esperandoFotos.add(p.leadId);
|
||||||
|
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
|
||||||
|
const empresa = p.empresa || 'Reformix';
|
||||||
|
const ctx = p.contexto ? ` sobre ${p.contexto}` : '';
|
||||||
|
const mensaje =
|
||||||
|
`¡Hola ${primerNombre}! Soy Luisa, de ${empresa}. 😊 Gracias por tu llamada${ctx}. ` +
|
||||||
|
`Para terminar tu presupuesto con el render, mándame una foto del espacio 📸`;
|
||||||
|
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', mensaje, { botStep: 'pide_fotos' });
|
||||||
|
await this.enviarMensaje(jid, mensaje);
|
||||||
|
this.logger.log(`[FOTOS] recogida iniciada para lead ${p.leadId} (cross-canal)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resuelve el jid real del teléfono (vía onWhatsApp) y registra el contexto del lead.
|
||||||
|
private async resolverJidYRegistrar(leadId: string, telefono: string, nombre: string, botStep: string): Promise<string> {
|
||||||
|
const tel = (telefono || '').replace(/\D/g, '');
|
||||||
|
let jid = `${tel}@s.whatsapp.net`;
|
||||||
|
try {
|
||||||
|
const res = await this.sock?.onWhatsApp(tel);
|
||||||
|
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
|
||||||
|
} catch {
|
||||||
|
/* jid por defecto */
|
||||||
|
}
|
||||||
|
this.jidToLeadId.set(jid, leadId);
|
||||||
|
this.webhookListener.registerJid(tel, jid);
|
||||||
|
if (!this.leadCache.has(leadId)) {
|
||||||
|
this.leadCache.set(leadId, { leadId, telefono: tel, nombre: nombre || '', botStep, viable: null });
|
||||||
|
}
|
||||||
|
return jid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizarTelefono(jid: string): string {
|
||||||
|
return jid.split('@')[0].replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsApp puede entregar mensajes desde una dirección @lid (id de privacidad) en vez del número.
|
||||||
|
// Resolvemos el número real vía remoteJidAlt o el mapa LID→PN de Baileys; si no, caemos al jid.
|
||||||
|
private resolverTelefono(msg: any): string {
|
||||||
|
const jid: string = msg.key?.remoteJid || '';
|
||||||
|
if (jid.endsWith('@lid')) {
|
||||||
|
const alt = msg.key?.remoteJidAlt;
|
||||||
|
if (typeof alt === 'string' && alt.includes('@s.whatsapp.net')) return this.normalizarTelefono(alt);
|
||||||
|
try {
|
||||||
|
const pn = (this.sock as any)?.signalRepository?.lidMapping?.getPNForLID?.(jid);
|
||||||
|
if (typeof pn === 'string' && pn) return this.normalizarTelefono(pn);
|
||||||
|
} catch {
|
||||||
|
/* sin mapping disponible */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.normalizarTelefono(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calcularDelayEscritura(longitudTexto: number): number {
|
||||||
|
const min = 1500;
|
||||||
|
const max = 4000;
|
||||||
|
const factor = Math.min(longitudTexto / 120, 1);
|
||||||
|
return Math.round(min + (max - min) * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async conectar() {
|
private async conectar() {
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
|
||||||
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
|
|
||||||
|
this.baileysLogger = pino({ level: 'info' }) as any;
|
||||||
|
|
||||||
this.sock = makeWASocket({
|
this.sock = makeWASocket({
|
||||||
|
version,
|
||||||
auth: state,
|
auth: state,
|
||||||
printQRInTerminal: true,
|
printQRInTerminal: false,
|
||||||
logger: pino({ level: 'silent' }) as any,
|
logger: this.baileysLogger,
|
||||||
|
// true: marca el dispositivo "online" al conectar para que WhatsApp le ENTREGUE los mensajes
|
||||||
|
// entrantes tras reconectar (con false, al reanudar la sesión quedaba "no disponible" y no
|
||||||
|
// recibía nada aunque el socket dijera "open").
|
||||||
|
markOnlineOnConnect: true,
|
||||||
|
generateHighQualityLinkPreview: false,
|
||||||
|
syncFullHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sock.ev.on('creds.update', saveCreds);
|
this.sock.ev.on('creds.update', saveCreds);
|
||||||
@@ -58,194 +313,308 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.sock.ev.on('connection.update', (update) => {
|
this.sock.ev.on('connection.update', (update) => {
|
||||||
const { connection, lastDisconnect, qr } = 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) {
|
if (qr) {
|
||||||
console.log('\n📲 Escanea el QR de arriba con WhatsApp\n');
|
QRCode.generate(qr, { small: true });
|
||||||
|
console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n');
|
||||||
|
this.webhookListener.setQr(qr);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection === 'close') {
|
if (connection === 'close') {
|
||||||
const shouldReconnect =
|
const shouldReconnect =
|
||||||
(lastDisconnect?.error as Boom)?.output?.statusCode !==
|
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||||
DisconnectReason.loggedOut;
|
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
|
||||||
|
this.webhookListener.setConectado(false);
|
||||||
this.logger.warn(
|
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
|
||||||
`Conexion cerrada. Reconectar: ${shouldReconnect}. Razon: ${lastDisconnect?.error?.message}`,
|
else this.logger.error('Sesion cerrada (logged out).');
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldReconnect) {
|
|
||||||
setTimeout(() => this.conectar(), 5000);
|
|
||||||
} else {
|
|
||||||
this.logger.error(
|
|
||||||
'Sesion cerrada (logged out). Elimina auth_info_baileys y reinicia.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (connection === 'open') {
|
} else if (connection === 'open') {
|
||||||
this.logger.log(
|
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
|
||||||
'✅ WhatsApp conectado. Luisa está lista para recibir mensajes.',
|
this.webhookListener.setConectado(true);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||||
if (type !== 'notify') return;
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg.key.fromMe) continue; // ignorar mensajes propios
|
this.webhookListener.pushInbound({
|
||||||
|
type,
|
||||||
|
remoteJid: msg.key.remoteJid ?? null,
|
||||||
|
remoteJidAlt: (msg.key as any).remoteJidAlt ?? null,
|
||||||
|
fromMe: !!msg.key.fromMe,
|
||||||
|
msgType: msg.message ? Object.keys(msg.message)[0] : null,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type !== 'notify') return;
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.key.fromMe) continue;
|
||||||
if (!msg.key.remoteJid) continue;
|
if (!msg.key.remoteJid) continue;
|
||||||
|
if (msg.key.remoteJid.includes('@g.us')) continue;
|
||||||
|
|
||||||
await this.procesarMensaje(msg);
|
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
|
||||||
|
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
|
||||||
|
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
|
||||||
|
|
||||||
|
await this.encolarMensaje(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private extraerTextoPlano(msg: any): string | null {
|
||||||
* Procesa un mensaje entrante de WhatsApp.
|
const msgContent = msg.message;
|
||||||
* Identifica el tipo (texto, audio, imagen), normaliza el contenido,
|
if (!msgContent) return null;
|
||||||
* consulta/crea el lead, llama a Claude y envía la respuesta.
|
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||||
*/
|
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||||
private async procesarMensaje(
|
return texto.trim() ? texto : null;
|
||||||
msg: proto.IWebMessageInfo,
|
}
|
||||||
): Promise<void> {
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private crearMsgConTexto(msg: any, texto: string): any {
|
||||||
|
return { ...msg, message: { conversation: texto } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encolarMensaje(msg: any): Promise<void> {
|
||||||
const jid = msg.key.remoteJid!;
|
const jid = msg.key.remoteJid!;
|
||||||
// Normalizar el número de teléfono (quitar el @s.whatsapp.net y el sufijo de grupo)
|
const textoPlano = this.extraerTextoPlano(msg);
|
||||||
const telefono = jid.replace('@s.whatsapp.net', '').replace('@g.us', '');
|
|
||||||
|
if (textoPlano === null) {
|
||||||
|
await this.procesarMensaje(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ultimoMsgPorJid.set(jid, msg);
|
||||||
|
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
|
||||||
|
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
|
||||||
|
this.ultimoMsgPorJid.delete(jid);
|
||||||
|
await this.procesarMensaje(this.crearMsgConTexto(baseMsg, combinedMessage));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
|
||||||
|
let leadId = this.webhookListener.getLeadIdByTelefono(telefono);
|
||||||
|
|
||||||
|
// Fallback: si no está en memoria (reinicio del bot), recuperarlo de la BD por teléfono.
|
||||||
|
if (!leadId) {
|
||||||
|
leadId = await this.api.buscarLeadPorTelefono(telefono);
|
||||||
|
if (leadId) {
|
||||||
|
this.webhookListener.ensureSession(telefono, leadId);
|
||||||
|
this.logger.log(`Lead ${leadId} recuperado por teléfono ${telefono} (sin sesión en memoria).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webhookListener.pushInbound({ stage: 'match', telefono, leadId: leadId ?? null, at: new Date().toISOString() });
|
||||||
|
if (!leadId) {
|
||||||
|
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webhookListener.registerJid(telefono, jid);
|
||||||
|
this.jidToLeadId.set(jid, leadId);
|
||||||
|
|
||||||
|
let ctx = this.leadCache.get(leadId);
|
||||||
|
if (!ctx) {
|
||||||
|
const lead = await this.api.getLead(leadId);
|
||||||
|
ctx = {
|
||||||
|
leadId,
|
||||||
|
telefono,
|
||||||
|
nombre: lead?.nombre || '',
|
||||||
|
botStep: lead?.botStep || 'nuevo',
|
||||||
|
viable: lead?.viable ?? null,
|
||||||
|
};
|
||||||
|
this.leadCache.set(leadId, ctx);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async procesarMensaje(msg: any): Promise<void> {
|
||||||
|
const jid = msg.key.remoteJid!;
|
||||||
|
if (jid.includes('@g.us')) return;
|
||||||
|
|
||||||
|
const telefono = this.resolverTelefono(msg);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Identificar o crear el lead
|
const ctx = await this.getOrCreateContext(telefono, jid);
|
||||||
const lead = await this.leadsService.findOrCreate(telefono);
|
if (!ctx) return;
|
||||||
|
|
||||||
// Ignorar leads ya terminados
|
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
|
||||||
if (['completado', 'no_viable', 'perdido'].includes(lead.estado_actual)) {
|
|
||||||
this.logger.log(
|
let textoNormalizado = '';
|
||||||
`Lead id=${lead.id} en estado=${lead.estado_actual}. Mensaje ignorado.`,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Determinar el tipo de mensaje y normalizarlo a texto
|
|
||||||
let textoNormalizado = '';
|
|
||||||
const msgContent = msg.message;
|
|
||||||
|
|
||||||
if (!msgContent) return;
|
|
||||||
|
|
||||||
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
if (msgContent.conversation || msgContent.extendedTextMessage) {
|
||||||
// Texto plano
|
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
|
||||||
textoNormalizado =
|
|
||||||
msgContent.conversation ||
|
|
||||||
msgContent.extendedTextMessage?.text ||
|
|
||||||
'';
|
|
||||||
} else if (msgContent.audioMessage) {
|
} else if (msgContent.audioMessage) {
|
||||||
// Audio → Claude transcripción
|
const audioMeta = msgContent.audioMessage;
|
||||||
this.logger.log(`Audio recibido de lead id=${lead.id}. Transcribiendo...`);
|
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
|
||||||
const buffer = await downloadMediaMessage(msg, 'buffer', {});
|
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
|
||||||
const mimeType =
|
if (!this.sock) return;
|
||||||
msgContent.audioMessage.mimetype || 'audio/ogg; codecs=opus';
|
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||||
textoNormalizado = await this.mediaService.transcribirAudio(
|
logger: this.baileysLogger,
|
||||||
buffer as Buffer,
|
reuploadRequest: this.sock.updateMediaMessage,
|
||||||
mimeType,
|
});
|
||||||
);
|
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||||
|
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
|
||||||
} else if (msgContent.imageMessage) {
|
} else if (msgContent.imageMessage) {
|
||||||
// Imagen → Claude Vision
|
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
|
||||||
this.logger.log(
|
if (!this.sock) return;
|
||||||
`Imagen recibida de lead id=${lead.id}. Analizando con Vision...`,
|
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||||
);
|
logger: this.baileysLogger,
|
||||||
const buffer = await downloadMediaMessage(msg, 'buffer', {});
|
reuploadRequest: this.sock.updateMediaMessage,
|
||||||
|
});
|
||||||
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||||
textoNormalizado = await this.mediaService.inferirImagen(
|
textoNormalizado = await this.mediaService.inferirImagen(
|
||||||
buffer as Buffer,
|
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
||||||
mimeType,
|
mimeType,
|
||||||
lead.estado_actual,
|
'en_proceso',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Si el lead envió un caption junto con la imagen, concatenarlo
|
|
||||||
if (msgContent.imageMessage.caption) {
|
if (msgContent.imageMessage.caption) {
|
||||||
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
|
||||||
`Tipo de mensaje no soportado de lead id=${lead.id}. Ignorando.`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!textoNormalizado.trim()) return;
|
if (!textoNormalizado.trim()) return;
|
||||||
|
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
|
||||||
|
|
||||||
// 3. Guardar el mensaje del usuario en historial
|
if (primerMensajeDeUsuario) {
|
||||||
await this.conversacionService.guardarMensaje(
|
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
|
||||||
lead.id,
|
|
||||||
'user',
|
|
||||||
textoNormalizado,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Construir historial y llamar a Claude
|
|
||||||
const historial =
|
|
||||||
await this.conversacionService.obtenerHistorialComoMessages(lead.id);
|
|
||||||
|
|
||||||
const { respuesta, entidad, viable } = await this.claudeService.llamarClaude(
|
|
||||||
lead,
|
|
||||||
historial.slice(0, -1), // el último ya es el mensaje actual
|
|
||||||
textoNormalizado,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. Actualizar datos del lead con lo extraído por Claude
|
|
||||||
if (entidad && Object.keys(entidad).length > 0) {
|
|
||||||
await this.leadsService.updateDatos(lead.id, entidad);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Manejar el flag viable
|
if (msgContent.imageMessage) {
|
||||||
if (viable !== undefined && viable !== null) {
|
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
|
||||||
await this.leadsService.marcarViable(lead, viable);
|
logger: this.baileysLogger,
|
||||||
this.logger.log(
|
reuploadRequest: this.sock.updateMediaMessage,
|
||||||
`Lead id=${lead.id} marcado como viable=${viable}`,
|
});
|
||||||
);
|
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
|
||||||
} else {
|
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
|
||||||
// Avanzar estado si sigue en_proceso
|
await this.api.enviarIngesta(ctx.leadId, [{
|
||||||
if (lead.estado_actual === 'nuevo') {
|
tipo: 'foto',
|
||||||
await this.leadsService.updateEstado(lead, 'en_proceso');
|
imagen: `data:${mimeType};base64,${base64}`,
|
||||||
|
zona: 'otro',
|
||||||
|
momento: 'antes',
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
|
||||||
|
botStep: ctx.botStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
|
||||||
|
|
||||||
|
const leadParaClaude = {
|
||||||
|
id: ctx.leadId,
|
||||||
|
telefono: ctx.telefono,
|
||||||
|
nombre: ctx.nombre,
|
||||||
|
estado_actual: ctx.botStep || 'nuevo',
|
||||||
|
espacio: null as string | null,
|
||||||
|
rango_m2: null as string | null,
|
||||||
|
estilo: null as string | null,
|
||||||
|
urgencia: null as string | null,
|
||||||
|
presupuesto_declarado: null as string | null,
|
||||||
|
viable: ctx.viable as boolean | null,
|
||||||
|
email: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
|
||||||
|
leadParaClaude as any,
|
||||||
|
historial.slice(0, -1),
|
||||||
|
textoNormalizado,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
|
||||||
|
|
||||||
|
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
|
||||||
|
const entidadMap: Record<string, unknown> = {};
|
||||||
|
if (entidad) {
|
||||||
|
for (const [k, v] of Object.entries(entidad)) {
|
||||||
|
const mapped = this.mapearCampoALegacy(k);
|
||||||
|
entidadMap[mapped] = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
|
||||||
|
if (nuevoEstado) ctx.botStep = nuevoEstado;
|
||||||
|
if (viable !== undefined) ctx.viable = viable;
|
||||||
|
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Guardar respuesta de Claude en historial
|
// ¿Estamos en el cierre? Por estado (errático) O porque Luisa anuncia el presupuesto.
|
||||||
await this.conversacionService.guardarMensaje(
|
const estadosCierre = ['presupuesto', 'fin_viable', 'fin_no_viable'];
|
||||||
lead.id,
|
const anunciaPresupuesto =
|
||||||
'assistant',
|
/presupuesto/i.test(respuesta) &&
|
||||||
respuesta,
|
/prepar|recib|enseguida|en un momento|te lo env|lo env|aqu[ií] mismo/i.test(respuesta);
|
||||||
);
|
const esCierre = estadosCierre.includes(ctx.botStep) || anunciaPresupuesto;
|
||||||
|
|
||||||
// 8. Enviar respuesta por WhatsApp
|
// Al cerrar, dispara el post-análisis de toda la conversación (una sola vez).
|
||||||
|
if (esCierre && !this.leadsAnalizados.has(ctx.leadId)) {
|
||||||
|
this.leadsAnalizados.add(ctx.leadId);
|
||||||
|
this.api
|
||||||
|
.analizarConversacion(ctx.leadId)
|
||||||
|
.then((ok) => this.logger.log(`[ANALISIS] lead ${ctx.leadId}: ${ok ? 'ok' : 'fallo'}`))
|
||||||
|
.catch((e: any) => this.logger.error(`[ANALISIS] ${e.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
|
||||||
|
botStep: ctx.botStep,
|
||||||
|
});
|
||||||
await this.enviarMensaje(jid, respuesta);
|
await this.enviarMensaje(jid, respuesta);
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
// Tras cerrar, pide una foto para el render (si no la hemos pedido/recibido ya).
|
||||||
`Error procesando mensaje de ${telefono}: ${error.message}`,
|
if (esCierre && !this.esperandoFotos.has(ctx.leadId) && !this.pipelineDisparado.has(ctx.leadId)) {
|
||||||
error.stack,
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private mapearCampoALegacy(campo: string): string {
|
||||||
* Envía un mensaje de texto por WhatsApp.
|
const map: Record<string, string> = {
|
||||||
*/
|
espacio: 'espacio',
|
||||||
|
rango_m2: 'rangoM2',
|
||||||
|
estilo: 'estilo',
|
||||||
|
urgencia: 'urgencia',
|
||||||
|
presupuesto_declarado: 'presupuestoDeclarado',
|
||||||
|
nombre: 'nombre',
|
||||||
|
};
|
||||||
|
return map[campo] || campo;
|
||||||
|
}
|
||||||
|
|
||||||
async enviarMensaje(jid: string, texto: string): Promise<void> {
|
async enviarMensaje(jid: string, texto: string): Promise<void> {
|
||||||
if (!this.sock) {
|
if (!this.sock) return;
|
||||||
this.logger.error('Socket de WhatsApp no disponible');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await this.sock.sendMessage(jid, { text: texto });
|
const jidPresencia = jid.includes('@lid')
|
||||||
|
? `${jid.split('@')[0]}@s.whatsapp.net`
|
||||||
|
: jid;
|
||||||
|
await this.sock.sendPresenceUpdate('composing', jidPresencia);
|
||||||
|
await this.delay(this.calcularDelayEscritura(texto.length));
|
||||||
|
await this.sock.sendPresenceUpdate('paused', jidPresencia);
|
||||||
|
const safeSock = wrapSocket(this.sock);
|
||||||
|
await safeSock.sendMessage(jid, { text: texto });
|
||||||
this.logger.log(`Mensaje enviado a ${jid}`);
|
this.logger.log(`Mensaje enviado a ${jid}`);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
|
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Envía el mensaje de apertura de Luisa a un número de teléfono.
|
|
||||||
* Lo usa el Scheduler para disparar el primer contacto.
|
|
||||||
*/
|
|
||||||
async enviarApertura(telefono: string, mensajeApertura: string): Promise<void> {
|
|
||||||
const jid = `${telefono}@s.whatsapp.net`;
|
|
||||||
await this.enviarMensaje(jid, mensajeApertura);
|
|
||||||
}
|
|
||||||
|
|
||||||
isConectado(): boolean {
|
isConectado(): boolean {
|
||||||
return this.sock !== null;
|
return this.sock !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"noEmit": true
|
"noEmit": false
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": [
|
||||||
}
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
1
mvp/Whatsapp-bot/tsconfig.tsbuildinfo
Normal file
1
mvp/Whatsapp-bot/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1508,7 +1508,7 @@ h3, h4, h5, h6 {
|
|||||||
<a href="#faq">Preguntas</a>
|
<a href="#faq">Preguntas</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<a href="#" class="btn btn-ghost btn-sm">Entrar</a>
|
<a href="/login" class="btn btn-ghost btn-sm">Entrar</a>
|
||||||
<a href="/signup" class="btn btn-primary btn-sm">Empezar gratis</a>
|
<a href="/signup" class="btn btn-primary btn-sm">Empezar gratis</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1539,7 +1539,7 @@ h3, h4, h5, h6 {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hero visual: panel de leads mock -->
|
<!-- Hero visual: panel de leads mock -->
|
||||||
<div class="hero-visual reveal" id="demo" aria-label="Vista previa del panel de leads">
|
<div class="hero-visual reveal" aria-label="Vista previa del panel de leads">
|
||||||
<div class="mock-bar">
|
<div class="mock-bar">
|
||||||
<span class="mock-dot" style="background: var(--color-danger-500);"></span>
|
<span class="mock-dot" style="background: var(--color-danger-500);"></span>
|
||||||
<span class="mock-dot" style="background: var(--color-warning-500);"></span>
|
<span class="mock-dot" style="background: var(--color-warning-500);"></span>
|
||||||
@@ -1607,6 +1607,33 @@ h3, h4, h5, h6 {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================================
|
||||||
|
Demo en vídeo
|
||||||
|
============================================ -->
|
||||||
|
<section class="section" id="demo">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head center reveal">
|
||||||
|
<p class="section-kicker">En 2 minutos</p>
|
||||||
|
<h2 class="section-title">Míralo funcionando <em>de principio a fin</em>.</h2>
|
||||||
|
<p class="section-lede">Te enseñamos cómo Reformix atiende a tu cliente, calcula el presupuesto orientativo y te lo deja en el panel — sin que tú levantes el teléfono.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reveal" style="max-width: 900px; margin: 0 auto;">
|
||||||
|
<!-- Placeholder de vídeo: sustituir este bloque por un <iframe>/<video> cuando esté el vídeo. -->
|
||||||
|
<div style="position: relative; aspect-ratio: 16 / 9; border-radius: var(--radius-2xl); overflow: hidden; background: radial-gradient(120% 120% at 50% 0%, var(--color-neutral-900), var(--color-neutral-950)); border: 1px solid var(--color-neutral-200); box-shadow: var(--shadow-xl); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-4);">
|
||||||
|
<span style="display: inline-flex; align-items: center; justify-content: center; width: 76px; height: 76px; border-radius: var(--radius-full); background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.25);">
|
||||||
|
<svg width="30" height="30" viewBox="0 0 24 24" fill="#ffffff" aria-hidden="true"><path d="M8 5v14l11-7z"></path></svg>
|
||||||
|
</span>
|
||||||
|
<span style="color: rgba(255,255,255,0.85); font-size: var(--text-sm); letter-spacing: 0.04em; text-transform: uppercase;">Vídeo demo · próximamente</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reveal" style="text-align: center; margin-top: var(--space-8);">
|
||||||
|
<a href="/signup" class="btn btn-primary btn-lg">Empezar ahora</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ============================================
|
<!-- ============================================
|
||||||
Logo strip
|
Logo strip
|
||||||
============================================ -->
|
============================================ -->
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
|
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
|
||||||
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
|
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
|
||||||
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
|
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
|
||||||
|
|
||||||
|
# Retell.ai — agente de voz saliente del funnel B2C. OPCIONALES: sin ellas la llamada no se
|
||||||
|
# dispara y el pipeline sigue en modo simulado. El agente se crea a mano en el panel de Retell.
|
||||||
|
RETELL_API_KEY=""
|
||||||
|
RETELL_AGENT_ID=""
|
||||||
|
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
|
||||||
|
# Allowlist de pruebas: si tiene valor (CSV de E.164), SOLO se llama a esos números; el resto se
|
||||||
|
# omite (la llamada no se lanza). Vaciar para volver a llamar a todos. Ej: "+34651194617"
|
||||||
|
RETELL_ALLOWED_NUMBERS=""
|
||||||
|
|
||||||
|
# EP de ingesta del lead (/api/leads/:id/ingesta). Clave compartida que valida al llamante
|
||||||
|
# externo (Authorization: Bearer ...). Sin ella, el EP responde 401.
|
||||||
|
FUNNEL_API_KEY=""
|
||||||
|
|
||||||
|
# Email (SMTP) para enviar el presupuesto y el enlace al formulario. OPCIONALES: sin SMTP_HOST +
|
||||||
|
# EMAIL_FROM el envío degrada a no-op (la entrega queda marcada como simulada). Mailhog local:
|
||||||
|
# SMTP_HOST=localhost SMTP_PORT=1025 (sin user/pass).
|
||||||
|
SMTP_HOST=""
|
||||||
|
SMTP_PORT="587"
|
||||||
|
SMTP_USER=""
|
||||||
|
SMTP_PASS=""
|
||||||
|
EMAIL_FROM="" # remitente, p. ej. "Reformas Ejemplo <no-reply@reformix.es>"
|
||||||
|
|
||||||
|
# Webhooks salientes hacia el flujo externo (n8n/generador). OPCIONALES: sin URL la señal no se
|
||||||
|
# manda. PERFIL = perfil completo (generar renders/agente); WHATSAPP = entrega del PDF;
|
||||||
|
# WHATSAPP_START = arrancar la conversación de WhatsApp con el lead.
|
||||||
|
PERFIL_WEBHOOK_URL=""
|
||||||
|
WHATSAPP_WEBHOOK_URL=""
|
||||||
|
WHATSAPP_START_WEBHOOK_URL=""
|
||||||
|
|
||||||
|
# Base pública de la app, para construir enlaces absolutos (enlace al formulario en el email).
|
||||||
|
APP_URL="http://localhost:3000"
|
||||||
|
|||||||
6
mvp/b2c/.gitignore
vendored
6
mvp/b2c/.gitignore
vendored
@@ -40,3 +40,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
|
||||||
|
api-docs/reformix-ingesta.postman_collection.json
|
||||||
|
|
||||||
|
# Logs locales del dev server
|
||||||
|
dev.log
|
||||||
|
|||||||
279
mvp/b2c/api-docs/README.md
Normal file
279
mvp/b2c/api-docs/README.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# API — Ingesta del perfil del lead
|
||||||
|
|
||||||
|
Endpoint único y asíncrono para enriquecer el perfil de un lead del funnel B2C. Cada llamada puede
|
||||||
|
traer **imágenes, imagen+texto o solo texto**, etiquetado por **zona** y, en fotos, por **momento**
|
||||||
|
(antes/después). Lo usan tanto el formulario web como los flujos externos (agente de llamada, bot de
|
||||||
|
WhatsApp, generador de renders).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/leads/:id/ingesta
|
||||||
|
```
|
||||||
|
|
||||||
|
- `:id` = UUID del lead (el que crea el funnel al capturar nombre/teléfono/email).
|
||||||
|
- **Auth:** header `Authorization: Bearer <FUNNEL_API_KEY>`. Sin él, o con clave incorrecta → `401`.
|
||||||
|
- **Content-Type:** `application/json`.
|
||||||
|
|
||||||
|
## Cuerpo (JSON)
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `items` | array | Lista de items `foto` o `texto`. Por defecto `[]`. |
|
||||||
|
| `perfilCompleto` | boolean | Opcional. `true` señala al flujo externo que genere renders/agente. |
|
||||||
|
| `finalizar` | boolean | Opcional. `true` construye el PDF y lo entrega (email + señal WhatsApp). |
|
||||||
|
|
||||||
|
Debe llegar **al menos un item o un flag**; una llamada totalmente vacía → `422`.
|
||||||
|
|
||||||
|
### Item `foto`
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tipo` | `"foto"` | Obligatorio. |
|
||||||
|
| `imagen` | string | Obligatorio. Data URI (`data:image/...;base64,...`) o URL `http(s)`. |
|
||||||
|
| `zona` | enum | Opcional: `cocina`,`bano`,`salon`,`comedor`,`integral`,`otro`. Si falta, se asume el tipo de reforma del lead. |
|
||||||
|
| `momento` | `"antes"`\|`"despues"` | Por defecto `"antes"`. Los renders del flujo externo se mandan como `despues`. |
|
||||||
|
| `orden` | number | Opcional. Si falta, continúa el máximo actual del lead. |
|
||||||
|
|
||||||
|
### Item `texto`
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tipo` | `"texto"` | Obligatorio. |
|
||||||
|
| `texto` | string | Obligatorio, no vacío. Ej. `"suelo premium"`. |
|
||||||
|
| `zona` | enum | Opcional (mismo enum que en `foto`). |
|
||||||
|
|
||||||
|
## Respuesta
|
||||||
|
|
||||||
|
`200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"fotos": 1,
|
||||||
|
"notas": 1,
|
||||||
|
"perfilSenalado": false,
|
||||||
|
"finalizado": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `fotos` / `notas`: cuántos items de cada tipo se guardaron.
|
||||||
|
- `perfilSenalado`: `true` si `perfilCompleto` disparó el webhook y respondió ok.
|
||||||
|
- `finalizado`: `null` si no se pidió `finalizar`; si sí,
|
||||||
|
`{ ok, emailEnviado, whatsappSenal }`.
|
||||||
|
|
||||||
|
### Códigos de error
|
||||||
|
|
||||||
|
| Código | Cuándo |
|
||||||
|
| --- | --- |
|
||||||
|
| `401` | Falta `Authorization: Bearer`, o la clave no coincide con `FUNNEL_API_KEY`. |
|
||||||
|
| `404` | El lead `:id` no existe. |
|
||||||
|
| `422` | JSON inválido, item mal formado, o llamada vacía (sin items ni flag). |
|
||||||
|
|
||||||
|
## Ejemplos (curl)
|
||||||
|
|
||||||
|
Sustituye `LEAD`, `KEY` y el host según tu entorno.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Solo texto: añade un dato a la zona baño
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"items":[{"tipo":"texto","zona":"bano","texto":"suelo premium"}]}'
|
||||||
|
|
||||||
|
# 2) Solo imagen (antes)
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"items":[{"tipo":"foto","zona":"bano","imagen":"https://cdn/ejemplo/bano-antes.jpg"}]}'
|
||||||
|
|
||||||
|
# 3) Imagen + texto en la misma llamada
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"items":[
|
||||||
|
{"tipo":"foto","zona":"cocina","imagen":"data:image/jpeg;base64,..."},
|
||||||
|
{"tipo":"texto","zona":"cocina","texto":"encimera de cuarzo"}
|
||||||
|
]}'
|
||||||
|
|
||||||
|
# 4) Perfil completo: que el flujo externo genere renders / corra el agente
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"perfilCompleto":true}'
|
||||||
|
|
||||||
|
# 5) Devolver renders "después" y finalizar (genera PDF + email + señal WhatsApp)
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/ingesta" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"items":[{"tipo":"foto","zona":"cocina","momento":"despues","imagen":"https://cdn/render-cocina.jpg"}],"finalizar":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhooks salientes (hacia el flujo externo)
|
||||||
|
|
||||||
|
La app **emite** estas señales; el flujo externo (n8n/generador) las recibe y, cuando toca,
|
||||||
|
devuelve los resultados llamando de nuevo a este mismo EP (las "después" con `finalizar:true`).
|
||||||
|
Cada webhook es opcional: sin su URL en el entorno, la señal simplemente no se manda.
|
||||||
|
|
||||||
|
### `PERFIL_WEBHOOK_URL` — perfil completo
|
||||||
|
|
||||||
|
Disparado por `perfilCompleto:true`. Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"leadId": "uuid",
|
||||||
|
"cliente": { "nombre": "...", "telefono": "...", "email": "...", "provincia": "..." },
|
||||||
|
"reforma": { "tipo": "cocina", "m2Suelo": 12, "calidad": "media",
|
||||||
|
"estructural": false, "urgencia": "media", "presupuestoTarget": 800000 },
|
||||||
|
"empresa": { "tenantId": "uuid", "nombre": "Reformas Ejemplo" },
|
||||||
|
"zonas": [
|
||||||
|
{ "zona": "cocina", "notas": ["encimera de cuarzo"],
|
||||||
|
"fotos": { "antes": ["url", "..."], "despues": [] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> El flujo externo genera los renders y los devuelve como items `foto` con `momento:"despues"`
|
||||||
|
> por `POST /api/leads/:id/ingesta`, y cierra con `finalizar:true`.
|
||||||
|
|
||||||
|
### `WHATSAPP_WEBHOOK_URL` — entrega del PDF
|
||||||
|
|
||||||
|
Disparado por `finalizar:true`. Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"leadId": "uuid",
|
||||||
|
"telefono": "+34...",
|
||||||
|
"nombre": "...",
|
||||||
|
"empresa": "Reformas Ejemplo",
|
||||||
|
"pdfBase64": "JVBERi0xLj...",
|
||||||
|
"filename": "presupuesto-nombre.pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WHATSAPP_START_WEBHOOK_URL` — arranque de conversación
|
||||||
|
|
||||||
|
Disparado cuando el lead elige continuar por WhatsApp en el funnel. Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "leadId": "uuid", "telefono": "+34...", "nombre": "...", "empresa": "Reformas Ejemplo" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API — EPs del bot de WhatsApp (Luisa)
|
||||||
|
|
||||||
|
El bot de WhatsApp es externo y **no toca Postgres directamente**: puebla la BD vía estos EPs HTTP.
|
||||||
|
Todos comparten la misma auth que la ingesta (`Authorization: Bearer <FUNNEL_API_KEY>`),
|
||||||
|
`Content-Type: application/json`, y `:id` = UUID del lead. Errores comunes:
|
||||||
|
|
||||||
|
| Código | Cuándo |
|
||||||
|
| --- | --- |
|
||||||
|
| `401` | Falta `Authorization: Bearer`, o la clave no coincide con `FUNNEL_API_KEY`. |
|
||||||
|
| `404` | El lead `:id` no existe. |
|
||||||
|
| `422` | JSON inválido, o cuerpo que no pasa validación. |
|
||||||
|
|
||||||
|
## `POST /api/leads/:id/conversacion`
|
||||||
|
|
||||||
|
Añade **un turno** del chat al historial (`conversacion_whatsapp`) y, opcionalmente, actualiza el
|
||||||
|
estado del mensaje y el paso del bot en el lead.
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `rol` | `"user"`\|`"assistant"`\|`"system"` | Obligatorio. |
|
||||||
|
| `mensaje` | string | Obligatorio, no vacío. |
|
||||||
|
| `mediaType` | string | Opcional (ej. `"image"`, `"audio"`). |
|
||||||
|
| `mediaUrl` | string | Opcional. |
|
||||||
|
| `transcripcionAudio` | string | Opcional (transcripción de una nota de voz). |
|
||||||
|
| `estadoWa` | enum | Opcional: `sin_enviar`,`enviado`,`entregado`,`leido`,`fallido`. Actualiza `leads.estado_wa`. |
|
||||||
|
| `botStep` | string | Opcional. Actualiza `leads.bot_step` (texto libre, ej. `pide_fotos`). |
|
||||||
|
|
||||||
|
Respuesta `200`: `{ "ok": true, "id": "<uuid del turno>" }`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/conversacion" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"rol":"user","mensaje":"Quiero reformar la cocina","estadoWa":"leido","botStep":"espacio"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## `POST /api/leads/:id/perfil`
|
||||||
|
|
||||||
|
Actualización **parcial** del lead con lo que el bot va extrayendo. Solo escribe los campos
|
||||||
|
enviados; el cuerpo debe traer **al menos uno**.
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `botStep` | string | Paso del bot. |
|
||||||
|
| `estadoWa` | enum | `sin_enviar`,`enviado`,`entregado`,`leido`,`fallido`. |
|
||||||
|
| `canalOrigen` | enum | `formulario_web`,`whatsapp`,`llamada`,`referido`,`anuncio`. |
|
||||||
|
| `viable` | boolean | Si el lead es viable. |
|
||||||
|
| `espacio` | string | Extracción cruda del espacio. |
|
||||||
|
| `rangoM2` | string | Rango de m² en crudo. |
|
||||||
|
| `estilo` | string | Estilo en crudo. |
|
||||||
|
| `presupuestoDeclarado` | string | Presupuesto en crudo. |
|
||||||
|
| `fotosSolicitadasAt` | string (ISO datetime) | Cuándo se pidieron las fotos. |
|
||||||
|
| `tipoReforma` | enum | Normalizado: `cocina`,`bano`,`salon`,`comedor`,`integral`,`otro`. |
|
||||||
|
| `m2Suelo` | number (>0) | Normalizado. |
|
||||||
|
| `calidadGlobal` | enum | `basica`,`media`,`premium`. |
|
||||||
|
| `urgencia` | enum | `alta`,`media`,`baja`. |
|
||||||
|
| `presupuestoTarget` | number (int ≥0) | Normalizado, en **céntimos**. |
|
||||||
|
| `tasteText` | string | Texto libre de preferencias. |
|
||||||
|
| `estructural` | boolean | Si hay obra estructural. |
|
||||||
|
|
||||||
|
Respuesta `200`: `{ "ok": true, "actualizado": ["tipoReforma","m2Suelo"] }`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/perfil" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"tipoReforma":"cocina","m2Suelo":12.5,"calidadGlobal":"premium","urgencia":"alta","viable":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## `POST /api/leads/:id/calificacion`
|
||||||
|
|
||||||
|
**Upsert** de la calificación del lead (una por lead). Recalculable: `onConflict` actualiza la fila
|
||||||
|
existente. Cuerpo con **al menos un campo**.
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `score` | number (int 0-100) | Opcional. |
|
||||||
|
| `nivel` | `"A"`\|`"B"`\|`"C"`\|`"D"` | Opcional. |
|
||||||
|
| `criterios` | objeto/JSON libre | Opcional (desglose de criterios). |
|
||||||
|
| `notasAgente` | string | Opcional. |
|
||||||
|
|
||||||
|
Respuesta `200`: `{ "ok": true }`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/calificacion" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"score":78,"nivel":"B","criterios":{"presupuesto":"ok","urgencia":"media"},"notasAgente":"Lead caliente"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## `POST /api/leads/:id/intento`
|
||||||
|
|
||||||
|
Registra un intento de contacto (`intentos_contacto`).
|
||||||
|
|
||||||
|
| Campo | Tipo | Notas |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `canal` | `"formulario"`\|`"whatsapp"`\|`"llamada"` | Obligatorio. |
|
||||||
|
| `numeroIntento` | number (int ≥1) | Obligatorio. |
|
||||||
|
| `resultado` | enum | Opcional: `exitoso`,`no_contesta`,`ocupado`,`rechaza`,`error_tecnico`. |
|
||||||
|
| `completado` | boolean | Opcional (por defecto `false`). |
|
||||||
|
| `duracionSeg` | number (int ≥0) | Opcional. |
|
||||||
|
| `notas` | string | Opcional. |
|
||||||
|
| `metadata` | objeto/JSON libre | Opcional (ej. `{ "retellCallId": "call_123" }`). |
|
||||||
|
|
||||||
|
Respuesta `200`: `{ "ok": true, "id": "<uuid del intento>" }`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$HOST/api/leads/$LEAD/intento" \
|
||||||
|
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"canal":"whatsapp","numeroIntento":1,"resultado":"exitoso","completado":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> `visitas` y `worker_jobs` quedan fuera de estos EPs: son cola interna / panel del reformista, no
|
||||||
|
> los puebla el bot por API. Si el flujo externo necesita escribirlos, se abre como decisión aparte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- **Storage:** las imágenes se guardan tal cual se reciben (data URI o URL) en `lead_fotos.url`;
|
||||||
|
las notas en `lead_notas`. Ver `mvp/b2c/db-schema/`.
|
||||||
|
- **Email:** la entrega por email es real vía SMTP (`SMTP_*` + `EMAIL_FROM`). Sin configurar,
|
||||||
|
`finalizado.emailEnviado` será `false` y el evento queda marcado como simulado.
|
||||||
|
- Variables de entorno: ver `mvp/b2c/.env.example`.
|
||||||
107
mvp/b2c/api-docs/smoke-bot-eps.mjs
Normal file
107
mvp/b2c/api-docs/smoke-bot-eps.mjs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// Smoke test de los EPs del bot de WhatsApp contra un entorno real.
|
||||||
|
// Crea un lead de PRUEBA, ejerce los 4 EPs por HTTP, verifica las escrituras en la BD y borra
|
||||||
|
// el lead (el cascade limpia las filas hijas). No toca leads reales.
|
||||||
|
//
|
||||||
|
// Uso (PowerShell):
|
||||||
|
// $env:DATABASE_URL="postgres://..."; $env:BASE_URL="https://reformix.dv3.com.es"; $env:FUNNEL_API_KEY="..."
|
||||||
|
// node mvp/b2c/api-docs/smoke-bot-eps.mjs
|
||||||
|
//
|
||||||
|
// DATABASE_URL solo hace falta para crear/verificar/limpiar el lead de prueba; el bot real
|
||||||
|
// únicamente necesita BASE_URL + FUNNEL_API_KEY para usar los EPs.
|
||||||
|
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
const { DATABASE_URL, BASE_URL, FUNNEL_API_KEY } = process.env;
|
||||||
|
if (!DATABASE_URL || !BASE_URL || !FUNNEL_API_KEY) {
|
||||||
|
console.error('Faltan env: DATABASE_URL, BASE_URL y FUNNEL_API_KEY son obligatorias.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(DATABASE_URL, { prepare: false });
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const r = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${FUNNEL_API_KEY}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const json = await r.json().catch(() => null);
|
||||||
|
return { status: r.status, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
let leadId;
|
||||||
|
let ok = true;
|
||||||
|
const check = (label, cond, extra) => {
|
||||||
|
ok = ok && cond;
|
||||||
|
console.log(`${cond ? 'OK ' : 'FAIL'} ${label}${extra ? ` ${extra}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tenant] = await sql`select id, nombre from tenants limit 1`;
|
||||||
|
if (!tenant) throw new Error('No hay tenants en la BD.');
|
||||||
|
console.log(`tenant: ${tenant.nombre} (${tenant.id})`);
|
||||||
|
|
||||||
|
const inserted = await sql`
|
||||||
|
insert into leads (tenant_id, nombre, telefono, email, tipo_reforma, consent_privacidad, consent_contratacion)
|
||||||
|
values (${tenant.id}, 'TEST EP Bot (smoke)', '+34600000000', 'smoke-bot-ep@example.com', 'cocina', true, true)
|
||||||
|
returning id`;
|
||||||
|
leadId = inserted[0].id;
|
||||||
|
console.log(`lead de prueba creado: ${leadId}\n`);
|
||||||
|
|
||||||
|
const r1 = await api(`/api/leads/${leadId}/conversacion`, {
|
||||||
|
rol: 'user', mensaje: 'Quiero reformar la cocina', estadoWa: 'leido', botStep: 'espacio',
|
||||||
|
});
|
||||||
|
check('POST conversacion → 200', r1.status === 200, JSON.stringify(r1.json));
|
||||||
|
|
||||||
|
const r2 = await api(`/api/leads/${leadId}/perfil`, {
|
||||||
|
tipoReforma: 'cocina', m2Suelo: 12.5, calidadGlobal: 'premium', urgencia: 'alta', viable: true, botStep: 'tamano',
|
||||||
|
});
|
||||||
|
check('POST perfil → 200', r2.status === 200, JSON.stringify(r2.json));
|
||||||
|
|
||||||
|
const r3a = await api(`/api/leads/${leadId}/calificacion`, { score: 60, nivel: 'C', criterios: { fase: 'inicial' } });
|
||||||
|
check('POST calificacion #1 (insert) → 200', r3a.status === 200, JSON.stringify(r3a.json));
|
||||||
|
const r3b = await api(`/api/leads/${leadId}/calificacion`, { score: 82, nivel: 'A', notasAgente: 'Lead caliente' });
|
||||||
|
check('POST calificacion #2 (upsert) → 200', r3b.status === 200, JSON.stringify(r3b.json));
|
||||||
|
|
||||||
|
const r4 = await api(`/api/leads/${leadId}/intento`, {
|
||||||
|
canal: 'whatsapp', numeroIntento: 1, resultado: 'exitoso', completado: true, metadata: { origen: 'smoke' },
|
||||||
|
});
|
||||||
|
check('POST intento → 200', r4.status === 200, JSON.stringify(r4.json));
|
||||||
|
|
||||||
|
console.log('\n— verificación en BD —');
|
||||||
|
const conv = await sql`select rol, mensaje from conversacion_whatsapp where lead_id=${leadId}`;
|
||||||
|
check('conversacion_whatsapp: 1 turno', conv.length === 1, JSON.stringify(conv));
|
||||||
|
|
||||||
|
const [lead] = await sql`
|
||||||
|
select tipo_reforma, m2_suelo, calidad_global, urgencia, viable, bot_step, estado_wa from leads where id=${leadId}`;
|
||||||
|
check(
|
||||||
|
'leads(perfil): cocina/12.5/premium/alta/viable + bot_step=tamano + estado_wa=leido',
|
||||||
|
lead.tipo_reforma === 'cocina' && Number(lead.m2_suelo) === 12.5 && lead.calidad_global === 'premium' &&
|
||||||
|
lead.urgencia === 'alta' && lead.viable === true && lead.bot_step === 'tamano' && lead.estado_wa === 'leido',
|
||||||
|
JSON.stringify(lead),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calif = await sql`select score, nivel, notas_agente from lead_calificacion where lead_id=${leadId}`;
|
||||||
|
check(
|
||||||
|
'lead_calificacion: 1 fila tras upsert, score=82 nivel=A notas conservadas',
|
||||||
|
calif.length === 1 && calif[0].score === 82 && calif[0].nivel === 'A' && calif[0].notas_agente === 'Lead caliente',
|
||||||
|
JSON.stringify(calif),
|
||||||
|
);
|
||||||
|
|
||||||
|
const intentos = await sql`select canal, resultado, completado, numero_intento from intentos_contacto where lead_id=${leadId}`;
|
||||||
|
check(
|
||||||
|
'intentos_contacto: 1 intento whatsapp exitoso completado',
|
||||||
|
intentos.length === 1 && intentos[0].canal === 'whatsapp' && intentos[0].resultado === 'exitoso' &&
|
||||||
|
intentos[0].completado === true && intentos[0].numero_intento === 1,
|
||||||
|
JSON.stringify(intentos),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (leadId) {
|
||||||
|
await sql`delete from leads where id=${leadId}`;
|
||||||
|
console.log(`\nlead de prueba borrado: ${leadId}`);
|
||||||
|
}
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${ok ? 'TODO OK ✅' : 'HAY FALLOS ❌'}`);
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
53
mvp/b2c/db-schema/README.md
Normal file
53
mvp/b2c/db-schema/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Esquema de la base de datos — Reformix B2C
|
||||||
|
|
||||||
|
`schema.sql` es el **DDL consolidado** de toda la base de datos en su estado actual:
|
||||||
|
12 enums, 14 tablas, sus claves foráneas e índices. Sirve para que el equipo entienda,
|
||||||
|
consulte y proponga cambios sobre el modelo de datos sin tener que leer las migraciones
|
||||||
|
una a una.
|
||||||
|
|
||||||
|
## Tablas
|
||||||
|
|
||||||
|
| Tabla | Para qué |
|
||||||
|
| --- | --- |
|
||||||
|
| `tenants` | Reformistas (multi-tenant; en el MVP solo "Reformas Ejemplo") |
|
||||||
|
| `plans` | Planes de suscripción |
|
||||||
|
| `users` / `sessions` | Auth del panel del reformista |
|
||||||
|
| `leads` | Lead del cliente final + estado del funnel + resultado (presupuesto, render, transcripción) |
|
||||||
|
| `lead_fotos` | Fotos que sube el cliente del espacio a reformar |
|
||||||
|
| `lead_pipeline_eventos` | Traza de cada paso del pipeline (prellamada, llamada, render, presupuesto, WhatsApp) |
|
||||||
|
| `lead_estado_history` | Historial de cambios de estado comercial del lead |
|
||||||
|
| `catalog_items` | Catálogo de materiales del reformista (precio, calidad, unidad) — entrada del motor de presupuesto |
|
||||||
|
| `pricing_config` | Config de precios del reformista (mano de obra, márgenes…) |
|
||||||
|
| `precision_history` | Histórico de precisión de las estimaciones |
|
||||||
|
| `galeria_fotos` | Galería de trabajos del reformista |
|
||||||
|
| `testimonios` / `testimonio_fotos` | Reseñas con fotos |
|
||||||
|
|
||||||
|
## Importante: la fuente de la verdad es `src/db/schema.ts`
|
||||||
|
|
||||||
|
**No edites `schema.sql` a mano.** El esquema real vive en [`src/db/schema.ts`](../src/db/schema.ts)
|
||||||
|
(Drizzle ORM) y los cambios se aplican con migraciones en [`drizzle/`](../drizzle/).
|
||||||
|
Este archivo es una **foto** generada a partir de ese schema.
|
||||||
|
|
||||||
|
### Para cambiar el modelo de datos
|
||||||
|
|
||||||
|
1. Edita `src/db/schema.ts`.
|
||||||
|
2. Genera la migración: `npx drizzle-kit generate`
|
||||||
|
3. Aplícala: `npx drizzle-kit migrate`
|
||||||
|
4. Regenera esta foto (ver abajo).
|
||||||
|
|
||||||
|
### Regenerar `schema.sql`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mvp/b2c
|
||||||
|
npm run db:export
|
||||||
|
```
|
||||||
|
|
||||||
|
### Levantar una base de datos local desde cero con este SQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
|
||||||
|
psql "postgresql://postgres:reformix@localhost:5432/reformix" -f db-schema/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
> El `export` no incluye datos semilla (tenant de ejemplo, catálogo, planes). Para eso usa
|
||||||
|
> el seed del proyecto si existe, o inserta los registros base manualmente.
|
||||||
330
mvp/b2c/db-schema/schema.sql
Normal file
330
mvp/b2c/db-schema/schema.sql
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');
|
||||||
|
CREATE TYPE "public"."canal_contacto" AS ENUM('formulario', 'whatsapp', 'llamada');
|
||||||
|
CREATE TYPE "public"."canal_origen" AS ENUM('formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio');
|
||||||
|
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');
|
||||||
|
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');
|
||||||
|
CREATE TYPE "public"."estado_wa" AS ENUM('sin_enviar', 'enviado', 'entregado', 'leido', 'fallido');
|
||||||
|
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');
|
||||||
|
CREATE TYPE "public"."job_estado" AS ENUM('pendiente', 'procesando', 'completado', 'error');
|
||||||
|
CREATE TYPE "public"."job_tipo" AS ENUM('analisis_fotos', 'render', 'presupuesto_ia');
|
||||||
|
CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido');
|
||||||
|
CREATE TYPE "public"."nivel_calificacion" AS ENUM('A', 'B', 'C', 'D');
|
||||||
|
CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado');
|
||||||
|
CREATE TYPE "public"."resultado_contacto" AS ENUM('exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico');
|
||||||
|
CREATE TYPE "public"."rol_mensaje" AS ENUM('user', 'assistant', 'system');
|
||||||
|
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');
|
||||||
|
CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto');
|
||||||
|
CREATE TYPE "public"."tipo_reforma" AS ENUM('cocina', 'bano', 'salon', 'comedor', 'integral', 'otro');
|
||||||
|
CREATE TYPE "public"."unidad_medida" AS ENUM('m2', 'ml', 'ud');
|
||||||
|
CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja');
|
||||||
|
CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin');
|
||||||
|
CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado');
|
||||||
|
CREATE TYPE "public"."visita_estado" AS ENUM('propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada');
|
||||||
|
CREATE TABLE "catalog_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"categoria" "categoria_material" NOT NULL,
|
||||||
|
"nombre" text NOT NULL,
|
||||||
|
"calidad" "calidad" NOT NULL,
|
||||||
|
"precio_unit" integer NOT NULL,
|
||||||
|
"unidad" "unidad_medida" NOT NULL,
|
||||||
|
"descriptor_render" text DEFAULT '' NOT NULL,
|
||||||
|
"es_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"sku" text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "conversacion_whatsapp" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"rol" "rol_mensaje" NOT NULL,
|
||||||
|
"mensaje" text NOT NULL,
|
||||||
|
"media_type" text,
|
||||||
|
"media_url" text,
|
||||||
|
"transcripcion_audio" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "galeria_fotos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"titulo" text,
|
||||||
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "intentos_contacto" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"canal" "canal_contacto" NOT NULL,
|
||||||
|
"resultado" "resultado_contacto",
|
||||||
|
"completado" boolean DEFAULT false NOT NULL,
|
||||||
|
"numero_intento" integer NOT NULL,
|
||||||
|
"duracion_seg" integer,
|
||||||
|
"intentado_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"notas" text,
|
||||||
|
"metadata" jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "lead_calificacion" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"score" integer,
|
||||||
|
"nivel" "nivel_calificacion",
|
||||||
|
"criterios" jsonb,
|
||||||
|
"notas_agente" text,
|
||||||
|
"calificado_por" uuid,
|
||||||
|
"calificado_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "lead_calificacion_lead_id_unique" UNIQUE("lead_id"),
|
||||||
|
CONSTRAINT "lead_calificacion_score_check" CHECK ("lead_calificacion"."score" >= 0 AND "lead_calificacion"."score" <= 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "lead_estado_history" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"estado" "lead_estado" NOT NULL,
|
||||||
|
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"changed_by" text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "lead_fotos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"momento" "foto_momento" DEFAULT 'antes' NOT NULL,
|
||||||
|
"zona" "tipo_reforma",
|
||||||
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "lead_notas" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"zona" "tipo_reforma",
|
||||||
|
"texto" text NOT NULL,
|
||||||
|
"origen" text DEFAULT 'ep' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "lead_pipeline_eventos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"stage" "pipeline_stage" NOT NULL,
|
||||||
|
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"metadata" jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "leads" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"nombre" text NOT NULL,
|
||||||
|
"telefono" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"provincia" text,
|
||||||
|
"tipo_reforma" "tipo_reforma",
|
||||||
|
"consent_privacidad" boolean DEFAULT false NOT NULL,
|
||||||
|
"consent_contratacion" boolean DEFAULT false NOT NULL,
|
||||||
|
"pipeline_stage" "pipeline_stage" DEFAULT 'form_completado' NOT NULL,
|
||||||
|
"estado" "lead_estado" DEFAULT 'nuevo' NOT NULL,
|
||||||
|
"presupuesto_estimado" integer,
|
||||||
|
"transcripcion" text,
|
||||||
|
"entidades" jsonb,
|
||||||
|
"render_url" text,
|
||||||
|
"pdf_url" text,
|
||||||
|
"audio_url" text,
|
||||||
|
"notas" text,
|
||||||
|
"testimonio_solicitado_at" timestamp with time zone,
|
||||||
|
"m2_suelo" double precision,
|
||||||
|
"altura_techo" double precision,
|
||||||
|
"calidad_global" "calidad",
|
||||||
|
"estructural" boolean DEFAULT false NOT NULL,
|
||||||
|
"anterior_a_2000" boolean DEFAULT false NOT NULL,
|
||||||
|
"cambio_distribucion" boolean DEFAULT false NOT NULL,
|
||||||
|
"material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"desglose_snapshot" jsonb,
|
||||||
|
"urgencia" "urgencia",
|
||||||
|
"presupuesto_target" integer,
|
||||||
|
"taste_text" text,
|
||||||
|
"preferences_snapshot" jsonb,
|
||||||
|
"estado_wa" "estado_wa",
|
||||||
|
"bot_step" text,
|
||||||
|
"canal_origen" "canal_origen",
|
||||||
|
"espacio" text,
|
||||||
|
"rango_m2" text,
|
||||||
|
"estilo" text,
|
||||||
|
"presupuesto_declarado" text,
|
||||||
|
"viable" boolean,
|
||||||
|
"fotos_solicitadas_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "plans" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"nombre" text NOT NULL,
|
||||||
|
"precio_mensual" integer NOT NULL,
|
||||||
|
"leads_incluidos" integer NOT NULL,
|
||||||
|
"features" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"activo" boolean DEFAULT true NOT NULL,
|
||||||
|
CONSTRAINT "plans_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "precision_history" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"estimated" integer NOT NULL,
|
||||||
|
"final" integer NOT NULL,
|
||||||
|
"delta_pct" numeric(6, 2) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "pricing_config" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"altura_techo_default" double precision DEFAULT 2.5 NOT NULL,
|
||||||
|
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL,
|
||||||
|
"baremo_minimo" integer,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tenants" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"nombre_empresa" text NOT NULL,
|
||||||
|
"logo_url" text,
|
||||||
|
"provincia" text,
|
||||||
|
"whatsapp_business" text,
|
||||||
|
"seo_title" text,
|
||||||
|
"seo_description" text,
|
||||||
|
"about_enabled" boolean DEFAULT false NOT NULL,
|
||||||
|
"about_foto_url" text,
|
||||||
|
"about_texto" text,
|
||||||
|
"anios_experiencia" integer,
|
||||||
|
"theme_preset" text DEFAULT 'pizarra' NOT NULL,
|
||||||
|
"theme_color" text,
|
||||||
|
"cif" text,
|
||||||
|
"direccion" text,
|
||||||
|
"telefono" text,
|
||||||
|
"email" text,
|
||||||
|
"web" text,
|
||||||
|
"plan_id" uuid,
|
||||||
|
"subscription_status" "subscription_status" DEFAULT 'trial' NOT NULL,
|
||||||
|
"envio_presupuesto" "envio_presupuesto_mode" DEFAULT 'automatico' NOT NULL,
|
||||||
|
"trial_ends_at" timestamp with time zone,
|
||||||
|
"stripe_customer_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tenants_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "testimonio_fotos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"testimonio_id" uuid NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "testimonios" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"lead_id" uuid,
|
||||||
|
"nombre" text NOT NULL,
|
||||||
|
"contexto" text,
|
||||||
|
"rating" integer NOT NULL,
|
||||||
|
"texto" text NOT NULL,
|
||||||
|
"estado" "testimonio_estado" DEFAULT 'pendiente' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"password_hash" text NOT NULL,
|
||||||
|
"nombre" text,
|
||||||
|
"role" "user_role" DEFAULT 'reformista' NOT NULL,
|
||||||
|
"tenant_id" uuid,
|
||||||
|
"status" "user_status" DEFAULT 'activo' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "visitas" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"fecha_propuesta" timestamp with time zone,
|
||||||
|
"fecha_confirmada" timestamp with time zone,
|
||||||
|
"estado" "visita_estado" DEFAULT 'propuesta' NOT NULL,
|
||||||
|
"direccion" text,
|
||||||
|
"notas" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "worker_jobs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"tipo" "job_tipo" NOT NULL,
|
||||||
|
"estado_job" "job_estado" DEFAULT 'pendiente' NOT NULL,
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"webhook_url" text,
|
||||||
|
"resultado_url" text,
|
||||||
|
"intentos" integer DEFAULT 0 NOT NULL,
|
||||||
|
"error_msg" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "conversacion_whatsapp" ADD CONSTRAINT "conversacion_whatsapp_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "intentos_contacto" ADD CONSTRAINT "intentos_contacto_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_calificado_por_users_id_fk" FOREIGN KEY ("calificado_por") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "lead_pipeline_eventos" ADD CONSTRAINT "lead_pipeline_eventos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "precision_history" ADD CONSTRAINT "precision_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "pricing_config" ADD CONSTRAINT "pricing_config_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_testimonios_id_fk" FOREIGN KEY ("testimonio_id") REFERENCES "public"."testimonios"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "worker_jobs" ADD CONSTRAINT "worker_jobs_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id");
|
||||||
|
CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku");
|
||||||
|
CREATE INDEX "idx_conversacion_whatsapp_lead_id" ON "conversacion_whatsapp" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "idx_conversacion_whatsapp_created_at" ON "conversacion_whatsapp" USING btree ("created_at");
|
||||||
|
CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id");
|
||||||
|
CREATE INDEX "idx_intentos_contacto_lead_id" ON "intentos_contacto" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "idx_lead_calificacion_lead_id" ON "lead_calificacion" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "leads_tenant_created_idx" ON "leads" USING btree ("tenant_id","created_at");
|
||||||
|
CREATE INDEX "leads_estado_idx" ON "leads" USING btree ("estado");
|
||||||
|
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");
|
||||||
|
CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado");
|
||||||
|
CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id");
|
||||||
|
CREATE INDEX "idx_visitas_lead_id" ON "visitas" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "idx_visitas_tenant_id" ON "visitas" USING btree ("tenant_id");
|
||||||
|
CREATE INDEX "idx_worker_jobs_lead_id" ON "worker_jobs" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "idx_worker_jobs_estado" ON "worker_jobs" USING btree ("estado_job");
|
||||||
|
CREATE INDEX "idx_worker_jobs_tipo" ON "worker_jobs" USING btree ("tipo");
|
||||||
33
mvp/b2c/drizzle/0006_aspiring_susan_delgado.sql
Normal file
33
mvp/b2c/drizzle/0006_aspiring_susan_delgado.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto');--> statement-breakpoint
|
||||||
|
CREATE TABLE "testimonio_fotos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"testimonio_id" uuid NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "testimonios" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"lead_id" uuid,
|
||||||
|
"nombre" text NOT NULL,
|
||||||
|
"contexto" text,
|
||||||
|
"rating" integer NOT NULL,
|
||||||
|
"texto" text NOT NULL,
|
||||||
|
"estado" "testimonio_estado" DEFAULT 'pendiente' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "testimonio_solicitado_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "seo_title" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "seo_description" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "about_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "about_foto_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "about_texto" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "anios_experiencia" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_testimonios_id_fk" FOREIGN KEY ("testimonio_id") REFERENCES "public"."testimonios"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado");--> statement-breakpoint
|
||||||
|
CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id");
|
||||||
13
mvp/b2c/drizzle/0007_pale_chat.sql
Normal file
13
mvp/b2c/drizzle/0007_pale_chat.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "galeria_fotos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"titulo" text,
|
||||||
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "theme_preset" text DEFAULT 'pizarra' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "theme_color" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id");
|
||||||
13
mvp/b2c/drizzle/0008_sharp_bloodaxe.sql
Normal file
13
mvp/b2c/drizzle/0008_sharp_bloodaxe.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TYPE "public"."foto_momento" AS ENUM('antes', 'despues');--> statement-breakpoint
|
||||||
|
CREATE TABLE "lead_notas" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"zona" "tipo_reforma",
|
||||||
|
"texto" text NOT NULL,
|
||||||
|
"origen" text DEFAULT 'ep' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "lead_fotos" ADD COLUMN "momento" "foto_momento" DEFAULT 'antes' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lead_fotos" ADD COLUMN "zona" "tipo_reforma";--> statement-breakpoint
|
||||||
|
ALTER TABLE "lead_notas" ADD CONSTRAINT "lead_notas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
3
mvp/b2c/drizzle/0009_white_agent_brand.sql
Normal file
3
mvp/b2c/drizzle/0009_white_agent_brand.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "leads" ADD COLUMN "anterior_a_2000" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "cambio_distribucion" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pricing_config" ADD COLUMN "extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL;
|
||||||
96
mvp/b2c/drizzle/0010_square_vulture.sql
Normal file
96
mvp/b2c/drizzle/0010_square_vulture.sql
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
CREATE TYPE "public"."canal_contacto" AS ENUM('formulario', 'whatsapp', 'llamada');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."canal_origen" AS ENUM('formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."estado_wa" AS ENUM('sin_enviar', 'enviado', 'entregado', 'leido', 'fallido');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."job_estado" AS ENUM('pendiente', 'procesando', 'completado', 'error');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."job_tipo" AS ENUM('analisis_fotos', 'render', 'presupuesto_ia');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."nivel_calificacion" AS ENUM('A', 'B', 'C', 'D');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."resultado_contacto" AS ENUM('exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."rol_mensaje" AS ENUM('user', 'assistant', 'system');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."visita_estado" AS ENUM('propuesta', 'confirmada', 'realizada', 'cancelada', 'reprogramada');--> statement-breakpoint
|
||||||
|
CREATE TABLE "conversacion_whatsapp" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"rol" "rol_mensaje" NOT NULL,
|
||||||
|
"mensaje" text NOT NULL,
|
||||||
|
"media_type" text,
|
||||||
|
"media_url" text,
|
||||||
|
"transcripcion_audio" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "intentos_contacto" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"canal" "canal_contacto" NOT NULL,
|
||||||
|
"resultado" "resultado_contacto",
|
||||||
|
"completado" boolean DEFAULT false NOT NULL,
|
||||||
|
"numero_intento" integer NOT NULL,
|
||||||
|
"duracion_seg" integer,
|
||||||
|
"intentado_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"notas" text,
|
||||||
|
"metadata" jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lead_calificacion" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"score" integer,
|
||||||
|
"nivel" "nivel_calificacion",
|
||||||
|
"criterios" jsonb,
|
||||||
|
"notas_agente" text,
|
||||||
|
"calificado_por" uuid,
|
||||||
|
"calificado_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "lead_calificacion_lead_id_unique" UNIQUE("lead_id"),
|
||||||
|
CONSTRAINT "lead_calificacion_score_check" CHECK ("lead_calificacion"."score" >= 0 AND "lead_calificacion"."score" <= 100)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "visitas" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"fecha_propuesta" timestamp with time zone,
|
||||||
|
"fecha_confirmada" timestamp with time zone,
|
||||||
|
"estado" "visita_estado" DEFAULT 'propuesta' NOT NULL,
|
||||||
|
"direccion" text,
|
||||||
|
"notas" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "worker_jobs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"lead_id" uuid NOT NULL,
|
||||||
|
"tipo" "job_tipo" NOT NULL,
|
||||||
|
"estado_job" "job_estado" DEFAULT 'pendiente' NOT NULL,
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"webhook_url" text,
|
||||||
|
"resultado_url" text,
|
||||||
|
"intentos" integer DEFAULT 0 NOT NULL,
|
||||||
|
"error_msg" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "estado_wa" "estado_wa";--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "canal_origen" "canal_origen";--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "espacio" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "rango_m2" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "estilo" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "presupuesto_declarado" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "viable" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "fotos_solicitadas_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "conversacion_whatsapp" ADD CONSTRAINT "conversacion_whatsapp_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "intentos_contacto" ADD CONSTRAINT "intentos_contacto_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lead_calificacion" ADD CONSTRAINT "lead_calificacion_calificado_por_users_id_fk" FOREIGN KEY ("calificado_por") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "visitas" ADD CONSTRAINT "visitas_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "worker_jobs" ADD CONSTRAINT "worker_jobs_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_conversacion_whatsapp_lead_id" ON "conversacion_whatsapp" USING btree ("lead_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_conversacion_whatsapp_created_at" ON "conversacion_whatsapp" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_intentos_contacto_lead_id" ON "intentos_contacto" USING btree ("lead_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_lead_calificacion_lead_id" ON "lead_calificacion" USING btree ("lead_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_visitas_lead_id" ON "visitas" USING btree ("lead_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_visitas_tenant_id" ON "visitas" USING btree ("tenant_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_worker_jobs_lead_id" ON "worker_jobs" USING btree ("lead_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_worker_jobs_estado" ON "worker_jobs" USING btree ("estado_job");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_worker_jobs_tipo" ON "worker_jobs" USING btree ("tipo");
|
||||||
1
mvp/b2c/drizzle/0011_warm_post.sql
Normal file
1
mvp/b2c/drizzle/0011_warm_post.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "leads" ADD COLUMN "bot_step" text;
|
||||||
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "pricing_config" ADD COLUMN "baremo_minimo" integer;
|
||||||
1488
mvp/b2c/drizzle/meta/0006_snapshot.json
Normal file
1488
mvp/b2c/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1583
mvp/b2c/drizzle/meta/0007_snapshot.json
Normal file
1583
mvp/b2c/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1673
mvp/b2c/drizzle/meta/0008_snapshot.json
Normal file
1673
mvp/b2c/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1694
mvp/b2c/drizzle/meta/0009_snapshot.json
Normal file
1694
mvp/b2c/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2446
mvp/b2c/drizzle/meta/0010_snapshot.json
Normal file
2446
mvp/b2c/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2452
mvp/b2c/drizzle/meta/0011_snapshot.json
Normal file
2452
mvp/b2c/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,55 @@
|
|||||||
"when": 1780237037524,
|
"when": 1780237037524,
|
||||||
"tag": "0005_tearful_maverick",
|
"tag": "0005_tearful_maverick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780308810691,
|
||||||
|
"tag": "0006_aspiring_susan_delgado",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780313493522,
|
||||||
|
"tag": "0007_pale_chat",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780505942614,
|
||||||
|
"tag": "0008_sharp_bloodaxe",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780569557328,
|
||||||
|
"tag": "0009_white_agent_brand",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780584271011,
|
||||||
|
"tag": "0010_square_vulture",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780593183911,
|
||||||
|
"tag": "0011_warm_post",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781189893331,
|
||||||
|
"tag": "0012_lame_sentinel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,17 @@ const nextConfig: NextConfig = {
|
|||||||
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
|
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
|
||||||
serverExternalPackages: ['@react-pdf/renderer'],
|
serverExternalPackages: ['@react-pdf/renderer'],
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
// beforeFiles: estas reglas ganan a las rutas del filesystem (incluida [slug]).
|
||||||
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.
|
// La raíz y /b2b sirven la landing B2B estática (public/b2b.html); cada reformista
|
||||||
{ source: "/b2b", destination: "/b2b.html" },
|
// tiene su funnel en /{slug} vía app/[slug]/page.tsx.
|
||||||
];
|
return {
|
||||||
|
beforeFiles: [
|
||||||
|
{ source: "/", destination: "/b2b.html" },
|
||||||
|
{ source: "/b2b", destination: "/b2b.html" },
|
||||||
|
],
|
||||||
|
afterFiles: [],
|
||||||
|
fallback: [],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
53
mvp/b2c/package-lock.json
generated
53
mvp/b2c/package-lock.json
generated
@@ -11,17 +11,22 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-easy-crop": "^5.5.7",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
@@ -1436,7 +1441,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2965,6 +2969,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.15",
|
"version": "19.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||||
@@ -4533,6 +4547,12 @@
|
|||||||
"url": "https://dotenvx.com"
|
"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": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.10",
|
"version": "0.31.10",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||||
@@ -7200,6 +7220,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
|
||||||
|
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-svg-path": {
|
"node_modules/normalize-svg-path": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
@@ -7209,6 +7238,12 @@
|
|||||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-wheel": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -7635,6 +7670,20 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-easy-crop": {
|
||||||
|
"version": "5.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
|
||||||
|
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"normalize-wheel": "^1.0.1",
|
||||||
|
"tslib": "^2.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.4.0",
|
||||||
|
"react-dom": ">=16.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -7959,7 +8008,6 @@
|
|||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -8003,7 +8051,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
"db:export": "drizzle-kit export --dialect=postgresql --schema=./src/db/schema.ts > db-schema/schema.sql",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -20,17 +21,22 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-easy-crop": "^5.5.7",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitest/coverage-v8": "^4.1.7",
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
|
|||||||
BIN
mvp/b2c/public/48caf530-fa5f-476b-8120-195cfce7a9ec.webp
Normal file
BIN
mvp/b2c/public/48caf530-fa5f-476b-8120-195cfce7a9ec.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
File diff suppressed because it is too large
Load Diff
BIN
mvp/b2c/public/reformas-ejemplo.png
Normal file
BIN
mvp/b2c/public/reformas-ejemplo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
BIN
mvp/b2c/public/whatsapp.png
Normal file
BIN
mvp/b2c/public/whatsapp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
68
mvp/b2c/src/app/[slug]/page.tsx
Normal file
68
mvp/b2c/src/app/[slug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Hero from '@/components/Hero/Hero';
|
||||||
|
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
||||||
|
import Features from '@/components/Features/Features';
|
||||||
|
import QuienesSomos from '@/components/funnel/QuienesSomos';
|
||||||
|
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
|
||||||
|
import GaleriaTrabajos from '@/components/funnel/GaleriaTrabajos';
|
||||||
|
import Footer from '@/components/Footer/Footer';
|
||||||
|
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||||
|
import { getTenantBySlug, getPublishedTestimonios, getGaleria } from '@/lib/funnel/public-queries';
|
||||||
|
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const tenant = await getTenantBySlug(slug);
|
||||||
|
if (!tenant) return { title: 'Reforma no encontrada' };
|
||||||
|
return {
|
||||||
|
title: tenant.seoTitle ?? `${tenant.nombreEmpresa} · Presupuesto de reforma`,
|
||||||
|
description:
|
||||||
|
tenant.seoDescription ??
|
||||||
|
`Pide tu presupuesto de reforma a ${tenant.nombreEmpresa}. Render IA y presupuesto orientativo en minutos.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FunnelPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const tenant = await getTenantBySlug(slug);
|
||||||
|
if (!tenant) notFound();
|
||||||
|
|
||||||
|
const [testimonios, galeria] = await Promise.all([
|
||||||
|
getPublishedTestimonios(tenant.id),
|
||||||
|
getGaleria(tenant.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||||
|
style={themeStyle(tenant.themePreset, tenant.themeColor)}
|
||||||
|
>
|
||||||
|
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
|
||||||
|
<main id="main-content">
|
||||||
|
<Hero slug={tenant.slug} />
|
||||||
|
<ReformaSlider />
|
||||||
|
<Features />
|
||||||
|
{tenant.aboutEnabled && tenant.aboutTexto && (
|
||||||
|
<QuienesSomos
|
||||||
|
nombreEmpresa={tenant.nombreEmpresa}
|
||||||
|
fotoUrl={tenant.aboutFotoUrl}
|
||||||
|
texto={tenant.aboutTexto}
|
||||||
|
aniosExperiencia={tenant.aniosExperiencia}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GaleriaTrabajos fotos={galeria} nombreEmpresa={tenant.nombreEmpresa} />
|
||||||
|
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
mvp/b2c/src/app/api/leads/[id]/analizar/route.ts
Normal file
14
mvp/b2c/src/app/api/leads/[id]/analizar/route.ts
Normal 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);
|
||||||
|
}
|
||||||
38
mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts
Normal file
38
mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { db } from '@/db';
|
||||||
|
import { leadCalificacion } from '@/db/schema';
|
||||||
|
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||||
|
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||||
|
import { calificacionSchema } from '@/lib/funnel/bot-schemas';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Upsert de la calificación del lead (una por lead). El bot la recalcula a medida que avanza
|
||||||
|
// la conversación; onConflict actualiza la fila existente.
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const v = await validarBotRequest(req, params, calificacionSchema);
|
||||||
|
if ('error' in v) return v.error;
|
||||||
|
const { leadId, body } = v;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(leadCalificacion)
|
||||||
|
.values({
|
||||||
|
leadId,
|
||||||
|
score: body.score,
|
||||||
|
nivel: body.nivel,
|
||||||
|
criterios: body.criterios,
|
||||||
|
notasAgente: body.notasAgente,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: leadCalificacion.leadId,
|
||||||
|
set: {
|
||||||
|
score: body.score,
|
||||||
|
nivel: body.nivel,
|
||||||
|
criterios: body.criterios,
|
||||||
|
notasAgente: body.notasAgente,
|
||||||
|
calificadoAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true }, 200);
|
||||||
|
}
|
||||||
54
mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts
Normal file
54
mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { leads, conversacionWhatsapp } from '@/db/schema';
|
||||||
|
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
|
||||||
|
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||||
|
import { conversacionSchema } from '@/lib/funnel/bot-schemas';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Historial de la conversación de WhatsApp (orden cronológico) para que el bot recupere el contexto.
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
|
||||||
|
const { id } = await params;
|
||||||
|
const turnos = await db
|
||||||
|
.select({
|
||||||
|
rol: conversacionWhatsapp.rol,
|
||||||
|
mensaje: conversacionWhatsapp.mensaje,
|
||||||
|
createdAt: conversacionWhatsapp.createdAt,
|
||||||
|
})
|
||||||
|
.from(conversacionWhatsapp)
|
||||||
|
.where(eq(conversacionWhatsapp.leadId, id))
|
||||||
|
.orderBy(conversacionWhatsapp.createdAt);
|
||||||
|
return jsonResponse(turnos, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añade un turno de la conversación de WhatsApp al historial del lead, y opcionalmente actualiza
|
||||||
|
// el estado del mensaje (estado_wa) y el paso del bot (bot_step) en el lead.
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const v = await validarBotRequest(req, params, conversacionSchema);
|
||||||
|
if ('error' in v) return v.error;
|
||||||
|
const { leadId, body } = v;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(conversacionWhatsapp)
|
||||||
|
.values({
|
||||||
|
leadId,
|
||||||
|
rol: body.rol,
|
||||||
|
mensaje: body.mensaje,
|
||||||
|
mediaType: body.mediaType ?? null,
|
||||||
|
mediaUrl: body.mediaUrl ?? null,
|
||||||
|
transcripcionAudio: body.transcripcionAudio ?? null,
|
||||||
|
})
|
||||||
|
.returning({ id: conversacionWhatsapp.id });
|
||||||
|
|
||||||
|
if (body.estadoWa || body.botStep) {
|
||||||
|
await db
|
||||||
|
.update(leads)
|
||||||
|
.set({ estadoWa: body.estadoWa, botStep: body.botStep, updatedAt: new Date() })
|
||||||
|
.where(eq(leads.id, leadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true, id: row.id }, 200);
|
||||||
|
}
|
||||||
88
mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts
Normal file
88
mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
|
||||||
|
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
|
||||||
|
import { autorizado, jsonResponse as json } from '@/lib/api/funnel-auth';
|
||||||
|
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
|
||||||
|
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
|
||||||
|
import { finalizarYEntregar } from '@/lib/funnel/finalizar';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// EP único de ingesta async del perfil del lead. Acepta imágenes, imagen+texto o solo texto,
|
||||||
|
// etiquetado por zona y momento, y dos flags: perfilCompleto (señala al flujo externo que genere
|
||||||
|
// renders/agente) y finalizar (construye el PDF y lo entrega por email + señal WhatsApp).
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!autorizado(req)) return json({ ok: false, error: 'No autorizado.' }, 401);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await req.json();
|
||||||
|
} catch {
|
||||||
|
return json({ ok: false, error: 'JSON inválido.' }, 422);
|
||||||
|
}
|
||||||
|
const parsed = ingestaBodySchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return json({ ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }, 422);
|
||||||
|
}
|
||||||
|
const body = parsed.data;
|
||||||
|
|
||||||
|
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, id)).limit(1);
|
||||||
|
if (!lead) return json({ ok: false, error: 'Lead no encontrado.' }, 404);
|
||||||
|
|
||||||
|
const fotosItems = body.items.filter((i) => i.tipo === 'foto');
|
||||||
|
const notasItems = body.items.filter((i) => i.tipo === 'texto');
|
||||||
|
|
||||||
|
if (fotosItems.length > 0) {
|
||||||
|
const [ultimo] = await db
|
||||||
|
.select({ orden: leadFotos.orden })
|
||||||
|
.from(leadFotos)
|
||||||
|
.where(eq(leadFotos.leadId, id))
|
||||||
|
.orderBy(desc(leadFotos.orden))
|
||||||
|
.limit(1);
|
||||||
|
let siguiente = (ultimo?.orden ?? -1) + 1;
|
||||||
|
const filas: NewLeadFoto[] = fotosItems.map((f) => ({
|
||||||
|
leadId: id,
|
||||||
|
url: f.imagen,
|
||||||
|
momento: f.momento,
|
||||||
|
zona: f.zona ?? null,
|
||||||
|
orden: f.orden ?? siguiente++,
|
||||||
|
}));
|
||||||
|
await db.insert(leadFotos).values(filas);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notasItems.length > 0) {
|
||||||
|
const filas: NewLeadNota[] = notasItems.map((n) => ({
|
||||||
|
leadId: id,
|
||||||
|
texto: n.texto,
|
||||||
|
zona: n.zona ?? null,
|
||||||
|
origen: 'ep',
|
||||||
|
}));
|
||||||
|
await db.insert(leadNotas).values(filas);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fotosItems.length > 0 || notasItems.length > 0) {
|
||||||
|
await db.insert(leadPipelineEventos).values({
|
||||||
|
leadId: id,
|
||||||
|
stage: 'fotos_subidas',
|
||||||
|
metadata: { origen: 'ep', fotos: fotosItems.length, notas: notasItems.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const perfilSenalado = body.perfilCompleto ? await señalarPerfilCompleto(id) : false;
|
||||||
|
const finalizado = body.finalizar ? await finalizarYEntregar(id) : null;
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
fotos: fotosItems.length,
|
||||||
|
notas: notasItems.length,
|
||||||
|
perfilSenalado,
|
||||||
|
finalizado,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
31
mvp/b2c/src/app/api/leads/[id]/intento/route.ts
Normal file
31
mvp/b2c/src/app/api/leads/[id]/intento/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { db } from '@/db';
|
||||||
|
import { intentosContacto } from '@/db/schema';
|
||||||
|
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||||
|
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||||
|
import { intentoSchema } from '@/lib/funnel/bot-schemas';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Registra un intento de contacto (formulario/whatsapp/llamada) con su resultado.
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const v = await validarBotRequest(req, params, intentoSchema);
|
||||||
|
if ('error' in v) return v.error;
|
||||||
|
const { leadId, body } = v;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(intentosContacto)
|
||||||
|
.values({
|
||||||
|
leadId,
|
||||||
|
canal: body.canal,
|
||||||
|
resultado: body.resultado,
|
||||||
|
completado: body.completado ?? false,
|
||||||
|
numeroIntento: body.numeroIntento,
|
||||||
|
duracionSeg: body.duracionSeg,
|
||||||
|
notas: body.notas,
|
||||||
|
metadata: body.metadata,
|
||||||
|
})
|
||||||
|
.returning({ id: intentosContacto.id });
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true, id: row.id }, 200);
|
||||||
|
}
|
||||||
28
mvp/b2c/src/app/api/leads/[id]/perfil/route.ts
Normal file
28
mvp/b2c/src/app/api/leads/[id]/perfil/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { leads } from '@/db/schema';
|
||||||
|
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||||
|
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||||
|
import { perfilSchema } from '@/lib/funnel/bot-schemas';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Actualización parcial del lead con lo que el bot va extrayendo (espacio, m², estilo, urgencia,
|
||||||
|
// presupuesto, viabilidad, estado de la conversación…). Solo escribe los campos enviados.
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const v = await validarBotRequest(req, params, perfilSchema);
|
||||||
|
if ('error' in v) return v.error;
|
||||||
|
const { leadId, body } = v;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(leads)
|
||||||
|
.set({
|
||||||
|
...body,
|
||||||
|
fotosSolicitadasAt: body.fotosSolicitadasAt ? new Date(body.fotosSolicitadasAt) : undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(leads.id, leadId));
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true, actualizado: Object.keys(body) }, 200);
|
||||||
|
}
|
||||||
34
mvp/b2c/src/app/api/leads/[id]/route.ts
Normal file
34
mvp/b2c/src/app/api/leads/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { leads } from '@/db/schema';
|
||||||
|
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// Estado del lead para el bot de WhatsApp: le permite retomar la conversación (botStep, viabilidad,
|
||||||
|
// extracción en crudo) tras un reinicio sin perder contexto. Devuelve el objeto plano (no envuelto).
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401);
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const [lead] = await db
|
||||||
|
.select({
|
||||||
|
id: leads.id,
|
||||||
|
nombre: leads.nombre,
|
||||||
|
telefono: leads.telefono,
|
||||||
|
botStep: leads.botStep,
|
||||||
|
estadoWa: leads.estadoWa,
|
||||||
|
espacio: leads.espacio,
|
||||||
|
rangoM2: leads.rangoM2,
|
||||||
|
estilo: leads.estilo,
|
||||||
|
presupuestoDeclarado: leads.presupuestoDeclarado,
|
||||||
|
viable: leads.viable,
|
||||||
|
})
|
||||||
|
.from(leads)
|
||||||
|
.where(eq(leads.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!lead) return jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404);
|
||||||
|
return jsonResponse(lead, 200);
|
||||||
|
}
|
||||||
37
mvp/b2c/src/app/api/leads/by-phone/route.ts
Normal file
37
mvp/b2c/src/app/api/leads/by-phone/route.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
122
mvp/b2c/src/app/api/retell/webhook/route.ts
Normal file
122
mvp/b2c/src/app/api/retell/webhook/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { desc, eq, like } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
|
||||||
|
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
||||||
|
import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion';
|
||||||
|
import { pedirFotosWhatsapp } from '@/lib/webhooks';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const ok = (extra: Record<string, unknown> = {}) =>
|
||||||
|
new Response(JSON.stringify({ ok: true, ...extra }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Webhook de Retell (eventos de llamada). Al terminar la llamada (call_analyzed / call_ended)
|
||||||
|
// releemos la llamada por su call_id desde la API de Retell (dato autoritativo y autenticado con
|
||||||
|
// nuestra API key, así un webhook falso no puede inyectar datos), guardamos la transcripción real,
|
||||||
|
// descargamos la grabación a nuestro sistema (lead.audio_url) y dejamos el análisis en el pipeline.
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
let body: { event?: string; call?: { call_id?: string } } = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return new Response('bad json', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = body.event;
|
||||||
|
const callId = body.call?.call_id;
|
||||||
|
// Solo nos interesa el final de la llamada; respondemos 200 a todo para que Retell no reintente.
|
||||||
|
if (!callId || (event !== 'call_analyzed' && event !== 'call_ended')) return ok({ skipped: true });
|
||||||
|
|
||||||
|
const detalle = await obtenerLlamada(callId);
|
||||||
|
if (!detalle) return ok({ matched: false, motivo: 'call no encontrada' });
|
||||||
|
|
||||||
|
// Mapeo al lead: por metadata.lead_id; si falta (llamadas sin metadata), fallback al lead más
|
||||||
|
// reciente con ese teléfono (últimos 9 dígitos, tolerante a +34/espacios).
|
||||||
|
let leadId = detalle.leadId;
|
||||||
|
if (!leadId && detalle.toNumber) {
|
||||||
|
const dig = detalle.toNumber.replace(/\D/g, '').slice(-9);
|
||||||
|
if (dig.length === 9) {
|
||||||
|
const [m] = await db
|
||||||
|
.select({ id: leads.id })
|
||||||
|
.from(leads)
|
||||||
|
.where(like(leads.telefono, `%${dig}%`))
|
||||||
|
.orderBy(desc(leads.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
leadId = m?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!leadId) return ok({ matched: false, motivo: 'sin lead' });
|
||||||
|
|
||||||
|
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||||
|
if (!lead) return ok({ matched: false, motivo: 'lead no existe' });
|
||||||
|
|
||||||
|
const audioUrl = detalle.recordingUrl ? await descargarGrabacion(detalle.recordingUrl) : null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(leads)
|
||||||
|
.set({
|
||||||
|
transcripcion: detalle.transcript ?? undefined,
|
||||||
|
audioUrl: audioUrl ?? undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(leads.id, leadId));
|
||||||
|
|
||||||
|
await db.insert(leadPipelineEventos).values({
|
||||||
|
leadId,
|
||||||
|
stage: 'llamada_completada',
|
||||||
|
metadata: {
|
||||||
|
via: 'webhook',
|
||||||
|
real: true,
|
||||||
|
retellCallId: detalle.callId,
|
||||||
|
callStatus: detalle.callStatus,
|
||||||
|
duracionSeg: detalle.duracionSeg,
|
||||||
|
grabacionGuardada: Boolean(audioUrl),
|
||||||
|
analysis: detalle.analysis ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada.
|
||||||
|
const analisis = detalle.transcript
|
||||||
|
? await analizarTranscripcion(leadId, detalle.transcript, 'llamada')
|
||||||
|
: { ok: false as const, error: 'sin transcripción' };
|
||||||
|
|
||||||
|
// Cross-canal: tras la llamada, Luisa escribe al lead por WhatsApp, referencia lo hablado y le
|
||||||
|
// pide las fotos (el agente de voz le dijo que las enviara por ahí).
|
||||||
|
let fotosPedidas = false;
|
||||||
|
if (analisis.ok) {
|
||||||
|
const [info] = await db
|
||||||
|
.select({ nombre: leads.nombre, telefono: leads.telefono, tenantId: leads.tenantId })
|
||||||
|
.from(leads)
|
||||||
|
.where(eq(leads.id, leadId))
|
||||||
|
.limit(1);
|
||||||
|
if (info) {
|
||||||
|
const [t] = await db
|
||||||
|
.select({ nombre: tenants.nombreEmpresa })
|
||||||
|
.from(tenants)
|
||||||
|
.where(eq(tenants.id, info.tenantId))
|
||||||
|
.limit(1);
|
||||||
|
const tipo = (analisis.perfil?.tipoReforma as string | undefined) ?? null;
|
||||||
|
const contexto = tipo ? `la reforma de tu ${tipo}` : 'tu reforma';
|
||||||
|
fotosPedidas = await pedirFotosWhatsapp({
|
||||||
|
leadId,
|
||||||
|
telefono: info.telefono,
|
||||||
|
nombre: info.nombre,
|
||||||
|
empresa: t?.nombre ?? 'Reformix',
|
||||||
|
contexto,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
matched: true,
|
||||||
|
leadId,
|
||||||
|
transcript: Boolean(detalle.transcript),
|
||||||
|
grabacion: Boolean(audioUrl),
|
||||||
|
analizado: analisis.ok,
|
||||||
|
fotosPedidas,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Instrument Serif (display) — usada por los presets de tema con titulares serif.
|
||||||
|
Los .woff2 viven en /public/b2b-assets/fonts. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Instrument Serif';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/b2b-assets/fonts/421ba28b-7abe-4b86-a87c-fcd3e94378f7.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Instrument Serif';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/b2b-assets/fonts/15d36112-e39a-4059-ae12-06c58a5747ac.woff2') format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--color-black: #0a0a0a;
|
--color-black: #0a0a0a;
|
||||||
@@ -25,11 +44,34 @@
|
|||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||||
|
|
||||||
|
/* Paleta de marca B2B "Architectural Warmth" — panel y área autenticada */
|
||||||
|
--color-primary-50: #f4f8f5;
|
||||||
|
--color-primary-100: #e8f0eb;
|
||||||
|
--color-primary-500: #4d8a6d;
|
||||||
|
--color-primary-700: #2f5c46;
|
||||||
|
--color-primary-900: #1f3a2e;
|
||||||
|
--color-stone-50: #f7f8f7;
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--transition-fast: 150ms ease;
|
--transition-fast: 150ms ease;
|
||||||
--transition-base: 250ms ease;
|
--transition-base: 250ms ease;
|
||||||
--transition-slow: 400ms 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 {
|
@layer base {
|
||||||
@@ -111,4 +153,23 @@
|
|||||||
.badge-accent {
|
.badge-accent {
|
||||||
@apply bg-accent-light text-accent;
|
@apply bg-accent-light text-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Botón con el color de marca del reformista (tema de la landing). */
|
||||||
|
.btn-brand {
|
||||||
|
background-color: var(--brand, #0a0a0a);
|
||||||
|
color: var(--brand-contrast, #ffffff);
|
||||||
|
border: 2px solid var(--brand, #0a0a0a);
|
||||||
|
}
|
||||||
|
.btn-brand:hover {
|
||||||
|
background-color: var(--brand-dark, #1a1a1a);
|
||||||
|
border-color: var(--brand-dark, #1a1a1a);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Presets de tema con titulares en serif (terracota, arena). */
|
||||||
|
.theme-serif :is(h1, h2, h3) {
|
||||||
|
font-family: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,59 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
import { login } from './actions';
|
import { login } from './actions';
|
||||||
|
import AuthShell from '@/components/auth/AuthShell';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [error, formAction, pending] = useActionState(login, null);
|
const [error, formAction, pending] = useActionState(login, null);
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
<AuthShell
|
||||||
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
photo="/despues.webp"
|
||||||
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
|
photoAlt="Cocina reformada"
|
||||||
|
caption="Tus leads, ya cualificados."
|
||||||
|
captionSub="Render IA, presupuesto orientativo y datos del cliente. Todo en un panel."
|
||||||
|
>
|
||||||
|
<form action={formAction} className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl tracking-tight text-black">Entra en tu panel</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Gestiona tus leads y tu funnel.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-gray-700">Email</span>
|
<span className="font-medium text-gray-700">Email</span>
|
||||||
<input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-gray-700">Contraseña</span>
|
<span className="font-medium text-gray-700">Contraseña</span>
|
||||||
<input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
|
|
||||||
|
<button type="submit" disabled={pending} className="btn bg-primary-700 text-white hover:bg-primary-900 w-full disabled:opacity-60">
|
||||||
{pending ? 'Entrando…' : 'Entrar'}
|
{pending ? 'Entrando…' : 'Entrar'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
¿No tienes cuenta?{' '}
|
||||||
|
<Link href="/signup" className="font-semibold text-primary-700 hover:underline">
|
||||||
|
Empieza gratis
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
mvp/b2c/src/app/opinion/[id]/page.tsx
Normal file
50
mvp/b2c/src/app/opinion/[id]/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getLeadForReview } from '@/lib/funnel/public-queries';
|
||||||
|
import { enviarOpinion } from '../actions';
|
||||||
|
import OpinionForm from '@/components/funnel/OpinionForm';
|
||||||
|
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function OpinionPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getLeadForReview(id);
|
||||||
|
if (!data || !data.tenant) notFound();
|
||||||
|
|
||||||
|
const { lead, tenant, yaEnviado } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TenantBrand
|
||||||
|
nombreEmpresa={tenant.nombreEmpresa}
|
||||||
|
logoUrl={tenant.logoUrl}
|
||||||
|
subtitle="Tu opinión"
|
||||||
|
/>
|
||||||
|
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-black">
|
||||||
|
{lead.nombre.split(' ')[0]}, ¿qué tal fue tu reforma con {tenant.nombreEmpresa}?
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">
|
||||||
|
Tu opinión nos ayuda muchísimo. Cuéntanos cómo fue y, si quieres, sube alguna foto del
|
||||||
|
resultado.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{yaEnviado ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center flex flex-col gap-3">
|
||||||
|
<span className="text-4xl" aria-hidden="true">
|
||||||
|
✅
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-black">Ya hemos recibido tu opinión</h2>
|
||||||
|
<p className="text-sm text-gray-500">¡Gracias por tomarte el tiempo!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
|
||||||
|
<OpinionForm action={enviarOpinion.bind(null, id)} nombreCliente={lead.nombre} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
mvp/b2c/src/app/opinion/actions.ts
Normal file
74
mvp/b2c/src/app/opinion/actions.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { testimonios, testimonioFotos } from '@/db/schema';
|
||||||
|
import { getLeadForReview } from '@/lib/funnel/public-queries';
|
||||||
|
|
||||||
|
const MAX_FOTOS = 4;
|
||||||
|
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
|
||||||
|
|
||||||
|
export type EnviarOpinionResult = { ok: boolean; error?: string };
|
||||||
|
|
||||||
|
async function fileToDataUri(file: File): Promise<string | null> {
|
||||||
|
if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null;
|
||||||
|
if (!file.type.startsWith('image/')) return null;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// El cliente final deja su opinión desde el funnel de review (/opinion/[id]).
|
||||||
|
// Entra como 'pendiente'; el reformista la aprueba antes de que salga en su landing.
|
||||||
|
export async function enviarOpinion(
|
||||||
|
leadId: string,
|
||||||
|
_prev: EnviarOpinionResult | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<EnviarOpinionResult> {
|
||||||
|
const data = await getLeadForReview(leadId);
|
||||||
|
if (!data || !data.tenant) {
|
||||||
|
return { ok: false, error: 'No hemos podido identificar tu solicitud.' };
|
||||||
|
}
|
||||||
|
if (data.yaEnviado) {
|
||||||
|
return { ok: false, error: 'Ya hemos recibido tu opinión. ¡Gracias!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rating = Number(formData.get('rating'));
|
||||||
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||||
|
return { ok: false, error: 'Selecciona una valoración de 1 a 5 estrellas.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const texto = String(formData.get('texto') ?? '').trim();
|
||||||
|
if (texto.length < 10) {
|
||||||
|
return { ok: false, error: 'Cuéntanos un poco más sobre tu experiencia (mínimo 10 caracteres).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nombre = String(formData.get('nombre') ?? '').trim() || data.lead.nombre;
|
||||||
|
const contexto = String(formData.get('contexto') ?? '').trim() || null;
|
||||||
|
|
||||||
|
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
|
||||||
|
const dataUris: string[] = [];
|
||||||
|
for (const file of archivos.slice(0, MAX_FOTOS)) {
|
||||||
|
const uri = await fileToDataUri(file);
|
||||||
|
if (uri) dataUris.push(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [testimonio] = await db
|
||||||
|
.insert(testimonios)
|
||||||
|
.values({
|
||||||
|
tenantId: data.tenant.id,
|
||||||
|
leadId,
|
||||||
|
nombre,
|
||||||
|
contexto,
|
||||||
|
rating,
|
||||||
|
texto,
|
||||||
|
estado: 'pendiente',
|
||||||
|
})
|
||||||
|
.returning({ id: testimonios.id });
|
||||||
|
|
||||||
|
if (dataUris.length > 0) {
|
||||||
|
await db.insert(testimonioFotos).values(
|
||||||
|
dataUris.map((url, orden) => ({ testimonioId: testimonio.id, url, orden }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
12
mvp/b2c/src/app/opinion/layout.tsx
Normal file
12
mvp/b2c/src/app/opinion/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default function OpinionLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<footer className="border-t border-gray-200 bg-white">
|
||||||
|
<div className="container py-6 text-xs text-gray-400 text-center">
|
||||||
|
Tu opinión ayuda a otros clientes a decidir con confianza.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import Navbar from '@/components/Navbar/Navbar';
|
|
||||||
import Hero from '@/components/Hero/Hero';
|
|
||||||
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
|
||||||
import Features from '@/components/Features/Features';
|
|
||||||
import Testimonials from '@/components/Testimonials/Testimonials';
|
|
||||||
import ContactForm from '@/components/ContactForm/ContactForm';
|
|
||||||
import Footer from '@/components/Footer/Footer';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* <Navbar /> */}
|
|
||||||
<main id="main-content">
|
|
||||||
<Hero />
|
|
||||||
<ReformaSlider />
|
|
||||||
<Features />
|
|
||||||
<Testimonials />
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getLead } from '@/db/queries';
|
import { getLead } from '@/db/queries';
|
||||||
|
import { getPricingConfigFor } from '@/db/pricing-queries';
|
||||||
import EstadoControl from '@/components/panel/EstadoControl';
|
import EstadoControl from '@/components/panel/EstadoControl';
|
||||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||||
|
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
||||||
|
import LeadFotosGaleria from '@/components/panel/LeadFotosGaleria';
|
||||||
import {
|
import {
|
||||||
PIPELINE_LABEL,
|
PIPELINE_LABEL,
|
||||||
PIPELINE_NEXT,
|
PIPELINE_NEXT,
|
||||||
@@ -11,14 +15,25 @@ import {
|
|||||||
formatEuros,
|
formatEuros,
|
||||||
formatFecha,
|
formatFecha,
|
||||||
} from '@/lib/funnel';
|
} from '@/lib/funnel';
|
||||||
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
|
import { recalcularPresupuesto, enviarPresupuesto, solicitarOpinion } from '../actions';
|
||||||
import type { BudgetResult } from '@/budget/types';
|
import type { BudgetResult } from '@/budget/types';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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 (
|
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>
|
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
@@ -30,7 +45,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
const data = await getLead(id);
|
const data = await getLead(id);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
const { lead, fotos, eventos, precision } = data;
|
const { lead, fotos, notas, eventos, precision } = data;
|
||||||
const reachedStages = new Set(eventos.map((e) => e.stage));
|
const reachedStages = new Set(eventos.map((e) => e.stage));
|
||||||
|
|
||||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||||
@@ -38,9 +53,22 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
||||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||||
|
|
||||||
|
// Baremo de rentabilidad del reformista (informativo): si el presupuesto estimado no lo alcanza,
|
||||||
|
// se marca en rojo. null = sin baremo o sin presupuesto aún (no se marca nada).
|
||||||
|
const baremoMinimo = (await getPricingConfigFor(lead.tenantId)).baremoMinimo ?? null;
|
||||||
|
const pasaBaremo =
|
||||||
|
baremoMinimo != null && lead.presupuestoEstimado != null
|
||||||
|
? lead.presupuestoEstimado >= baremoMinimo
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const h = await headers();
|
||||||
|
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||||
|
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||||
|
const opinionUrl = `${proto}://${host}/opinion/${lead.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
|
<Link href="/panel" className="text-sm text-gray-500 hover:text-primary-700 w-fit">
|
||||||
← Volver a leads
|
← Volver a leads
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -48,24 +76,69 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-black tracking-tight text-black">{lead.nombre}</h1>
|
<h1 className="font-display text-3xl tracking-tight text-black">{lead.nombre}</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
|
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
|
||||||
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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-xs text-gray-400">Presupuesto estimado</div>
|
||||||
<div className="text-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
|
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
|
||||||
|
{formatEuros(lead.presupuestoEstimado)}
|
||||||
|
</div>
|
||||||
|
{pasaBaremo === false && baremoMinimo != null && (
|
||||||
|
<div className="mt-0.5 text-xs font-semibold text-red-600">
|
||||||
|
Por debajo de tu baremo ({formatEuros(baremoMinimo)})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EstadoControl
|
<div data-tour="ficha-estado">
|
||||||
leadId={lead.id}
|
<EstadoControl
|
||||||
estado={lead.estado}
|
leadId={lead.id}
|
||||||
presupuestoEstimado={lead.presupuestoEstimado}
|
estado={lead.estado}
|
||||||
/>
|
presupuestoEstimado={lead.presupuestoEstimado}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Solicitar opinión al cliente */}
|
||||||
|
<Section title="Opinión del cliente">
|
||||||
|
{lead.testimonioSolicitadoAt ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Solicitada el {formatFecha(lead.testimonioSolicitadoAt)}. Comparte este enlace con el
|
||||||
|
cliente para que deje su opinión. Cuando la envíe, la verás en{' '}
|
||||||
|
<Link href="/panel/opiniones" className="text-primary-700 underline underline-offset-2">
|
||||||
|
Opiniones
|
||||||
|
</Link>{' '}
|
||||||
|
para aprobarla.
|
||||||
|
</p>
|
||||||
|
<OpinionLinkBox url={opinionUrl} />
|
||||||
|
</div>
|
||||||
|
) : lead.estado === 'ganado' ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Si la reforma ha ido bien, pídele su opinión. Generamos un enlace para que valore y
|
||||||
|
suba fotos del resultado.
|
||||||
|
</p>
|
||||||
|
<form action={solicitarOpinion.bind(null, lead.id)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
|
||||||
|
>
|
||||||
|
Solicitar opinión al cliente
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Disponible cuando marques este lead como <span className="font-medium">ganado</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Timeline del funnel */}
|
{/* Timeline del funnel */}
|
||||||
<Section title="Progreso en el funnel">
|
<Section title="Progreso en el funnel">
|
||||||
<ol className="flex flex-col gap-2">
|
<ol className="flex flex-col gap-2">
|
||||||
@@ -120,7 +193,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 4. Render */}
|
{/* 4. Render */}
|
||||||
<Section title="Render generado">
|
<Section title="Render generado" tour="ficha-render">
|
||||||
{lead.renderUrl ? (
|
{lead.renderUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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" />
|
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
|
||||||
@@ -211,7 +284,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/panel/${lead.id}/presupuesto?download=1`}
|
href={`/panel/${lead.id}/presupuesto?download=1`}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
|
||||||
>
|
>
|
||||||
Descargar PDF
|
Descargar PDF
|
||||||
</a>
|
</a>
|
||||||
@@ -219,7 +292,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
href={`/panel/${lead.id}/presupuesto`}
|
href={`/panel/${lead.id}/presupuesto`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
className="text-sm font-medium text-gray-500 hover:text-black"
|
className="text-sm font-medium text-gray-500 hover:text-primary-700"
|
||||||
>
|
>
|
||||||
Ver en el navegador
|
Ver en el navegador
|
||||||
</a>
|
</a>
|
||||||
@@ -232,15 +305,10 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fotos subidas */}
|
{/* Fotos y notas por zona */}
|
||||||
{fotos.length > 0 && (
|
{(fotos.length > 0 || notas.length > 0) && (
|
||||||
<Section title="Fotos subidas por el cliente">
|
<Section title="Fotos y detalles por zona">
|
||||||
<div className="flex flex-wrap gap-3">
|
<LeadFotosGaleria fotos={fotos} notas={notas} tipoLead={lead.tipoReforma} />
|
||||||
{fotos.map((f) => (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -272,12 +340,12 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Presupuesto desglosado */}
|
{/* Presupuesto desglosado */}
|
||||||
<Section title="Presupuesto desglosado">
|
<Section title="Presupuesto desglosado" tour="ficha-desglose">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
|
||||||
>
|
>
|
||||||
Recalcular desde el catálogo
|
Recalcular desde el catálogo
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { renderToBuffer } from '@react-pdf/renderer';
|
|
||||||
import { getLead } from '@/db/queries';
|
import { getLead } from '@/db/queries';
|
||||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
||||||
import { TIPO_LABEL } from '@/lib/funnel';
|
|
||||||
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
|
|
||||||
import type { BudgetResult } from '@/budget/types';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -14,34 +10,18 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
// getLead aplica el scoping por tenant del panel: sirve de guardia de auth/404.
|
||||||
const data = await getLead(id);
|
const data = await getLead(id);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const pdf = await construirPresupuestoPdf(id);
|
||||||
|
if (!pdf) notFound();
|
||||||
|
|
||||||
const descargar = new URL(req.url).searchParams.get('download') === '1';
|
const descargar = new URL(req.url).searchParams.get('download') === '1';
|
||||||
|
return new Response(new Uint8Array(pdf.buffer), {
|
||||||
const { lead } = data;
|
|
||||||
const empresa = await getTenantPerfil();
|
|
||||||
|
|
||||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
|
||||||
const desglose = snapshot?.result ?? null;
|
|
||||||
|
|
||||||
const buffer = await renderToBuffer(
|
|
||||||
PresupuestoDoc({
|
|
||||||
empresa,
|
|
||||||
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
|
|
||||||
reforma: {
|
|
||||||
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
|
|
||||||
fecha: lead.createdAt,
|
|
||||||
},
|
|
||||||
desglose,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
||||||
return new Response(new Uint8Array(buffer), {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="presupuesto-${slug || lead.id}.pdf"`,
|
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="${pdf.filename}"`,
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ export async function enviarPresupuesto(leadId: string) {
|
|||||||
revalidatePath(`/panel/${leadId}`);
|
revalidatePath(`/panel/${leadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function solicitarOpinion(leadId: string) {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(leads)
|
||||||
|
.set({ testimonioSolicitadoAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
|
||||||
|
.returning({ id: leads.id });
|
||||||
|
if (!updated) throw new Error('Lead no encontrado.');
|
||||||
|
|
||||||
|
revalidatePath(`/panel/${leadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function recalcularPresupuesto(leadId: string) {
|
export async function recalcularPresupuesto(leadId: string) {
|
||||||
const tenantId = await getTenantId();
|
const tenantId = await getTenantId();
|
||||||
const [lead] = await db
|
const [lead] = await db
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, ne } from 'drizzle-orm';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { tenants } from '@/db/schema';
|
import { tenants } from '@/db/schema';
|
||||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||||
|
import { validarSlug } from '@/lib/validation/signup';
|
||||||
|
import { THEME_PRESETS, isHexColor, type ThemePresetId } from '@/lib/funnel/themes';
|
||||||
|
|
||||||
const LOGO_MAX_BYTES = 500_000;
|
const LOGO_MAX_BYTES = 500_000;
|
||||||
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
||||||
|
const ABOUT_FOTO_MAX_BYTES = 1_500_000;
|
||||||
|
const ABOUT_FOTO_TIPOS = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
|
||||||
function limpiar(raw: FormDataEntryValue | null): string | null {
|
function limpiar(raw: FormDataEntryValue | null): string | null {
|
||||||
const s = String(raw ?? '').trim();
|
const s = String(raw ?? '').trim();
|
||||||
@@ -20,16 +24,47 @@ export async function actualizarEmpresa(formData: FormData) {
|
|||||||
if (!nombreEmpresa) {
|
if (!nombreEmpresa) {
|
||||||
throw new Error('El nombre de la empresa es obligatorio.');
|
throw new Error('El nombre de la empresa es obligatorio.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slugRaw = limpiar(formData.get('slug'));
|
||||||
|
if (!slugRaw) {
|
||||||
|
throw new Error('El enlace de tu funnel es obligatorio.');
|
||||||
|
}
|
||||||
|
const validacion = validarSlug(slugRaw);
|
||||||
|
if (!validacion.ok) {
|
||||||
|
throw new Error(validacion.error);
|
||||||
|
}
|
||||||
|
const slug = validacion.slug;
|
||||||
|
|
||||||
|
// El slug es único en todo el sistema; comprobamos que no lo use otro reformista.
|
||||||
|
const [colision] = await db
|
||||||
|
.select({ id: tenants.id })
|
||||||
|
.from(tenants)
|
||||||
|
.where(and(eq(tenants.slug, slug), ne(tenants.id, tenantId)))
|
||||||
|
.limit(1);
|
||||||
|
if (colision) {
|
||||||
|
throw new Error('Ese enlace ya está en uso. Elige otro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aniosRaw = Number(formData.get('aniosExperiencia'));
|
||||||
|
const aniosExperiencia =
|
||||||
|
Number.isInteger(aniosRaw) && aniosRaw > 0 && aniosRaw <= 100 ? aniosRaw : null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(tenants)
|
.update(tenants)
|
||||||
.set({
|
.set({
|
||||||
nombreEmpresa,
|
nombreEmpresa,
|
||||||
|
slug,
|
||||||
cif: limpiar(formData.get('cif')),
|
cif: limpiar(formData.get('cif')),
|
||||||
direccion: limpiar(formData.get('direccion')),
|
direccion: limpiar(formData.get('direccion')),
|
||||||
provincia: limpiar(formData.get('provincia')),
|
provincia: limpiar(formData.get('provincia')),
|
||||||
telefono: limpiar(formData.get('telefono')),
|
telefono: limpiar(formData.get('telefono')),
|
||||||
email: limpiar(formData.get('email')),
|
email: limpiar(formData.get('email')),
|
||||||
web: limpiar(formData.get('web')),
|
web: limpiar(formData.get('web')),
|
||||||
|
seoTitle: limpiar(formData.get('seoTitle')),
|
||||||
|
seoDescription: limpiar(formData.get('seoDescription')),
|
||||||
|
aboutEnabled: formData.get('aboutEnabled') === 'on',
|
||||||
|
aboutTexto: limpiar(formData.get('aboutTexto')),
|
||||||
|
aniosExperiencia,
|
||||||
})
|
})
|
||||||
.where(eq(tenants.id, tenantId));
|
.where(eq(tenants.id, tenantId));
|
||||||
revalidatePath('/panel/empresa');
|
revalidatePath('/panel/empresa');
|
||||||
@@ -64,3 +99,52 @@ export async function quitarLogo() {
|
|||||||
revalidatePath('/panel/empresa');
|
revalidatePath('/panel/empresa');
|
||||||
revalidatePath('/panel');
|
revalidatePath('/panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function subirAboutFoto(
|
||||||
|
_prev: LogoResult | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<LogoResult> {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
const file = formData.get('aboutFoto');
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return { ok: false, error: 'Selecciona un archivo de imagen.' };
|
||||||
|
}
|
||||||
|
if (!ABOUT_FOTO_TIPOS.includes(file.type)) {
|
||||||
|
return { ok: false, error: 'Formato no válido. Usa PNG, JPG o WEBP.' };
|
||||||
|
}
|
||||||
|
if (file.size > ABOUT_FOTO_MAX_BYTES) {
|
||||||
|
return { ok: false, error: 'La foto no puede superar los 1,5 MB.' };
|
||||||
|
}
|
||||||
|
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
|
||||||
|
const dataUri = `data:${file.type};base64,${base64}`;
|
||||||
|
await db.update(tenants).set({ aboutFotoUrl: dataUri }).where(eq(tenants.id, tenantId));
|
||||||
|
revalidatePath('/panel/empresa');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function quitarAboutFoto() {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
await db.update(tenants).set({ aboutFotoUrl: null }).where(eq(tenants.id, tenantId));
|
||||||
|
revalidatePath('/panel/empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarda el tema de la landing del reformista: preset + color personalizado opcional.
|
||||||
|
export async function guardarTema(
|
||||||
|
_prev: LogoResult | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<LogoResult> {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
const presetRaw = String(formData.get('themePreset') ?? '');
|
||||||
|
const themePreset: ThemePresetId = presetRaw in THEME_PRESETS ? (presetRaw as ThemePresetId) : 'pizarra';
|
||||||
|
|
||||||
|
const usarColor = formData.get('usarColor') === 'on';
|
||||||
|
const colorRaw = String(formData.get('themeColor') ?? '').trim();
|
||||||
|
const themeColor = usarColor && isHexColor(colorRaw) ? colorRaw : null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tenants)
|
||||||
|
.set({ themePreset, themeColor })
|
||||||
|
.where(eq(tenants.id, tenantId));
|
||||||
|
revalidatePath('/panel/empresa');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
import { getTenantPerfil } from '@/db/tenant-queries';
|
||||||
import { actualizarEmpresa } from './actions';
|
import { actualizarEmpresa } from './actions';
|
||||||
import LogoUploader from '@/components/panel/LogoUploader';
|
import LogoUploader from '@/components/panel/LogoUploader';
|
||||||
|
import AboutFotoUploader from '@/components/panel/AboutFotoUploader';
|
||||||
|
import ThemePicker from '@/components/panel/ThemePicker';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function EmpresaPage() {
|
export default async function EmpresaPage() {
|
||||||
const perfil = await getTenantPerfil();
|
const perfil = await getTenantPerfil();
|
||||||
|
|
||||||
|
const h = await headers();
|
||||||
|
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||||
|
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||||
|
const funnelUrl = `${proto}://${host}/${perfil.slug}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 max-w-2xl">
|
<div className="space-y-10 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
|
<h1 className="font-display text-3xl tracking-tight text-black">Datos de empresa</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
|
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
|
||||||
cliente. Manténlos al día.
|
cliente. Manténlos al día.
|
||||||
@@ -34,6 +42,32 @@ export default async function EmpresaPage() {
|
|||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="text-sm md:col-span-2">
|
||||||
|
<span className="block text-gray-500 mb-1">Enlace de tu funnel *</span>
|
||||||
|
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<span className="px-3 py-2 text-gray-400 bg-gray-50 border-r border-gray-200 select-none whitespace-nowrap">
|
||||||
|
{host}/
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="slug"
|
||||||
|
required
|
||||||
|
defaultValue={perfil.slug}
|
||||||
|
pattern="[a-z0-9-]+"
|
||||||
|
className="flex-1 px-3 py-2 outline-none min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="block text-gray-400 mt-1.5 text-xs">
|
||||||
|
Esta es la dirección que compartes con tus clientes:{' '}
|
||||||
|
<a
|
||||||
|
href={funnelUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-700 underline underline-offset-2 break-all"
|
||||||
|
>
|
||||||
|
{funnelUrl}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
<span className="block text-gray-500 mb-1">CIF / NIF</span>
|
<span className="block text-gray-500 mb-1">CIF / NIF</span>
|
||||||
<input
|
<input
|
||||||
@@ -83,11 +117,96 @@ export default async function EmpresaPage() {
|
|||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
|
||||||
|
<div className="md:col-span-2 border-t border-gray-100 pt-5 mt-1">
|
||||||
|
<h3 className="font-bold text-black">SEO de tu funnel</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Personaliza cómo aparece tu página en Google y al compartirla. Si lo dejas vacío,
|
||||||
|
usamos un texto por defecto con el nombre de tu empresa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm md:col-span-2">
|
||||||
|
<span className="block text-gray-500 mb-1">Título (title)</span>
|
||||||
|
<input
|
||||||
|
name="seoTitle"
|
||||||
|
defaultValue={perfil.seoTitle ?? ''}
|
||||||
|
maxLength={70}
|
||||||
|
placeholder={`${perfil.nombreEmpresa} · Presupuesto de reforma`}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm md:col-span-2">
|
||||||
|
<span className="block text-gray-500 mb-1">Descripción (meta description)</span>
|
||||||
|
<textarea
|
||||||
|
name="seoDescription"
|
||||||
|
defaultValue={perfil.seoDescription ?? ''}
|
||||||
|
maxLength={160}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Pide tu presupuesto de reforma con render IA en minutos."
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 border-t border-gray-100 pt-5 mt-1">
|
||||||
|
<h3 className="font-bold text-black">Bloque “Quiénes somos”</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Si lo activas, en tu funnel aparece un bloque con tu foto, tu historia y tus años de
|
||||||
|
experiencia.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm md:col-span-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="aboutEnabled"
|
||||||
|
defaultChecked={perfil.aboutEnabled}
|
||||||
|
className="w-4 h-4 accent-[#2f5c46]"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700">Mostrar el bloque “Quiénes somos” en mi funnel</span>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-gray-500 mb-1">Años de experiencia</span>
|
||||||
|
<input
|
||||||
|
name="aniosExperiencia"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
defaultValue={perfil.aniosExperiencia ?? ''}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm md:col-span-2">
|
||||||
|
<span className="block text-gray-500 mb-1">Texto de presentación</span>
|
||||||
|
<textarea
|
||||||
|
name="aboutTexto"
|
||||||
|
defaultValue={perfil.aboutTexto ?? ''}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Cuéntale al cliente quién eres, tu trayectoria y por qué confiar en ti."
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="md:col-span-2 justify-self-start inline-flex items-center justify-center rounded-lg bg-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-900">
|
||||||
Guardar datos
|
Guardar datos
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="font-bold text-black mb-1">Foto de “Quiénes somos”</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Tu foto o la de tu equipo. Aparece en el bloque “Quiénes somos” de tu funnel.
|
||||||
|
</p>
|
||||||
|
<AboutFotoUploader fotoUrl={perfil.aboutFotoUrl} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="font-bold text-black mb-1">Tema de tu funnel</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Elige los colores y la tipografía con los que tus clientes ven tu landing. Puedes partir
|
||||||
|
de un preset y, si quieres, fijar tu propio color de marca.
|
||||||
|
</p>
|
||||||
|
<ThemePicker themePreset={perfil.themePreset} themeColor={perfil.themeColor} />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
56
mvp/b2c/src/app/panel/galeria/actions.ts
Normal file
56
mvp/b2c/src/app/panel/galeria/actions.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { galeriaFotos } from '@/db/schema';
|
||||||
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||||
|
import { GALERIA_MAX_FOTOS } from '@/lib/galeria';
|
||||||
|
|
||||||
|
const GALERIA_MAX_BYTES = 2_000_000;
|
||||||
|
const GALERIA_TIPOS = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
|
||||||
|
export type GaleriaResult = { ok: boolean; error?: string };
|
||||||
|
|
||||||
|
export async function subirFotoGaleria(
|
||||||
|
_prev: GaleriaResult | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<GaleriaResult> {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
const file = formData.get('foto');
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return { ok: false, error: 'Selecciona una imagen.' };
|
||||||
|
}
|
||||||
|
if (!GALERIA_TIPOS.includes(file.type)) {
|
||||||
|
return { ok: false, error: 'Formato no válido. Usa PNG, JPG o WEBP.' };
|
||||||
|
}
|
||||||
|
if (file.size > GALERIA_MAX_BYTES) {
|
||||||
|
return { ok: false, error: 'La imagen no puede superar los 2 MB.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existentes = await db
|
||||||
|
.select({ id: galeriaFotos.id })
|
||||||
|
.from(galeriaFotos)
|
||||||
|
.where(eq(galeriaFotos.tenantId, tenantId));
|
||||||
|
if (existentes.length >= GALERIA_MAX_FOTOS) {
|
||||||
|
return { ok: false, error: `Has alcanzado el máximo de ${GALERIA_MAX_FOTOS} fotos.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const titulo = String(formData.get('titulo') ?? '').trim() || null;
|
||||||
|
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
|
||||||
|
const dataUri = `data:${file.type};base64,${base64}`;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(galeriaFotos)
|
||||||
|
.values({ tenantId, url: dataUri, titulo, orden: existentes.length });
|
||||||
|
revalidatePath('/panel/galeria');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarFotoGaleria(id: string) {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
await db
|
||||||
|
.delete(galeriaFotos)
|
||||||
|
.where(and(eq(galeriaFotos.id, id), eq(galeriaFotos.tenantId, tenantId)));
|
||||||
|
revalidatePath('/panel/galeria');
|
||||||
|
}
|
||||||
73
mvp/b2c/src/app/panel/galeria/page.tsx
Normal file
73
mvp/b2c/src/app/panel/galeria/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { getGaleriaPanel } from '@/db/tenant-queries';
|
||||||
|
import { eliminarFotoGaleria } from './actions';
|
||||||
|
import { GALERIA_MAX_FOTOS } from '@/lib/galeria';
|
||||||
|
import GaleriaUploader from '@/components/panel/GaleriaUploader';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function GaleriaPage() {
|
||||||
|
const fotos = await getGaleriaPanel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl tracking-tight text-black">Galería de trabajos</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Sube fotos de reformas que ya has hecho. Aparecen en tu funnel para dar confianza al
|
||||||
|
cliente antes de pedir presupuesto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GaleriaUploader total={fotos.length} max={GALERIA_MAX_FOTOS} />
|
||||||
|
|
||||||
|
{fotos.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Aún no has subido ninguna foto. La galería no se mostrará en tu funnel hasta que añadas la
|
||||||
|
primera.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
|
{fotos.map((foto) => (
|
||||||
|
<figure
|
||||||
|
key={foto.id}
|
||||||
|
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-white"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={foto.url}
|
||||||
|
alt={foto.titulo ?? 'Reforma'}
|
||||||
|
className="aspect-[4/3] w-full object-cover"
|
||||||
|
/>
|
||||||
|
{foto.titulo && (
|
||||||
|
<figcaption className="px-3 py-2 text-xs font-medium text-gray-700 truncate">
|
||||||
|
{foto.titulo}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
<form action={eliminarFotoGaleria.bind(null, foto.id)} className="absolute top-2 right-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="Eliminar foto"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-gray-600 shadow-sm transition hover:bg-red-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ import { db } from '@/db';
|
|||||||
import { tenants } from '@/db/schema';
|
import { tenants } from '@/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import AppNav from '@/components/AppNav';
|
import AppNav from '@/components/AppNav';
|
||||||
|
import PanelTour from '@/components/panel/PanelTour';
|
||||||
|
|
||||||
const PANEL_LINKS = [
|
const PANEL_LINKS = [
|
||||||
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||||
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
|
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
|
||||||
|
{ href: '/panel/galeria', label: 'Galería', icon: 'galeria' },
|
||||||
|
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
|
||||||
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
|
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -25,14 +28,14 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
|||||||
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
|
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-stone-50">
|
||||||
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
|
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
|
||||||
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
<Link href="/panel" className="flex items-center gap-2 min-w-0">
|
<Link href="/panel" className="flex items-center gap-2 min-w-0">
|
||||||
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
|
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-primary-700 text-white font-black italic text-lg leading-none">
|
||||||
R
|
R
|
||||||
</span>
|
</span>
|
||||||
<span className="font-extrabold tracking-tight text-black">Reformix</span>
|
<span className="font-bold tracking-tight text-black">Reformix</span>
|
||||||
<span className="hidden sm:inline text-gray-300">/</span>
|
<span className="hidden sm:inline text-gray-300">/</span>
|
||||||
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
|
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
|
||||||
{nombreEmpresa}
|
{nombreEmpresa}
|
||||||
@@ -42,6 +45,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
||||||
|
<PanelTour />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
mvp/b2c/src/app/panel/opiniones/actions.ts
Normal file
36
mvp/b2c/src/app/panel/opiniones/actions.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { testimonios } from '@/db/schema';
|
||||||
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||||
|
|
||||||
|
type TestimonioEstado = (typeof testimonios.estado.enumValues)[number];
|
||||||
|
|
||||||
|
async function setEstado(testimonioId: string, estado: TestimonioEstado) {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(testimonios)
|
||||||
|
.set({ estado })
|
||||||
|
.where(and(eq(testimonios.id, testimonioId), eq(testimonios.tenantId, tenantId)))
|
||||||
|
.returning({ id: testimonios.id });
|
||||||
|
if (!updated) throw new Error('Opinión no encontrada.');
|
||||||
|
revalidatePath('/panel/opiniones');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicarTestimonio(testimonioId: string) {
|
||||||
|
await setEstado(testimonioId, 'publicado');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ocultarTestimonio(testimonioId: string) {
|
||||||
|
await setEstado(testimonioId, 'oculto');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarTestimonio(testimonioId: string) {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
await db
|
||||||
|
.delete(testimonios)
|
||||||
|
.where(and(eq(testimonios.id, testimonioId), eq(testimonios.tenantId, tenantId)));
|
||||||
|
revalidatePath('/panel/opiniones');
|
||||||
|
}
|
||||||
125
mvp/b2c/src/app/panel/opiniones/page.tsx
Normal file
125
mvp/b2c/src/app/panel/opiniones/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { getTestimoniosPanel, type TestimonioPanel } from '@/db/tenant-queries';
|
||||||
|
import { formatFecha } from '@/lib/funnel';
|
||||||
|
import { publicarTestimonio, ocultarTestimonio, eliminarTestimonio } from './actions';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const ESTADO_BADGE: Record<TestimonioPanel['estado'], { label: string; className: string }> = {
|
||||||
|
pendiente: { label: 'Pendiente', className: 'bg-amber-100 text-amber-700' },
|
||||||
|
publicado: { label: 'Publicado', className: 'bg-green-100 text-green-700' },
|
||||||
|
oculto: { label: 'Oculto', className: 'bg-gray-100 text-gray-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function Estrellas({ rating }: { rating: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0.5" aria-label={`${rating} de 5 estrellas`}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<svg
|
||||||
|
key={n}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={`w-4 h-4 ${n <= rating ? 'fill-yellow-400' : 'fill-gray-200'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OpinionesPage() {
|
||||||
|
const testimonios = await getTestimoniosPanel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl tracking-tight text-black">Opiniones</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Las opiniones que te dejan tus clientes. Aprueba las que quieras mostrar en tu funnel; solo
|
||||||
|
las publicadas aparecen en tu página.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testimonios.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-sm text-gray-400">
|
||||||
|
Aún no tienes opiniones. Solicítalas a tus clientes desde la ficha de un lead ganado.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{testimonios.map((t) => {
|
||||||
|
const badge = ESTADO_BADGE[t.estado];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-black">{t.nombre}</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${badge.className}`}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t.contexto && <span className="text-xs text-gray-500">{t.contexto}</span>}
|
||||||
|
</div>
|
||||||
|
<Estrellas rating={t.rating} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed italic">{t.texto}</p>
|
||||||
|
|
||||||
|
{t.fotos.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{t.fotos.map((url, i) => (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="w-20 h-20 object-cover rounded-lg border border-gray-200"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
|
||||||
|
<span className="text-xs text-gray-400 mr-auto">{formatFecha(t.createdAt)}</span>
|
||||||
|
{t.estado !== 'publicado' && (
|
||||||
|
<form action={publicarTestimonio.bind(null, t.id)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Publicar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{t.estado === 'publicado' && (
|
||||||
|
<form action={ocultarTestimonio.bind(null, t.id)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-gray-300 text-gray-700 text-xs font-semibold hover:border-gray-500"
|
||||||
|
>
|
||||||
|
Ocultar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<form action={eliminarTestimonio.bind(null, t.id)}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 py-1.5 rounded-lg text-red-500 text-xs font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user