Compare commits
2
Commits
main
...
6d67b23e55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d67b23e55 | ||
|
|
dcbf750c09 |
@@ -1,42 +1,193 @@
|
|||||||
# AgendaPro — Auditoría de funcionalidades (basada en investigación real)
|
# AgendaPro — Auditoría de producto e investigación de mercado
|
||||||
|
|
||||||
Fuentes: agendapro.com/mx (site + blog), comparativa Wellbe vs AgendaPro, artículo de política de cancelaciones (CEO Julio Guzmán). G2/Capterra/Trustpilot bloquearon scraping (403), pero el blog oficial expone casos de uso y pain points reales.
|
> Revisión: **2026-07-28**. Sustituye la versión anterior de este archivo, que daba por faltantes
|
||||||
|
> cinco módulos que ya están construidos (caja, comisiones, recordatorios, booking público y política
|
||||||
|
> de cancelación). Cada fila de las tablas de abajo se verificó contra el esquema de
|
||||||
|
> [server/db.ts](server/db.ts), los endpoints montados en [server/index.ts](server/index.ts) y las
|
||||||
|
> páginas de [src/pages/](src/pages/) — no contra memoria ni contra el documento anterior.
|
||||||
|
|
||||||
## Lo que YA tenemos (vs AgendaPro real)
|
---
|
||||||
- ✅ Calendario drag & drop (mes/semana/día/lista) con auto-asignación a empleados
|
|
||||||
- ✅ Servicios, empleados, clientes + historial/ficha
|
|
||||||
- ✅ Tickets, pagos, propinas
|
|
||||||
- ✅ Dashboard dueño (mejor empleado/servicio, top tickets, top/frecuentes clientes, ingresos)
|
|
||||||
- ✅ Multi-tenant SaaS + consola admin + plantillas
|
|
||||||
- ✅ Reseñas/ratings
|
|
||||||
|
|
||||||
## Brechas CRÍTICAS vs AgendaPro real (oportunidades de mejora)
|
## 1. El producto que emulamos
|
||||||
1. **Política de cancelación + no-shows** — AgendaPro destaca esto; en MX la inasistencia es 15–35%. Nosotros solo marcamos estado, sin penalización ni depósito. **#1 pain real.**
|
|
||||||
2. **Comisiones** — cálculo automático por venta/servicio. Nosotros no lo tenemos. Pain operativo grande.
|
|
||||||
3. **Cierre de caja / flujo de caja** — apertura/cierre diario, ingresos/egresos. Nosotros solo listamos tickets.
|
|
||||||
4. **Página de reservas online pública (24/7)** — el cliente se auto-agenda. Nuestro mayor gap de adquisición. Reduce ruido de WhatsApp.
|
|
||||||
5. **Recordatorios automáticos** (WhatsApp/SMS/email) — reducen inasistencias. Nosotros no tenemos centro de notificaciones.
|
|
||||||
6. **Inventario** — control de productos con alertas de stock bajo.
|
|
||||||
7. **Lista de espera / waitlist** — para rellenar cancelaciones.
|
|
||||||
8. **Fidelización / giftcards / membresías** — paquetes y crédito.
|
|
||||||
9. **Encuestas de satisfacción (NPS)** — aparte de reseñas puntuales.
|
|
||||||
10. **Multi-sucursal** dentro de un negocio (ya tenemos multi-tenant, no multi-branch).
|
|
||||||
11. **Sincronización Google Calendar.**
|
|
||||||
|
|
||||||
## Pain points reales de los usuarios (del blog/casos)
|
AgendaPro es el software de agendamiento líder en Latinoamérica: **+20.000 negocios**, presencia en
|
||||||
- No-shows = pérdida directa de ingresos (un salón perdía 15 citas/mes).
|
**+100 países**, foco en México, Colombia, Argentina y Chile. Su promesa comercial es *«el único
|
||||||
- Cierre de caja con descuadre ("menos dinero del que debería haber").
|
software que ordena tu negocio y acelera su crecimiento un 82%»*.
|
||||||
- Cálculo manual de comisiones = error y tiempo.
|
|
||||||
- Inventario desordenado en salones.
|
|
||||||
- Cobranza incómoda ("recordatorios de pago").
|
|
||||||
- Pérdida de clientes por falta de fidelización.
|
|
||||||
- Saturación de recepción y WhatsApps repetitivos preguntando horario/precio.
|
|
||||||
|
|
||||||
## Priorización (impacto × factibilidad) — lo que implementaremos
|
**Verticales:** salones de belleza, spas, barberías, peluquerías, centros de estética, clínicas,
|
||||||
- **P0 Política de cancelación + no-show** (company) — resuelve el dolor #1.
|
psicólogos, nutricionistas, fisioterapeutas, podólogos y bienestar en general.
|
||||||
- **P0 Comisiones** (company/empleado) — dolor operativo top.
|
|
||||||
- **P0 Página pública de reservas /b/:slug** (cliente) — mayor diferenciador, reduce WhatsApp.
|
|
||||||
- **P1 Cierre de caja** (company) — control financiero del día.
|
|
||||||
- **P1 Centro de notificaciones/recordatorios** (company) — reduce no-shows (simulado, sin WhatsApp real).
|
|
||||||
|
|
||||||
Perspectivas cubiertas: **cliente** (booking público), **empresa** (caja, comisiones, política, dashboard), **empleado** (sus comisiones, su agenda).
|
### 1.1 La escalera de planes (esto *es* el producto)
|
||||||
|
|
||||||
|
El modelo de negocio de AgendaPro es la escalera de planes, y cada módulo está deliberadamente
|
||||||
|
asignado a un escalón. Precios de lista en USD (LatAm) y EUR (España):
|
||||||
|
|
||||||
|
| Plan | USD/mes | EUR/mes | Profesionales | Correos mkt | Qué añade sobre el plan anterior |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Individual** | $9 | €10 | 1 | 500 | Agenda ilimitada + presencia en Marketplace, CRM, recordatorios automáticos, sitio de reservas, reportes de gestión, sistema de caja, niveles de acceso, control de ocupación, gestión de presupuesto |
|
||||||
|
| **Básico** | $29 | €19 | hasta 20 | 1.000 | **Inventario** (control + alertas de stock bajo), **Comisiones** (cálculo automático) |
|
||||||
|
| **Premium** ★ | $59 | €59 | hasta 20 | 2.000 | **Encuestas de satisfacción**, **Fichas personalizables**, **Ficha clínica + consentimiento informado**, **Giftcards**, **Presupuestos**, email automático de cumpleaños, sitio con URL y colores propios |
|
||||||
|
| **Pro** | $199 | €199 | hasta 20 | 5.000 | **Acceso a API**, soporte personalizado, integración Google Analytics / Meta Pixel |
|
||||||
|
|
||||||
|
★ = el que ellos marcan como «más popular».
|
||||||
|
|
||||||
|
**Complementos que se cobran aparte** (dato relevante: lo que su marketing presenta como bandera
|
||||||
|
central no viene incluido en ningún plan):
|
||||||
|
|
||||||
|
| Add-on | Precio | Detalle |
|
||||||
|
|---|---|---|
|
||||||
|
| WhatsApp | desde $7 USD / €5 al mes | **50 mensajes mensuales** |
|
||||||
|
| Videoconferencia | desde $11 USD al mes | pack de 2.500 min |
|
||||||
|
| Charly (asistente de marketing con IA) | desde $55 USD al mes | pago por resultados |
|
||||||
|
| Facturación electrónica | «próximamente» | — |
|
||||||
|
|
||||||
|
Prueba gratuita: **7 días**.
|
||||||
|
|
||||||
|
### 1.2 Módulos que anuncian, agrupados como ellos los agrupan
|
||||||
|
|
||||||
|
- **Citas:** agenda online, sitio de reservas 24/7, recordatorios automáticos por WhatsApp y email,
|
||||||
|
«IA de recordatorios por WhatsApp», administración de horarios.
|
||||||
|
- **CRM:** base de datos de clientes, historial de visitas, control de sesiones y tratamientos, ficha
|
||||||
|
del cliente, promociones personalizadas.
|
||||||
|
- **Inventario:** control de inventario, alertas de inventario bajo, comisiones por venta de productos.
|
||||||
|
- **Marketing y fidelización:** integración con Google Reserve en Google My Business, LinkPro (tarjeta
|
||||||
|
de presentación para redes), campañas de email marketing, programas de lealtad y giftcards, acceso
|
||||||
|
al **marketplace** de servicios.
|
||||||
|
- **Pagos:** registro y reportes de pagos, pagos con terminal, pagos online y link de pago,
|
||||||
|
facturación / CFDI, pago de sesiones.
|
||||||
|
- **Control del negocio:** control de caja, reportes de ingresos y egresos, reportes de ventas con IA,
|
||||||
|
control de múltiples sucursales, cálculo automático de comisiones.
|
||||||
|
- **Gimnasios / fitness:** agenda de clases grupales, control de membresías, gestión de entrenadores.
|
||||||
|
- **Multi-sucursal:** varias sedes desde una sola cuenta, con reportes consolidados.
|
||||||
|
|
||||||
|
### 1.3 Dónde AgendaPro es débil (según reseñas de usuarios)
|
||||||
|
|
||||||
|
Esto importa: son los huecos donde un competidor puede ganar en lugar de empatar.
|
||||||
|
|
||||||
|
1. **Fichas clínicas genéricas.** No tienen CIE-10 electrónico ni plantillas por especialidad. Es la
|
||||||
|
queja más concreta y sistemática.
|
||||||
|
2. **Permisos poco granulares por sede.** Problema real para clínicas medianas y grandes: no se puede
|
||||||
|
acotar bien qué ve cada persona en cada sucursal.
|
||||||
|
3. **Precio y alzas periódicas.** Usuarios reportan subidas recurrentes y retiro de funcionalidades de
|
||||||
|
planes que ya pagaban.
|
||||||
|
4. **Prueba de 7 días**, contra 14+ de la competencia.
|
||||||
|
5. **WhatsApp de pago y racionado** (50 mensajes/mes desde $7), siendo el canal que su propio marketing
|
||||||
|
pone al frente.
|
||||||
|
6. **Curva de costo para negocios chicos:** los planes intermedios superan los $40–80 USD/mes, lo que
|
||||||
|
los saca de rango para un negocio de 1–3 personas.
|
||||||
|
|
||||||
|
### 1.4 El marco competitivo, y la parte que no se resuelve con features
|
||||||
|
|
||||||
|
| | Modelo | Implicación |
|
||||||
|
|---|---|---|
|
||||||
|
| **AgendaPro** | Suscripción por escalones + add-ons | Ingreso predecible; el cliente paga antes de ver valor |
|
||||||
|
| **Fresha** | **$0 de mensualidad**; comisión sobre clientes nuevos del marketplace + ~2,19% + $0,20 USD por transacción | Sin barrera de entrada; monetiza adquisición y pagos |
|
||||||
|
|
||||||
|
La conclusión honesta de la investigación: **el foso de los dos líderes no es el software, es el
|
||||||
|
marketplace.** Fresha y AgendaPro traen clientes nuevos al negocio; eso no se replica implementando
|
||||||
|
módulos. Cualquier plan de equivalencia funcional debe asumir que empata en producto y no en
|
||||||
|
distribución.
|
||||||
|
|
||||||
|
### 1.5 Datos de industria (con su fuente, y una corrección)
|
||||||
|
|
||||||
|
- **No-shows: 10%–30%** según sector; **15%–25%** en belleza y estética. En clínicas de bienestar
|
||||||
|
(España) 12%–19%, hasta **23%** en odontología y masajes.
|
||||||
|
- **Recordatorios por WhatsApp a 3 días y 24 h antes reducen las ausencias entre 30% y 50%.**
|
||||||
|
- **Depósitos recomendados: 20%–30%** del valor del servicio, y el depósito debe **abonarse al costo
|
||||||
|
final**, no ser un cargo extra.
|
||||||
|
- **Ventana de cancelación estándar: 24–48 h**; 72–96 h en sectores de alta demanda.
|
||||||
|
- **Escala de penalización de ejemplo:** gratis con más de 48 h; 25% entre 48 y 24 h; 50% entre 24 y
|
||||||
|
12 h; 100% el mismo día.
|
||||||
|
- Coste ilustrativo: 5 citas perdidas por semana a 60 € ≈ **18.000 €/año** de ingreso perdido.
|
||||||
|
|
||||||
|
> **Corrección al documento anterior:** la versión previa de este archivo afirmaba «en MX la
|
||||||
|
> inasistencia es 15–35%» sin fuente. No encontré respaldo para el techo del 35%. Los rangos de arriba
|
||||||
|
> sí están sostenidos. Además, el artículo de política de cancelaciones de AgendaPro —citado antes como
|
||||||
|
> fuente de cifras— **no contiene estadísticas de no-show**: solo recomendaciones. Las cifras de arriba
|
||||||
|
> vienen de fuentes de industria independientes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auditoría: qué tiene hoy AgendaMax
|
||||||
|
|
||||||
|
Verificado contra esquema, endpoints y páginas.
|
||||||
|
|
||||||
|
### 2.1 Construido y funcionando
|
||||||
|
|
||||||
|
| Capacidad de AgendaPro | Estado | Evidencia en el repo |
|
||||||
|
|---|---|---|
|
||||||
|
| Agenda / calendario | ✅ | [CalendarPage.tsx](src/pages/CalendarPage.tsx) con FullCalendar, 3 breakpoints, drag & drop; `/api/appointments` |
|
||||||
|
| Sitio de reservas público 24/7 | ✅ | `/b/:slug` → [BookingPage.tsx](src/pages/public/BookingPage.tsx); `/api/public/:slug`, `/slots`, `/book` |
|
||||||
|
| Guard anti doble-reserva | ✅ **superior** | `runInTransaction` + `BEGIN IMMEDIATE` + `isAvailable` + INSERT en la misma tx ([scheduling.ts](server/lib/scheduling.ts)) |
|
||||||
|
| Auto-asignación de especialista | ✅ **no lo tiene AgendaPro** | `autoAssign`, `pickBestSlotEmployee`, `scoreCandidate` con especialidades y `efficiency_score` |
|
||||||
|
| Horarios por negocio y por empleado | ✅ | `businesses.working_hours`, `employees.working_hours` (JSON 1..7, `null` = hereda) |
|
||||||
|
| CRM / ficha e historial de cliente | ✅ | `clients` + [ClientDetailPage.tsx](src/pages/ClientDetailPage.tsx) con `no_show_count` |
|
||||||
|
| Comisiones | ✅ | `services.commission_pct`, `employees.commission_pct`, snapshot en `tickets.commission`; `/api/dashboard/commissions`, `/api/me/commissions`, [MyPerformancePage.tsx](src/pages/MyPerformancePage.tsx) |
|
||||||
|
| Control de caja / cierre diario | ✅ | `cash_sessions` (apertura, cierre, `expected_amount` → descuadre) + `cash_entries` (income/expense); `/api/cash/*`; [CashPage.tsx](src/pages/CashPage.tsx) |
|
||||||
|
| Reportes de gestión | ✅ | 8 endpoints en `/api/dashboard/*` + [DashboardPage.tsx](src/pages/DashboardPage.tsx) con recharts |
|
||||||
|
| Reseñas / ratings | ✅ | tabla `reviews` (AgendaPro no lo vende como módulo) |
|
||||||
|
| Multi-tenant + consola de plataforma | ✅ **superior** | `/api/admin/*`, plantillas por vertical, alta de negocio, `reset-demo`, cascade |
|
||||||
|
| Sitio de reservas con marca propia | ✅ | landing + booking con paleta heredada del panel (AgendaPro lo cobra en Premium) |
|
||||||
|
| App instalable | ✅ | PWA con service worker y prompt de instalación (equivalente funcional a su app nativa) |
|
||||||
|
| Aislamiento por tenant verificado | ✅ | `test:e2e` y `test:admin` comprueban 403 y no-visibilidad cruzada |
|
||||||
|
|
||||||
|
### 2.2 Construido a medias — el hueco entre «configurable» y «operante»
|
||||||
|
|
||||||
|
Estas tres son las más engañosas de la auditoría: existen en el esquema y en la UI, pero **no cierran
|
||||||
|
el ciclo**. Un demo puede mostrarlas y no hacen nada.
|
||||||
|
|
||||||
|
| Capacidad | Qué hay | Qué falta |
|
||||||
|
|---|---|---|
|
||||||
|
| **Política de cancelación y depósito** | `businesses.cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`; `GET /api/appointments/:id/cancellation-policy`; editor en [SettingsPage.tsx](src/pages/SettingsPage.tsx) | **Nunca se cobra nada.** No hay captura de depósito en el booking, ni aplicación de la penalización a un ticket, ni registro del cargo. La política se configura y se consulta; no tiene efecto económico. |
|
||||||
|
| **Recordatorios automáticos** | tabla `notifications` (canales `whatsapp`/`sms`/`email`, tipos `confirmation`/`reminder`/`cancellation`/`follow_up`/`review`), `/api/notifications` con `regenerate`/`send`/`cancel`/`stats`, [NotificationsPage.tsx](src/pages/NotificationsPage.tsx) | Es una **bandeja simulada**: `send` marca `sent` sin salir a ningún proveedor. Aceptable para demo, pero no equivale al módulo real. |
|
||||||
|
| **Niveles de acceso y permisos** | 3 roles fijos (`admin`/`owner`/`employee`) con autorización real en middlewares y aislamiento probado | Sin permisos granulares. Nota: **AgendaPro también es débil aquí** (§1.3), así que es una oportunidad, no solo una deuda. |
|
||||||
|
|
||||||
|
### 2.3 Brechas reales — lo que no existe
|
||||||
|
|
||||||
|
Ordenado por el escalón de plan al que pertenece en AgendaPro, porque eso define qué se puede
|
||||||
|
reclamar como «equivalente al plan X».
|
||||||
|
|
||||||
|
| # | Brecha | Plan AgendaPro | Notas de implementación |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Inventario de productos + alertas de stock bajo** | Básico | No hay tabla de productos ni movimientos de stock |
|
||||||
|
| 2 | **Venta multi-ítem (POS real)** | Básico/Individual | **Bloqueo estructural:** hoy `tickets` es 1 ticket = 1 cita = 1 servicio (`service_id` único). No se puede vender «corte + shampoo + giftcard» en una venta |
|
||||||
|
| 3 | **Comisión por venta de productos** | Básico | Depende de 1 y 2 |
|
||||||
|
| 4 | **Encuestas de satisfacción / NPS** | Premium | `reviews` cubre la reseña puntual, no la campaña de medición |
|
||||||
|
| 5 | **Fichas personalizables + ficha clínica + consentimiento informado** | Premium | Hoy solo `clients.notes`, texto libre. **Aquí AgendaPro es débil** (§1.3) |
|
||||||
|
| 6 | **Giftcards** | Premium | Depende de 2 |
|
||||||
|
| 7 | **Presupuestos** | Premium | Depende de 2 |
|
||||||
|
| 8 | **Paquetes, sesiones y membresías** | transversal | «Control de sesiones y tratamientos», «pago de sesiones», membresías de gimnasio. Nada de saldo prepago |
|
||||||
|
| 9 | **Clases grupales (capacidad > 1)** | fitness | `appointments` es estrictamente 1:1. Toca la invariante de agendado |
|
||||||
|
| 10 | **Multi-sucursal con reportes consolidados** | transversal | Tenemos multi-*tenant*, no multi-*branch*. Cambio de esquema profundo |
|
||||||
|
| 11 | **Email marketing / campañas / cumpleaños** | Individual+ (con cuotas por plan) | `notifications` es transaccional, no de campañas |
|
||||||
|
| 12 | **Pagos en línea / link de pago / cobro anticipado** | transversal | Cero captura de pago. Es lo que bloquea §2.2 |
|
||||||
|
| 13 | **Facturación electrónica / CFDI** | add-on | En AgendaPro está «próximamente» |
|
||||||
|
| 14 | **Google Reserve / Google Calendar** | Individual+ | Integración externa |
|
||||||
|
| 15 | **Control de ocupación (% de agenda ocupada)** | Individual | [metrics.ts](server/lib/metrics.ts) solo tiene `cancelRate`, `noShowRate`, `completionRate`. **Barato y visible** |
|
||||||
|
| 16 | **Gating por plan** | — | `businesses.plan` existe pero **no restringe nada**. Sin esto, la escalera de planes —que es el producto de AgendaPro— no se está emulando |
|
||||||
|
| 17 | **Videoconferencia** | add-on | |
|
||||||
|
| 18 | **API pública documentada** | Pro | |
|
||||||
|
| 19 | **Marketplace** | Individual+ | **El foso real (§1.4).** No es un módulo, es un canal de distribución |
|
||||||
|
| 20 | **Reportes con IA** | add-on (Charly) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Lectura de la auditoría
|
||||||
|
|
||||||
|
Tres conclusiones que condicionan cualquier plan:
|
||||||
|
|
||||||
|
1. **La brecha no es de agenda, es de dinero.** Todo lo que falta cuelga de dos cosas que no existen:
|
||||||
|
la **venta multi-ítem** (brecha 2) y la **captura de pago** (brecha 12). Inventario, giftcards,
|
||||||
|
presupuestos, paquetes, comisión de producto y el cierre de la política de cancelación dependen de
|
||||||
|
una o de las dos. Atacar módulos sueltos antes de eso produce features que no se pueden conectar.
|
||||||
|
|
||||||
|
2. **Ya somos mejores en el núcleo de agendado.** Auto-asignación por especialidad y eficiencia, guard
|
||||||
|
transaccional anti doble-reserva y consola de plataforma multi-tenant no están en la oferta de
|
||||||
|
AgendaPro. La equivalencia que falta es comercial y administrativa, no de calendario.
|
||||||
|
|
||||||
|
3. **La escalera de planes no se está emulando.** Es lo más desalineado del proyecto respecto al
|
||||||
|
producto real: `businesses.plan` es decorativo. Es además de las cosas más baratas de arreglar y la
|
||||||
|
que más hace que el demo se lea como un SaaS y no como una app.
|
||||||
|
|
||||||
|
El backlog priorizado y la descomposición en sub-proyectos viven en el spec de diseño de esta tanda,
|
||||||
|
no en este archivo. Este documento es la línea base de hechos.
|
||||||
|
|||||||
@@ -409,6 +409,29 @@ prueba contra producción o retrasa el chunk a propósito con `route()`.
|
|||||||
- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son
|
- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son
|
||||||
regenerables; no los versiones ni los tomes como fuente de verdad.
|
regenerables; no los versiones ni los tomes como fuente de verdad.
|
||||||
|
|
||||||
|
## Persistencia en producción
|
||||||
|
|
||||||
|
`data/agendapro.db` es el estado del producto y el despliegue **no** lo recrea. Dos
|
||||||
|
piezas lo sostienen, y hay que tocarlas juntas:
|
||||||
|
|
||||||
|
- **El `Dockerfile` NO declara `VOLUME /app/data`.** Esa instrucción hace que Docker
|
||||||
|
fabrique un volumen **anónimo** en cada arranque, y Coolify los purga al recrear el
|
||||||
|
contenedor: cada despliegue estrenaba base vacía, `ensureSeed()` la resembraba y se
|
||||||
|
perdían citas, clientes y todo lo configurado en Ajustes, sin error ni aviso. Se
|
||||||
|
verificó comparando el nombre del volumen entre despliegues: cambiaba cada vez y no
|
||||||
|
quedaba ningún huérfano con datos. **No lo reintroduzcas.**
|
||||||
|
- **La persistencia la aporta el volumen con nombre declarado en Coolify**
|
||||||
|
(`s30f7egdlkx4wyjp59o1iunc-agendamax-data` → `/app/data`, fila de
|
||||||
|
`local_persistent_volumes`). Ojo: la API de Coolify 4.1.2 devuelve 404 en
|
||||||
|
`/applications/*`, así que eso se configura por la UI o por su base de datos, no por
|
||||||
|
API. Si despliegas en otro host, declara allí un volumen equivalente: sin él la base
|
||||||
|
es efímera otra vez.
|
||||||
|
|
||||||
|
Respaldo: `/root/scripts/backup-agendamax.sh` en el host Proxmox, por cron diario a las
|
||||||
|
03:30, con retención de 14 días en `/root/backups/agendamax/`. Usa `VACUUM INTO`, no
|
||||||
|
`cp`: la base corre en WAL y copiar el `.db` en caliente da una foto anterior al último
|
||||||
|
checkpoint. El script verifica `integrity_check` y descarta el respaldo si falla.
|
||||||
|
|
||||||
## Seguridad
|
## Seguridad
|
||||||
|
|
||||||
Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y
|
Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
|||||||
|
# Propuesta técnica breve: plataforma de gestión para Yola Franco Spa
|
||||||
|
|
||||||
|
**Versión:** 0.1 — propuesta conceptual
|
||||||
|
**Objetivo:** construir una web app de operación diaria para el spa, con una experiencia extremadamente rápida para empleadas y un dashboard de control para la dueña, conectada con GoHighLevel (GHL) mediante API y webhooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumen de la solución
|
||||||
|
|
||||||
|
La plataforma funcionará como un sistema operativo interno del spa:
|
||||||
|
|
||||||
|
- **Dueña/administradora:** visualiza ventas, citas, rendimiento, clientes, servicios, campañas y operación.
|
||||||
|
- **Empleada:** gestiona su agenda, crea y modifica citas, consulta clientes y registra la atención con el mínimo número de clics.
|
||||||
|
- **GoHighLevel:** conserva la relación omnicanal y automatizaciones de marketing. La app sincroniza contactos, conversaciones y eventos de cita mediante API/webhooks.
|
||||||
|
- **PostgreSQL:** fuente de datos operativos de la plataforma: citas, clientes, servicios, empleadas, pagos, comisiones, auditoría y sincronizaciones.
|
||||||
|
|
||||||
|
La recomendación es no intentar clonar toda la superficie de AgendaPro en la primera versión. El MVP debe resolver primero la operación diaria, la visibilidad del negocio y la integración confiable con GHL.
|
||||||
|
|
||||||
|
## 2. Alcance funcional
|
||||||
|
|
||||||
|
### 2.1 Panel de la administradora
|
||||||
|
|
||||||
|
1. **Dashboard ejecutivo**
|
||||||
|
- Ventas del día, semana y mes.
|
||||||
|
- Citas agendadas, atendidas, canceladas y no-show.
|
||||||
|
- Ingresos por servicio, empleada y canal.
|
||||||
|
- Ticket promedio.
|
||||||
|
- Tasa de recompra y clientes nuevos.
|
||||||
|
- Ocupación de agenda por empleada.
|
||||||
|
- Top clientes por frecuencia, gasto y última visita.
|
||||||
|
- Top servicios y servicios con baja demanda.
|
||||||
|
- Fuente de adquisición: orgánico, Instagram, Facebook, WhatsApp, campañas GHL, referido u otro.
|
||||||
|
|
||||||
|
2. **Calendario global**
|
||||||
|
- Vista diaria, semanal y mensual.
|
||||||
|
- Filtros por empleada, servicio, estado y ubicación.
|
||||||
|
- Crear, mover, confirmar, reprogramar y cancelar citas.
|
||||||
|
- Bloqueos de horario, descansos, vacaciones y días no laborables.
|
||||||
|
|
||||||
|
3. **Clientes/CRM operativo**
|
||||||
|
- Búsqueda por nombre, teléfono, correo o identificador GHL.
|
||||||
|
- Historial de citas, servicios, pagos, notas y conversaciones enlazadas.
|
||||||
|
- Etiquetas: nuevo, frecuente, VIP, inactivo, campaña, referido, etc.
|
||||||
|
- Consentimiento de comunicaciones y preferencias.
|
||||||
|
- Próxima recomendación de servicio y fecha sugerida de regreso.
|
||||||
|
- Detección de posibles duplicados antes de crear un cliente.
|
||||||
|
|
||||||
|
4. **Catálogo y configuración**
|
||||||
|
- Servicios, categorías, duración, precio, buffer y empleadas habilitadas.
|
||||||
|
- Horarios de atención y reglas de disponibilidad.
|
||||||
|
- Comisiones por servicio o por empleada.
|
||||||
|
- Paquetes, promociones y tarjetas/membresías en una fase posterior.
|
||||||
|
|
||||||
|
5. **Reportes**
|
||||||
|
- Exportación CSV/XLSX de clientes, citas y ventas.
|
||||||
|
- Reporte por periodo, empleada, servicio y canal.
|
||||||
|
- Registro de cambios y actividad administrativa.
|
||||||
|
|
||||||
|
### 2.2 Panel de la empleada
|
||||||
|
|
||||||
|
Diseñado primero para móvil y tablet, con navegación reducida:
|
||||||
|
|
||||||
|
- **Hoy:** próximas citas, hora, cliente, servicio y estado.
|
||||||
|
- **Mi agenda:** día/semana con bloques visuales.
|
||||||
|
- **Nueva cita rápida:** seleccionar fecha/hora, servicio, cliente y confirmar.
|
||||||
|
- **Búsqueda de cliente:** resultados mientras se escribe; evitar duplicados.
|
||||||
|
- **Alta rápida:** nombre y teléfono obligatorios; correo y notas opcionales.
|
||||||
|
- **Acciones de una cita:** confirmar, iniciar, completar, reprogramar, cancelar y marcar no-show.
|
||||||
|
- **Ficha resumida:** historial reciente, notas relevantes, preferencias y próxima visita.
|
||||||
|
- **Rendimiento personal:** citas atendidas, ventas generadas, ticket promedio, cancelaciones y comisión estimada.
|
||||||
|
|
||||||
|
La empleada no debe ver información financiera global ni clientes ajenos a sus permisos, salvo que la administradora lo configure.
|
||||||
|
|
||||||
|
## 3. Roles y permisos
|
||||||
|
|
||||||
|
Usar autorización basada en roles (RBAC), no solamente ocultamiento visual:
|
||||||
|
|
||||||
|
| Recurso | Administradora | Empleada |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Dashboard global | Sí | No |
|
||||||
|
| Dashboard personal | Sí | Sí |
|
||||||
|
| Calendario global | Sí | Según permiso |
|
||||||
|
| Mi agenda | Sí | Sí |
|
||||||
|
| Crear cita | Sí | Sí |
|
||||||
|
| Editar/cancelar cualquier cita | Sí | Solo propias, según regla |
|
||||||
|
| Ver clientes | Todos | Necesarios para operar |
|
||||||
|
| Exportar clientes/ventas | Sí | No |
|
||||||
|
| Editar precios/comisiones | Sí | No |
|
||||||
|
| Ver ventas globales | Sí | No |
|
||||||
|
| Configurar GHL | Sí | No |
|
||||||
|
| Auditoría | Sí | No |
|
||||||
|
|
||||||
|
La aplicación debe estar preparada para `tenant_id`, aunque inicialmente exista un solo spa. Esto evita rediseñar la base si después se ofrecen cuentas a otros negocios.
|
||||||
|
|
||||||
|
## 4. Arquitectura propuesta
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Web app responsive]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[API Python: FastAPI]
|
||||||
|
| | |
|
||||||
|
| | +--> [Worker: Celery/RQ + Redis]
|
||||||
|
| +-----------> [GoHighLevel API]
|
||||||
|
+-------------------> [PostgreSQL]
|
||||||
|
|
|
||||||
|
+--> métricas/reportes
|
||||||
|
|
||||||
|
[GHL Webhooks] ---> [Endpoint seguro] ---> [Event inbox] ---> [Worker]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Componentes
|
||||||
|
|
||||||
|
- **Frontend:** Next.js/React + TypeScript, PWA instalable, Tailwind CSS o sistema de componentes equivalente.
|
||||||
|
- **Backend:** Python 3.12+ con FastAPI, Pydantic y SQLAlchemy 2.x/SQLModel.
|
||||||
|
- **Base de datos:** PostgreSQL 16+, migraciones con Alembic.
|
||||||
|
- **Cola y caché:** Redis; workers para webhooks, sincronizaciones y mensajes sin bloquear la interfaz.
|
||||||
|
- **Autenticación:** sesiones seguras con cookies HttpOnly o JWT de corta duración con refresh rotativo. MFA para la administradora en una fase posterior.
|
||||||
|
- **Despliegue inicial:** Docker Compose en staging; producción con PostgreSQL administrado, Redis administrado y servicio web/worker separado.
|
||||||
|
- **Observabilidad:** logs estructurados, Sentry/OpenTelemetry opcional, métricas de errores de integración y tiempos de respuesta.
|
||||||
|
|
||||||
|
## 5. Modelo de datos inicial
|
||||||
|
|
||||||
|
Tablas principales:
|
||||||
|
|
||||||
|
- `tenants`: spa/cuenta, zona horaria, configuración y estado.
|
||||||
|
- `users`: usuarios internos, correo, estado y último acceso.
|
||||||
|
- `roles`, `user_roles`: administradora y empleada.
|
||||||
|
- `employees`: perfil operativo, especialidades, horarios y comisión.
|
||||||
|
- `services`: nombre, categoría, duración, precio, buffer, activo.
|
||||||
|
- `employee_services`: servicios que puede realizar cada empleada.
|
||||||
|
- `customers`: nombre, teléfono normalizado, correo, consentimiento, `ghl_contact_id`.
|
||||||
|
- `customer_tags`: etiquetas operativas y de adquisición.
|
||||||
|
- `appointments`: cliente, empleada, servicio, inicio, fin, estado, origen, notas y `ghl_appointment_id`.
|
||||||
|
- `appointment_events`: historial de cambios de una cita.
|
||||||
|
- `payments`: monto, método, estado, referencia y fecha.
|
||||||
|
- `campaign_attributions`: UTM, campaña, fuente, medio y primer/último contacto.
|
||||||
|
- `conversations`: referencia a conversación/canal en GHL; no necesariamente almacenar todo el contenido si GHL es la fuente principal.
|
||||||
|
- `integration_connections`: ubicación GHL, tokens cifrados, scopes y estado.
|
||||||
|
- `integration_events`: webhook/evento recibido, payload hash, estado, reintentos e idempotency key.
|
||||||
|
- `outbox_events`: eventos internos pendientes de enviar a GHL.
|
||||||
|
- `audit_logs`: quién cambió qué, cuándo y desde dónde.
|
||||||
|
|
||||||
|
### Reglas importantes
|
||||||
|
|
||||||
|
- Normalizar teléfonos a formato E.164 (`+52...`) antes de buscar o crear contactos.
|
||||||
|
- `UNIQUE (tenant_id, normalized_phone)` para evitar duplicados básicos.
|
||||||
|
- Guardar fechas en UTC y mostrar en `America/Mexico_City`.
|
||||||
|
- Usar `timestamptz` y rangos para impedir doble reserva.
|
||||||
|
- Crear una restricción de exclusión PostgreSQL por empleada para evitar solapamientos de citas confirmadas.
|
||||||
|
- No borrar clientes físicamente; usar estado, anonimización y política de retención.
|
||||||
|
|
||||||
|
## 6. Integración con GoHighLevel
|
||||||
|
|
||||||
|
### 6.1 Autenticación y configuración
|
||||||
|
|
||||||
|
Preferir OAuth 2.0 para una integración comercial reutilizable. Para una sola subcuenta controlada por el equipo, puede iniciarse con credenciales de ubicación/API adecuadas, almacenadas cifradas en el servidor.
|
||||||
|
|
||||||
|
No guardar tokens en frontend ni en PostgreSQL en texto plano. Usar un gestor de secretos o variables de entorno del servidor; las variables deben contener secretos, no configuración funcional.
|
||||||
|
|
||||||
|
Configurar en GHL:
|
||||||
|
|
||||||
|
- Location/Sub-account ID del spa.
|
||||||
|
- Client ID y Client Secret de la aplicación, si se usa OAuth.
|
||||||
|
- Scopes mínimos necesarios.
|
||||||
|
- URLs de redirección OAuth.
|
||||||
|
- URLs de webhooks.
|
||||||
|
- Firma/secreto de validación de webhooks, si está disponible en el evento utilizado.
|
||||||
|
|
||||||
|
### 6.2 Sincronización de contactos
|
||||||
|
|
||||||
|
**Crear desde la app:**
|
||||||
|
|
||||||
|
1. La empleada captura teléfono y nombre.
|
||||||
|
2. La API busca primero en PostgreSQL por teléfono normalizado.
|
||||||
|
3. Si existe `ghl_contact_id`, actualiza o reutiliza el contacto.
|
||||||
|
4. Si no existe, consulta GHL por teléfono/correo.
|
||||||
|
5. Si tampoco existe, crea el contacto mediante API.
|
||||||
|
6. Guarda `ghl_contact_id`, respuesta resumida y evento de sincronización.
|
||||||
|
7. La cita se crea solamente después de resolver el cliente local.
|
||||||
|
|
||||||
|
**Actualizar desde la app:** usar una cola `outbox_events`, con reintentos y clave de idempotencia. La pantalla no debe quedar bloqueada si GHL está temporalmente fuera de servicio; debe mostrar “guardado local / sincronización pendiente”.
|
||||||
|
|
||||||
|
**Recibir desde GHL:** registrar webhooks de creación/actualización de contacto, cambios relevantes y eventos de conversación disponibles para la cuenta. El webhook debe responder rápido con HTTP 2xx y procesarse en segundo plano.
|
||||||
|
|
||||||
|
### 6.3 Conversaciones y mensajes
|
||||||
|
|
||||||
|
La app puede mostrar una vista resumida de conversaciones, pero conviene mantener GHL como sistema principal de mensajería omnicanal:
|
||||||
|
|
||||||
|
- GHL recibe mensajes de WhatsApp, Facebook e Instagram.
|
||||||
|
- GHL dispara webhooks hacia la plataforma cuando exista un evento compatible.
|
||||||
|
- La plataforma almacena metadatos y referencias, no necesariamente todo el historial.
|
||||||
|
- Cuando la dueña o empleada envía un mensaje desde la app, el backend ejecuta una petición autenticada a la API de GHL.
|
||||||
|
- El frontend nunca llama directamente a GHL.
|
||||||
|
|
||||||
|
**Flujo de envío:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend -> POST /api/conversations/{id}/messages
|
||||||
|
-> valida permiso y contenido
|
||||||
|
-> crea outbox_event
|
||||||
|
-> worker llama API GHL
|
||||||
|
-> guarda resultado/id externo
|
||||||
|
-> frontend recibe estado enviado/fallido
|
||||||
|
```
|
||||||
|
|
||||||
|
Debe contemplar límites de frecuencia, reintentos con backoff, mensajes duplicados, archivos multimedia y errores de permisos. Las capacidades exactas de envío deben validarse contra la versión actual de la API y los canales habilitados en la subcuenta.
|
||||||
|
|
||||||
|
### 6.4 Webhooks seguros e idempotentes
|
||||||
|
|
||||||
|
Endpoint sugerido:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/integrations/gohighlevel/webhooks/{tenant_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Proceso:
|
||||||
|
|
||||||
|
1. Validar firma, secreto o mecanismo oficial disponible.
|
||||||
|
2. Validar tamaño y estructura del payload.
|
||||||
|
3. Calcular hash del evento y revisar `integration_events`.
|
||||||
|
4. Si ya fue procesado, responder 200 sin duplicar efectos.
|
||||||
|
5. Insertar el evento en la bandeja de entrada.
|
||||||
|
6. Responder 200 rápidamente.
|
||||||
|
7. Worker transforma el evento y actualiza contacto/conversación/cita.
|
||||||
|
8. Registrar resultado, duración y número de reintentos.
|
||||||
|
|
||||||
|
## 7. API interna sugerida
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/auth/login
|
||||||
|
GET /api/dashboard/summary?from=&to=
|
||||||
|
GET /api/calendar?from=&to=&employee_id=
|
||||||
|
POST /api/appointments
|
||||||
|
PATCH /api/appointments/{id}
|
||||||
|
POST /api/appointments/{id}/confirm
|
||||||
|
POST /api/appointments/{id}/complete
|
||||||
|
POST /api/appointments/{id}/cancel
|
||||||
|
GET /api/customers?query=
|
||||||
|
POST /api/customers
|
||||||
|
GET /api/customers/{id}
|
||||||
|
PATCH /api/customers/{id}
|
||||||
|
GET /api/services
|
||||||
|
POST /api/services
|
||||||
|
GET /api/employees
|
||||||
|
GET /api/reports/sales
|
||||||
|
GET /api/reports/performance
|
||||||
|
GET /api/conversations
|
||||||
|
POST /api/conversations/{id}/messages
|
||||||
|
POST /api/integrations/gohighlevel/connect
|
||||||
|
POST /api/integrations/gohighlevel/sync
|
||||||
|
POST /api/integrations/gohighlevel/webhooks/{tenant_id}
|
||||||
|
GET /api/integrations/jobs/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Todos los endpoints deben validar `tenant_id`, rol, permisos de recurso y esquema de entrada. La API debe devolver errores consistentes (`code`, `message`, `details`, `request_id`).
|
||||||
|
|
||||||
|
## 8. Métricas que importan al dueño
|
||||||
|
|
||||||
|
### Ventas
|
||||||
|
|
||||||
|
- Ingresos brutos/netos por periodo.
|
||||||
|
- Ticket promedio.
|
||||||
|
- Ventas por servicio, empleada y canal.
|
||||||
|
- Métodos de pago.
|
||||||
|
- Comisiones.
|
||||||
|
|
||||||
|
### Operación
|
||||||
|
|
||||||
|
- Utilización de horas disponibles.
|
||||||
|
- Citas atendidas, canceladas, reprogramadas y no-show.
|
||||||
|
- Tiempo promedio entre citas.
|
||||||
|
- Huecos disponibles próximos 7/14 días.
|
||||||
|
|
||||||
|
### Clientes
|
||||||
|
|
||||||
|
- Clientes nuevos vs recurrentes.
|
||||||
|
- Recompra a 30/60/90 días.
|
||||||
|
- Frecuencia y valor acumulado.
|
||||||
|
- Clientes inactivos.
|
||||||
|
- Fuente de adquisición y campaña.
|
||||||
|
|
||||||
|
### Marketing/GHL
|
||||||
|
|
||||||
|
- Contactos creados.
|
||||||
|
- Conversaciones iniciadas.
|
||||||
|
- Leads que terminaron en cita.
|
||||||
|
- Citas por campaña/UTM.
|
||||||
|
- Tiempo de respuesta, si GHL expone el dato necesario.
|
||||||
|
|
||||||
|
Los dashboards deben mostrar periodo, filtros, definición de cada métrica y fuente del dato. No presentar “ROI” si no se cuenta con costo de campaña confiable.
|
||||||
|
|
||||||
|
## 9. Experiencia de usuario y responsive
|
||||||
|
|
||||||
|
Prioridad de diseño: **la empleada opera con una mano y pocos segundos disponibles**.
|
||||||
|
|
||||||
|
- Mobile-first; soportar 360 px de ancho como mínimo.
|
||||||
|
- Botón persistente “Nueva cita”.
|
||||||
|
- Búsqueda global rápida de cliente.
|
||||||
|
- Calendario con colores por estado, no solamente por empleada.
|
||||||
|
- Formularios cortos y autoguardado de notas.
|
||||||
|
- Confirmación clara antes de cancelar o modificar una cita.
|
||||||
|
- Estados offline/pending para sincronizaciones.
|
||||||
|
- Accesibilidad WCAG 2.2 AA como objetivo.
|
||||||
|
- PWA para acceso desde la pantalla de inicio; no asumir aplicación nativa en el MVP.
|
||||||
|
|
||||||
|
## 10. Seguridad, privacidad y operación
|
||||||
|
|
||||||
|
- HTTPS obligatorio y cookies `Secure`, `HttpOnly`, `SameSite`.
|
||||||
|
- Hash de contraseñas con Argon2id o bcrypt configurado correctamente.
|
||||||
|
- Rate limiting en login, búsquedas y envío de mensajes.
|
||||||
|
- Validación de permisos en backend.
|
||||||
|
- Auditoría de cambios sensibles.
|
||||||
|
- Cifrado de secretos y datos sensibles en reposo cuando el proveedor lo permita.
|
||||||
|
- Backups automáticos de PostgreSQL y prueba periódica de restauración.
|
||||||
|
- Protección contra CSRF si se usan cookies de sesión.
|
||||||
|
- Sanitización de notas y contenido de mensajes.
|
||||||
|
- Política de privacidad, consentimiento para marketing y procedimiento de eliminación/anonimización.
|
||||||
|
- No guardar datos completos de tarjeta; integrar un proveedor de pagos si después se requiere cobro en línea.
|
||||||
|
|
||||||
|
## 11. Fases recomendadas
|
||||||
|
|
||||||
|
### Fase 0 — Descubrimiento técnico
|
||||||
|
|
||||||
|
- Confirmar documentación y scopes vigentes de GHL.
|
||||||
|
- Confirmar canales disponibles y eventos de webhook.
|
||||||
|
- Levantar catálogo, horarios, empleadas, reglas de reserva y métodos de pago.
|
||||||
|
- Definir si la fuente principal de agenda será la nueva app o AgendaPro durante la transición.
|
||||||
|
|
||||||
|
### Fase 1 — MVP operativo
|
||||||
|
|
||||||
|
- Login y RBAC.
|
||||||
|
- Clientes y búsqueda anti-duplicados.
|
||||||
|
- Servicios y empleadas.
|
||||||
|
- Calendario y nueva cita rápida.
|
||||||
|
- Estados de cita.
|
||||||
|
- Dashboard básico.
|
||||||
|
- PostgreSQL, migraciones, backups y auditoría.
|
||||||
|
|
||||||
|
### Fase 2 — Integración GHL
|
||||||
|
|
||||||
|
- Conexión segura con subcuenta.
|
||||||
|
- Crear/actualizar contactos.
|
||||||
|
- Sincronización inicial controlada.
|
||||||
|
- Webhooks idempotentes.
|
||||||
|
- Outbox y workers.
|
||||||
|
- Vista de conversaciones y envío de mensajes compatible con canales habilitados.
|
||||||
|
|
||||||
|
### Fase 3 — Analítica y crecimiento
|
||||||
|
|
||||||
|
- Ventas y pagos.
|
||||||
|
- Comisiones.
|
||||||
|
- Campañas/UTM.
|
||||||
|
- Recompra y reactivación.
|
||||||
|
- Reportes exportables.
|
||||||
|
- Paquetes, promociones, recordatorios y membresías.
|
||||||
|
|
||||||
|
## 12. Criterios de aceptación del MVP
|
||||||
|
|
||||||
|
- Una empleada puede crear una cita en menos de un minuto desde móvil.
|
||||||
|
- La búsqueda por teléfono encuentra un cliente existente sin crear duplicado.
|
||||||
|
- Dos usuarios no pueden reservar el mismo horario para la misma empleada.
|
||||||
|
- La administradora puede filtrar citas, ventas y rendimiento por periodo y empleada.
|
||||||
|
- Un cliente creado localmente se sincroniza con GHL o queda claramente marcado como pendiente.
|
||||||
|
- Un webhook repetido no duplica clientes, citas ni mensajes.
|
||||||
|
- Una caída temporal de GHL no borra ni impide guardar la operación local.
|
||||||
|
- Cada cambio relevante deja registro de usuario, fecha y acción.
|
||||||
|
- Los permisos impiden a una empleada consultar reportes globales o modificar precios.
|
||||||
|
- Los datos mostrados en dashboard incluyen su periodo y fuente.
|
||||||
|
|
||||||
|
## 13. Riesgos y decisiones pendientes
|
||||||
|
|
||||||
|
1. **API de GHL:** endpoints, scopes, límites y eventos disponibles pueden variar por versión y plan; deben validarse en un spike antes de comprometer el alcance.
|
||||||
|
2. **Doble agenda:** operar simultáneamente AgendaPro y la nueva app puede producir conflictos. Se debe elegir una fuente de verdad o construir sincronización explícita.
|
||||||
|
3. **Mensajería omnicanal:** Instagram, Facebook y WhatsApp pueden tener restricciones distintas; el sistema debe degradar con gracia y mostrar el estado real.
|
||||||
|
4. **Migración de datos:** antes de importar contactos hay que normalizar teléfonos y definir política de duplicados.
|
||||||
|
5. **Privacidad:** nombre, teléfono, historial y conversaciones requieren consentimiento, controles de acceso y política de retención.
|
||||||
|
6. **Pagos:** si inicialmente solo se registra pago manual, etiquetarlo como registro operativo y no como conciliación bancaria.
|
||||||
|
|
||||||
|
## Recomendación final
|
||||||
|
|
||||||
|
Construir primero una **agenda operacional mobile-first con clientes y dashboard**, y después agregar la capa omnicanal de GHL mediante una arquitectura de eventos (`outbox`, `event inbox`, workers e idempotencia). PostgreSQL es una elección adecuada para escalar la operación y los mensajes referenciados, pero GHL debe permanecer como sistema de conversaciones mientras la app se consolida como sistema de agenda, clientes y rendimiento.
|
||||||
|
|
||||||
|
El siguiente paso técnico recomendable es un **spike de integración de 3–5 días** que pruebe: autenticación GHL, creación/búsqueda de contacto, recepción de un webhook, envío de un mensaje permitido y sincronización de una cita. El resultado debe incluir scopes reales, payloads, límites y decisiones de fuente de verdad antes de iniciar el desarrollo completo.
|
||||||
Generated
+160
@@ -22,6 +22,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.451.0",
|
"lucide-react": "^0.451.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
@@ -1815,6 +1817,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||||
|
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -4196,6 +4210,95 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||||
|
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.16.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -4446,6 +4549,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.12.1",
|
"version": "10.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
@@ -5082,6 +5224,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -6041,6 +6192,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
+8
-1
@@ -26,7 +26,12 @@
|
|||||||
"audit:visual": "node visual-audit.mjs",
|
"audit:visual": "node visual-audit.mjs",
|
||||||
"audit:responsive": "node responsive-audit.mjs",
|
"audit:responsive": "node responsive-audit.mjs",
|
||||||
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
|
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
|
||||||
"test:pwa": "node pwa-e2e.mjs"
|
"test:pwa": "node pwa-e2e.mjs",
|
||||||
|
"pg:up": "docker compose -f platform/docker-compose.yml up -d",
|
||||||
|
"pg:down": "docker compose -f platform/docker-compose.yml down",
|
||||||
|
"pg:migrate": "node scripts/run-tsx.mjs platform/db/migrate.ts",
|
||||||
|
"platform": "node scripts/run-tsx.mjs platform/index.ts",
|
||||||
|
"test:platform": "cross-env DATABASE_URL=postgres://yola:[email protected]:5434/yola_test node --import tsx --test --test-concurrency=1 platform/test/*.test.ts platform/lib/*.test.ts platform/crm/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
@@ -43,6 +48,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.451.0",
|
"lucide-react": "^0.451.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Base de datos de la plataforma
|
||||||
|
DATABASE_URL=postgres://yola:[email protected]:5434/yola
|
||||||
|
TEST_DATABASE_URL=postgres://yola:[email protected]:5434/yola_test
|
||||||
|
PLATFORM_PORT=3100
|
||||||
|
|
||||||
|
# ── Bucéfalo CRM ────────────────────────────────────────────────────────────
|
||||||
|
# El token es de SUBCUENTA (PIT). No lo subas al repo: platform/.env está
|
||||||
|
# gitignorado. Si sospechas que se filtró, regenéralo en el CRM.
|
||||||
|
CRM_BASE_URL=https://services.leadconnectorhq.com
|
||||||
|
|
||||||
|
# Clave maestra con la que se cifran en Postgres los tokens de cada subcuenta.
|
||||||
|
# 32 bytes en base64. Genérala UNA vez con:
|
||||||
|
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# Si la pierdes, los tokens guardados dejan de descifrarse y hay que volver a
|
||||||
|
# vincular cada subcuenta a mano. Guárdala donde guardes los secretos.
|
||||||
|
CRM_MASTER_KEY=
|
||||||
|
|
||||||
|
# HEREDADAS: a partir del multi-tenant, cada negocio guarda sus credenciales
|
||||||
|
# cifradas en la base y se ponen desde la consola de administración. Estas dos
|
||||||
|
# solo las usa el script de migración de la credencial del primer negocio.
|
||||||
|
CRM_LOCATION_ID=
|
||||||
|
CRM_TOKEN=
|
||||||
|
|
||||||
|
# Mientras esta variable tenga valor, el servidor SOLO envía mensajes a esta
|
||||||
|
# dirección, sin importar a quién apunte la interfaz. Es la red de seguridad
|
||||||
|
# que impide escribirle a los 3 200 contactos reales del cliente por accidente.
|
||||||
|
CRM_TEST_EMAIL=[email protected]
|
||||||
|
|
||||||
|
# Descomenta para permitir envíos a las clientas de verdad. Es una decisión
|
||||||
|
# deliberada del dueño del proyecto, no un ajuste de configuración.
|
||||||
|
# CRM_ALLOW_REAL_SENDS=1
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# `platform/` — backend Postgres de Yola Franco Spa
|
||||||
|
|
||||||
|
Backend nuevo sobre PostgreSQL 16 para la plataforma del spa. Habla los mismos
|
||||||
|
contratos `/api` que el frontend de este repo, así que la SPA de `src/` funciona
|
||||||
|
contra él sin cambios de stack.
|
||||||
|
|
||||||
|
**El `server/` de SQLite sigue en pie y sin tocar**: es la demo de AgendaPro y el
|
||||||
|
punto de comparación. Los dos backends no se hablan ni comparten base.
|
||||||
|
|
||||||
|
Plan e historia de las decisiones:
|
||||||
|
[`docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md`](../docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md).
|
||||||
|
|
||||||
|
## Arranque
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pg:up # Postgres 16 en Docker, puerto 5434
|
||||||
|
npm run pg:migrate # aplica las migraciones pendientes
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/seed.ts # siembra el spa y citas de hoy
|
||||||
|
npm run platform # API en :3100
|
||||||
|
|
||||||
|
# el frontend contra este backend:
|
||||||
|
API_URL=http://127.0.0.1:3100 npx vite --port 5175
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuenta de la dueña: `[email protected]` / `demo1234`. El personal entra con
|
||||||
|
`karla@`, `brenda@` y `[email protected]`, misma contraseña.
|
||||||
|
|
||||||
|
**Puerto 5434 y no 5432/5433:** los dos están ocupados por contenedores de otros
|
||||||
|
proyectos en esta máquina.
|
||||||
|
|
||||||
|
## Pruebas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:platform # 38 pruebas
|
||||||
|
node --import tsx --test platform/lib/phone.test.ts # solo las puras
|
||||||
|
```
|
||||||
|
|
||||||
|
Corren contra la base `yola_test`, que se crea una vez:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec yola-postgres psql -U yola -d postgres -c "CREATE DATABASE yola_test OWNER yola"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--test-concurrency=1` en el script **no es cosmético**: cada archivo de prueba
|
||||||
|
hace `DROP SCHEMA public` y en paralelo se pisan entre sí.
|
||||||
|
`resetDb()` se niega a correr si `DATABASE_URL` no apunta a `yola_test`.
|
||||||
|
|
||||||
|
## Las tres decisiones que sostienen el diseño
|
||||||
|
|
||||||
|
1. **La doble reserva la impide el motor, no un `if`.** `appointments` lleva una
|
||||||
|
restricción `EXCLUDE USING gist (employee_id WITH =, during WITH &&)` sobre un
|
||||||
|
`tstzrange` generado. Postgres rechaza la fila con `23P01` y el router lo
|
||||||
|
traduce a un 409 en español. Requiere la extensión `btree_gist`, que aplica
|
||||||
|
`000_bootstrap.sql`.
|
||||||
|
2. **`visits` está separada de `appointments`.** Una cita es una intención; una
|
||||||
|
visita es un hecho con dinero. Un solo registro que sirve para planear y para
|
||||||
|
cerrar termina sin cerrarse nunca — es exactamente lo que dejó 3 002
|
||||||
|
oportunidades congeladas en el CRM del spa. Por eso el "no vino" es un estado
|
||||||
|
de la cita y **no** crea una visita vacía.
|
||||||
|
3. **`clients.phone_e164` es la clave de identidad.** Índice único parcial por
|
||||||
|
`(business_id, phone_e164)`, parcial porque el 40.8 % del histórico medido no
|
||||||
|
tiene teléfono y esas clientas tienen que poder existir: quedan marcadas
|
||||||
|
`contactable = false`.
|
||||||
|
|
||||||
|
## Deuda conocida, dicha sin rodeos
|
||||||
|
|
||||||
|
- **La autenticación no se endureció.** El token es el id del usuario en texto
|
||||||
|
plano y la contraseña se compara sin hashear, portado tal cual del backend de
|
||||||
|
demo. Arreglarlo es un entregable propio: bcrypt/Argon2id + sesión real +
|
||||||
|
`src/lib/api.ts` + el `AuthProvider` + las pruebas, todo a la vez. A medias
|
||||||
|
rompe el login.
|
||||||
|
- **Sin rate limiting y con `cors()` abierto.** Igual que el backend de demo.
|
||||||
|
- **La validación de entrada es manual.** `zod` está en `dependencies` y sigue
|
||||||
|
sin importarse en ningún archivo.
|
||||||
|
- **De Bucéfalo CRM falta lo de entrada.** Lo que hay está en la segunda mitad de
|
||||||
|
este documento; lo que no: webhooks (exigen OAuth y este token es un PIT), el
|
||||||
|
espejo persistido de conversaciones —las tablas existen y nadie las escribe—, y
|
||||||
|
el arrastre de citas.
|
||||||
|
- **El catálogo sembrado no es el del negocio.** Los nombres salen del
|
||||||
|
vocabulario medido en los hilos del CRM; **las duraciones y los precios son
|
||||||
|
marcadores de posición** y hay que sustituirlos por los reales antes de
|
||||||
|
enseñar esto como catálogo del spa.
|
||||||
|
- **Falta parte de la superficie de `/api`.** Hoy están `auth`, `business`,
|
||||||
|
`clients`, `appointments`, `attendance`, `day-close`, `crm` y `messages`.
|
||||||
|
Servicios, empleados, tablero, caja, tickets y recordatorios siguen solo en el
|
||||||
|
backend de SQLite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Integración con Bucéfalo CRM
|
||||||
|
|
||||||
|
Subcuenta **Yola Franco Spa**. Todo el diseño sale de hallazgos **medidos** contra el CRM real,
|
||||||
|
no de la especificación. Dos documentos, y conviene no confundirlos:
|
||||||
|
|
||||||
|
- [`crm/HALLAZGOS.md`](crm/HALLAZGOS.md) — los **47 hallazgos empíricos**: qué se ejerció, contra qué
|
||||||
|
y con qué resultado. Es la fuente de verdad y manda sobre la documentación oficial.
|
||||||
|
- [`crm/API.md`](crm/API.md) — la **referencia de endpoints**: rutas, parámetros, scopes, límites de
|
||||||
|
tasa, y la lista explícita de lo que la documentación oficial dice mal o no dice.
|
||||||
|
|
||||||
|
Los spikes que produjeron los hallazgos se pueden volver a correr.
|
||||||
|
|
||||||
|
## Puesta en marcha
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clave maestra del cifrado de credenciales (una vez por instalación):
|
||||||
|
cp platform/.env.example platform/.env
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
# → pégala en CRM_MASTER_KEY
|
||||||
|
|
||||||
|
# 2. Vincula la subcuenta desde la consola de administración:
|
||||||
|
# PUT /api/admin/businesses/:id/crm { location_id, token, label }
|
||||||
|
# Las credenciales se COMPRUEBAN contra el CRM antes de guardarse.
|
||||||
|
|
||||||
|
# 3. Trae los contactos (o pulsa el botón en la pantalla de Clientes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Para migrar un negocio que ya estaba vinculado por variables de entorno:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Qué hace hoy
|
||||||
|
|
||||||
|
| Pieza | Estado |
|
||||||
|
|---|---|
|
||||||
|
| **Traer contactos con su atribución UTM** | ✅ 3 210 en ~22 s, idempotente, con botón en Clientes |
|
||||||
|
| **Deduplicar por id → teléfono → correo** | ✅ Misma cadena que el CRM aplica. Cero duplicados sobre datos reales |
|
||||||
|
| **Proyectar citas como oportunidades** | ✅ `SERVICIO — CLIENTA`, importe del servicio, `open`/`won`/`lost` |
|
||||||
|
| **Bandeja de salida con reintentos** | ✅ Encolada en la misma transacción del cambio, despachada cada minuto |
|
||||||
|
| **Leer conversaciones y responder** | ✅ Solo correo: WhatsApp y SMS no están conectados en la subcuenta |
|
||||||
|
| **Ver la atribución en la ficha** | ✅ Fuente, medio, campaña, UTM, anuncio y fecha de sincronización |
|
||||||
|
|
||||||
|
## Multi-tenancy: una credencial por negocio
|
||||||
|
|
||||||
|
**Cada negocio guarda su propio `locationId` y su token privado, cifrado en la
|
||||||
|
base.** Antes el `locationId` era por negocio pero el token era una variable de
|
||||||
|
entorno global: con dos cuentas, el servidor usaba el token de la primera contra
|
||||||
|
la subcuenta de la segunda —401 en el mejor caso, escritura en la subcuenta
|
||||||
|
equivocada en el peor—. Tres piezas lo sostienen y hay que tocarlas juntas:
|
||||||
|
|
||||||
|
- **`CrmOptions.token` es obligatorio** ([crm/client.ts](crm/client.ts)). No tiene
|
||||||
|
valor por defecto a propósito: olvidarlo es un error de compilación, no una
|
||||||
|
petición con la credencial de otro cliente.
|
||||||
|
- **`CrmCtx { businessId, locationId, token }`** ([crm/ctx.ts](crm/ctx.ts)) sustituye
|
||||||
|
al `locationId: string` suelto que antes viajaba por once firmas. Es un objeto y
|
||||||
|
no dos parámetros porque dos `string` seguidos se cruzan sin que el compilador
|
||||||
|
diga nada. `ctxDe(businessId)` es el **único** sitio donde el token existe
|
||||||
|
descifrado, y solo en memoria.
|
||||||
|
- **El token se cifra con AES-256-GCM** ([lib/crypto.ts](lib/crypto.ts)), autenticado
|
||||||
|
a propósito: una fila manipulada hace que el descifrado **falle**, en vez de
|
||||||
|
devolver basura que acabaríamos mandando como credencial. La clave maestra vive
|
||||||
|
en `CRM_MASTER_KEY`, fuera de la base.
|
||||||
|
|
||||||
|
Lo único de la credencial que sale del servidor es `token_fingerprint`, los 6
|
||||||
|
últimos caracteres. Ni la API, ni los registros, ni `audit_log` ven el token.
|
||||||
|
|
||||||
|
El estrangulador también es **por token** y aprende la cuota de las cabeceras
|
||||||
|
`x-ratelimit-*` que el CRM devuelve: son 100 peticiones por 10 s, no la estimación
|
||||||
|
de 1 cada 650 ms con la que se escribió el cliente.
|
||||||
|
|
||||||
|
## La consola de administración de plataforma
|
||||||
|
|
||||||
|
`/api/admin`, solo para el rol `admin` (cuyo `business_id` es NULL):
|
||||||
|
|
||||||
|
| Endpoint | Qué hace |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/admin/businesses` | Las cuentas, con el estado de su vínculo. Nunca devuelve el token |
|
||||||
|
| `POST /api/admin/businesses` | Alta de cuenta y su dueña, en una transacción. El negocio nace con slug y horario |
|
||||||
|
| `PUT /api/admin/businesses/:id/crm` | Vincula la subcuenta. **Comprueba las credenciales contra el CRM antes de guardarlas** |
|
||||||
|
| `DELETE /api/admin/businesses/:id/crm` | Desvincula. Borra la credencial y conserva lo sincronizado |
|
||||||
|
| `PATCH /api/admin/businesses/:id` | Suspender o reactivar, renombrar, cambiar zona horaria |
|
||||||
|
|
||||||
|
## Sincronización por identificador
|
||||||
|
|
||||||
|
`POST /api/crm/sync/:entidad/:id` resuelve **una** entidad. Las cinco están ejercidas contra la
|
||||||
|
subcuenta real. La dirección la decide la entidad, no quien llama:
|
||||||
|
|
||||||
|
| Entidad | Dirección | Por qué |
|
||||||
|
|---|---|---|
|
||||||
|
| `contacto` | ← del CRM | Es su dueño: ahí viven la deduplicación y las automatizaciones |
|
||||||
|
| `conversacion` | ← del CRM | Se espeja con todos sus mensajes |
|
||||||
|
| `mensaje` | ← del CRM | Se espeja **con su hilo**: `messages.conversation_id` es obligatorio |
|
||||||
|
| `cita` | → al CRM | MEDIDO: el calendario del CRM tiene **una** cita en dos años |
|
||||||
|
| `servicio` | → al CRM | MEDIDO: su catálogo está **vacío** |
|
||||||
|
|
||||||
|
`POST /api/crm/sync/conversations` espeja las conversaciones recientes con sus mensajes.
|
||||||
|
|
||||||
|
**Si el spa empieza a agendar dentro del CRM, la premisa de las dos últimas se cae** y habrá que
|
||||||
|
decidir cuál de los dos manda cuando difieran. Conviene decidirlo antes de que pase.
|
||||||
|
|
||||||
|
## Las tres decisiones que no son obvias
|
||||||
|
|
||||||
|
**1. La sincronización de contactos va en una sola dirección: del CRM hacia aquí.**
|
||||||
|
El contacto es del CRM —es su llave de deduplicación y donde viven las automatizaciones—, así que
|
||||||
|
esta sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar pasa por la
|
||||||
|
bandeja de salida, que es otra cosa y tiene otras garantías.
|
||||||
|
|
||||||
|
**2. La oportunidad se recicla, no se duplica.** La subcuenta tiene
|
||||||
|
`allowDuplicateOpportunity: false`, y eso hace que el CRM rechace una segunda oportunidad por
|
||||||
|
contacto **aunque la primera esté cerrada**. La plataforma intenta crear y, si recibe ese rechazo,
|
||||||
|
reutiliza la existente con el nombre, el importe y el estado de la cita nueva. Si alguien activa
|
||||||
|
ese ajuste en el CRM, pasa a «una cita = una oportunidad» sin tocar código.
|
||||||
|
|
||||||
|
**3. Un fallo de transporte no se reintenta.** Un `5xx` es una respuesta: el servidor habló. Un
|
||||||
|
timeout no dice nada sobre si la escritura entró, y reenviarlo es fabricar la doble creación. Esas
|
||||||
|
filas quedan en `indeterminado` y se resuelven **leyendo**.
|
||||||
|
|
||||||
|
## Modo prueba de mensajes
|
||||||
|
|
||||||
|
Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa dirección**, sin importar a
|
||||||
|
quién apunte la interfaz. La subcuenta es la de un cliente real con 3 200 contactos: un bucle mal
|
||||||
|
escrito escribiría a personas de verdad. Se levanta con `CRM_ALLOW_REAL_SENDS=1`, y esa es una
|
||||||
|
decisión deliberada, no un descuido de configuración.
|
||||||
|
|
||||||
|
## Lo que NO hace, y conviene tener presente
|
||||||
|
|
||||||
|
- **La bandeja de mensajes todavía lee en vivo del CRM**, no del espejo. Las tablas `conversations`
|
||||||
|
y `messages` ya se llenan (`POST /api/crm/sync/conversations`), pero `MessagesPage` sigue sin
|
||||||
|
apuntar a ellas.
|
||||||
|
- **No recibe webhooks.** Exigen OAuth y este token es un PIT. La entrada es por sondeo: el botón.
|
||||||
|
- **No confirma entrega de correo.** El CRM acusa «encolado». La interfaz dice «en camino» a
|
||||||
|
propósito, y no «entregado».
|
||||||
|
- **No trae el catálogo de servicios**, porque el del CRM está vacío. Duración y precio viven aquí,
|
||||||
|
y lo que sí se puede es **publicarlos** hacia el CRM.
|
||||||
|
- **4 de cada 10 clientas no tienen teléfono.** El panel lo enseña en ámbar. Es el techo de
|
||||||
|
utilidad de cualquier recordatorio, y se arregla pidiendo el teléfono al agendar, no con código.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike.ts # sondeo de lectura
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts # escrituras, con relectura
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-opps.ts # regla de duplicados
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-dup.ts # ajustes de la subcuenta
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts # conectar y autodetectar
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts # listar basura de pruebas
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar
|
||||||
|
```
|
||||||
|
|
||||||
|
Los spikes **escriben en la subcuenta real del cliente**. Todo lo que crean lleva el tag
|
||||||
|
`agendamax:prueba` y el correo autorizado, y `crm-limpiar-pruebas.ts` los borra.
|
||||||
|
|
||||||
|
## Sondeos contra el CRM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts # lectura por id (solo lectura)
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts # los 7 calendarios (solo lectura)
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts # qué permisos tiene el token, sin crear nada
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts # ¿se puede deshacer?, sin crear nada
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts # ESCRIBE: crea, relee y borra
|
||||||
|
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <id> # mueve la credencial del .env a la base, cifrada
|
||||||
|
node platform/scripts/admin-ui-check.mjs # la consola de cuentas, en navegador
|
||||||
|
```
|
||||||
|
|
||||||
|
Los tres primeros **no escriben nada**. El de escritura crea, relee y **borra en un `finally`**, así
|
||||||
|
que no deja rastro aunque falle a mitad — y antes de escribir se comprobó con `crm-spike-borrado.ts`
|
||||||
|
que el borrado existe. Preguntar si se puede deshacer **antes** de tocar el CRM de un cliente, no
|
||||||
|
después.
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Referencia de la API de Bucéfalo CRM
|
||||||
|
|
||||||
|
Host base: `https://services.leadconnectorhq.com` · Autenticación: `Authorization: Bearer <token privado de subcuenta>`.
|
||||||
|
|
||||||
|
Este documento es la **referencia de endpoints**. Los hallazgos empíricos —lo que se ha ejercido
|
||||||
|
contra la subcuenta real y con qué resultado— viven en [`HALLAZGOS.md`](HALLAZGOS.md), y **mandan
|
||||||
|
sobre lo que diga aquí**: la documentación oficial de esta API está incompleta o desactualizada en
|
||||||
|
varios puntos concretos, todos marcados abajo.
|
||||||
|
|
||||||
|
Cada entrada lleva su origen:
|
||||||
|
**[DOC]** de la especificación OpenAPI pública del proveedor · **[MEDIDO]** ejercido contra la
|
||||||
|
subcuenta real de este proyecto · **[INFERENCIA]** deducción no verificada, que **no debe usarse como
|
||||||
|
base para escribir código sin comprobarla antes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## La cabecera `Version`: donde más chocan la documentación y la realidad
|
||||||
|
|
||||||
|
| Familia | Según la documentación | Lo que funciona [MEDIDO] |
|
||||||
|
|---|---|---|
|
||||||
|
| `/contacts/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||||
|
| `/locations/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||||
|
| `/conversations/` | `2021-04-15` | **`2021-07-28`** ⚠ |
|
||||||
|
| `/calendars/` | `2021-04-15` | **`v3`** ⚠ |
|
||||||
|
| `/opportunities/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||||
|
|
||||||
|
`client.ts` la elige sola por el prefijo de la ruta. **No la cambies «para alinearla con la
|
||||||
|
documentación»**: con estos valores se ejercieron los 3 210 contactos, los 3 213 hilos y los 7
|
||||||
|
calendarios. Equivocarla es un `400`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tres convenciones de paginación distintas en la misma API
|
||||||
|
|
||||||
|
Confundirlas devuelve listas **incompletas sin ningún error**, que es la peor forma de fallar.
|
||||||
|
|
||||||
|
| Qué se pagina | Cómo | Detalle |
|
||||||
|
|---|---|---|
|
||||||
|
| Contactos | `searchAfter` | El cursor sale del **último elemento** del array, no de la raíz de la respuesta. Con `page` se topa un techo de profundidad antes de los 3 200 |
|
||||||
|
| Conversaciones | `startAfterDate` | El valor de ordenación del último hilo de la página |
|
||||||
|
| Mensajes | `lastMessageId` + `nextPage` | El id del último mensaje, y un booleano que dice si hay más |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A · Contactos
|
||||||
|
|
||||||
|
### `GET /contacts/{contactId}`
|
||||||
|
Scope `contacts.readonly`. Devuelve el contacto **envuelto en `contact`**.
|
||||||
|
**[MEDIDO]** El `404` existe aunque la documentación no lo liste.
|
||||||
|
**[MEDIDO]** `attributionSource` trae `sessionSource` y `adId` poblados, que el esquema oficial **no
|
||||||
|
declara**. `mapAtribucion()` es más fiel que el esquema.
|
||||||
|
|
||||||
|
### `POST /contacts/search`
|
||||||
|
Scope `contacts.readonly`. **El esquema del cuerpo NO está documentado**: el `$ref` del OpenAPI
|
||||||
|
apunta a un objeto vacío. Todo lo que sabemos es medido:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "locationId": "…", "pageLimit": 100, "searchAfter": [1724900000000, "seD4Pf…"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**[MEDIDO]** 3 210 contactos en ~22 s, idempotente. **[INFERENCIA]** 100 es probablemente el máximo
|
||||||
|
de `pageLimit`; no está escrito en ningún sitio.
|
||||||
|
**[MEDIDO]** El buscador descarta `utmCampaign` y conserva `campaign`. Al dar de alta hay que mandar
|
||||||
|
**los dos**.
|
||||||
|
|
||||||
|
**Filtrar por id, teléfono o correo dentro de este buscador no está documentado en ninguna parte.**
|
||||||
|
Lo que funciona: por id → `GET /contacts/{id}`; por teléfono o correo → `GET /contacts/?query=`.
|
||||||
|
|
||||||
|
### `GET /contacts/`
|
||||||
|
**Marcado DEPRECADO** por la propia documentación. `limit` máximo 100, default 20. Útil solo para
|
||||||
|
búsquedas puntuales de 1-5 resultados, que es como lo usa `buscarPorIdentificador`.
|
||||||
|
|
||||||
|
**Trampa de forma:** aquí la atribución se llama **`attributions` (array)**; en `GET /contacts/{id}`
|
||||||
|
se llama **`attributionSource` (objeto)**. Misma información, dos formas.
|
||||||
|
|
||||||
|
### `POST /contacts/`
|
||||||
|
Scope `contacts.write`.
|
||||||
|
**[MEDIDO]** `locationId` va en el cuerpo del POST y **rompe el PUT** con `422 property locationId
|
||||||
|
should not exist`.
|
||||||
|
**[MEDIDO]** Un duplicado responde `400` **con `meta.contactId`**: es idempotencia regalada por el
|
||||||
|
servidor, y mejor que un `upsert`, cuya rama de actualización descarta la atribución.
|
||||||
|
**[MEDIDO]** La atribución **solo se escribe en el alta**; un PUT posterior devuelve `200` sin
|
||||||
|
guardar nada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B · Conversaciones y mensajes
|
||||||
|
|
||||||
|
### `GET /conversations/search`
|
||||||
|
Scope `conversations.readonly`. Filtros documentados: `locationId` (obligatorio), **`contactId`**,
|
||||||
|
**`id`**, `assignedTo`, `followers`, `mentions`, `query`, `sort`, `sortBy`, `status`,
|
||||||
|
`lastMessageType` (43 valores), `lastMessageDirection`, `lastMessageAction`, `startDate`/`endDate`
|
||||||
|
(epoch ms), `startAfterDate`, `limit`.
|
||||||
|
|
||||||
|
### `GET /conversations/{conversationId}`
|
||||||
|
**[MEDIDO]** Devuelve los campos **en la raíz**, sin envoltorio.
|
||||||
|
**[MEDIDO, hallazgo 38] NO devuelve el nombre del contacto ni un canal reconocible** — eso solo
|
||||||
|
viene del buscador. Sincronizar un hilo por su id degradaba el nombre a «Sin nombre»; ver
|
||||||
|
`syncConversations.ts`, que ya no deja que un dato pobre pise a uno bueno.
|
||||||
|
**Trampa de forma:** aquí `type` es un **número**; en el buscador es la cadena `TYPE_PHONE`.
|
||||||
|
|
||||||
|
### `GET /conversations/{conversationId}/messages`
|
||||||
|
Scope **`conversations/message.readonly`** — distinto de `conversations.readonly`. Tener uno no da
|
||||||
|
el otro.
|
||||||
|
**[MEDIDO]** La respuesta viene **anidada dos niveles**: `{ messages: { messages: [...],
|
||||||
|
lastMessageId, nextPage } }`.
|
||||||
|
|
||||||
|
### `GET /conversations/messages/{id}`
|
||||||
|
**[MEDIDO]** Funciona y devuelve el mensaje en la raíz. El parámetro de ruta no está declarado en la
|
||||||
|
especificación — es un fallo de la documentación, no de la API.
|
||||||
|
|
||||||
|
### `POST /conversations/messages`
|
||||||
|
Scope `conversations/message.write`. Tipos: `SMS`, `RCS`, `Email`, `WhatsApp`, `IG`, `FB`, `Custom`,
|
||||||
|
`Live_Chat`, `TIKTOK`.
|
||||||
|
**[CONTRADICCIÓN]** La documentación marca `subType` y `status` como obligatorios. `enviarCorreo()`
|
||||||
|
**no manda ninguno** y responde `200`. Manda lo medido; no los añadas «por cumplir el esquema» sin
|
||||||
|
un sondeo, porque cualquier clave inesperada es `422`.
|
||||||
|
**[MEDIDO]** La respuesta es `Email queued successfully`: acuse de **encolado**, no de entrega. Y al
|
||||||
|
releer el hilo, el `status` del mensaje propio viene `null` (hallazgo 23). **No se puede afirmar que
|
||||||
|
llegó.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C · Calendarios, citas y servicios
|
||||||
|
|
||||||
|
### `GET /calendars/`
|
||||||
|
Scope `calendars.readonly`, `Version: v3`. **[MEDIDO]** 7 calendarios en la subcuenta, uno
|
||||||
|
`Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`).
|
||||||
|
|
||||||
|
### `GET /calendars/events`
|
||||||
|
Scope `calendars/events.readonly`.
|
||||||
|
**[MEDIDO, hallazgo 28]** Exige uno de `calendarId`, `userId` o `groupId`; sin ellos es
|
||||||
|
`422 Either of userId, calendarId or groupId is required`. **No existe «todas las citas de la
|
||||||
|
subcuenta»**: hay que iterar los calendarios.
|
||||||
|
**Asimetría de formato:** la **entrada** (`startTime`/`endTime` del query) va en **milisegundos
|
||||||
|
epoch**; la **salida** viene en **ISO con desplazamiento** (`2026-12-28T14:00:00-06:00`).
|
||||||
|
|
||||||
|
### `GET /calendars/events/appointments/{eventId}`
|
||||||
|
**[MEDIDO, hallazgo 43] Sigue devolviendo la cita después de borrarla** — es un borrado lógico.
|
||||||
|
Para comprobar si una cita existe, **lista el rango del calendario**; por id da un falso positivo.
|
||||||
|
|
||||||
|
### `POST /calendars/events/appointments`
|
||||||
|
Scope `calendars/events.write` — **[MEDIDO, hallazgo 33] el token lo tiene**.
|
||||||
|
Obligatorios: `calendarId`, `locationId`, `contactId`, `startTime`.
|
||||||
|
`startTime`/`endTime` en **ISO con desplazamiento**, no en epoch — al revés que el filtro de arriba.
|
||||||
|
Palancas que este proyecto usa a propósito:
|
||||||
|
- `toNotify: false` — la plataforma ya avisó a la clienta; que el CRM no lo haga otra vez.
|
||||||
|
- `ignoreFreeSlotValidation: true` — AgendaMax es la fuente de verdad del horario y su base ya
|
||||||
|
impide el solape.
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgo 42]** Verificado de punta a punta: creada, releída con la **hora de pared
|
||||||
|
idéntica** a la escrita, y borrada.
|
||||||
|
|
||||||
|
### `PUT /calendars/events/appointments/{eventId}`
|
||||||
|
**No admite `locationId` ni `contactId`.** Misma trampa que en contactos: no recicles el cuerpo del
|
||||||
|
alta.
|
||||||
|
|
||||||
|
### `DELETE /calendars/events/{eventId}`
|
||||||
|
**[MEDIDO]** Existe y el token lo tiene. Borrado lógico (ver hallazgo 43).
|
||||||
|
|
||||||
|
### Servicios: `GET`/`POST /calendars/services/catalog`
|
||||||
|
Scopes `calendars.readonly` / **`calendars.write`** — **[MEDIDO, hallazgo 34] el token lo tiene**.
|
||||||
|
Un «servicio» es una prestación vendible con duración, precio, categoría, personal y variaciones:
|
||||||
|
literalmente el modelo de un spa.
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgos 6 y 32] El catálogo de esta subcuenta está VACÍO.** Por eso la duración y el
|
||||||
|
precio viven en la plataforma y «sincronizar servicios» solo puede significar **publicar**.
|
||||||
|
|
||||||
|
Obligatorios: `locationId`, `name`, `slug`, **`staff[]` con al menos un miembro** (como
|
||||||
|
`[{ id: "…" }]`, no como `["…"]`).
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgos 44-45] La primera publicación en una subcuenta sin catálogo falla** con
|
||||||
|
`400 No default service category found for this location` — y **ese mismo intento hace que el CRM
|
||||||
|
cree la categoría por defecto**. El reintento entra sin cambiar nada. `publicarServicio` reintenta
|
||||||
|
una vez y **solo** ante ese mensaje.
|
||||||
|
|
||||||
|
### `GET /calendars/service-categories`
|
||||||
|
**[MEDIDO, hallazgo 46]** Esta es la ruta correcta. `/calendars/services/categories` cae en el
|
||||||
|
comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID`, que se lee como «no
|
||||||
|
existe el recurso» cuando en realidad significa «no existe la ruta».
|
||||||
|
|
||||||
|
### `DELETE /calendars/services/catalog/{serviceId}`
|
||||||
|
**[MEDIDO]** Existe y el token lo tiene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D · Subcuentas
|
||||||
|
|
||||||
|
### `GET /locations/{locationId}`
|
||||||
|
Scope `locations.readonly`. **Un token privado de subcuenta basta**; no hace falta token de agencia.
|
||||||
|
|
||||||
|
**[MEDIDO] `settings` trae una quinta clave que la documentación no declara:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"contactUniqueIdentifiers": ["email", "phone"]
|
||||||
|
```
|
||||||
|
|
||||||
|
No es un detalle: es la cadena de identidad que el CRM aplica para deduplicar, y la justificación de
|
||||||
|
que la del proyecto (id → teléfono → correo) **coincida con la suya** en vez de pelearse con ella.
|
||||||
|
|
||||||
|
`allowDuplicateOpportunity: false` es la causa del `400 OPPORTUNITY_NO_DUPLICATE`, y es **un
|
||||||
|
interruptor de la interfaz**, no un límite duro.
|
||||||
|
|
||||||
|
### Cómo validar que un token corresponde a una subcuenta
|
||||||
|
**No hay endpoint de introspección de token.** El método correcto, y el que usa
|
||||||
|
`PUT /api/admin/businesses/:id/crm`, es leer `GET /locations/{locationId}` con ese token y **comparar
|
||||||
|
`location.id` con el esperado** — identidad contra identidad, no un `200` genérico.
|
||||||
|
|
||||||
|
**[MEDIDO] El `401` de esta API es ambiguo**: significa a la vez «token caducado», «al token le falta
|
||||||
|
el scope» y (probablemente) «el token es de otra subcuenta». Por eso el mensaje de error de la
|
||||||
|
consola nombra las tres posibilidades en vez de afirmar una.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E · Límites de tasa
|
||||||
|
|
||||||
|
**[MEDIDO, hallazgo 37]** Cabeceras reales de la subcuenta:
|
||||||
|
|
||||||
|
| Cabecera | Valor observado |
|
||||||
|
|---|---|
|
||||||
|
| `x-ratelimit-max` | `100` |
|
||||||
|
| `x-ratelimit-interval-milliseconds` | `10000` |
|
||||||
|
| `x-ratelimit-limit-daily` | `200000` |
|
||||||
|
| `x-ratelimit-remaining` | va bajando en la ventana |
|
||||||
|
| `x-ratelimit-daily-remaining` | `199970` |
|
||||||
|
|
||||||
|
O sea **1 petición cada 100 ms**, no cada 650 como asumía el cliente originalmente. `client.ts` lee
|
||||||
|
esas cabeceras en cada respuesta y ajusta el intervalo con un 50 % de margen; el 650 ms queda solo
|
||||||
|
como respaldo para la primera petición de un token, antes de haber visto ninguna cabecera.
|
||||||
|
|
||||||
|
La cuota es **por aplicación y por subcuenta**: añadir cuentas no reparte el límite, cada una tiene
|
||||||
|
el suyo. Por eso el estrangulador es **por token** y no global.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F · Scopes
|
||||||
|
|
||||||
|
```
|
||||||
|
contacts.readonly contacts.write
|
||||||
|
conversations.readonly conversations.write
|
||||||
|
conversations/message.readonly conversations/message.write
|
||||||
|
calendars.readonly calendars.write
|
||||||
|
calendars/events.readonly calendars/events.write
|
||||||
|
calendars/groups.readonly calendars/groups.write
|
||||||
|
locations.readonly locations.write
|
||||||
|
locations/customFields.* locations/customValues.* locations/tags.*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dos parejas que se confunden fácil y dan `401` donde no se espera:**
|
||||||
|
`conversations.readonly` **no** incluye `conversations/message.readonly`, y `calendars.readonly`
|
||||||
|
**no** incluye `calendars/events.readonly`.
|
||||||
|
|
||||||
|
**[MEDIDO] Estado del token de esta subcuenta**, al 2026-08-29:
|
||||||
|
|
||||||
|
| Scope | Estado |
|
||||||
|
|---|---|
|
||||||
|
| contactos, conversaciones, mensajes, subcuenta | ✔ |
|
||||||
|
| `calendars/events.write` | ✔ (hallazgo 33) |
|
||||||
|
| `calendars.write` | ✔ (hallazgo 34) |
|
||||||
|
| usuarios | ✔ **hoy** — daba `401` cuando se midió por primera vez (hallazgos 14 → 35) |
|
||||||
|
| crear pipelines | ✖ `401 The token is not authorized for this scope` |
|
||||||
|
|
||||||
|
**Un permiso medido una vez no queda medido para siempre.** El de usuarios cambió entre dos
|
||||||
|
mediciones porque alguien tocó el token. Conviene comprobar los permisos al vincular una subcuenta y
|
||||||
|
volver a hacerlo cuando algo falle con `401`, en vez de fiarse de una tabla escrita en el pasado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lo que NO está documentado y no debe inventarse
|
||||||
|
|
||||||
|
1. El esquema del cuerpo de `POST /contacts/search`: filtros, operadores y campos filtrables. El
|
||||||
|
OpenAPI lo declara como objeto vacío.
|
||||||
|
2. El máximo real de `pageLimit`.
|
||||||
|
3. El valor del techo de profundidad al paginar por número de página. Que existe está medido; cuánto
|
||||||
|
es, no.
|
||||||
|
4. El cuerpo de la respuesta `429` y la política de reintento recomendada.
|
||||||
|
5. Si los tokens privados tienen cuota distinta de las aplicaciones de marketplace.
|
||||||
|
6. Un endpoint de introspección de token.
|
||||||
|
7. El código exacto que devuelve un token de **otra** subcuenta. Se infiere `401`; comprobarlo exige
|
||||||
|
un segundo token y este proyecto solo tiene uno.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
**Exigen OAuth.** Un token privado de subcuenta no puede suscribirse, así que **la entrada de datos
|
||||||
|
es por sondeo**: los botones de sincronización y `POST /api/crm/sync/:entidad/:id`. Si algún día se
|
||||||
|
quiere tiempo real, hay que pasar por OAuth, y eso es un entregable propio.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Hallazgos medidos contra Bucéfalo CRM
|
||||||
|
|
||||||
|
Subcuenta **Yola Franco Spa** (`Pk89Wa23QaxvkOfKgwjZ`) · **2026-08-29** · token PIT de subcuenta.
|
||||||
|
|
||||||
|
Todo lo de aquí se ejerció contra el CRM real y **se verificó releyendo**, nunca aceptando un
|
||||||
|
`200` como prueba. Los spikes que lo produjeron están en `platform/scripts/crm-spike*.ts` y se
|
||||||
|
pueden volver a correr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lo que quedó confirmado
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | La subcuenta responde: `Yola Franco Spa`, tz `America/Mexico_City`, país `MX` | El token y el `locationId` son correctos |
|
||||||
|
| 2 | **3 209 contactos** y **3 210 conversaciones** | Hay material real que sincronizar |
|
||||||
|
| 3 | `attributionSource` viene **poblado** con datos reales (`sessionSource`, `medium`, `campaign`, `campaignId`, `adId`, `utmMedium`, `utmContent`) | La atribución UTM que pide el proyecto **existe y se puede traer** |
|
||||||
|
| 4 | Pipeline único: **`Standar`** `Mrclt4VzRZV1DI4Vbt5c`, 9 etapas, con **`Ganado`** (`b91c1653-…`) y **`Perdido`** (`04b28d7f-…`) | Hay dónde aterrizar `won` y `lost` sin inventar nada |
|
||||||
|
| 5 | **SÍ existen 7 calendarios**, uno de ellos `Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`) | Resuelve la pregunta que el análisis previo marcaba como bloqueante |
|
||||||
|
| 6 | **`GET /calendars/services/catalog` devuelve `services: []`** | El catálogo de servicios del CRM está **vacío**: la duración y el precio tienen que vivir en la plataforma. Confirma la sospecha previa |
|
||||||
|
| 7 | `POST /contacts/` con `attributionSource` → **persiste íntegro** (verificado releyendo) | Se puede dar de alta con UTM completo |
|
||||||
|
| 8 | `POST /contacts/` duplicado → **`400` con `meta.contactId` y `meta.matchingField`** | **Idempotencia real y gratuita.** Es mejor que `upsert`, cuya rama *actualizar* descarta la atribución |
|
||||||
|
| 9 | `POST /opportunities/` → crea y **el importe persiste** | El valor del servicio llega al CRM |
|
||||||
|
| 10 | `POST /conversations/messages` con `type: "Email"` → `200` `Email queued successfully` con `conversationId`, `messageId`, `threadId` | Hay canal de vuelta para probar mensajes |
|
||||||
|
|
||||||
|
## Lo que NO funciona como uno esperaría
|
||||||
|
|
||||||
|
| # | Hallazgo | Cómo se sortea |
|
||||||
|
|---|---|---|
|
||||||
|
| 11 | **`PUT /opportunities/{id}/status` rechaza `pipelineStageId`** con `422 property pipelineStageId should not exist` | El estado y la etapa se cambian en **dos llamadas**: `/status` con solo `status`, y `PUT /opportunities/{id}` con `pipelineId` + `pipelineStageId` |
|
||||||
|
| 12 | **`POST /opportunities/` rechaza una segunda oportunidad del mismo contacto aunque la primera esté en `won`** (`400 OPPORTUNITY_NO_DUPLICATE` con `meta.existingId`) | Ver «La decisión de las oportunidades» abajo |
|
||||||
|
| 13 | La causa es el ajuste **`settings.allowDuplicateOpportunity: false`** de la subcuenta | **Es un ajuste, no un límite duro.** El cliente puede activarlo |
|
||||||
|
| 14 | ~~`GET /users/?locationId` → **`401` fuera de scope**~~ **OBSOLETO — ver hallazgo 35: hoy responde `200`** | El token PIT no listaba personal. Volvió a medirse el 2026-08-29 y sí lo lista |
|
||||||
|
| 15 | `POST /opportunities/pipelines` → **`401` `The token is not authorized for this scope`** | `pipelines.create` no está en el token. Rediseñar el pipeline a etapas de spa es trabajo de UI, no de código |
|
||||||
|
| 16 | `GET /contacts/{id}/opportunities` → **`404`, la ruta no existe** | Se usa `GET /opportunities/search?location_id=&contact_id=`, que sí funciona |
|
||||||
|
|
||||||
|
## Lo que el CRM usa para deduplicar, y coincide con lo pedido
|
||||||
|
|
||||||
|
`GET /locations/{id}` devuelve:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"settings": {
|
||||||
|
"allowDuplicateContact": false,
|
||||||
|
"allowDuplicateOpportunity": false,
|
||||||
|
"contactUniqueIdentifiers": ["email", "phone"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La cadena de identidad pedida para el proyecto —**id de contacto → teléfono → correo**— es
|
||||||
|
exactamente la que el CRM aplica. Con `allowDuplicateContact: false`, el propio CRM devuelve el
|
||||||
|
`contactId` existente en el `400`: la deduplicación no hay que construirla, hay que **leerla del
|
||||||
|
rechazo**.
|
||||||
|
|
||||||
|
## La decisión de las oportunidades
|
||||||
|
|
||||||
|
El encargo es «una cita = una oportunidad», con el nombre `SERVICIO + NOMBRE CONTACTO`, el importe
|
||||||
|
del servicio, y `open` / `won` / `lost` según el estado. El hallazgo 12 lo impide **hoy**: con
|
||||||
|
`allowDuplicateOpportunity: false`, una clienta que vuelve por segunda vez no puede estrenar
|
||||||
|
oportunidad, y una clienta de spa vuelve muchas veces.
|
||||||
|
|
||||||
|
Se implementan los dos caminos y la plataforma elige solo, sin configuración:
|
||||||
|
|
||||||
|
1. **Intenta crear.** Si el CRM la acepta, una cita = una oportunidad, tal como se pidió.
|
||||||
|
2. **Si responde `400 OPPORTUNITY_NO_DUPLICATE`**, toma el `meta.existingId` y **recicla esa
|
||||||
|
oportunidad**: le pone el nombre de la cita nueva, su importe y su estado. Verificado que se
|
||||||
|
puede renombrar, cambiar el importe, cerrar y **reabrir** una ya cerrada.
|
||||||
|
|
||||||
|
Con el ajuste desactivado, la oportunidad representa *la cita vigente de la clienta* y el histórico
|
||||||
|
completo vive en AgendaMax. Con el ajuste activado, el modelo pasa a ser el pedido **sin tocar una
|
||||||
|
línea de código**.
|
||||||
|
|
||||||
|
> **Para que sea «una cita = una oportunidad» hace falta que alguien active
|
||||||
|
> _Allow Duplicate Opportunity_ en los ajustes de la subcuenta.** Es un interruptor de la UI del
|
||||||
|
> CRM; el token no puede cambiarlo. Mientras tanto el MVP funciona reciclando.
|
||||||
|
|
||||||
|
## Cabeceras y trampas
|
||||||
|
|
||||||
|
- `Version: 2021-07-28` para contactos, oportunidades y conversaciones; **`Version: v3` para todo
|
||||||
|
`/calendars/`**. Equivocarla es `400`.
|
||||||
|
- `locationId` **va en el cuerpo del `POST /contacts/`** y **rompe el `PUT`** (`422 property
|
||||||
|
locationId should not exist`). Es una asimetría fácil de cruzar reciclando código.
|
||||||
|
- Hay lista blanca de propiedades: cualquier clave desconocida es `422` y no crea nada. Es un fallo
|
||||||
|
seguro y sirve de herramienta de descubrimiento.
|
||||||
|
- La atribución **es de una sola oportunidad**: se escribe en el alta y un `PUT` posterior devuelve
|
||||||
|
`200` sin guardar nada.
|
||||||
|
- `campaign` hay que mandarlo **además** de `utmCampaign`: el buscador de contactos descarta
|
||||||
|
`utmCampaign` y conserva `campaign`.
|
||||||
|
|
||||||
|
## Hallazgos posteriores, ya con la integración escrita
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 17 | **`PUT .../status` mueve la etapa por su cuenta.** Con la etapa escrita primero y el estado después, el CRM la devolvió de «Ganado» a «Cotización Aceptada» | El orden correcto es **estado primero, etapa después** |
|
||||||
|
| 18 | Ese movimiento es **asíncrono y gana igualmente en `won`**: se reescribió y releyó tres veces y el CRM la volvió a mover después de que la relectura ya confirmaba la nuestra. En `lost` sí respeta «Perdido» | Hay una regla del lado del CRM que gobierna la etapa en `won`. **No se pelea con ella**: lo que el negocio pidió mapear es el `status`, y ese sí queda estable |
|
||||||
|
| 19 | El buscador de contactos pagina con **`searchAfter`**, tomado del último contacto de la página anterior | Con `page` se topa un techo de profundidad mucho antes de los 3 200 |
|
||||||
|
| 20 | Sincronización completa medida: **3 210 contactos en 22 s**, idempotente (segunda corrida: 0 creados, 3 210 actualizados) | El botón puede correr en primer plano sin tarea de fondo |
|
||||||
|
| 21 | Calidad real de los contactos traídos: **59,4 % con teléfono normalizable**, **8 con correo** de 3 215, 801 con campaña, 824 con anuncio | Coincide con la auditoría independiente previa (59,8 % y 0,2 %). **Cuatro de cada diez clientas no son contactables** |
|
||||||
|
| 22 | El prefijo `521` heredado de mensajería aparece en los teléfonos reales (`+5215656592254`) y se colapsa bien a `+525656592254`. **Cero duplicados** por teléfono tras sincronizar 3 210 | La deduplicación por E.164 funciona sobre datos reales |
|
||||||
|
| 23 | El mensaje enviado **aparece al releer el hilo**, pero su `status` viene `null` | El CRM no expone el estado de entrega ahí: sigue sin poder afirmarse que llegó |
|
||||||
|
|
||||||
|
## Lo que sigue sin verificarse
|
||||||
|
|
||||||
|
- **Que el correo se entregue.** `Email queued successfully` es acuse de encolado, no de entrega.
|
||||||
|
Exige mirar una bandeja real.
|
||||||
|
- **WhatsApp y SMS**: no están conectados en la subcuenta. Fuera del MVP.
|
||||||
|
- **Escritura de citas al calendario del CRM** (`POST /calendars/events/appointments`): no se ha
|
||||||
|
ejercido. El MVP proyecta las citas como oportunidades, no como eventos de calendario.
|
||||||
|
- **Webhooks**: exigen OAuth, que este token no es. La sincronización de entrada es por sondeo.
|
||||||
|
|
||||||
|
## Datos de prueba creados en la subcuenta real
|
||||||
|
|
||||||
|
Contacto `WzBTBaHkNnpmjMb1Avx3` (`urieljareth@grupo-e3.com`, tag `agendamax:prueba`) y su
|
||||||
|
oportunidad `IMkYdAkBowggN9aKVbfc`. Se limpian con
|
||||||
|
`node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sondeo de lectura por id — 2026-08-29
|
||||||
|
|
||||||
|
Medido con `crm-spike-lectura-id.ts` y `crm-spike-calendarios.ts`, ambos **solo lectura**. Cubre
|
||||||
|
las cinco entidades que pide la sincronización por id: contactos, conversaciones, mensajes, citas
|
||||||
|
y servicios.
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 24 | `GET /conversations/{id}` **funciona** y trae `contactId`, `messageTypes`, `unreadCount`, `firstUnreadInboundMessageId` | Se puede anclar una conversación por su id sin recorrer la lista |
|
||||||
|
| 25 | `GET /conversations/search?contactId=…` **filtra por contacto** | Es el camino para «las conversaciones de esta clienta» sin traerse las 3 213 |
|
||||||
|
| 26 | `GET /conversations/{id}/messages` pagina con **`lastMessageId` + `nextPage`**, no con `page` ni `searchAfter` | Tercera convención de paginación distinta en la misma API. No reciclar la de contactos |
|
||||||
|
| 27 | **`GET /conversations/messages/{id}` funciona**: trae un mensaje suelto por su id, con `from`, `messageType`, `contentType`, `meta` | Permite reconciliar un mensaje concreto sin releer el hilo entero |
|
||||||
|
| 28 | `GET /calendars/events` **exige** uno de `userId`, `calendarId` o `groupId`; sin ellos es `422`. Con un `userId` inexistente es `400 User with id … not found` | No hay forma de pedir «todas las citas de la subcuenta» en una llamada: hay que iterar los calendarios |
|
||||||
|
| 29 | **La subcuenta tiene 1 sola cita en total** en los 7 calendarios, en una ventana de 2 años atrás y 1 adelante, y está en **`Servicio Spa`** (`LXZIuRPYa3uCPlUlsqY7`) | El calendario del CRM está prácticamente sin usar. La agenda real no vive ahí: nace en la plataforma. Confirma que la sincronización de citas es **empuje**, no arrastre |
|
||||||
|
| 30 | Forma del evento: `appointmentStatus`, `assignedUserId`, `calendarId`, `contactId`, `startTime`, `endTime`, `dateAdded`, `address`. Devuelve **además** `appoinmentStatus` — con la errata — con el mismo valor | Si se lee el estado, leer `appointmentStatus` y tolerar la errata: es del CRM, no nuestra |
|
||||||
|
| 31 | `GET /calendars/groups` → 0 grupos | No hay agrupación que aprovechar |
|
||||||
|
| 32 | `GET /calendars/services/catalog` → `services: []` **reconfirmado** | El catálogo de servicios del CRM sigue vacío. Duración y precio viven en la plataforma, y «sincronizar servicios» no puede significar traerlos de allá |
|
||||||
|
|
||||||
|
### Lo que esto decide
|
||||||
|
|
||||||
|
- **Contactos, conversaciones y mensajes**: los tres se pueden traer por id concreto. La
|
||||||
|
sincronización selectiva que pide el encargo es viable tal cual, sin rodeos.
|
||||||
|
- **Citas**: no hay nada que arrastrar. La dirección útil es empujar la cita de la plataforma al
|
||||||
|
calendario del CRM (`POST /calendars/events/appointments`, **aún sin ejercer**) además de la
|
||||||
|
oportunidad que ya se proyecta.
|
||||||
|
- **Servicios**: no hay catálogo en el CRM que sincronizar. Lo único con sentido es publicar hacia
|
||||||
|
allá los de la plataforma, y eso exige comprobar antes si el token tiene permiso de escritura
|
||||||
|
sobre `/calendars/services` — no se ha probado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sondeo de permisos y de límites — 2026-08-29
|
||||||
|
|
||||||
|
Medido con `crm-spike-permisos.ts`. La técnica no crea nada: se manda un `POST` **deliberadamente
|
||||||
|
incompleto** y se mira qué error vuelve. Un `401 not authorized for this scope` significa que falta
|
||||||
|
el permiso; un `422` sobre los campos significa que el permiso está y lo que falla es el cuerpo.
|
||||||
|
Distinguir esas dos cosas era lo único que faltaba para saber si se puede planificar escritura, y
|
||||||
|
no costó un solo registro basura en la subcuenta del cliente.
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 33 | **`calendars/events.write` SÍ está.** `POST /calendars/events/appointments` responde `422 calendarId should not be empty · startTime must be a valid ISO 8601 date string`, no `401` | **Se pueden escribir citas al calendario del CRM.** Deja de ser una incógnita: el MVP puede proyectar la cita como evento además de como oportunidad |
|
||||||
|
| 34 | **`calendars.write` SÍ está.** `POST /calendars/services/catalog` responde `422 name should not be empty · At least one staff member is required` | **Se puede poblar el catálogo de servicios del CRM.** Que esté vacío (hallazgo 6) no es un límite de la API: es que nadie lo llenó |
|
||||||
|
| 35 | **`GET /users/?locationId` responde `200` con 6 usuarios.** Contradice el hallazgo 14, medido semanas antes | El token tiene ahora permiso de personal. Esto **desbloquea el `staff[]` obligatorio** del alta de servicios, que era el impedimento práctico del hallazgo 34. Ids disponibles, entre ellos `6HCOVjDvvdUlv1bjbR7W` (Yola Spa Recepción), que es el `assignedUserId` de la única cita real |
|
||||||
|
| 36 | `GET /contacts/{id}/appointments` responde `200` con `{"events":[]}` | Hay una ruta directa para «las citas de esta clienta» sin recorrer calendarios. Devuelve vacío porque la subcuenta casi no tiene citas (hallazgo 29) |
|
||||||
|
| 37 | **Cabeceras de límite reales**: `x-ratelimit-max: 100`, `x-ratelimit-interval-milliseconds: 10000`, `x-ratelimit-limit-daily: 200000` | La cuota es **1 petición cada 100 ms**, no cada 650. El cliente estrangula **6,5× por debajo** de lo permitido. Bajar `MIN_INTERVAL_MS` acortaría la sincronización de 22 s a ~4 s. Hasta hoy nadie leía esas cabeceras: el 650 ms era una estimación observada, no una cuota conocida |
|
||||||
|
|
||||||
|
### Lo que esto cambia
|
||||||
|
|
||||||
|
- **Las cinco entidades del encargo son viables.** Contactos, conversaciones y mensajes se leen por
|
||||||
|
id (24-27); las citas se pueden **escribir** al calendario (33) además de proyectarse como
|
||||||
|
oportunidad; y los servicios se pueden **publicar** al catálogo (34) ahora que hay ids de personal
|
||||||
|
(35). Ninguna queda bloqueada por permisos.
|
||||||
|
- **«Sincronizar servicios» solo puede significar empujar**, nunca traer: el catálogo del CRM está
|
||||||
|
vacío y la duración y el precio los define el negocio en la plataforma.
|
||||||
|
- **Un permiso medido una vez no queda medido para siempre.** El hallazgo 14 era cierto cuando se
|
||||||
|
midió y hoy es falso, porque alguien cambió el token o sus permisos. Conviene que la plataforma
|
||||||
|
compruebe los permisos al vincular una subcuenta y lo vuelva a hacer cuando algo falle con `401`,
|
||||||
|
en vez de fiarse de una tabla escrita en el pasado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejercido con la sincronización por id escrita — 2026-08-29
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 38 | **`GET /conversations/{id}` no devuelve el nombre del contacto ni un canal reconocible.** Solo el buscador los trae | Sincronizar un hilo por su id **degradaba** un nombre bueno a «Sin nombre» y el canal a «Desconocido». El upsert ya no deja que un dato pobre pise a uno que ya se tenía, y el nombre se toma de la clienta enlazada |
|
||||||
|
| 39 | El canal del hilo **se puede deducir de su último mensaje**, que sí lo trae | Evita el «Desconocido» sin gastar una petición más. Se excluyen los `TYPE_ACTIVITY_*`, que son notas del propio CRM y no un canal por el que hablar con la clienta |
|
||||||
|
| 40 | Los hilos reales traen **`TYPE_INSTAGRAM`** y **`TYPE_ACTIVITY_OPPORTUNITY`**, que no estaban en ningún mapa | Instagram es un canal de verdad de este negocio; las actividades no lo son y conviene distinguirlas en la bandeja |
|
||||||
|
| 41 | Sincronización por id verificada de punta a punta contra la subcuenta real: contacto (`creado`), conversación (`espejada`, 3 mensajes) y mensaje suelto (`espejado con su hilo`) | Las tres entidades que el CRM posee se pueden traer una a una. La conversación quedó enlazada con la clienta local por `crm_contact_id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primera escritura de citas y servicios al CRM — 2026-08-29
|
||||||
|
|
||||||
|
Ejercida con `crm-spike-escritura-cita-servicio.ts`, que crea, **relee**, borra y
|
||||||
|
**confirma el borrado** en la misma corrida. Nada quedó en la subcuenta: la limpieza va en un
|
||||||
|
`finally`, así que se ejecuta aunque el sondeo falle a mitad. Antes se comprobó con
|
||||||
|
`crm-spike-borrado.ts` que el borrado existe — preguntar si se puede deshacer **antes** de escribir
|
||||||
|
en el CRM de un cliente real, no después.
|
||||||
|
|
||||||
|
| # | Hallazgo | Consecuencia |
|
||||||
|
|---|---|---|
|
||||||
|
| 42 | **Escribir una cita al calendario funciona.** `POST /calendars/events/appointments` la crea, se relee con el contacto correcto, el estado `confirmed`, y **la hora de pared idéntica a la escrita**: se mandó `2026-12-28T14:00:00-06:00` y se releyó igual | La proyección de citas al calendario deja de ser una incógnita. Y confirma que `isoConDesplazamiento` acierta: escribir con `toISOString()` habría movido la hora que el CRM enseña |
|
||||||
|
| 43 | **`GET /calendars/events/appointments/{id}` SIGUE devolviendo la cita después de borrarla.** El listado del rango sí deja de incluirla | Es un borrado lógico. Comprobar existencia por id da un falso positivo: **la comprobación fiable es listar el calendario** |
|
||||||
|
| 44 | **La primera publicación de un servicio falla** con `400 No default service category found for this location`, aunque `staff`, `name` y `slug` sean correctos | La documentación marca `serviceCategoryId` como opcional. No lo es cuando la subcuenta no tiene ninguna categoría |
|
||||||
|
| 45 | **Ese mismo intento fallido hace que el CRM cree la categoría por defecto** (`GET /calendars/service-categories` la devuelve con `isSystemGenerated: true` y fecha del segundo del fallo). El reintento entra sin cambiar nada | `publicarServicio` reintenta **una vez** y **solo** ante ese mensaje. Reintentar un POST a ciegas fabrica duplicados |
|
||||||
|
| 46 | La ruta de categorías es **`/calendars/service-categories`**, no `/calendars/services/categories` — esta última cae en el comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID` | Un 404 con ese texto significa «la ruta no existe», no «el recurso no existe». Es fácil de leer al revés |
|
||||||
|
| 47 | Publicar servicio verificado de punta a punta: catálogo **0 → 1**, con la duración correcta, y borrado después dejándolo en 0 | Las cinco entidades del encargo quedan ejercidas contra el CRM real |
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { isoConDesplazamiento, estadoCitaCrm } from "./calendars.ts";
|
||||||
|
|
||||||
|
// La suite corre con la zona del proceso FIJADA a otra distinta de la del
|
||||||
|
// negocio, para que un cálculo que se ancle a la del proceso falle aquí.
|
||||||
|
process.env.TZ = "UTC";
|
||||||
|
|
||||||
|
test("isoConDesplazamiento escribe la hora de pared del negocio con su desplazamiento", () => {
|
||||||
|
// 2026-09-03 11:00 en México = 17:00Z
|
||||||
|
const d = new Date("2026-09-03T17:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T11:00:00-06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isoConDesplazamiento no usa la zona del proceso", () => {
|
||||||
|
const d = new Date("2026-09-03T17:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "UTC"), "2026-09-03T17:00:00+00:00");
|
||||||
|
// Misma entrada, dos zonas, dos horas de pared distintas.
|
||||||
|
assert.notEqual(
|
||||||
|
isoConDesplazamiento(d, "UTC"),
|
||||||
|
isoConDesplazamiento(d, "America/Mexico_City")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isoConDesplazamiento: la medianoche se escribe 00, no 24", () => {
|
||||||
|
// 00:00 del 4 de septiembre en México = 06:00Z
|
||||||
|
const d = new Date("2026-09-04T06:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-04T00:00:00-06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isoConDesplazamiento: una cita de la tarde cae en el día correcto", () => {
|
||||||
|
// 19:00 de México del día 3 = 01:00Z del día 4. El día de pared es el 3.
|
||||||
|
const d = new Date("2026-09-04T01:00:00Z");
|
||||||
|
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T19:00:00-06:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("estadoCitaCrm traduce los estados de la plataforma a los del CRM", () => {
|
||||||
|
assert.equal(estadoCitaCrm("scheduled"), "confirmed");
|
||||||
|
assert.equal(estadoCitaCrm("completed"), "showed");
|
||||||
|
assert.equal(estadoCitaCrm("no_show"), "noshow");
|
||||||
|
assert.equal(estadoCitaCrm("cancelled"), "cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("estadoCitaCrm: un estado que no conocemos no inventa, cae en confirmed", () => {
|
||||||
|
assert.equal(estadoCitaCrm("lo-que-sea"), "confirmed");
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { crmRequest, CrmError, VERSION_CALENDARS } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
export interface CrmCalendar {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
calendarType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmEvent {
|
||||||
|
id: string;
|
||||||
|
calendarId: string;
|
||||||
|
contactId?: string;
|
||||||
|
title?: string;
|
||||||
|
appointmentStatus?: string;
|
||||||
|
assignedUserId?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AltaCita {
|
||||||
|
calendarId: string;
|
||||||
|
contactId: string;
|
||||||
|
/** ISO con desplazamiento, no epoch. Ver `isoConDesplazamiento`. */
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
title: string;
|
||||||
|
assignedUserId?: string;
|
||||||
|
appointmentStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO con el desplazamiento horario del NEGOCIO.
|
||||||
|
*
|
||||||
|
* El CRM acepta `2026-09-03T11:00:00-06:00` en el alta de citas, y **no**
|
||||||
|
* milisegundos — al revés que el filtro de rango de `/calendars/events`, que sí
|
||||||
|
* los exige. Esa asimetría es de la API, no nuestra.
|
||||||
|
*
|
||||||
|
* Y no vale `toISOString()`: devuelve UTC con `Z`, y aunque el instante sea el
|
||||||
|
* mismo, la hora de pared que el CRM enseña en su interfaz sale de lo que se
|
||||||
|
* escribe aquí. Se construye con `Intl` y nunca con `new Date(y, m, d, …)`, que
|
||||||
|
* resuelve el reloj en la zona del proceso — el error que ya costó un fallo de
|
||||||
|
* producción en este repo (ver la sección de zonas horarias de CLAUDE.md).
|
||||||
|
*/
|
||||||
|
export function isoConDesplazamiento(d: Date, tz: string): string {
|
||||||
|
const zona = tz || "America/Mexico_City";
|
||||||
|
const p = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: zona,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(d);
|
||||||
|
const g = (t: string) => p.find((x) => x.type === t)!.value;
|
||||||
|
|
||||||
|
const off = new Intl.DateTimeFormat("en-US", { timeZone: zona, timeZoneName: "longOffset" })
|
||||||
|
.formatToParts(d)
|
||||||
|
.find((x) => x.type === "timeZoneName")!.value;
|
||||||
|
const m = off.match(/GMT([+-])(\d{2}):(\d{2})/);
|
||||||
|
const desp = m ? `${m[1]}${m[2]}:${m[3]}` : "+00:00";
|
||||||
|
|
||||||
|
// `en-CA` con hour12:false puede rendir la medianoche como 24; el CRM espera 00.
|
||||||
|
const hora = g("hour") === "24" ? "00" : g("hour");
|
||||||
|
return `${g("year")}-${g("month")}-${g("day")}T${hora}:${g("minute")}:${g("second")}${desp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la cita de la plataforma → estado del CRM.
|
||||||
|
*
|
||||||
|
* En la PETICIÓN el enum admite `new|confirmed|cancelled|showed|noshow|invalid`.
|
||||||
|
* En la respuesta hay dos más (`active`, `completed`) que el CRM asigna por su
|
||||||
|
* cuenta y no se pueden escribir.
|
||||||
|
*/
|
||||||
|
export function estadoCitaCrm(estado: string): string {
|
||||||
|
switch (estado) {
|
||||||
|
case "completed":
|
||||||
|
return "showed";
|
||||||
|
case "no_show":
|
||||||
|
return "noshow";
|
||||||
|
case "cancelled":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "confirmed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listarCalendarios(ctx: CrmCtx): Promise<CrmCalendar[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return r?.calendars ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El personal de la subcuenta.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 35): esta ruta devolvía `401` cuando se midió por primera vez
|
||||||
|
* y hoy responde `200` con 6 usuarios. Da los ids que `staff[]` exige al crear
|
||||||
|
* servicios y `assignedUserId` al crear citas. Si vuelve a dar 401, quien llame
|
||||||
|
* debe poder seguir sin ella, no romperse.
|
||||||
|
*/
|
||||||
|
export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/users/", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
});
|
||||||
|
return (r?.users ?? []).map((u: any) => ({ id: u.id, name: u.name ?? "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerCita(ctx: CrmCtx, eventId: string): Promise<CrmEvent | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${eventId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return (r?.event ?? r?.appointment ?? r) as CrmEvent;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citas de un calendario en un rango.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 28): sin `calendarId`, `userId` o `groupId` la API responde
|
||||||
|
* `422 Either of userId, calendarId or groupId is required`. No existe «dame
|
||||||
|
* todas las citas de la subcuenta»: hay que iterar los calendarios.
|
||||||
|
*
|
||||||
|
* El rango va en **milisegundos epoch**, al revés que el alta.
|
||||||
|
*/
|
||||||
|
export async function citasEnRango(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
calendarId: string,
|
||||||
|
desdeMs: number,
|
||||||
|
hastaMs: number
|
||||||
|
): Promise<CrmEvent[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/events", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
query: {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
calendarId,
|
||||||
|
startTime: String(desdeMs),
|
||||||
|
endTime: String(hastaMs),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return r?.events ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crearCita(ctx: CrmCtx, a: AltaCita): Promise<{ id: string }> {
|
||||||
|
const r = await crmRequest<any>("POST", "/calendars/events/appointments", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: {
|
||||||
|
// `locationId` va en el POST y ROMPE el PUT con 422. No reciclar el cuerpo
|
||||||
|
// del alta para actualizar: es la misma trampa ya medida en contactos.
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
calendarId: a.calendarId,
|
||||||
|
contactId: a.contactId,
|
||||||
|
startTime: a.startTime,
|
||||||
|
endTime: a.endTime,
|
||||||
|
title: a.title,
|
||||||
|
appointmentStatus: a.appointmentStatus ?? "confirmed",
|
||||||
|
...(a.assignedUserId ? { assignedUserId: a.assignedUserId } : {}),
|
||||||
|
// La plataforma ya avisó a la clienta: que el CRM no dispare además sus
|
||||||
|
// automatizaciones y le llegue el mismo aviso dos veces.
|
||||||
|
toNotify: false,
|
||||||
|
// AgendaMax es la fuente de verdad del horario, y su base ya impide el
|
||||||
|
// solape con una restricción de exclusión. Que el CRM no rechace por su
|
||||||
|
// propia idea de disponibilidad, que no conoce la agenda real.
|
||||||
|
ignoreFreeSlotValidation: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = r?.id ?? r?.event?.id ?? r?.appointment?.id;
|
||||||
|
if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador");
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actualiza una cita ya escrita. Sin `locationId` ni `contactId`: el PUT los rechaza. */
|
||||||
|
export async function actualizarCita(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
eventId: string,
|
||||||
|
cambios: Partial<Omit<AltaCita, "contactId">>
|
||||||
|
): Promise<void> {
|
||||||
|
await crmRequest("PUT", `/calendars/events/appointments/${eventId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: {
|
||||||
|
...(cambios.calendarId ? { calendarId: cambios.calendarId } : {}),
|
||||||
|
...(cambios.startTime ? { startTime: cambios.startTime } : {}),
|
||||||
|
...(cambios.endTime ? { endTime: cambios.endTime } : {}),
|
||||||
|
...(cambios.title ? { title: cambios.title } : {}),
|
||||||
|
...(cambios.appointmentStatus
|
||||||
|
? { appointmentStatus: cambios.appointmentStatus }
|
||||||
|
: {}),
|
||||||
|
toNotify: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
esperaDeToken,
|
||||||
|
registrarPeticion,
|
||||||
|
MIN_INTERVAL_MS,
|
||||||
|
anotarLimites,
|
||||||
|
limitesDe,
|
||||||
|
intervaloDe,
|
||||||
|
} from "./client.ts";
|
||||||
|
|
||||||
|
test("el estrangulador cuenta por token, no globalmente", () => {
|
||||||
|
const ahora = 1_000_000;
|
||||||
|
registrarPeticion("token-A", ahora);
|
||||||
|
// El mismo token tiene que esperar…
|
||||||
|
assert.ok(esperaDeToken("token-A", ahora + 10) > 0);
|
||||||
|
// …pero otro token no espera nada: su límite es independiente.
|
||||||
|
assert.equal(esperaDeToken("token-B", ahora + 10), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pasado el intervalo, el mismo token deja de esperar", () => {
|
||||||
|
const ahora = 2_000_000;
|
||||||
|
registrarPeticion("token-C", ahora);
|
||||||
|
assert.equal(esperaDeToken("token-C", ahora + MIN_INTERVAL_MS + 1), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sin cabeceras se usa el intervalo conservador por defecto", () => {
|
||||||
|
assert.equal(intervaloDe("token-sin-datos"), MIN_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("las cabeceras del CRM mandan sobre el valor por defecto", () => {
|
||||||
|
// MEDIDO (hallazgo 37): la cuota real de la subcuenta.
|
||||||
|
anotarLimites(
|
||||||
|
"token-D",
|
||||||
|
new Headers({
|
||||||
|
"x-ratelimit-max": "100",
|
||||||
|
"x-ratelimit-interval-milliseconds": "10000",
|
||||||
|
"x-ratelimit-remaining": "94",
|
||||||
|
"x-ratelimit-daily-remaining": "199970",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const l = limitesDe("token-D");
|
||||||
|
assert.equal(l?.max, 100);
|
||||||
|
assert.equal(l?.ventanaMs, 10000);
|
||||||
|
assert.equal(l?.diarioRestante, 199970);
|
||||||
|
// 10000/100 = 100 ms teóricos, con 50 % de margen = 150
|
||||||
|
assert.equal(intervaloDe("token-D"), 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("con la ventana casi agotada se espacia más, para no comerse un 429", () => {
|
||||||
|
anotarLimites(
|
||||||
|
"token-E",
|
||||||
|
new Headers({
|
||||||
|
"x-ratelimit-max": "100",
|
||||||
|
"x-ratelimit-interval-milliseconds": "10000",
|
||||||
|
"x-ratelimit-remaining": "3",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.ok(intervaloDe("token-E") > 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una respuesta sin cabeceras de límite no borra lo que ya se sabía", () => {
|
||||||
|
anotarLimites(
|
||||||
|
"token-F",
|
||||||
|
new Headers({ "x-ratelimit-max": "100", "x-ratelimit-interval-milliseconds": "10000" })
|
||||||
|
);
|
||||||
|
anotarLimites("token-F", new Headers({}));
|
||||||
|
assert.equal(limitesDe("token-F")?.max, 100);
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
const BASE_URL_DEFAULT = "https://services.leadconnectorhq.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La cabecera `Version` no es opcional y no es una sola: la familia de
|
||||||
|
* calendarios exige `v3` y el resto `2021-07-28`. Omitirla o equivocarla es un
|
||||||
|
* 400, y es el error más fácil de cometer al reciclar código entre dominios.
|
||||||
|
*/
|
||||||
|
export const VERSION_DEFAULT = "2021-07-28";
|
||||||
|
export const VERSION_CALENDARS = "v3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intervalo conservador mientras el CRM no diga su cuota real.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 37): las cabeceras `x-ratelimit-*` declaran 100 peticiones
|
||||||
|
* por 10 s, o sea 1 cada 100 ms — 6,5 veces más de lo que este valor asume. Se
|
||||||
|
* mantiene como respaldo para la primera petición de un token, antes de haber
|
||||||
|
* visto ninguna cabecera; a partir de ahí manda `intervaloDe()`.
|
||||||
|
*/
|
||||||
|
export const MIN_INTERVAL_MS = 650;
|
||||||
|
/** Margen sobre la cuota declarada: no se corre al límite exacto. */
|
||||||
|
const MARGEN = 1.5;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export interface Limites {
|
||||||
|
max: number;
|
||||||
|
ventanaMs: number;
|
||||||
|
restantes: number;
|
||||||
|
diarioRestante: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitesPorToken = new Map<string, Limites>();
|
||||||
|
|
||||||
|
/** Registra lo que el CRM dice de su propia cuota. Una respuesta sin cabeceras
|
||||||
|
* no borra lo ya sabido: no todas las rutas las devuelven. */
|
||||||
|
export function anotarLimites(token: string, h: Headers): void {
|
||||||
|
const max = Number(h.get("x-ratelimit-max"));
|
||||||
|
const ventanaMs = Number(h.get("x-ratelimit-interval-milliseconds"));
|
||||||
|
if (!max || !ventanaMs) return;
|
||||||
|
limitesPorToken.set(token, {
|
||||||
|
max,
|
||||||
|
ventanaMs,
|
||||||
|
restantes: Number(h.get("x-ratelimit-remaining") ?? max),
|
||||||
|
diarioRestante: h.get("x-ratelimit-daily-remaining")
|
||||||
|
? Number(h.get("x-ratelimit-daily-remaining"))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitesDe(token: string): Limites | null {
|
||||||
|
return limitesPorToken.get(token) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuánto esperar entre peticiones de ESTE token.
|
||||||
|
*
|
||||||
|
* Se toma la cuota que el CRM declara, con un 50 % de margen y no al límite
|
||||||
|
* exacto: el worker de la bandeja y una sincronización manual pueden coincidir.
|
||||||
|
* Si la ventana está casi agotada se espacia hasta que se renueve, que sale más
|
||||||
|
* barato que comerse un 429 y su espera lineal de 5, 10 y 15 s.
|
||||||
|
*/
|
||||||
|
export function intervaloDe(token: string): number {
|
||||||
|
const l = limitesPorToken.get(token);
|
||||||
|
if (!l) return MIN_INTERVAL_MS;
|
||||||
|
const base = Math.ceil((l.ventanaMs / l.max) * MARGEN);
|
||||||
|
if (l.restantes <= 5) {
|
||||||
|
return Math.max(base, Math.ceil(l.ventanaMs / Math.max(1, l.restantes)));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CrmError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
readonly body?: unknown
|
||||||
|
) {
|
||||||
|
super(`CRM ${status}: ${message}`);
|
||||||
|
this.name = "CrmError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un fallo de transporte no es una respuesta: el servidor no habló, así que no
|
||||||
|
* se sabe si la escritura entró. Reenviarlo es fabricar la doble creación. Se
|
||||||
|
* marca aparte para que la bandeja de salida lo deje en `indeterminado` y lo
|
||||||
|
* resuelva **leyendo**, nunca reintentando.
|
||||||
|
*/
|
||||||
|
export class CrmTransportError extends Error {
|
||||||
|
readonly indeterminate = true;
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`CRM sin respuesta: ${message}`);
|
||||||
|
this.name = "CrmTransportError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un reloj por token, no uno global: el límite del CRM es por credencial, así
|
||||||
|
// que un semáforo único serializaría negocios que pueden ir en paralelo. Con
|
||||||
|
// diez cuentas, la décima esperaría a las nueve anteriores sin ninguna razón.
|
||||||
|
const ultimaPeticionPorToken = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Milisegundos que este token debe esperar antes de su próxima petición. */
|
||||||
|
export function esperaDeToken(token: string, ahora = Date.now()): number {
|
||||||
|
const ultima = ultimaPeticionPorToken.get(token) ?? 0;
|
||||||
|
return Math.max(0, intervaloDe(token) - (ahora - ultima));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registrarPeticion(token: string, ahora = Date.now()): void {
|
||||||
|
ultimaPeticionPorToken.set(token, ahora);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function throttle(token: string) {
|
||||||
|
const espera = esperaDeToken(token);
|
||||||
|
if (espera > 0) await new Promise((r) => setTimeout(r, espera));
|
||||||
|
registrarPeticion(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmOptions {
|
||||||
|
/**
|
||||||
|
* Token privado de la subcuenta. **Obligatorio y sin valor por defecto.**
|
||||||
|
*
|
||||||
|
* Antes caía a `requireEnv("CRM_TOKEN")`, una variable global del proceso: con
|
||||||
|
* dos negocios, olvidar el token no daba error — usaba el del primero contra
|
||||||
|
* la subcuenta del segundo. Al hacerlo obligatorio, ese olvido pasa a ser un
|
||||||
|
* error de compilación, que es el gate real de calidad de este repo.
|
||||||
|
*
|
||||||
|
* Sale siempre de `CrmCtx.token` (ver platform/crm/ctx.ts).
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
body?: unknown;
|
||||||
|
version?: string;
|
||||||
|
query?: Record<string, string | number | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crmRequest<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
opts: CrmOptions
|
||||||
|
): Promise<T> {
|
||||||
|
loadEnv();
|
||||||
|
const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT;
|
||||||
|
const token = opts.token;
|
||||||
|
|
||||||
|
let url = `${base}${path}`;
|
||||||
|
if (opts.query) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(opts.query)) {
|
||||||
|
if (v !== undefined) q.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = q.toString();
|
||||||
|
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version =
|
||||||
|
opts.version ?? (path.startsWith("/calendars/") ? VERSION_CALENDARS : VERSION_DEFAULT);
|
||||||
|
|
||||||
|
let intento = 0;
|
||||||
|
for (;;) {
|
||||||
|
await throttle(token);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
version,
|
||||||
|
accept: "application/json",
|
||||||
|
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Timeout / conexión caída: no hubo respuesta. Se reintenta el transporte
|
||||||
|
// solo en GET, que es idempotente por naturaleza; en escrituras se
|
||||||
|
// propaga para que arriba se resuelva leyendo.
|
||||||
|
if (method === "GET" && intento < MAX_RETRIES) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000 * 2 ** intento));
|
||||||
|
intento++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new CrmTransportError(`${e?.name ?? "Error"}: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El CRM declara su propia cuota en cada respuesta. Leerla es la única
|
||||||
|
// forma de no ir a ciegas: el intervalo por defecto era una estimación.
|
||||||
|
anotarLimites(token, res.headers);
|
||||||
|
|
||||||
|
if (res.status === 429 || res.status >= 500) {
|
||||||
|
if (intento < MAX_RETRIES) {
|
||||||
|
// 429 lineal (5/10/15 s), 5xx exponencial: es la política ya medida en
|
||||||
|
// el proyecto hermano.
|
||||||
|
const espera = res.status === 429 ? 5000 * (intento + 1) : 2000 * 2 ** intento;
|
||||||
|
await new Promise((r) => setTimeout(r, espera));
|
||||||
|
intento++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const texto = await res.text();
|
||||||
|
let cuerpo: any = null;
|
||||||
|
try {
|
||||||
|
cuerpo = texto ? JSON.parse(texto) : null;
|
||||||
|
} catch {
|
||||||
|
cuerpo = texto;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Rotar el token es trabajo humano: no se reintenta y se dice claro.
|
||||||
|
throw new CrmError(401, "Token rechazado — hay que regenerarlo en el CRM", cuerpo);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
(Array.isArray(cuerpo?.message) ? cuerpo.message.join("; ") : cuerpo?.message) ||
|
||||||
|
cuerpo?.error ||
|
||||||
|
res.statusText;
|
||||||
|
throw new CrmError(res.status, String(msg), cuerpo);
|
||||||
|
}
|
||||||
|
return cuerpo as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { crmRequest } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
import type { EtapasPipeline } from "./opportunities.ts";
|
||||||
|
|
||||||
|
export interface CrmConnection {
|
||||||
|
id: number;
|
||||||
|
business_id: number;
|
||||||
|
location_id: string;
|
||||||
|
pipeline_id: string | null;
|
||||||
|
stage_open_id: string | null;
|
||||||
|
stage_won_id: string | null;
|
||||||
|
stage_lost_id: string | null;
|
||||||
|
allow_duplicate_opp: boolean;
|
||||||
|
last_sync_at: string | null;
|
||||||
|
last_sync_status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerConexion(businessId: number): Promise<CrmConnection | null> {
|
||||||
|
const { rows } = await pool.query<CrmConnection>(
|
||||||
|
`SELECT * FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function etapasDe(c: CrmConnection): EtapasPipeline {
|
||||||
|
if (!c.pipeline_id) throw new Error("La conexión con el CRM no tiene pipeline configurado");
|
||||||
|
return {
|
||||||
|
pipelineId: c.pipeline_id,
|
||||||
|
open: c.stage_open_id,
|
||||||
|
won: c.stage_won_id,
|
||||||
|
lost: c.stage_lost_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta el pipeline y las etapas de la subcuenta y las guarda.
|
||||||
|
*
|
||||||
|
* Las etapas se eligen por nombre porque sus identificadores son opacos y
|
||||||
|
* distintos en cada subcuenta. Se busca «ganado» y «perdido»; si no aparecen,
|
||||||
|
* se cae a la primera y la última por posición, que es lo que un embudo suele
|
||||||
|
* significar. Ese respaldo se registra en `last_sync_status` para que no pase
|
||||||
|
* inadvertido.
|
||||||
|
*/
|
||||||
|
export async function autoconfigurar(ctx: CrmCtx): Promise<CrmConnection> {
|
||||||
|
const r = await crmRequest<any>("GET", "/opportunities/pipelines", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
});
|
||||||
|
const pipelines: any[] = r?.pipelines ?? [];
|
||||||
|
if (!pipelines.length) {
|
||||||
|
throw new Error("La subcuenta del CRM no tiene ningún pipeline");
|
||||||
|
}
|
||||||
|
const pipe = pipelines[0];
|
||||||
|
const stages: any[] = [...(pipe.stages ?? [])].sort(
|
||||||
|
(a, b) => (a.position ?? 0) - (b.position ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const porNombre = (...palabras: string[]) =>
|
||||||
|
stages.find((s) => {
|
||||||
|
const n = String(s.name ?? "").toLowerCase();
|
||||||
|
return palabras.some((p) => n.includes(p));
|
||||||
|
})?.id ?? null;
|
||||||
|
|
||||||
|
const won = porNombre("ganado", "won", "asisti", "complet");
|
||||||
|
const lost = porNombre("perdido", "lost", "cancel", "no asis");
|
||||||
|
const open = stages[0]?.id ?? null;
|
||||||
|
|
||||||
|
// MEDIDO: la subcuenta expone el ajuste que decide si una cita puede estrenar
|
||||||
|
// su propia oportunidad o hay que reciclar la de la clienta.
|
||||||
|
let permiteDuplicados = false;
|
||||||
|
try {
|
||||||
|
const loc = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
});
|
||||||
|
permiteDuplicados = Boolean(loc?.location?.settings?.allowDuplicateOpportunity);
|
||||||
|
} catch {
|
||||||
|
// Si no se puede leer, se asume el caso restrictivo: reciclar nunca rompe,
|
||||||
|
// crear a ciegas sí.
|
||||||
|
}
|
||||||
|
|
||||||
|
const nota =
|
||||||
|
won && lost
|
||||||
|
? `pipeline «${pipe.name}»; etapas detectadas por nombre`
|
||||||
|
: `pipeline «${pipe.name}»; OJO: no se hallaron etapas de ganado/perdido por nombre`;
|
||||||
|
|
||||||
|
// `location_id` NO se escribe aquí: lo puso `guardarCredencial` junto al token,
|
||||||
|
// y son la misma decisión. Escribirlo desde dos sitios permite que se separen.
|
||||||
|
const { rows } = await pool.query<CrmConnection>(
|
||||||
|
`INSERT INTO crm_connections
|
||||||
|
(business_id, location_id, pipeline_id, stage_open_id, stage_won_id, stage_lost_id,
|
||||||
|
allow_duplicate_opp, last_sync_status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
ON CONFLICT (business_id) DO UPDATE SET
|
||||||
|
pipeline_id = EXCLUDED.pipeline_id,
|
||||||
|
stage_open_id = EXCLUDED.stage_open_id,
|
||||||
|
stage_won_id = EXCLUDED.stage_won_id,
|
||||||
|
stage_lost_id = EXCLUDED.stage_lost_id,
|
||||||
|
allow_duplicate_opp = EXCLUDED.allow_duplicate_opp,
|
||||||
|
last_sync_status = EXCLUDED.last_sync_status
|
||||||
|
RETURNING *`,
|
||||||
|
[ctx.businessId, ctx.locationId, pipe.id, open, won, lost, permiteDuplicados, nota]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mapAtribucion, nombreDe } from "./contacts.ts";
|
||||||
|
|
||||||
|
test("aplana la atribución real que devuelve el CRM", () => {
|
||||||
|
// Este objeto es una captura literal de un contacto real de la subcuenta.
|
||||||
|
const a = mapAtribucion({
|
||||||
|
id: "x",
|
||||||
|
source: "WhatsApp",
|
||||||
|
attributionSource: {
|
||||||
|
sessionSource: "Paid Social",
|
||||||
|
medium: "instagram",
|
||||||
|
mediumId: "28379457221686971",
|
||||||
|
campaign: "Servicios-Interaccion-WhatsApp",
|
||||||
|
utmMedium: "Uñas-Interaccion-WA",
|
||||||
|
utmContent: "Post - Uñas - WA",
|
||||||
|
campaignId: "120249003827580681",
|
||||||
|
adId: "120249003827530681",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(a.crm_source, "WhatsApp");
|
||||||
|
assert.equal(a.attr_session_source, "Paid Social");
|
||||||
|
assert.equal(a.attr_medium, "instagram");
|
||||||
|
assert.equal(a.attr_campaign, "Servicios-Interaccion-WhatsApp");
|
||||||
|
assert.equal(a.attr_campaign_id, "120249003827580681");
|
||||||
|
assert.equal(a.attr_ad_id, "120249003827530681");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("«campaign» gana a «utmCampaign»", () => {
|
||||||
|
// No es un capricho de orden: el buscador de contactos del CRM descarta
|
||||||
|
// `utmCampaign` y conserva `campaign`. Leerlos al revés deja la campaña
|
||||||
|
// vacía en la mitad de los contactos.
|
||||||
|
const a = mapAtribucion({
|
||||||
|
id: "x",
|
||||||
|
attributionSource: { campaign: "la_buena", utmCampaign: "la_descartada" },
|
||||||
|
});
|
||||||
|
assert.equal(a.attr_campaign, "la_buena");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cae a utmCampaign cuando campaign no viene", () => {
|
||||||
|
const a = mapAtribucion({ id: "x", attributionSource: { utmCampaign: "solo_esta" } });
|
||||||
|
assert.equal(a.attr_campaign, "solo_esta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una atribución vacía no inventa valores", () => {
|
||||||
|
const a = mapAtribucion({ id: "x" });
|
||||||
|
assert.equal(a.attr_campaign, null);
|
||||||
|
assert.equal(a.attr_session_source, null);
|
||||||
|
assert.equal(a.crm_source, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("las cadenas vacías cuentan como ausencia, no como valor", () => {
|
||||||
|
const a = mapAtribucion({ id: "x", attributionSource: { campaign: "", utmCampaign: "buena" } });
|
||||||
|
assert.equal(a.attr_campaign, "buena");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el nombre sale de los tres orígenes que trae el CRM, en orden", () => {
|
||||||
|
assert.equal(nombreDe({ id: "1", firstName: "Ana", lastName: "Ruiz" }), "Ana Ruiz");
|
||||||
|
assert.equal(nombreDe({ id: "2", firstName: "Ana" }), "Ana");
|
||||||
|
assert.equal(nombreDe({ id: "3", contactName: "Ana R." }), "Ana R.");
|
||||||
|
assert.equal(nombreDe({ id: "4", email: "[email protected]" }), "[email protected]");
|
||||||
|
assert.equal(nombreDe({ id: "5" }), "Sin nombre");
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { crmRequest, CrmError } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
|
||||||
|
/** Lo que el CRM devuelve de un contacto, en la forma que nos interesa. */
|
||||||
|
export interface CrmContact {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
contactName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
dateAdded?: string | null;
|
||||||
|
dateOfBirth?: string | null;
|
||||||
|
attributionSource?: Record<string, string | null> | null;
|
||||||
|
customFields?: { id: string; value: unknown }[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La atribución, aplanada a las columnas de `clients`. */
|
||||||
|
export interface Atribucion {
|
||||||
|
crm_source: string | null;
|
||||||
|
attr_session_source: string | null;
|
||||||
|
attr_medium: string | null;
|
||||||
|
attr_campaign: string | null;
|
||||||
|
attr_campaign_id: string | null;
|
||||||
|
attr_utm_source: string | null;
|
||||||
|
attr_utm_medium: string | null;
|
||||||
|
attr_utm_content: string | null;
|
||||||
|
attr_ad_id: string | null;
|
||||||
|
attr_referrer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplana `attributionSource`.
|
||||||
|
*
|
||||||
|
* `campaign` se lee ANTES que `utmCampaign` a propósito: el buscador de
|
||||||
|
* contactos del CRM descarta `utmCampaign` y conserva `campaign`, así que
|
||||||
|
* leerlos al revés deja la campaña vacía en la mitad de los contactos.
|
||||||
|
*/
|
||||||
|
export function mapAtribucion(c: CrmContact): Atribucion {
|
||||||
|
const a = c.attributionSource ?? {};
|
||||||
|
const g = (...claves: string[]) => {
|
||||||
|
for (const k of claves) {
|
||||||
|
const v = (a as any)[k];
|
||||||
|
if (v !== undefined && v !== null && v !== "") return String(v);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
crm_source: c.source ?? null,
|
||||||
|
attr_session_source: g("sessionSource"),
|
||||||
|
attr_medium: g("medium"),
|
||||||
|
attr_campaign: g("campaign", "utmCampaign"),
|
||||||
|
attr_campaign_id: g("campaignId"),
|
||||||
|
attr_utm_source: g("utmSource"),
|
||||||
|
attr_utm_medium: g("utmMedium"),
|
||||||
|
attr_utm_content: g("utmContent"),
|
||||||
|
attr_ad_id: g("adId"),
|
||||||
|
attr_referrer: g("referrer", "url"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre presentable, con los tres orígenes que trae el CRM. */
|
||||||
|
export function nombreDe(c: CrmContact): string {
|
||||||
|
const compuesto = [c.firstName, c.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return compuesto || (c.contactName ?? "").trim() || (c.email ?? "").trim() || "Sin nombre";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una página del buscador de contactos. */
|
||||||
|
export interface PaginaContactos {
|
||||||
|
contacts: CrmContact[];
|
||||||
|
total: number;
|
||||||
|
searchAfter?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorre los contactos de la subcuenta.
|
||||||
|
*
|
||||||
|
* Pagina con `searchAfter` y no con `page`: el buscador tiene un techo de
|
||||||
|
* profundidad por número de página, y con 3 209 contactos se alcanza. El cursor
|
||||||
|
* sale del ÚLTIMO contacto de la página anterior.
|
||||||
|
*/
|
||||||
|
export async function buscarContactos(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
opts: { pageLimit?: number; searchAfter?: unknown } = {}
|
||||||
|
): Promise<PaginaContactos> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
pageLimit: opts.pageLimit ?? 100,
|
||||||
|
};
|
||||||
|
if (opts.searchAfter) body.searchAfter = opts.searchAfter;
|
||||||
|
|
||||||
|
const r = await crmRequest<any>("POST", "/contacts/search", { token: ctx.token, body });
|
||||||
|
return {
|
||||||
|
contacts: r?.contacts ?? [],
|
||||||
|
total: r?.total ?? 0,
|
||||||
|
searchAfter: r?.contacts?.length ? r.contacts[r.contacts.length - 1]?.searchAfter : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerContacto(ctx: CrmCtx, id: string): Promise<CrmContact | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: ctx.token });
|
||||||
|
return r?.contact ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca por un identificador natural. El CRM deduplica por email y teléfono. */
|
||||||
|
export async function buscarPorIdentificador(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
q: string
|
||||||
|
): Promise<CrmContact | null> {
|
||||||
|
const r = await crmRequest<any>("GET", "/contacts/", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId, query: q, limit: 5 },
|
||||||
|
});
|
||||||
|
return r?.contacts?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AltaContacto {
|
||||||
|
locationId: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
source?: string;
|
||||||
|
tags?: string[];
|
||||||
|
attribution?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoResolucion {
|
||||||
|
contact: CrmContact;
|
||||||
|
/** Cómo se llegó a él: importa para la auditoría y para depurar duplicados. */
|
||||||
|
via: "crm_id" | "telefono" | "correo" | "creado" | "duplicado_400";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve el contacto en el CRM siguiendo la cadena de identidad acordada:
|
||||||
|
* **id de contacto → teléfono → correo**, y si no existe, lo crea.
|
||||||
|
*
|
||||||
|
* Es la misma cadena que el CRM aplica por su cuenta
|
||||||
|
* (`contactUniqueIdentifiers: ["email","phone"]`), así que las dos coinciden y
|
||||||
|
* no se pelean.
|
||||||
|
*
|
||||||
|
* El caso interesante es el último: si el alta choca con un duplicado, el CRM
|
||||||
|
* responde `400` **con el `contactId` existente en `meta`**. Eso es idempotencia
|
||||||
|
* de verdad, regalada por el servidor, y es mejor que `upsert` — cuya rama
|
||||||
|
* *actualizar* descarta la atribución en silencio.
|
||||||
|
*/
|
||||||
|
export async function resolverContacto(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
datos: {
|
||||||
|
crmContactId?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
name: string;
|
||||||
|
source?: string;
|
||||||
|
tags?: string[];
|
||||||
|
attribution?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
): Promise<ResultadoResolucion> {
|
||||||
|
// 1. Por id del CRM, si ya lo teníamos anclado.
|
||||||
|
if (datos.crmContactId) {
|
||||||
|
const c = await obtenerContacto(ctx, datos.crmContactId);
|
||||||
|
if (c) return { contact: c, via: "crm_id" };
|
||||||
|
// El id guardado ya no resuelve: el contacto se borró en el CRM. Se sigue
|
||||||
|
// por los fallbacks en vez de fallar.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Por teléfono normalizado.
|
||||||
|
const tel = normalizePhone(datos.phone);
|
||||||
|
if (tel) {
|
||||||
|
const c = await buscarPorIdentificador(ctx, tel);
|
||||||
|
if (c) return { contact: c, via: "telefono" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Por correo.
|
||||||
|
if (datos.email) {
|
||||||
|
const c = await buscarPorIdentificador(ctx, datos.email);
|
||||||
|
if (c) return { contact: c, via: "correo" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Crear. La atribución solo entra AQUÍ: después es inmutable.
|
||||||
|
const partes = datos.name.trim().split(/\s+/);
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
// MEDIDO: `locationId` va en el POST de alta y ROMPE el PUT con
|
||||||
|
// `422 property locationId should not exist`. No reciclar este cuerpo.
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
firstName: partes[0] || datos.name,
|
||||||
|
lastName: partes.slice(1).join(" ") || undefined,
|
||||||
|
country: "MX",
|
||||||
|
source: datos.source ?? "AgendaMax",
|
||||||
|
};
|
||||||
|
if (tel) body.phone = tel;
|
||||||
|
if (datos.email) body.email = datos.email;
|
||||||
|
if (datos.tags?.length) body.tags = datos.tags;
|
||||||
|
if (datos.attribution && Object.keys(datos.attribution).length) {
|
||||||
|
body.attributionSource = datos.attribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("POST", "/contacts/", { token: ctx.token, body });
|
||||||
|
return { contact: r.contact, via: "creado" };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 400) {
|
||||||
|
const existente = (e.body as any)?.meta?.contactId;
|
||||||
|
if (existente) {
|
||||||
|
const c = await obtenerContacto(ctx, existente);
|
||||||
|
if (c) return { contact: c, via: "duplicado_400" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { normalizarTipo, formaDeMensajes } from "./conversations.ts";
|
||||||
|
|
||||||
|
test("normalizarTipo: la API devuelve número o cadena según el endpoint", () => {
|
||||||
|
// MEDIDO (hallazgo 24): `/conversations/{id}` da un número y el buscador una
|
||||||
|
// cadena `TYPE_SMS`. Es la misma información con dos formas.
|
||||||
|
assert.equal(normalizarTipo("TYPE_SMS"), "SMS");
|
||||||
|
assert.equal(normalizarTipo("TYPE_EMAIL"), "Email");
|
||||||
|
assert.equal(normalizarTipo(1), "Phone");
|
||||||
|
assert.equal(normalizarTipo(2), "Email");
|
||||||
|
assert.equal(normalizarTipo(3), "FB");
|
||||||
|
assert.equal(normalizarTipo(undefined), "Desconocido");
|
||||||
|
assert.equal(normalizarTipo(""), "Desconocido");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizarTipo: un tipo desconocido no se traga, se ve", () => {
|
||||||
|
assert.equal(normalizarTipo("TYPE_TIKTOK"), "TIKTOK");
|
||||||
|
assert.equal(normalizarTipo(99), "Desconocido");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formaDeMensajes: desanida la respuesta real, que trae messages.messages", () => {
|
||||||
|
const r = formaDeMensajes({
|
||||||
|
messages: { messages: [{ id: "m1" }], lastMessageId: "m1", nextPage: true },
|
||||||
|
});
|
||||||
|
assert.equal(r.mensajes.length, 1);
|
||||||
|
assert.equal(r.lastMessageId, "m1");
|
||||||
|
assert.equal(r.hayMas, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formaDeMensajes: tolera la forma plana por si la API cambia", () => {
|
||||||
|
const r = formaDeMensajes({ messages: [{ id: "m1" }] });
|
||||||
|
assert.equal(r.mensajes.length, 1);
|
||||||
|
assert.equal(r.hayMas, false);
|
||||||
|
assert.equal(r.lastMessageId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formaDeMensajes: una respuesta vacía no revienta", () => {
|
||||||
|
const r = formaDeMensajes({});
|
||||||
|
assert.deepEqual(r.mensajes, []);
|
||||||
|
assert.equal(r.hayMas, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizarTipo reconoce los canales reales de la subcuenta", () => {
|
||||||
|
// MEDIDO: los hilos reales traen TYPE_INSTAGRAM y TYPE_ACTIVITY_OPPORTUNITY.
|
||||||
|
assert.equal(normalizarTipo("TYPE_INSTAGRAM"), "Instagram");
|
||||||
|
assert.equal(normalizarTipo("TYPE_ACTIVITY_OPPORTUNITY"), "Actividad");
|
||||||
|
assert.equal(normalizarTipo("TYPE_WHATSAPP"), "WhatsApp");
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { crmRequest, CrmError } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
export interface CrmConversation {
|
||||||
|
id: string;
|
||||||
|
contactId?: string;
|
||||||
|
fullName?: string;
|
||||||
|
contactName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
lastMessageBody?: string;
|
||||||
|
lastMessageType?: string;
|
||||||
|
lastMessageDate?: string | number;
|
||||||
|
unreadCount?: number;
|
||||||
|
type?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmMessage {
|
||||||
|
id: string;
|
||||||
|
body?: string;
|
||||||
|
direction?: "inbound" | "outbound";
|
||||||
|
messageType?: string;
|
||||||
|
status?: string | null;
|
||||||
|
dateAdded?: string;
|
||||||
|
contactId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El canal llega como cadena (`TYPE_SMS`) desde el buscador y como número desde
|
||||||
|
* `GET /conversations/{id}`.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 24). Es la misma información con dos formas, y mezclarlas
|
||||||
|
* produce una bandeja que etiqueta mal los hilos. Un tipo que no se reconozca se
|
||||||
|
* deja pasar tal cual en vez de esconderlo: si el CRM añade un canal, se verá.
|
||||||
|
*/
|
||||||
|
const POR_NUMERO: Record<number, string> = {
|
||||||
|
1: "Phone",
|
||||||
|
2: "Email",
|
||||||
|
3: "FB",
|
||||||
|
4: "Review",
|
||||||
|
5: "SMS",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Los canales conocidos se rinden con su nombre propio; el resto pasa tal cual. */
|
||||||
|
const POR_NOMBRE: Record<string, string> = {
|
||||||
|
SMS: "SMS",
|
||||||
|
EMAIL: "Email",
|
||||||
|
CALL: "Llamada",
|
||||||
|
VOICEMAIL: "Buzón de voz",
|
||||||
|
WHATSAPP: "WhatsApp",
|
||||||
|
FB: "Facebook",
|
||||||
|
IG: "Instagram",
|
||||||
|
INSTAGRAM: "Instagram",
|
||||||
|
FACEBOOK: "Facebook",
|
||||||
|
GMB: "Google Business",
|
||||||
|
WEBCHAT: "Chat web",
|
||||||
|
// Los TYPE_ACTIVITY_* no son mensajes de la clienta: son notas que el propio
|
||||||
|
// CRM escribe en el hilo cuando pasa algo (se creó una oportunidad, se agendó
|
||||||
|
// una cita). Se etiquetan como actividad para poder distinguirlos en la
|
||||||
|
// bandeja en vez de mostrarlos como si alguien los hubiera escrito.
|
||||||
|
ACTIVITY_OPPORTUNITY: "Actividad",
|
||||||
|
ACTIVITY_APPOINTMENT: "Actividad",
|
||||||
|
ACTIVITY_CONTACT: "Actividad",
|
||||||
|
ACTIVITY: "Actividad",
|
||||||
|
REVIEW: "Reseña",
|
||||||
|
LIVE_CHAT: "Chat en vivo",
|
||||||
|
CUSTOM: "Otro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizarTipo(t: string | number | undefined | null): string {
|
||||||
|
if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido";
|
||||||
|
if (typeof t === "string" && t) {
|
||||||
|
const crudo = t.replace(/^TYPE_/, "");
|
||||||
|
return POR_NOMBRE[crudo] ?? crudo.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
return "Desconocido";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...],
|
||||||
|
* lastMessageId, nextPage } }` — anidada dos niveles.
|
||||||
|
*
|
||||||
|
* Esta es la TERCERA convención de paginación de la misma API: contactos usan
|
||||||
|
* `searchAfter`, conversaciones `startAfterDate`, y los mensajes `lastMessageId`
|
||||||
|
* con un booleano `nextPage`. Reciclar una por otra devuelve listas incompletas
|
||||||
|
* sin dar ningún error.
|
||||||
|
*/
|
||||||
|
export function formaDeMensajes(r: any): {
|
||||||
|
mensajes: CrmMessage[];
|
||||||
|
lastMessageId: string | null;
|
||||||
|
hayMas: boolean;
|
||||||
|
} {
|
||||||
|
const anidado = r?.messages?.messages;
|
||||||
|
if (Array.isArray(anidado)) {
|
||||||
|
return {
|
||||||
|
mensajes: anidado,
|
||||||
|
lastMessageId: r.messages.lastMessageId ?? null,
|
||||||
|
hayMas: Boolean(r.messages.nextPage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const plano = Array.isArray(r?.messages) ? r.messages : [];
|
||||||
|
return { mensajes: plano, lastMessageId: null, hayMas: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una conversación por su id. MEDIDO: los campos vienen en la raíz, sin envoltorio. */
|
||||||
|
export async function obtenerConversacion(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
id: string
|
||||||
|
): Promise<CrmConversation | null> {
|
||||||
|
try {
|
||||||
|
return await crmRequest<CrmConversation>("GET", `/conversations/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Las conversaciones de un contacto. MEDIDO (hallazgo 25): `contactId` es filtro. */
|
||||||
|
export async function conversacionesDeContacto(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
contactId: string
|
||||||
|
): Promise<CrmConversation[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId, contactId, limit: 50 },
|
||||||
|
});
|
||||||
|
return r?.conversations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarConversaciones(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
opts: { limit?: number; startAfterDate?: number } = {}
|
||||||
|
): Promise<{ conversations: CrmConversation[]; total: number }> {
|
||||||
|
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
limit: opts.limit ?? 20,
|
||||||
|
sortBy: "last_message_date",
|
||||||
|
sort: "desc",
|
||||||
|
startAfterDate: opts.startAfterDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { conversations: r?.conversations ?? [], total: r?.total ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mensajesDeConversacion(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
conversationId: string,
|
||||||
|
opts: { limit?: number; lastMessageId?: string } = {}
|
||||||
|
) {
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/${conversationId}/messages`, {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId },
|
||||||
|
});
|
||||||
|
return formaDeMensajes(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un mensaje suelto por su id. MEDIDO (hallazgo 27): funciona y viene en la raíz. */
|
||||||
|
export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise<CrmMessage | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/messages/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
});
|
||||||
|
return (r?.message ?? r) as CrmMessage;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { cifrar, descifrar, huella } from "../lib/crypto.ts";
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Todo lo que hace falta para hablar con la subcuenta de UN negocio.
|
||||||
|
*
|
||||||
|
* Sustituye al `locationId: string` suelto que antes viajaba por once firmas.
|
||||||
|
* Es un objeto y no dos parámetros a propósito: dos `string` seguidos se pueden
|
||||||
|
* cruzar sin que el compilador diga nada, y cruzarlos aquí significa mandar el
|
||||||
|
* token de un cliente a la subcuenta de otro.
|
||||||
|
*
|
||||||
|
* Es el ÚNICO sitio del código donde el token existe descifrado, y solo en
|
||||||
|
* memoria. Ni se registra, ni se audita, ni sale por la API.
|
||||||
|
*/
|
||||||
|
export interface CrmCtx {
|
||||||
|
businessId: number;
|
||||||
|
locationId: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilaCredencial {
|
||||||
|
location_id: string;
|
||||||
|
token_cipher: Buffer | null;
|
||||||
|
token_nonce: Buffer | null;
|
||||||
|
token_tag: Buffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga la credencial del negocio y la descifra.
|
||||||
|
*
|
||||||
|
* Distingue «no vinculado» de «vinculado sin token» a propósito: son dos
|
||||||
|
* situaciones con dos arreglos distintos, y un solo mensaje para las dos manda
|
||||||
|
* a quien lo lea a mirar donde no es.
|
||||||
|
*/
|
||||||
|
export async function ctxDe(businessId: number): Promise<CrmCtx> {
|
||||||
|
const { rows } = await pool.query<FilaCredencial>(
|
||||||
|
`SELECT location_id, token_cipher, token_nonce, token_tag
|
||||||
|
FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const c = rows[0];
|
||||||
|
if (!c) {
|
||||||
|
throw { status: 409, error: "Este negocio no está vinculado a Bucéfalo CRM" };
|
||||||
|
}
|
||||||
|
if (!c.token_cipher || !c.token_nonce || !c.token_tag) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error:
|
||||||
|
"Este negocio no tiene token de Bucéfalo CRM. Vincúlalo desde la consola de administración.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
businessId,
|
||||||
|
locationId: c.location_id,
|
||||||
|
token: descifrar({ cipher: c.token_cipher, nonce: c.token_nonce, tag: c.token_tag }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda o rota la credencial de un negocio. Idempotente.
|
||||||
|
*
|
||||||
|
* La etiqueta se conserva si no se manda otra: al rotar un token caducado nadie
|
||||||
|
* quiere volver a teclear el nombre de la subcuenta, y perderlo en silencio
|
||||||
|
* dejaría la consola llena de cuentas sin identificar.
|
||||||
|
*/
|
||||||
|
export async function guardarCredencial(
|
||||||
|
businessId: number,
|
||||||
|
locationId: string,
|
||||||
|
token: string,
|
||||||
|
label?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const c = cifrar(token);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO crm_connections
|
||||||
|
(business_id, location_id, token_cipher, token_nonce, token_tag,
|
||||||
|
token_fingerprint, token_updated_at, label)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6, now(), $7)
|
||||||
|
ON CONFLICT (business_id) DO UPDATE SET
|
||||||
|
location_id = EXCLUDED.location_id,
|
||||||
|
token_cipher = EXCLUDED.token_cipher,
|
||||||
|
token_nonce = EXCLUDED.token_nonce,
|
||||||
|
token_tag = EXCLUDED.token_tag,
|
||||||
|
token_fingerprint = EXCLUDED.token_fingerprint,
|
||||||
|
token_updated_at = now(),
|
||||||
|
label = COALESCE(EXCLUDED.label, crm_connections.label)`,
|
||||||
|
[businessId, locationId, c.cipher, c.nonce, c.tag, huella(token), label ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desvincula: borra la credencial y **conserva** la conexión y todo lo ya
|
||||||
|
* sincronizado. Quitar el token no es motivo para tirar 3 200 contactos, sus
|
||||||
|
* conversaciones y la atribución que costó traer.
|
||||||
|
*/
|
||||||
|
export async function olvidarCredencial(businessId: number): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_connections
|
||||||
|
SET token_cipher = NULL, token_nonce = NULL, token_tag = NULL,
|
||||||
|
token_fingerprint = NULL, token_updated_at = NULL
|
||||||
|
WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto construido desde el entorno, **solo para los scripts de sondeo**.
|
||||||
|
*
|
||||||
|
* El servidor nunca debe usar esto: sus credenciales salen de la base, por
|
||||||
|
* negocio, vía `ctxDe`. Aquí existe porque los spikes se lanzan a mano contra
|
||||||
|
* la subcuenta que esté configurada en `platform/.env`, antes incluso de que
|
||||||
|
* exista una fila en `crm_connections`.
|
||||||
|
*/
|
||||||
|
export function ctxDesdeEnv(businessId = 0): CrmCtx {
|
||||||
|
// Carga el .env explícitamente: depender de que otro import lo haya hecho
|
||||||
|
// antes funciona por casualidad y se rompe al reordenar los imports.
|
||||||
|
loadEnv();
|
||||||
|
const locationId = process.env.CRM_LOCATION_ID;
|
||||||
|
const token = process.env.CRM_TOKEN;
|
||||||
|
if (!locationId || !token) {
|
||||||
|
throw new Error(
|
||||||
|
"Faltan CRM_LOCATION_ID y/o CRM_TOKEN en platform/.env — este script los necesita para hablar con la subcuenta."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { businessId, locationId, token };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { crmRequest } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envío de mensajes hacia Bucéfalo CRM.
|
||||||
|
*
|
||||||
|
* La LECTURA de conversaciones y mensajes vive en `conversations.ts`: son dos
|
||||||
|
* responsabilidades distintas y la de lectura creció con la sincronización por
|
||||||
|
* id. Aquí queda solo lo que escribe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EnvioCorreo {
|
||||||
|
contactId: string;
|
||||||
|
emailTo: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoEnvio {
|
||||||
|
conversationId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
emailMessageId?: string;
|
||||||
|
msg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envía un correo por el CRM.
|
||||||
|
*
|
||||||
|
* **Un `200` aquí es acuse de encolado, no de entrega** — la respuesta literal
|
||||||
|
* es `Email queued successfully`. No se puede afirmar que el mensaje llegó sin
|
||||||
|
* mirar una bandeja real, y la interfaz no debe decir «enviado» como si fuera
|
||||||
|
* un hecho confirmado.
|
||||||
|
*
|
||||||
|
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||||
|
* canal ejercible hoy.
|
||||||
|
*/
|
||||||
|
export async function enviarCorreo(ctx: CrmCtx, e: EnvioCorreo): Promise<ResultadoEnvio> {
|
||||||
|
return crmRequest<ResultadoEnvio>("POST", "/conversations/messages", {
|
||||||
|
token: ctx.token,
|
||||||
|
body: {
|
||||||
|
type: "Email",
|
||||||
|
contactId: e.contactId,
|
||||||
|
subject: e.subject,
|
||||||
|
html: e.html,
|
||||||
|
emailTo: e.emailTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { estadoOportunidad, nombreOportunidad } from "./opportunities.ts";
|
||||||
|
|
||||||
|
test("el estado de la cita se mapea al de la oportunidad", () => {
|
||||||
|
assert.equal(estadoOportunidad("scheduled"), "open", "en espera → open");
|
||||||
|
assert.equal(estadoOportunidad("completed"), "won", "completada → won");
|
||||||
|
assert.equal(estadoOportunidad("cancelled"), "lost", "cancelada → lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no vino también es una pérdida para el embudo", () => {
|
||||||
|
// El matiz de POR QUÉ se perdió (no vino / canceló la clienta / canceló el
|
||||||
|
// spa) vive en AgendaMax: el CRM aplana los tres en `lost`.
|
||||||
|
assert.equal(estadoOportunidad("no_show"), "lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un estado desconocido no cierra la oportunidad", () => {
|
||||||
|
// Cerrar por error es peor que dejar abierto: `won` mete ingreso inventado en
|
||||||
|
// los reportes del CRM y `lost` mata una cita viva.
|
||||||
|
assert.equal(estadoOportunidad("cualquier_cosa"), "open");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el nombre de la oportunidad es SERVICIO + CLIENTA", () => {
|
||||||
|
assert.equal(
|
||||||
|
nombreOportunidad("Extensiones de pestañas", "Mariana López"),
|
||||||
|
"Extensiones de pestañas — Mariana López"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el nombre se recorta para no romper el límite del CRM", () => {
|
||||||
|
const n = nombreOportunidad("S".repeat(200), "C".repeat(200));
|
||||||
|
assert.equal(n.length, 255);
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { crmRequest, CrmError } from "./client.ts";
|
||||||
|
import type { CrmCtx } from "./ctx.ts";
|
||||||
|
|
||||||
|
/** El enum de la API. La cita en espera es `open`, completada `won`, cancelada `lost`. */
|
||||||
|
export type CrmOppStatus = "open" | "won" | "lost" | "abandoned";
|
||||||
|
|
||||||
|
export interface CrmOpportunity {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
status?: CrmOppStatus;
|
||||||
|
monetaryValue?: number;
|
||||||
|
pipelineId?: string;
|
||||||
|
pipelineStageId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estado de la cita en AgendaMax → estado de la oportunidad en el CRM. */
|
||||||
|
export function estadoOportunidad(estadoCita: string): CrmOppStatus {
|
||||||
|
switch (estadoCita) {
|
||||||
|
case "completed":
|
||||||
|
return "won";
|
||||||
|
case "cancelled":
|
||||||
|
case "no_show":
|
||||||
|
// Una cita a la que no vino la clienta tampoco produjo ingreso: para el
|
||||||
|
// embudo del CRM es una pérdida. El matiz de POR QUÉ se perdió (no vino,
|
||||||
|
// canceló ella, canceló el spa) vive en AgendaMax, que sí lo distingue.
|
||||||
|
return "lost";
|
||||||
|
default:
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El nombre de la oportunidad, con el formato acordado: SERVICIO + NOMBRE.
|
||||||
|
* Se recorta a 255 porque el CRM no documenta el límite y un nombre largo
|
||||||
|
* es la clase de cosa que falla en producción y no en pruebas.
|
||||||
|
*/
|
||||||
|
export function nombreOportunidad(servicio: string, cliente: string): string {
|
||||||
|
return `${servicio} — ${cliente}`.slice(0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EtapasPipeline {
|
||||||
|
pipelineId: string;
|
||||||
|
open?: string | null;
|
||||||
|
won?: string | null;
|
||||||
|
lost?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function etapaPara(estado: CrmOppStatus, etapas: EtapasPipeline): string | null {
|
||||||
|
if (estado === "won") return etapas.won ?? null;
|
||||||
|
if (estado === "lost") return etapas.lost ?? null;
|
||||||
|
return etapas.open ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerOportunidad(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
id: string
|
||||||
|
): Promise<CrmOpportunity | null> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", `/opportunities/${id}`, { token: ctx.token });
|
||||||
|
return r?.opportunity ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Las oportunidades de un contacto. `GET /contacts/{id}/opportunities` NO existe (404). */
|
||||||
|
export async function oportunidadesDeContacto(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
contactId: string
|
||||||
|
): Promise<CrmOpportunity[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/opportunities/search", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { location_id: ctx.locationId, contact_id: contactId, limit: 20 },
|
||||||
|
});
|
||||||
|
return r?.opportunities ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia el estado y la etapa.
|
||||||
|
*
|
||||||
|
* Tres cosas medidas gobiernan esta función, y las tres son contraintuitivas:
|
||||||
|
*
|
||||||
|
* 1. Son **dos llamadas**, no una: `PUT /opportunities/{id}/status` rechaza
|
||||||
|
* `pipelineStageId` con `422 property pipelineStageId should not exist`.
|
||||||
|
* 2. **El orden importa**: el `/status` mueve la etapa por su cuenta, así que
|
||||||
|
* va primero y la etapa deseada se escribe después. Al revés, el `/status`
|
||||||
|
* pisa la etapa recién puesta (medido: de «Ganado» a «Cotización Aceptada»).
|
||||||
|
* 3. En `won`, la subcuenta acaba imponiendo **su** etapa igualmente: se probó
|
||||||
|
* reescribir y releer tres veces y el CRM la devuelve a «Cotización
|
||||||
|
* Aceptada» de forma asíncrona, después de que la relectura ya confirmó la
|
||||||
|
* nuestra. Hay una regla del lado del CRM que gobierna eso, y pelearse con
|
||||||
|
* ella sería un bucle que nunca gana. En `lost` sí respeta «Perdido».
|
||||||
|
*
|
||||||
|
* Se escribe la etapa una vez y no se insiste. Lo que el negocio pidió mapear
|
||||||
|
* es el **estado** —`open` / `won` / `lost`—, y ese sí queda estable y
|
||||||
|
* verificado; la etapa es presentación y la manda el CRM.
|
||||||
|
*/
|
||||||
|
async function aplicarEstado(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
id: string,
|
||||||
|
estado: CrmOppStatus,
|
||||||
|
etapas: EtapasPipeline
|
||||||
|
): Promise<void> {
|
||||||
|
await crmRequest("PUT", `/opportunities/${id}/status`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { status: estado },
|
||||||
|
});
|
||||||
|
|
||||||
|
const etapa = etapaPara(estado, etapas);
|
||||||
|
if (!etapa) return;
|
||||||
|
|
||||||
|
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { pipelineId: etapas.pipelineId, pipelineStageId: etapa },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoOportunidad {
|
||||||
|
opportunity: CrmOpportunity;
|
||||||
|
via: "creada" | "reciclada" | "actualizada";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proyecta una cita al CRM como oportunidad.
|
||||||
|
*
|
||||||
|
* Dos caminos, y la plataforma elige sola sin configuración:
|
||||||
|
*
|
||||||
|
* 1. **Crear.** Si la subcuenta permite duplicados, cada cita estrena su
|
||||||
|
* oportunidad — que es el modelo pedido.
|
||||||
|
* 2. **Reciclar.** Si responde `400 OPPORTUNITY_NO_DUPLICATE`, el CRM entrega
|
||||||
|
* en `meta.existingId` la que ya existe, y se le pone el nombre, el importe
|
||||||
|
* y el estado de esta cita.
|
||||||
|
*
|
||||||
|
* El camino 2 es el que corre hoy en Yola: la subcuenta tiene
|
||||||
|
* `allowDuplicateOpportunity: false`. Ahí la oportunidad representa *la cita
|
||||||
|
* vigente de la clienta*, y el histórico completo vive en AgendaMax. Si alguien
|
||||||
|
* activa el ajuste en el CRM, esta misma función pasa al camino 1 sin cambios.
|
||||||
|
*/
|
||||||
|
export async function upsertOportunidad(
|
||||||
|
ctx: CrmCtx,
|
||||||
|
args: {
|
||||||
|
contactId: string;
|
||||||
|
nombre: string;
|
||||||
|
importe: number;
|
||||||
|
estado: CrmOppStatus;
|
||||||
|
etapas: EtapasPipeline;
|
||||||
|
/** Si ya la teníamos anclada, se actualiza directamente. */
|
||||||
|
oportunidadId?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<ResultadoOportunidad> {
|
||||||
|
const { contactId, nombre, importe, estado, etapas } = args;
|
||||||
|
|
||||||
|
// Ya anclada: actualizar en sitio.
|
||||||
|
if (args.oportunidadId) {
|
||||||
|
const existente = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||||
|
if (existente) {
|
||||||
|
await crmRequest("PUT", `/opportunities/${args.oportunidadId}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||||
|
});
|
||||||
|
await aplicarEstado(ctx, args.oportunidadId, estado, etapas);
|
||||||
|
const releida = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||||
|
return { opportunity: releida ?? existente, via: "actualizada" };
|
||||||
|
}
|
||||||
|
// El id guardado ya no resuelve; se sigue por el camino normal.
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
pipelineId: etapas.pipelineId,
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
name: nombre,
|
||||||
|
status: estado,
|
||||||
|
contactId,
|
||||||
|
monetaryValue: importe,
|
||||||
|
};
|
||||||
|
const etapa = etapaPara(estado, etapas);
|
||||||
|
if (etapa) body.pipelineStageId = etapa;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("POST", "/opportunities/", { token: ctx.token, body });
|
||||||
|
const creada = r?.opportunity;
|
||||||
|
// Se RELEE antes de dar el id por bueno: la respuesta de creación de esta
|
||||||
|
// API refleja lo que mandaste, no necesariamente lo que persistió.
|
||||||
|
const releida = creada?.id ? await obtenerOportunidad(ctx, creada.id) : null;
|
||||||
|
return { opportunity: releida ?? creada, via: "creada" };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CrmError && e.status === 400) {
|
||||||
|
const cuerpo = e.body as any;
|
||||||
|
const existenteId =
|
||||||
|
cuerpo?.meta?.existingId ??
|
||||||
|
(cuerpo?.code === "OPPORTUNITY_NO_DUPLICATE" ? cuerpo?.meta?.id : null);
|
||||||
|
|
||||||
|
let id: string | null = existenteId ?? null;
|
||||||
|
if (!id) {
|
||||||
|
// El 400 no trajo el id: se busca. Es el tercer mecanismo, el más débil,
|
||||||
|
// pero aquí la llave natural (contacto + subcuenta) es exacta.
|
||||||
|
const previas = await oportunidadesDeContacto(ctx, contactId);
|
||||||
|
id = previas[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||||
|
token: ctx.token,
|
||||||
|
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||||
|
});
|
||||||
|
await aplicarEstado(ctx, id, estado, etapas);
|
||||||
|
const releida = await obtenerOportunidad(ctx, id);
|
||||||
|
if (releida) return { opportunity: releida, via: "reciclada" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { PoolClient } from "pg";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { CrmTransportError } from "./client.ts";
|
||||||
|
import { proyectarCita } from "./syncAppointments.ts";
|
||||||
|
|
||||||
|
export type EntidadOutbox = "appointment" | "client" | "message";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clave de deduplicación **propia y estable**. Nunca se deriva del contenido:
|
||||||
|
* dos ediciones que dejan el mismo valor son dos intenciones distintas y las
|
||||||
|
* dos tienen que salir.
|
||||||
|
*/
|
||||||
|
export function claveDedup(
|
||||||
|
businessId: number,
|
||||||
|
entidad: EntidadOutbox,
|
||||||
|
entidadId: number,
|
||||||
|
operacion: string,
|
||||||
|
secuencia: number | string
|
||||||
|
): string {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update([businessId, entidad, entidadId, operacion, secuencia].join("|"))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encola un cambio para el CRM **dentro de la transacción que lo produjo**.
|
||||||
|
*
|
||||||
|
* Recibe el `PoolClient` a propósito: el cambio local y su fila de bandeja se
|
||||||
|
* escriben juntos o no se escriben. Sin eso aparece la escritura perdida — el
|
||||||
|
* usuario ve «guardado», el proceso muere antes de encolar, y nadie lo reclama
|
||||||
|
* nunca.
|
||||||
|
*/
|
||||||
|
export async function encolar(
|
||||||
|
tx: PoolClient,
|
||||||
|
args: {
|
||||||
|
businessId: number;
|
||||||
|
entidad: EntidadOutbox;
|
||||||
|
entidadId: number;
|
||||||
|
operacion: string;
|
||||||
|
payload: unknown;
|
||||||
|
secuencia?: number | string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const secuencia = args.secuencia ?? Date.now();
|
||||||
|
const dedup = claveDedup(
|
||||||
|
args.businessId,
|
||||||
|
args.entidad,
|
||||||
|
args.entidadId,
|
||||||
|
args.operacion,
|
||||||
|
secuencia
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO crm_outbox (business_id, entity, entity_id, operation, payload, dedup_key)
|
||||||
|
VALUES ($1,$2,$3,$4,$5::jsonb,$6)
|
||||||
|
ON CONFLICT (dedup_key) DO NOTHING`,
|
||||||
|
[
|
||||||
|
args.businessId,
|
||||||
|
args.entidad,
|
||||||
|
args.entidadId,
|
||||||
|
args.operacion,
|
||||||
|
JSON.stringify(args.payload ?? {}),
|
||||||
|
dedup,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumenDespacho {
|
||||||
|
tomadas: number;
|
||||||
|
confirmadas: number;
|
||||||
|
fallidas: number;
|
||||||
|
indeterminadas: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Despacha la bandeja de salida de un negocio.
|
||||||
|
*
|
||||||
|
* FIFO estricto y **una sola escritura en vuelo por registro**: el CRM
|
||||||
|
* estrangula por token y dos escrituras concurrentes sobre la misma cita
|
||||||
|
* corren contra una base que ya cambió.
|
||||||
|
*/
|
||||||
|
export async function despachar(
|
||||||
|
businessId: number,
|
||||||
|
limite = 25
|
||||||
|
): Promise<ResumenDespacho> {
|
||||||
|
const resumen: ResumenDespacho = {
|
||||||
|
tomadas: 0,
|
||||||
|
confirmadas: 0,
|
||||||
|
fallidas: 0,
|
||||||
|
indeterminadas: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, entity, entity_id, operation, attempts
|
||||||
|
FROM crm_outbox
|
||||||
|
WHERE business_id = $1 AND status IN ('pendiente','indeterminado')
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT $2`,
|
||||||
|
[businessId, limite]
|
||||||
|
);
|
||||||
|
resumen.tomadas = rows.length;
|
||||||
|
|
||||||
|
for (const fila of rows) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox SET status = 'enviando', attempts = attempts + 1 WHERE id = $1`,
|
||||||
|
[fila.id]
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
let crmId: string | null = null;
|
||||||
|
|
||||||
|
if (fila.entity === "appointment") {
|
||||||
|
const r = await proyectarCita(businessId, fila.entity_id);
|
||||||
|
crmId = r.crmOpportunityId;
|
||||||
|
} else {
|
||||||
|
// Todavía no hay más entidades salientes; se descarta explícitamente
|
||||||
|
// en vez de dejarla girando en la cola para siempre.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox
|
||||||
|
SET status = 'fallido', last_error = 'entidad no soportada todavía'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[fila.id]
|
||||||
|
);
|
||||||
|
resumen.fallidas++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox
|
||||||
|
SET status = 'confirmado', crm_id = $2, evidence = 'relectura', sent_at = now(),
|
||||||
|
last_error = NULL
|
||||||
|
WHERE id = $1`,
|
||||||
|
[fila.id, crmId]
|
||||||
|
);
|
||||||
|
resumen.confirmadas++;
|
||||||
|
} catch (e: any) {
|
||||||
|
// Un fallo de transporte NO se reintenta: el servidor no habló, así que
|
||||||
|
// no se sabe si la escritura entró, y reenviar es fabricar el duplicado.
|
||||||
|
// Queda en `indeterminado` para resolverlo LEYENDO.
|
||||||
|
const indeterminado = e instanceof CrmTransportError || e?.indeterminate === true;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_outbox SET status = $2, last_error = $3 WHERE id = $1`,
|
||||||
|
[
|
||||||
|
fila.id,
|
||||||
|
indeterminado ? "indeterminado" : fila.attempts >= 4 ? "fallido" : "pendiente",
|
||||||
|
String(e?.message ?? e).slice(0, 500),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (indeterminado) resumen.indeterminadas++;
|
||||||
|
else resumen.fallidas++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resumen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadoOutbox {
|
||||||
|
pendiente: number;
|
||||||
|
enviando: number;
|
||||||
|
confirmado: number;
|
||||||
|
fallido: number;
|
||||||
|
indeterminado: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function estadoOutbox(businessId: number): Promise<EstadoOutbox> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT status, count(*)::int AS c FROM crm_outbox WHERE business_id = $1 GROUP BY status`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const base: EstadoOutbox = {
|
||||||
|
pendiente: 0,
|
||||||
|
enviando: 0,
|
||||||
|
confirmado: 0,
|
||||||
|
fallido: 0,
|
||||||
|
indeterminado: 0,
|
||||||
|
};
|
||||||
|
for (const r of rows) (base as any)[r.status] = r.c;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { crmRequest, VERSION_CALENDARS } from "./client.ts";
|
||||||
|
import { ctxDe, type CrmCtx } from "./ctx.ts";
|
||||||
|
import { listarPersonal } from "./calendars.ts";
|
||||||
|
import { slugify } from "../lib/businessDefaults.ts";
|
||||||
|
|
||||||
|
export interface CrmService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
serviceDuration?: number;
|
||||||
|
serviceDurationUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El catálogo de servicios de la subcuenta.
|
||||||
|
*
|
||||||
|
* MEDIDO dos veces (hallazgos 6 y 32): devuelve `services: []`. El catálogo del
|
||||||
|
* CRM está VACÍO, no ausente — el modelo existe y admite duración, precio,
|
||||||
|
* categoría y variaciones. Simplemente nadie lo ha poblado.
|
||||||
|
*
|
||||||
|
* De ahí la dirección: «sincronizar servicios» no puede significar traerlos. La
|
||||||
|
* duración y el precio los define el negocio en la plataforma, y lo único con
|
||||||
|
* sentido es publicarlos hacia allá.
|
||||||
|
*/
|
||||||
|
export async function catalogoDelCrm(ctx: CrmCtx): Promise<CrmService[]> {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/services/catalog", {
|
||||||
|
token: ctx.token,
|
||||||
|
query: { locationId: ctx.locationId },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return r?.services ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoPublicacion {
|
||||||
|
crmServiceId: string;
|
||||||
|
nombre: string;
|
||||||
|
yaEstaba: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publica un servicio de la plataforma en el catálogo del CRM.
|
||||||
|
*
|
||||||
|
* MEDIDO (hallazgo 34): `calendars.write` está y `staff[]` con al menos un
|
||||||
|
* miembro es obligatorio — el `422` lo dice literalmente. Los ids de personal
|
||||||
|
* salen de `GET /users/`, que volvió a estar disponible (hallazgo 35).
|
||||||
|
*/
|
||||||
|
export async function publicarServicio(
|
||||||
|
businessId: number,
|
||||||
|
serviceId: number
|
||||||
|
): Promise<ResultadoPublicacion> {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, description, duration_min, price, color, crm_service_id
|
||||||
|
FROM services WHERE id = $1 AND business_id = $2 AND active = true`,
|
||||||
|
[serviceId, businessId]
|
||||||
|
);
|
||||||
|
const s = rows[0];
|
||||||
|
if (!s) throw { status: 404, error: "Ese servicio no existe en este negocio, o está inactivo" };
|
||||||
|
|
||||||
|
if (s.crm_service_id) {
|
||||||
|
// Ya publicado. Se comprueba que siga existiendo antes de darlo por bueno:
|
||||||
|
// alguien pudo borrarlo desde la interfaz del CRM.
|
||||||
|
const catalogo = await catalogoDelCrm(ctx);
|
||||||
|
if (catalogo.some((x) => x.id === s.crm_service_id)) {
|
||||||
|
return { crmServiceId: s.crm_service_id, nombre: s.name, yaEstaba: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let personal: { id: string; name: string }[] = [];
|
||||||
|
try {
|
||||||
|
personal = await listarPersonal(ctx);
|
||||||
|
} catch (e: any) {
|
||||||
|
// El permiso de personal se ha visto ir y venir (hallazgo 14 → 35). Si no
|
||||||
|
// está, se dice qué falta en vez de fallar con el 422 del catálogo.
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error:
|
||||||
|
"No se pudo leer el personal de la subcuenta, y el catálogo exige al menos una persona por servicio. Revisa que el token tenga permiso de usuarios.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!personal.length) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error:
|
||||||
|
"La subcuenta de Bucéfalo CRM no tiene personal, y el catálogo exige al menos una persona por servicio",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuerpo = {
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
name: s.name,
|
||||||
|
slug: slugify(s.name),
|
||||||
|
...(s.description ? { description: s.description } : {}),
|
||||||
|
...(s.color ? { eventColor: s.color } : {}),
|
||||||
|
serviceDuration: Number(s.duration_min),
|
||||||
|
serviceDurationUnit: "mins",
|
||||||
|
staff: personal.slice(0, 1).map((p) => ({ id: p.id })),
|
||||||
|
variations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let r: any;
|
||||||
|
try {
|
||||||
|
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: cuerpo,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// MEDIDO: la PRIMERA publicación en una subcuenta que nunca ha tenido
|
||||||
|
// catálogo falla con `400 No default service category found for this
|
||||||
|
// location` — y ese mismo intento hace que el CRM cree la categoría por
|
||||||
|
// defecto (`isSystemGenerated: true`). El reintento sí entra.
|
||||||
|
//
|
||||||
|
// Se reintenta UNA vez y solo ante ese mensaje concreto: reintentar a ciegas
|
||||||
|
// un POST es fabricar duplicados.
|
||||||
|
const msg = String(e?.message ?? "");
|
||||||
|
if (e?.status === 400 && /default service category/i.test(msg)) {
|
||||||
|
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||||
|
token: ctx.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: cuerpo,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crmServiceId = r?.service?.id ?? r?.id;
|
||||||
|
if (!crmServiceId) {
|
||||||
|
throw new Error("El CRM aceptó el servicio pero no devolvió su identificador");
|
||||||
|
}
|
||||||
|
|
||||||
|
// No se acepta el 200 como prueba: se relee el catálogo y se busca.
|
||||||
|
const catalogo = await catalogoDelCrm(ctx);
|
||||||
|
if (!catalogo.some((x) => x.id === crmServiceId)) {
|
||||||
|
throw new Error(
|
||||||
|
"El servicio no aparece al releer el catálogo del CRM: la escritura no persistió"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||||
|
[serviceId, crmServiceId]
|
||||||
|
);
|
||||||
|
return { crmServiceId, nombre: s.name, yaEstaba: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import { obtenerConexion, etapasDe } from "./connection.ts";
|
||||||
|
import { resolverContacto } from "./contacts.ts";
|
||||||
|
import {
|
||||||
|
upsertOportunidad,
|
||||||
|
estadoOportunidad,
|
||||||
|
nombreOportunidad,
|
||||||
|
} from "./opportunities.ts";
|
||||||
|
|
||||||
|
export interface ResultadoProyeccion {
|
||||||
|
appointmentId: number;
|
||||||
|
crmContactId: string;
|
||||||
|
crmOpportunityId: string;
|
||||||
|
status: string;
|
||||||
|
via: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proyecta UNA cita al CRM como oportunidad.
|
||||||
|
*
|
||||||
|
* Nombre `SERVICIO — CLIENTA`, importe el del servicio, y estado según la cita:
|
||||||
|
* en espera → `open`, completada → `won`, cancelada o no asistió → `lost`.
|
||||||
|
*
|
||||||
|
* Resuelve primero el contacto: `POST /calendars/...` y `POST /opportunities/`
|
||||||
|
* exigen `contactId`, así que una clienta nacida en la plataforma tiene que
|
||||||
|
* existir en el CRM antes de que su cita pueda salir.
|
||||||
|
*/
|
||||||
|
export async function proyectarCita(
|
||||||
|
businessId: number,
|
||||||
|
appointmentId: number
|
||||||
|
): Promise<ResultadoProyeccion> {
|
||||||
|
const conexion = await obtenerConexion(businessId);
|
||||||
|
if (!conexion) {
|
||||||
|
throw Object.assign(new Error("Este negocio no tiene conexión con Bucéfalo CRM"), {
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// La conexión da pipeline y etapas; el contexto da la credencial. Son dos
|
||||||
|
// cosas distintas y por eso se piden por separado.
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT a.id, a.status, a.price, a.crm_opportunity_id,
|
||||||
|
c.id AS client_id, c.name AS client_name, c.phone, c.email,
|
||||||
|
c.crm_contact_id, s.name AS service_name
|
||||||
|
FROM appointments a
|
||||||
|
JOIN clients c ON c.id = a.client_id
|
||||||
|
JOIN services s ON s.id = a.service_id
|
||||||
|
WHERE a.id = $1 AND a.business_id = $2`,
|
||||||
|
[appointmentId, businessId]
|
||||||
|
);
|
||||||
|
const cita = rows[0];
|
||||||
|
if (!cita) {
|
||||||
|
throw Object.assign(new Error("Cita no encontrada"), { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacto = await resolverContacto(ctx, {
|
||||||
|
crmContactId: cita.crm_contact_id,
|
||||||
|
phone: cita.phone,
|
||||||
|
email: cita.email,
|
||||||
|
name: cita.client_name,
|
||||||
|
source: "AgendaMax",
|
||||||
|
tags: ["agendamax"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se ancla el contacto en cuanto se conoce: si la oportunidad falla después,
|
||||||
|
// al menos la clienta ya no se volverá a crear duplicada.
|
||||||
|
if (contacto.contact.id !== cita.crm_contact_id) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE clients SET crm_contact_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||||
|
[cita.client_id, contacto.contact.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const estado = estadoOportunidad(cita.status);
|
||||||
|
const oportunidad = await upsertOportunidad(ctx, {
|
||||||
|
contactId: contacto.contact.id,
|
||||||
|
nombre: nombreOportunidad(cita.service_name, cita.client_name),
|
||||||
|
importe: Number(cita.price) || 0,
|
||||||
|
estado,
|
||||||
|
etapas: etapasDe(conexion),
|
||||||
|
oportunidadId: cita.crm_opportunity_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE appointments
|
||||||
|
SET crm_opportunity_id = $2, crm_status = $3, crm_synced_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[appointmentId, oportunidad.opportunity.id, estado]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appointmentId,
|
||||||
|
crmContactId: contacto.contact.id,
|
||||||
|
crmOpportunityId: oportunidad.opportunity.id,
|
||||||
|
status: estado,
|
||||||
|
via: `contacto:${contacto.via} · oportunidad:${oportunidad.via}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
import { buscarContactos, mapAtribucion, nombreDe, type CrmContact } from "./contacts.ts";
|
||||||
|
|
||||||
|
export interface ResumenSync {
|
||||||
|
runId: number;
|
||||||
|
fetched: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
total_crm: number;
|
||||||
|
status: "ok" | "error";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trae los contactos del CRM a la plataforma, con su atribución.
|
||||||
|
*
|
||||||
|
* Dirección: **una sola, del CRM hacia aquí.** El contacto es del CRM —es su
|
||||||
|
* llave de deduplicación y donde viven las automatizaciones—, así que esta
|
||||||
|
* sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar
|
||||||
|
* pasa por la bandeja de salida, que es otra cosa.
|
||||||
|
*
|
||||||
|
* Reconciliación, en el mismo orden que la cadena de identidad acordada:
|
||||||
|
* 1. `crm_contact_id` — si ya está anclado, es él y no se busca más.
|
||||||
|
* 2. teléfono normalizado a E.164.
|
||||||
|
* 3. correo.
|
||||||
|
* Si ninguno encaja, se crea la clienta.
|
||||||
|
*
|
||||||
|
* La atribución se sobrescribe siempre desde el CRM: es dato del CRM y él es su
|
||||||
|
* único dueño. El nombre, en cambio, **no pisa** uno editado en la plataforma
|
||||||
|
* si el CRM no trae nada mejor.
|
||||||
|
*/
|
||||||
|
export async function sincronizarContactos(
|
||||||
|
businessId: number,
|
||||||
|
opts: { userId?: number | null; maxPaginas?: number } = {}
|
||||||
|
): Promise<ResumenSync> {
|
||||||
|
// El contexto se pide UNA vez, al principio: descifra el token y ya no se
|
||||||
|
// vuelve a tocar la base para eso en toda la corrida.
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
|
||||||
|
const run = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||||
|
VALUES ($1,'contacts','pull',$2) RETURNING id`,
|
||||||
|
[businessId, opts.userId ?? null]
|
||||||
|
);
|
||||||
|
const runId = run.rows[0].id;
|
||||||
|
|
||||||
|
let fetched = 0;
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let cursor: unknown = undefined;
|
||||||
|
const maxPaginas = opts.maxPaginas ?? 60; // 60 × 100 = 6 000 contactos por corrida
|
||||||
|
|
||||||
|
for (let pagina = 0; pagina < maxPaginas; pagina++) {
|
||||||
|
const p = await buscarContactos(ctx, {
|
||||||
|
pageLimit: 100,
|
||||||
|
searchAfter: cursor,
|
||||||
|
});
|
||||||
|
total = p.total;
|
||||||
|
if (!p.contacts.length) break;
|
||||||
|
fetched += p.contacts.length;
|
||||||
|
|
||||||
|
for (const c of p.contacts) {
|
||||||
|
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||||
|
if (r === "created") created++;
|
||||||
|
else if (r === "updated") updated++;
|
||||||
|
else skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p.searchAfter) break;
|
||||||
|
cursor = p.searchAfter;
|
||||||
|
if (fetched >= total) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs
|
||||||
|
SET finished_at = now(), status = 'ok',
|
||||||
|
fetched = $2, created = $3, updated = $4, skipped = $5
|
||||||
|
WHERE id = $1`,
|
||||||
|
[runId, fetched, created, updated, skipped]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_connections
|
||||||
|
SET last_sync_at = now(),
|
||||||
|
last_sync_status = $2
|
||||||
|
WHERE business_id = $1`,
|
||||||
|
[businessId, `${created} nuevas, ${updated} actualizadas de ${fetched} leídas`]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { runId, fetched, created, updated, skipped, total_crm: total, status: "ok" };
|
||||||
|
} catch (e: any) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs
|
||||||
|
SET finished_at = now(), status = 'error', error = $2,
|
||||||
|
fetched = $3, created = $4, updated = $5, skipped = $6
|
||||||
|
WHERE id = $1`,
|
||||||
|
[runId, String(e?.message ?? e).slice(0, 500), fetched, created, updated, skipped]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_connections SET last_sync_status = $2 WHERE business_id = $1`,
|
||||||
|
[businessId, `error: ${String(e?.message ?? e).slice(0, 200)}`]
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resultado = "created" | "updated" | "skipped";
|
||||||
|
|
||||||
|
export async function upsertClienteDesdeCrm(
|
||||||
|
businessId: number,
|
||||||
|
c: CrmContact
|
||||||
|
): Promise<Resultado> {
|
||||||
|
const tel = normalizePhone(c.phone);
|
||||||
|
const email = (c.email ?? "").trim().toLowerCase() || null;
|
||||||
|
const nombre = nombreDe(c);
|
||||||
|
const attr = mapAtribucion(c);
|
||||||
|
const tags = Array.isArray(c.tags) ? c.tags.join(",") : null;
|
||||||
|
|
||||||
|
return withTx(async (tx) => {
|
||||||
|
// Cadena de identidad: id anclado → teléfono → correo.
|
||||||
|
let existente: { id: number; name: string } | null = null;
|
||||||
|
|
||||||
|
const porId = await tx.query(
|
||||||
|
`SELECT id, name FROM clients WHERE business_id = $1 AND crm_contact_id = $2`,
|
||||||
|
[businessId, c.id]
|
||||||
|
);
|
||||||
|
existente = porId.rows[0] ?? null;
|
||||||
|
|
||||||
|
if (!existente && tel) {
|
||||||
|
const porTel = await tx.query(
|
||||||
|
`SELECT id, name FROM clients
|
||||||
|
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||||
|
[businessId, tel]
|
||||||
|
);
|
||||||
|
existente = porTel.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
if (!existente && email) {
|
||||||
|
const porMail = await tx.query(
|
||||||
|
`SELECT id, name FROM clients
|
||||||
|
WHERE business_id = $1 AND lower(email) = $2 AND deleted_at IS NULL`,
|
||||||
|
[businessId, email]
|
||||||
|
);
|
||||||
|
existente = porMail.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = [
|
||||||
|
attr.crm_source,
|
||||||
|
attr.attr_session_source,
|
||||||
|
attr.attr_medium,
|
||||||
|
attr.attr_campaign,
|
||||||
|
attr.attr_campaign_id,
|
||||||
|
attr.attr_utm_source,
|
||||||
|
attr.attr_utm_medium,
|
||||||
|
attr.attr_utm_content,
|
||||||
|
attr.attr_ad_id,
|
||||||
|
attr.attr_referrer,
|
||||||
|
tags,
|
||||||
|
c.dateAdded ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE clients SET
|
||||||
|
crm_contact_id = $2,
|
||||||
|
crm_synced_at = now(),
|
||||||
|
-- El teléfono y el correo solo se rellenan si aquí faltaban: son
|
||||||
|
-- las llaves de identidad y pisarlas puede fusionar dos personas.
|
||||||
|
phone = COALESCE(phone, $3),
|
||||||
|
phone_e164 = COALESCE(phone_e164, $4),
|
||||||
|
email = COALESCE(email, $5),
|
||||||
|
-- El nombre solo se completa si el de aquí está vacío: alguien pudo
|
||||||
|
-- corregirlo en la plataforma y el CRM trae 56 % de apellidos.
|
||||||
|
name = CASE WHEN btrim(name) = '' THEN $6 ELSE name END,
|
||||||
|
crm_source = $7, attr_session_source = $8, attr_medium = $9,
|
||||||
|
attr_campaign = $10, attr_campaign_id = $11, attr_utm_source = $12,
|
||||||
|
attr_utm_medium = $13, attr_utm_content = $14, attr_ad_id = $15,
|
||||||
|
attr_referrer = $16, crm_tags = $17, crm_date_added = $18::timestamptz
|
||||||
|
WHERE id = $1`,
|
||||||
|
[existente.id, c.id, c.phone ?? null, tel, email, nombre, ...cols]
|
||||||
|
);
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO clients
|
||||||
|
(business_id, name, email, phone, phone_e164, crm_contact_id, crm_synced_at,
|
||||||
|
crm_source, attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||||
|
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id, attr_referrer,
|
||||||
|
crm_tags, crm_date_added, birth_date)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,now(),
|
||||||
|
$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::timestamptz,$19::date)`,
|
||||||
|
[
|
||||||
|
businessId,
|
||||||
|
nombre,
|
||||||
|
email,
|
||||||
|
c.phone ?? null,
|
||||||
|
tel,
|
||||||
|
c.id,
|
||||||
|
...cols,
|
||||||
|
c.dateOfBirth ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return "created";
|
||||||
|
} catch (e: any) {
|
||||||
|
// El índice único de teléfono ganó una carrera: otra clienta con el mismo
|
||||||
|
// número entró entre la consulta y este INSERT. Se ancla al existente en
|
||||||
|
// vez de perder el contacto.
|
||||||
|
if (e.code === "23505" && tel) {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE clients SET crm_contact_id = $3, crm_synced_at = now()
|
||||||
|
WHERE business_id = $1 AND phone_e164 = $2 AND crm_contact_id IS NULL`,
|
||||||
|
[businessId, tel, c.id]
|
||||||
|
);
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import {
|
||||||
|
buscarConversaciones,
|
||||||
|
mensajesDeConversacion,
|
||||||
|
obtenerConversacion,
|
||||||
|
normalizarTipo,
|
||||||
|
type CrmConversation,
|
||||||
|
type CrmMessage,
|
||||||
|
} from "./conversations.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fecha del CRM → `Date`.
|
||||||
|
*
|
||||||
|
* La API mezcla formatos: `lastMessageDate` llega como epoch en milisegundos y
|
||||||
|
* `dateAdded` como ISO. Aceptar los dos aquí evita repartir esa comprobación por
|
||||||
|
* todos los sitios que guardan una fecha.
|
||||||
|
*/
|
||||||
|
function fecha(v: string | number | undefined | null): Date | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertConversacion(
|
||||||
|
businessId: number,
|
||||||
|
c: CrmConversation
|
||||||
|
): Promise<number> {
|
||||||
|
const { rows } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO conversations
|
||||||
|
(business_id, crm_conversation_id, crm_contact_id, contact_name,
|
||||||
|
last_message_type, last_message_body, last_message_at, unread_count,
|
||||||
|
client_id, synced_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||||||
|
(SELECT id FROM clients
|
||||||
|
WHERE business_id = $1 AND crm_contact_id = $3 AND deleted_at IS NULL
|
||||||
|
LIMIT 1),
|
||||||
|
now())
|
||||||
|
ON CONFLICT (business_id, crm_conversation_id) DO UPDATE SET
|
||||||
|
crm_contact_id = COALESCE(EXCLUDED.crm_contact_id, conversations.crm_contact_id),
|
||||||
|
-- Ni el nombre ni el canal se degradan.
|
||||||
|
--
|
||||||
|
-- MEDIDO: GET /conversations/{id} NO devuelve el nombre del contacto ni
|
||||||
|
-- un canal reconocible; eso solo viene del buscador. Sincronizar un hilo
|
||||||
|
-- por su id sobrescribia un nombre bueno con "Sin nombre" y el canal con
|
||||||
|
-- "Desconocido". Un dato pobre no puede pisar a uno que ya se tenia.
|
||||||
|
contact_name = CASE
|
||||||
|
WHEN EXCLUDED.contact_name = 'Sin nombre'
|
||||||
|
THEN COALESCE(conversations.contact_name, EXCLUDED.contact_name)
|
||||||
|
ELSE EXCLUDED.contact_name
|
||||||
|
END,
|
||||||
|
last_message_type = CASE
|
||||||
|
WHEN EXCLUDED.last_message_type = 'Desconocido'
|
||||||
|
THEN COALESCE(conversations.last_message_type, EXCLUDED.last_message_type)
|
||||||
|
ELSE EXCLUDED.last_message_type
|
||||||
|
END,
|
||||||
|
last_message_body = COALESCE(EXCLUDED.last_message_body, conversations.last_message_body),
|
||||||
|
last_message_at = COALESCE(EXCLUDED.last_message_at, conversations.last_message_at),
|
||||||
|
unread_count = EXCLUDED.unread_count,
|
||||||
|
-- El enlace con la clienta solo se RELLENA, nunca se borra: si la
|
||||||
|
-- sincronizacion de contactos todavia no ha corrido, client_id es NULL,
|
||||||
|
-- y pisarlo con NULL mas tarde perderia un enlace ya resuelto.
|
||||||
|
client_id = COALESCE(conversations.client_id, EXCLUDED.client_id),
|
||||||
|
synced_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
businessId,
|
||||||
|
c.id,
|
||||||
|
c.contactId ?? null,
|
||||||
|
c.fullName || c.contactName || "Sin nombre",
|
||||||
|
normalizarTipo(c.lastMessageType),
|
||||||
|
c.lastMessageBody ?? null,
|
||||||
|
fecha(c.lastMessageDate),
|
||||||
|
c.unreadCount ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMensaje(
|
||||||
|
businessId: number,
|
||||||
|
conversationId: number,
|
||||||
|
m: CrmMessage
|
||||||
|
): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO messages
|
||||||
|
(business_id, conversation_id, crm_message_id, crm_contact_id,
|
||||||
|
direction, channel, channel_raw, body, status, sent_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (business_id, crm_message_id) DO UPDATE SET
|
||||||
|
-- El CRM es el dueno del historico: aqui se reescribe desde el, nunca se
|
||||||
|
-- edita. Solo cambian cuerpo y estado; el resto es inmutable.
|
||||||
|
body = EXCLUDED.body,
|
||||||
|
status = EXCLUDED.status`,
|
||||||
|
[
|
||||||
|
businessId,
|
||||||
|
conversationId,
|
||||||
|
m.id,
|
||||||
|
m.contactId ?? null,
|
||||||
|
m.direction === "outbound" ? "outbound" : "inbound",
|
||||||
|
normalizarTipo(m.messageType),
|
||||||
|
m.messageType ?? null,
|
||||||
|
m.body ?? null,
|
||||||
|
m.status ?? null,
|
||||||
|
fecha(m.dateAdded),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumenSyncConv {
|
||||||
|
conversaciones: number;
|
||||||
|
mensajes: number;
|
||||||
|
runId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espeja UNA conversación con todos sus mensajes.
|
||||||
|
*
|
||||||
|
* Pagina hasta 20 vueltas de 100: son 2 000 mensajes por hilo, muy por encima de
|
||||||
|
* cualquier conversación real, y el tope existe para que un `nextPage` que nunca
|
||||||
|
* deje de ser `true` no cuelgue la petición para siempre.
|
||||||
|
*/
|
||||||
|
export async function sincronizarConversacion(
|
||||||
|
businessId: number,
|
||||||
|
crmConversationId: string
|
||||||
|
): Promise<{ conversacion: number; mensajes: number }> {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const c = await obtenerConversacion(ctx, crmConversationId);
|
||||||
|
if (!c) throw { status: 404, error: "Esa conversación no existe en Bucéfalo CRM" };
|
||||||
|
|
||||||
|
// El endpoint de una conversación suelta no trae el nombre del contacto. Si
|
||||||
|
// la clienta ya está en la plataforma, se usa el suyo: es mejor dato que el
|
||||||
|
// relleno, y evita que la bandeja muestre "Sin nombre" para alguien conocido.
|
||||||
|
let nombre = c.fullName || c.contactName;
|
||||||
|
if (!nombre && c.contactId) {
|
||||||
|
const { rows } = await pool.query<{ name: string }>(
|
||||||
|
`SELECT name FROM clients
|
||||||
|
WHERE business_id = $1 AND crm_contact_id = $2 AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[businessId, c.contactId]
|
||||||
|
);
|
||||||
|
nombre = rows[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convId = await upsertConversacion(businessId, {
|
||||||
|
...c,
|
||||||
|
id: crmConversationId,
|
||||||
|
fullName: nombre,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const { mensajes, lastMessageId, hayMas } = await mensajesDeConversacion(
|
||||||
|
ctx,
|
||||||
|
crmConversationId,
|
||||||
|
{ limit: 100, lastMessageId: cursor }
|
||||||
|
);
|
||||||
|
for (const m of mensajes) {
|
||||||
|
await upsertMensaje(businessId, convId, m);
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
if (!hayMas || !lastMessageId || !mensajes.length) break;
|
||||||
|
cursor = lastMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// El canal del hilo se deduce de su ultimo mensaje real.
|
||||||
|
//
|
||||||
|
// GET /conversations/{id} no devuelve un canal reconocible, pero los mensajes
|
||||||
|
// que acabamos de traer si lo traen. Deducirlo de ahi es mejor que dejar
|
||||||
|
// "Desconocido" en la bandeja, y no cuesta ni una peticion mas.
|
||||||
|
// Se excluyen las actividades: son notas que el propio CRM escribe en el hilo,
|
||||||
|
// no un canal por el que hablar con la clienta.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE conversations c
|
||||||
|
SET last_message_type = COALESCE(
|
||||||
|
(SELECT m.channel FROM messages m
|
||||||
|
WHERE m.conversation_id = c.id AND m.channel <> 'Actividad'
|
||||||
|
ORDER BY m.sent_at DESC NULLS LAST, m.id DESC LIMIT 1),
|
||||||
|
c.last_message_type)
|
||||||
|
WHERE c.id = $1 AND c.last_message_type = 'Desconocido'`,
|
||||||
|
[convId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { conversacion: convId, mensajes: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Espeja las conversaciones más recientes con sus últimos mensajes. */
|
||||||
|
export async function sincronizarConversaciones(
|
||||||
|
businessId: number,
|
||||||
|
opts: { limit?: number; userId?: number | null } = {}
|
||||||
|
): Promise<ResumenSyncConv> {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const { rows: run } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||||
|
VALUES ($1,'conversations','pull',$2) RETURNING id`,
|
||||||
|
[businessId, opts.userId ?? null]
|
||||||
|
);
|
||||||
|
const runId = run[0].id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { conversations } = await buscarConversaciones(ctx, { limit: opts.limit ?? 50 });
|
||||||
|
let mensajes = 0;
|
||||||
|
for (const c of conversations) {
|
||||||
|
const convId = await upsertConversacion(businessId, c);
|
||||||
|
const { mensajes: ms } = await mensajesDeConversacion(ctx, c.id, { limit: 50 });
|
||||||
|
for (const m of ms) {
|
||||||
|
await upsertMensaje(businessId, convId, m);
|
||||||
|
mensajes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs
|
||||||
|
SET status='ok', finished_at=now(), fetched=$2, created=$3
|
||||||
|
WHERE id = $1`,
|
||||||
|
[runId, conversations.length, mensajes]
|
||||||
|
);
|
||||||
|
return { conversaciones: conversations.length, mensajes, runId };
|
||||||
|
} catch (e: any) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_sync_runs SET status='error', finished_at=now(), error=$2 WHERE id=$1`,
|
||||||
|
[runId, String(e?.error ?? e?.message ?? e).slice(0, 500)]
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ctxDe } from "./ctx.ts";
|
||||||
|
import { obtenerContacto } from "./contacts.ts";
|
||||||
|
import { upsertClienteDesdeCrm } from "./syncContacts.ts";
|
||||||
|
import { sincronizarConversacion } from "./syncConversations.ts";
|
||||||
|
import { obtenerMensaje } from "./conversations.ts";
|
||||||
|
import { proyectarCita } from "./syncAppointments.ts";
|
||||||
|
import { publicarServicio } from "./services.ts";
|
||||||
|
|
||||||
|
export const ENTIDADES = ["contacto", "conversacion", "mensaje", "cita", "servicio"] as const;
|
||||||
|
export type Entidad = (typeof ENTIDADES)[number];
|
||||||
|
|
||||||
|
export function esEntidad(v: string): v is Entidad {
|
||||||
|
return (ENTIDADES as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoUno {
|
||||||
|
entidad: Entidad;
|
||||||
|
id: string;
|
||||||
|
accion: string;
|
||||||
|
detalle: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza UNA entidad por su identificador.
|
||||||
|
*
|
||||||
|
* La dirección no es la misma para las cinco, y no es un capricho:
|
||||||
|
*
|
||||||
|
* - **contacto, conversación y mensaje se TRAEN**: el CRM es su dueño. Es donde
|
||||||
|
* viven la deduplicación y las automatizaciones.
|
||||||
|
* - **cita y servicio se EMPUJAN.** MEDIDO (hallazgos 29 y 32): el calendario
|
||||||
|
* del CRM tiene UNA cita en dos años y su catálogo de servicios está vacío.
|
||||||
|
* No hay nada que arrastrar; la agenda y el catálogo nacen en la plataforma.
|
||||||
|
*
|
||||||
|
* Si algún día el spa empieza a agendar dentro del CRM, esa premisa se cae y
|
||||||
|
* habrá que decidir cuál de los dos manda cuando difieran. Conviene decidirlo
|
||||||
|
* antes de que ocurra.
|
||||||
|
*/
|
||||||
|
export async function sincronizarPorId(
|
||||||
|
businessId: number,
|
||||||
|
entidad: Entidad,
|
||||||
|
id: string
|
||||||
|
): Promise<ResultadoUno> {
|
||||||
|
switch (entidad) {
|
||||||
|
case "contacto": {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const c = await obtenerContacto(ctx, id);
|
||||||
|
if (!c) throw { status: 404, error: "Ese contacto no existe en Bucéfalo CRM" };
|
||||||
|
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||||
|
return {
|
||||||
|
entidad,
|
||||||
|
id,
|
||||||
|
accion: r === "created" ? "creado" : r === "updated" ? "actualizado" : "sin cambios",
|
||||||
|
detalle: { resultado: r },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "conversacion": {
|
||||||
|
const r = await sincronizarConversacion(businessId, id);
|
||||||
|
return { entidad, id, accion: "espejada", detalle: r };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mensaje": {
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const m = await obtenerMensaje(ctx, id);
|
||||||
|
if (!m) throw { status: 404, error: "Ese mensaje no existe en Bucéfalo CRM" };
|
||||||
|
if (!m.conversationId) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error: "El mensaje no dice a qué conversación pertenece, y sin ella no se puede guardar",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Se sincroniza el hilo entero: `messages.conversation_id` es obligatorio,
|
||||||
|
// así que un mensaje suelto sin su conversación no tiene dónde ir.
|
||||||
|
const r = await sincronizarConversacion(businessId, m.conversationId);
|
||||||
|
return {
|
||||||
|
entidad,
|
||||||
|
id,
|
||||||
|
accion: "espejado con su hilo",
|
||||||
|
detalle: { ...r, conversacion_crm: m.conversationId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cita": {
|
||||||
|
const n = Number(id);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw { status: 400, error: "El identificador de la cita es el numérico de la plataforma" };
|
||||||
|
}
|
||||||
|
const r = await proyectarCita(businessId, n);
|
||||||
|
return { entidad, id, accion: "empujada", detalle: r as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "servicio": {
|
||||||
|
const n = Number(id);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw {
|
||||||
|
status: 400,
|
||||||
|
error: "El identificador del servicio es el numérico de la plataforma",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const r = await publicarServicio(businessId, n);
|
||||||
|
return {
|
||||||
|
entidad,
|
||||||
|
id,
|
||||||
|
accion: r.yaEstaba ? "ya estaba publicado" : "publicado",
|
||||||
|
detalle: r as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { despachar } from "./outbox.ts";
|
||||||
|
|
||||||
|
let corriendo = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vacía la bandeja de salida cada cierto tiempo.
|
||||||
|
*
|
||||||
|
* Es un intervalo y no una cola de verdad a propósito: hay un solo negocio, el
|
||||||
|
* CRM estrangula a ~1 petición cada 0.65 s, y el volumen real son unas pocas
|
||||||
|
* citas al día. Redis y un worker aparte serían infraestructura sin problema
|
||||||
|
* que resolver. Cuando haya varios negocios habrá que revisarlo, porque el
|
||||||
|
* estrangulamiento es **por token** y estos despachos serían secuenciales.
|
||||||
|
*
|
||||||
|
* La guarda `corriendo` evita que dos vueltas se solapen: dos escrituras
|
||||||
|
* concurrentes sobre la misma cita corren contra una base que ya cambió.
|
||||||
|
*/
|
||||||
|
export function arrancarWorker(intervaloMs = 60_000): NodeJS.Timeout {
|
||||||
|
const tick = async () => {
|
||||||
|
if (corriendo) return;
|
||||||
|
corriendo = true;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ business_id: number }>(
|
||||||
|
`SELECT DISTINCT business_id FROM crm_outbox WHERE status = 'pendiente'`
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
const res = await despachar(r.business_id, 25);
|
||||||
|
if (res.tomadas) {
|
||||||
|
console.log(
|
||||||
|
`[crm-worker] negocio ${r.business_id}: ${res.confirmadas} confirmadas, ` +
|
||||||
|
`${res.fallidas} fallidas, ${res.indeterminadas} indeterminadas`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Un fallo aquí no debe tumbar el servidor: la bandeja seguirá llena y el
|
||||||
|
// panel de clientes lo enseña, que es justo para lo que existe.
|
||||||
|
console.error("[crm-worker]", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
corriendo = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = setInterval(tick, intervaloMs);
|
||||||
|
// No mantiene vivo el proceso: si el servidor se cierra, no hay que esperarlo.
|
||||||
|
t.unref?.();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { pool } from "./pool.ts";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplica en orden alfabético los .sql que aún no estén en schema_migrations.
|
||||||
|
* Cada archivo corre dentro de su propia transacción: si falla a la mitad, no
|
||||||
|
* queda registrado y la siguiente corrida lo reintenta entero.
|
||||||
|
*/
|
||||||
|
export async function runMigrations(): Promise<string[]> {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename text PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const { rows } = await pool.query<{ filename: string }>(
|
||||||
|
`SELECT filename FROM schema_migrations`
|
||||||
|
);
|
||||||
|
const applied = new Set(rows.map((r) => r.filename));
|
||||||
|
|
||||||
|
const ran: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(sql);
|
||||||
|
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
ran.push(file);
|
||||||
|
console.log(`[migrate] aplicada ${file}`);
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw new Error(`Migración ${file} falló: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permite `node scripts/run-tsx.mjs platform/db/migrate.ts` desde la línea de comandos.
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
||||||
|
runMigrations()
|
||||||
|
.then((ran) => {
|
||||||
|
console.log(ran.length ? `[migrate] ${ran.length} aplicadas` : "[migrate] al día");
|
||||||
|
return pool.end();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- btree_gist permite mezclar un igualador (employee_id) con un operador de
|
||||||
|
-- solapamiento (&&) dentro de la misma restricción de exclusión. Sin esta
|
||||||
|
-- extensión, EXCLUDE USING gist (employee_id WITH =, during WITH &&) no compila.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Núcleo de la plataforma. Nombres de tabla y columna en inglés a propósito:
|
||||||
|
-- son los que shared/types.ts y el frontend ya consumen.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE businesses (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
industry text NOT NULL DEFAULT 'Estética y Spa',
|
||||||
|
currency text NOT NULL DEFAULT 'MXN',
|
||||||
|
currency_symbol text NOT NULL DEFAULT '$',
|
||||||
|
phone text,
|
||||||
|
address text,
|
||||||
|
slug text UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT 'America/Mexico_City',
|
||||||
|
working_hours jsonb NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE employees (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
phone text,
|
||||||
|
color text NOT NULL DEFAULT '#3b66ff',
|
||||||
|
role text NOT NULL DEFAULT 'specialist',
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
working_hours jsonb, -- NULL = hereda del negocio
|
||||||
|
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE services (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
category text NOT NULL DEFAULT 'General',
|
||||||
|
duration_min integer NOT NULL DEFAULT 60,
|
||||||
|
price numeric(10,2) NOT NULL DEFAULT 0,
|
||||||
|
color text NOT NULL DEFAULT '#3b66ff',
|
||||||
|
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE employee_services (
|
||||||
|
employee_id bigint NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
service_id bigint NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (employee_id, service_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
password text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
role text NOT NULL CHECK (role IN ('admin','owner','employee')),
|
||||||
|
employee_id bigint REFERENCES employees(id),
|
||||||
|
avatar_color text NOT NULL DEFAULT '#3b66ff',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- La clienta. `phone_e164` es la clave de identidad: es lo único que puede
|
||||||
|
-- reconciliar el mismo número que llega por canales distintos, y el índice
|
||||||
|
-- parcial de abajo es lo que impide el duplicado.
|
||||||
|
CREATE TABLE clients (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
phone text, -- lo que tecleó la persona, tal cual
|
||||||
|
phone_e164 text, -- lo normalizado; NULL si no se pudo
|
||||||
|
contactable boolean GENERATED ALWAYS AS (phone_e164 IS NOT NULL) STORED,
|
||||||
|
birth_date date,
|
||||||
|
notes text,
|
||||||
|
tags text,
|
||||||
|
source_channel text, -- whatsapp|facebook|instagram|mostrador|referido
|
||||||
|
-- Se declara desde el día uno aunque la Fase 2 aún no exista: es el ancla de
|
||||||
|
-- correlación con Bucéfalo CRM, y añadirla después obliga a un backfill que
|
||||||
|
-- no se puede hacer sin releer el CRM entero.
|
||||||
|
crm_contact_id text,
|
||||||
|
crm_synced_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz -- baja lógica: la clienta nunca se borra
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Un mismo teléfono no puede repetirse dentro de un negocio. Es parcial porque
|
||||||
|
-- el 40.8 % del histórico medido no tiene teléfono y esas filas deben convivir.
|
||||||
|
CREATE UNIQUE INDEX clients_phone_unique
|
||||||
|
ON clients (business_id, phone_e164)
|
||||||
|
WHERE phone_e164 IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX clients_business_name ON clients (business_id, name);
|
||||||
|
|
||||||
|
CREATE TABLE appointments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
client_id bigint NOT NULL REFERENCES clients(id),
|
||||||
|
employee_id bigint NOT NULL REFERENCES employees(id),
|
||||||
|
service_id bigint NOT NULL REFERENCES services(id),
|
||||||
|
start_at timestamptz NOT NULL,
|
||||||
|
-- `end_at` se materializa, no se deriva: si mañana cambia la duración del
|
||||||
|
-- servicio, las citas ya agendadas no deben moverse.
|
||||||
|
end_at timestamptz NOT NULL,
|
||||||
|
during tstzrange GENERATED ALWAYS AS (tstzrange(start_at, end_at, '[)')) STORED,
|
||||||
|
status text NOT NULL DEFAULT 'scheduled'
|
||||||
|
CHECK (status IN ('scheduled','completed','cancelled','no_show')),
|
||||||
|
cancelled_by text CHECK (cancelled_by IN ('client','business')),
|
||||||
|
cancel_reason text,
|
||||||
|
price numeric(10,2) NOT NULL DEFAULT 0,
|
||||||
|
notes text,
|
||||||
|
source_channel text,
|
||||||
|
created_by_user_id bigint REFERENCES users(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT appointments_end_after_start CHECK (end_at > start_at),
|
||||||
|
CONSTRAINT appointments_cancelled_by_only_when_cancelled
|
||||||
|
CHECK (cancelled_by IS NULL OR status = 'cancelled'),
|
||||||
|
-- Aquí está la diferencia con el backend de SQLite: la doble reserva deja de
|
||||||
|
-- ser una validación que alguien puede saltarse y pasa a ser el motor
|
||||||
|
-- rechazando la fila. Las canceladas no reservan hueco.
|
||||||
|
CONSTRAINT appointments_no_overlap EXCLUDE USING gist (
|
||||||
|
employee_id WITH =,
|
||||||
|
during WITH &&
|
||||||
|
) WHERE (status <> 'cancelled')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX appointments_business_start ON appointments (business_id, start_at);
|
||||||
|
CREATE INDEX appointments_employee_start ON appointments (employee_id, start_at);
|
||||||
|
CREATE INDEX appointments_client ON appointments (client_id);
|
||||||
|
|
||||||
|
-- La visita es el hecho consumado, y está separada de la cita a propósito:
|
||||||
|
-- una cita es una intención. Fusionarlas es el error que dejó 3 002
|
||||||
|
-- oportunidades congeladas en el CRM — un registro que sirve para planear y
|
||||||
|
-- para cerrar termina sin cerrarse nunca.
|
||||||
|
CREATE TABLE visits (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
appointment_id bigint UNIQUE REFERENCES appointments(id),
|
||||||
|
client_id bigint NOT NULL REFERENCES clients(id),
|
||||||
|
employee_id bigint NOT NULL REFERENCES employees(id),
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
total_charged numeric(10,2),
|
||||||
|
payment_method text CHECK (payment_method IN ('cash','card','transfer','other')),
|
||||||
|
recorded_by_user_id bigint REFERENCES users(id),
|
||||||
|
recorded_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX visits_business_occurred ON visits (business_id, occurred_at);
|
||||||
|
CREATE INDEX visits_client ON visits (client_id);
|
||||||
|
|
||||||
|
-- Historial de la cita. Append-only: nunca se actualiza ni se borra.
|
||||||
|
CREATE TABLE appointment_events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
appointment_id bigint NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
|
||||||
|
actor_user_id bigint REFERENCES users(id),
|
||||||
|
action text NOT NULL, -- created|rescheduled|cancelled|attended|no_show
|
||||||
|
from_status text,
|
||||||
|
to_status text,
|
||||||
|
detail jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX appointment_events_appointment ON appointment_events (appointment_id, created_at);
|
||||||
|
|
||||||
|
-- Quién cambió qué, cuándo y desde dónde.
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint,
|
||||||
|
actor_user_id bigint REFERENCES users(id),
|
||||||
|
entity text NOT NULL,
|
||||||
|
entity_id bigint,
|
||||||
|
action text NOT NULL,
|
||||||
|
before jsonb,
|
||||||
|
after jsonb,
|
||||||
|
ip text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX audit_log_business_created ON audit_log (business_id, created_at DESC);
|
||||||
|
CREATE INDEX audit_log_entity ON audit_log (entity, entity_id);
|
||||||
|
|
||||||
|
-- El cierre de día. Una fila por día cerrado; la restricción única es lo que
|
||||||
|
-- hace que cerrar dos veces no sea posible.
|
||||||
|
CREATE TABLE day_closures (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
business_date date NOT NULL,
|
||||||
|
closed_by_user_id bigint NOT NULL REFERENCES users(id),
|
||||||
|
closed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
attended_count integer NOT NULL,
|
||||||
|
no_show_count integer NOT NULL,
|
||||||
|
cancelled_count integer NOT NULL,
|
||||||
|
UNIQUE (business_id, business_date)
|
||||||
|
);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Integración con Bucéfalo CRM.
|
||||||
|
--
|
||||||
|
-- Todo lo de aquí está diseñado contra hallazgos MEDIDOS contra la subcuenta
|
||||||
|
-- real de Yola Franco Spa (Pk89Wa23QaxvkOfKgwjZ) el 2026-08-29, no contra la
|
||||||
|
-- especificación. Ver platform/crm/HALLAZGOS.md.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- La conexión con la subcuenta. Una fila por negocio.
|
||||||
|
-- El token NO vive aquí: vive en el entorno del servidor. Esta tabla guarda
|
||||||
|
-- qué subcuenta, qué pipeline y qué etapas usa cada negocio.
|
||||||
|
CREATE TABLE crm_connections (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL UNIQUE REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
location_id text NOT NULL,
|
||||||
|
pipeline_id text,
|
||||||
|
stage_open_id text,
|
||||||
|
stage_won_id text,
|
||||||
|
stage_lost_id text,
|
||||||
|
-- MEDIDO: la subcuenta trae `allowDuplicateOpportunity: false`, así que el
|
||||||
|
-- CRM rechaza una segunda oportunidad por contacto AUNQUE la anterior esté
|
||||||
|
-- cerrada. Mientras esté en false, la plataforma recicla la oportunidad
|
||||||
|
-- existente en vez de crear una por cita. Si el cliente activa el ajuste,
|
||||||
|
-- esta bandera pasa a true y cada cita estrena la suya.
|
||||||
|
allow_duplicate_opp boolean NOT NULL DEFAULT false,
|
||||||
|
last_sync_at timestamptz,
|
||||||
|
last_sync_status text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Atribución de la clienta. Se separa de `clients` porque son 10+ columnas que
|
||||||
|
-- solo existen si el contacto vino del CRM, y porque el CRM las declara
|
||||||
|
-- inmutables: se escriben en el alta y un PUT posterior devuelve 200 sin
|
||||||
|
-- guardar nada. Aquí son espejo de lectura.
|
||||||
|
ALTER TABLE clients
|
||||||
|
ADD COLUMN crm_source text,
|
||||||
|
ADD COLUMN attr_session_source text,
|
||||||
|
ADD COLUMN attr_medium text,
|
||||||
|
ADD COLUMN attr_campaign text,
|
||||||
|
ADD COLUMN attr_campaign_id text,
|
||||||
|
ADD COLUMN attr_utm_source text,
|
||||||
|
ADD COLUMN attr_utm_medium text,
|
||||||
|
ADD COLUMN attr_utm_content text,
|
||||||
|
ADD COLUMN attr_ad_id text,
|
||||||
|
ADD COLUMN attr_referrer text,
|
||||||
|
ADD COLUMN crm_tags text,
|
||||||
|
ADD COLUMN crm_date_added timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX clients_crm_contact ON clients (crm_contact_id)
|
||||||
|
WHERE crm_contact_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- La cita se proyecta al CRM como oportunidad.
|
||||||
|
ALTER TABLE appointments
|
||||||
|
ADD COLUMN crm_opportunity_id text,
|
||||||
|
ADD COLUMN crm_synced_at timestamptz,
|
||||||
|
ADD COLUMN crm_status text; -- lo que el CRM cree: open|won|lost
|
||||||
|
|
||||||
|
CREATE INDEX appointments_crm_opp ON appointments (crm_opportunity_id)
|
||||||
|
WHERE crm_opportunity_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Bandeja de salida. Existe desde el día uno a propósito: la API del CRM falla,
|
||||||
|
-- y sin cola un fallo se traga la cita de una clienta sin que nadie lo sepa.
|
||||||
|
-- El cambio local y su fila de bandeja se escriben en la MISMA transacción; sin
|
||||||
|
-- eso aparece la escritura perdida (el usuario ve "guardado", el proceso muere
|
||||||
|
-- antes de encolar, y nadie lo reclama nunca).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE crm_outbox (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
entity text NOT NULL, -- client | appointment | message
|
||||||
|
entity_id bigint NOT NULL,
|
||||||
|
operation text NOT NULL, -- create | update | status | send
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
-- pendiente → enviando → confirmado | fallido | indeterminado
|
||||||
|
--
|
||||||
|
-- `indeterminado` no es un adorno: es donde cae un fallo de TRANSPORTE
|
||||||
|
-- (timeout, conexión caída). Un 5xx es una respuesta —el servidor habló—;
|
||||||
|
-- un timeout no dice nada sobre si la escritura entró. Reenviarlo es
|
||||||
|
-- fabricar la doble creación, así que se resuelve leyendo, nunca reenviando.
|
||||||
|
status text NOT NULL DEFAULT 'pendiente'
|
||||||
|
CHECK (status IN ('pendiente','enviando','confirmado','fallido','indeterminado')),
|
||||||
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
last_error text,
|
||||||
|
-- Clave de deduplicación propia y estable. NUNCA se deriva del contenido:
|
||||||
|
-- dos ediciones que dejan el mismo valor son dos intenciones distintas.
|
||||||
|
dedup_key text NOT NULL,
|
||||||
|
crm_id text, -- se llena tras RELEER, no tras el 200
|
||||||
|
evidence text, -- relectura | 400_meta | busqueda
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
sent_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX crm_outbox_pendientes ON crm_outbox (business_id, status, id)
|
||||||
|
WHERE status IN ('pendiente','indeterminado');
|
||||||
|
CREATE INDEX crm_outbox_entidad ON crm_outbox (entity, entity_id);
|
||||||
|
CREATE UNIQUE INDEX crm_outbox_dedup ON crm_outbox (dedup_key);
|
||||||
|
|
||||||
|
-- Historial de cada corrida del botón de sincronización.
|
||||||
|
CREATE TABLE crm_sync_runs (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL, -- contacts | appointments
|
||||||
|
direction text NOT NULL, -- pull | push
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
status text NOT NULL DEFAULT 'corriendo'
|
||||||
|
CHECK (status IN ('corriendo','ok','error')),
|
||||||
|
fetched integer NOT NULL DEFAULT 0,
|
||||||
|
created integer NOT NULL DEFAULT 0,
|
||||||
|
updated integer NOT NULL DEFAULT 0,
|
||||||
|
skipped integer NOT NULL DEFAULT 0,
|
||||||
|
error text,
|
||||||
|
started_by_user_id bigint REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX crm_sync_runs_business ON crm_sync_runs (business_id, started_at DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Espejo de conversaciones y mensajes. El CRM es el dueño: aquí solo se
|
||||||
|
-- guardan metadatos y referencias, y nunca se editan — se reescriben desde el
|
||||||
|
-- CRM. La plataforma solo CREA mensajes salientes.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE conversations (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
crm_conversation_id text NOT NULL,
|
||||||
|
client_id bigint REFERENCES clients(id),
|
||||||
|
crm_contact_id text,
|
||||||
|
contact_name text,
|
||||||
|
last_message_type text,
|
||||||
|
last_message_body text,
|
||||||
|
last_message_at timestamptz,
|
||||||
|
unread_count integer NOT NULL DEFAULT 0,
|
||||||
|
synced_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (business_id, crm_conversation_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX conversations_reciente ON conversations (business_id, last_message_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||||
|
conversation_id bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
crm_message_id text,
|
||||||
|
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||||||
|
channel text NOT NULL, -- Email | SMS | WhatsApp | FB | IG…
|
||||||
|
body text,
|
||||||
|
subject text,
|
||||||
|
status text, -- del CRM: queued|sent|delivered|failed
|
||||||
|
sent_by_user_id bigint REFERENCES users(id),
|
||||||
|
sent_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (business_id, crm_message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX messages_conversacion ON messages (conversation_id, sent_at);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- De un negocio con un token global, a N negocios con credencial propia.
|
||||||
|
--
|
||||||
|
-- Hasta aquí `crm_connections.location_id` ya era por negocio, pero el token
|
||||||
|
-- vivía en la variable de entorno CRM_TOKEN, una sola para todo el proceso
|
||||||
|
-- (platform/crm/client.ts). Con dos negocios eso usa el token del primero
|
||||||
|
-- contra la subcuenta del segundo: 401 en el mejor caso, escritura en la
|
||||||
|
-- subcuenta equivocada en el peor.
|
||||||
|
--
|
||||||
|
-- El token se guarda CIFRADO con AES-256-GCM (platform/lib/crypto.ts). La clave
|
||||||
|
-- maestra vive en CRM_MASTER_KEY, fuera de la base: quien consiga un volcado de
|
||||||
|
-- Postgres no consigue los tokens de los clientes.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE crm_connections
|
||||||
|
ADD COLUMN token_cipher bytea,
|
||||||
|
ADD COLUMN token_nonce bytea,
|
||||||
|
ADD COLUMN token_tag bytea,
|
||||||
|
-- Los 6 últimos caracteres. Permite que la interfaz diga «termina en …f4a2c1»
|
||||||
|
-- y detectar una rotación, sin exponer nunca la credencial.
|
||||||
|
ADD COLUMN token_fingerprint text,
|
||||||
|
ADD COLUMN token_updated_at timestamptz,
|
||||||
|
-- MEDIDO (hallazgo 29): la subcuenta tiene 7 calendarios y la única cita real
|
||||||
|
-- está en «Servicio Spa». Sin fijar cuál, empujar una cita al calendario del
|
||||||
|
-- CRM sería adivinar a cuál.
|
||||||
|
ADD COLUMN calendar_id text,
|
||||||
|
-- La red de seguridad de mensajes pasa a ser POR NEGOCIO. Como variable de
|
||||||
|
-- entorno global decidía por todas las cuentas a la vez: o se abrían los
|
||||||
|
-- envíos reales para todas, o ninguna podía salir de pruebas.
|
||||||
|
ADD COLUMN test_email text,
|
||||||
|
ADD COLUMN allow_real_sends boolean NOT NULL DEFAULT false,
|
||||||
|
-- Nombre legible de la subcuenta, para que la administración no tenga que
|
||||||
|
-- reconocer cuentas por un identificador opaco.
|
||||||
|
ADD COLUMN label text;
|
||||||
|
|
||||||
|
-- La credencial va completa o no va. Media credencial produce un descifrado que
|
||||||
|
-- falla en tiempo de petición, y eso es un fallo lejos de su causa.
|
||||||
|
ALTER TABLE crm_connections
|
||||||
|
ADD CONSTRAINT crm_connections_credencial_completa CHECK (
|
||||||
|
(token_cipher IS NULL AND token_nonce IS NULL AND token_tag IS NULL)
|
||||||
|
OR
|
||||||
|
(token_cipher IS NOT NULL AND token_nonce IS NOT NULL AND token_tag IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN crm_connections.token_cipher IS
|
||||||
|
'Token privado de la subcuenta, cifrado con AES-256-GCM. Nunca se devuelve por la API.';
|
||||||
|
COMMENT ON COLUMN crm_connections.token_fingerprint IS
|
||||||
|
'Los 6 ultimos caracteres del token. Lo unico de la credencial que puede salir del servidor.';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Sincronización por id de las cinco entidades.
|
||||||
|
--
|
||||||
|
-- Las tablas `conversations` y `messages` se declararon en 002_crm.sql y hasta
|
||||||
|
-- ahora NADIE escribía en ellas: la bandeja consultaba el CRM en vivo en cada
|
||||||
|
-- carga. Eso significa que sin red no hay bandeja, que cada visita gasta cuota,
|
||||||
|
-- y que no se puede cruzar un hilo con una clienta sin volver a salir a internet.
|
||||||
|
-- Aquí se añade lo que faltaba para llenarlas.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE messages
|
||||||
|
-- De qué contacto del CRM es el mensaje, para cruzarlo con la clienta sin
|
||||||
|
-- pasar por la conversación.
|
||||||
|
ADD COLUMN crm_contact_id text,
|
||||||
|
-- El canal tal cual lo devolvió el CRM, además del normalizado. La API da el
|
||||||
|
-- tipo como número o como cadena según el endpoint, y guardar solo la versión
|
||||||
|
-- traducida perdería el dato original si mañana cambia la traducción.
|
||||||
|
ADD COLUMN channel_raw text;
|
||||||
|
|
||||||
|
CREATE INDEX messages_crm_contact ON messages (business_id, crm_contact_id)
|
||||||
|
WHERE crm_contact_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Cursor de la última sincronización de conversaciones, para continuar donde se
|
||||||
|
-- quedó en vez de releer las 3 213 cada vez.
|
||||||
|
ALTER TABLE crm_connections
|
||||||
|
ADD COLUMN conv_cursor_date bigint;
|
||||||
|
|
||||||
|
-- El servicio de la plataforma, una vez publicado en el catálogo del CRM.
|
||||||
|
-- MEDIDO (hallazgos 6 y 32): el catálogo del CRM está VACÍO, así que
|
||||||
|
-- «sincronizar servicios» solo puede significar empujar, nunca traer.
|
||||||
|
ALTER TABLE services
|
||||||
|
ADD COLUMN crm_service_id text,
|
||||||
|
ADD COLUMN crm_synced_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX services_crm ON services (crm_service_id) WHERE crm_service_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- La cita de la plataforma, una vez escrita como evento en el calendario del
|
||||||
|
-- CRM. Es distinto de `crm_opportunity_id`: la oportunidad es el embudo de
|
||||||
|
-- ventas y el evento es la agenda. Una cita puede tener las dos cosas.
|
||||||
|
ALTER TABLE appointments
|
||||||
|
ADD COLUMN crm_event_id text;
|
||||||
|
|
||||||
|
CREATE INDEX appointments_crm_event ON appointments (crm_event_id)
|
||||||
|
WHERE crm_event_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN crm_sync_runs.kind IS
|
||||||
|
'contacts | appointments | conversations | one — "one" es la sincronizacion de una sola entidad por id';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postgres devuelve NUMERIC como string para no perder precisión, y bigint igual.
|
||||||
|
* El frontend declara `number` en shared/types.ts, así que se convierten aquí, en
|
||||||
|
* el único sitio que abre conexiones, y no en cada handler.
|
||||||
|
*/
|
||||||
|
pg.types.setTypeParser(1700, (v: string) => Number(v)); // numeric
|
||||||
|
pg.types.setTypeParser(20, (v: string) => Number(v)); // int8 / bigint
|
||||||
|
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL || "postgres://yola:[email protected]:5434/yola";
|
||||||
|
|
||||||
|
export const pool = new Pool({ connectionString, max: 10 });
|
||||||
|
|
||||||
|
/** Ejecuta `fn` dentro de una transacción; hace ROLLBACK ante cualquier excepción. */
|
||||||
|
export async function withTx<T>(fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const out = await fn(client);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: yola-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: yola
|
||||||
|
POSTGRES_PASSWORD: yola_dev
|
||||||
|
POSTGRES_DB: yola
|
||||||
|
# 5434 y no 5432/5433: los dos están ocupados por contenedores de otros
|
||||||
|
# proyectos en esta máquina.
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
volumes:
|
||||||
|
- yola_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U yola -d yola"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
yola_pgdata:
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import { authRequired } from "./lib/auth.ts";
|
||||||
|
import { authRouter } from "./routes/auth.ts";
|
||||||
|
import { businessRouter } from "./routes/business.ts";
|
||||||
|
import { clientsRouter } from "./routes/clients.ts";
|
||||||
|
import { appointmentsRouter } from "./routes/appointments.ts";
|
||||||
|
import { attendanceRouter } from "./routes/attendance.ts";
|
||||||
|
import { dayCloseRouter } from "./routes/dayClose.ts";
|
||||||
|
import { adminRouter } from "./routes/admin.ts";
|
||||||
|
import { crmRouter } from "./routes/crm.ts";
|
||||||
|
import { messagesRouter } from "./routes/messages.ts";
|
||||||
|
import { arrancarWorker } from "./crm/worker.ts";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
|
app.use("/api/auth", authRouter);
|
||||||
|
app.use("/api/business", authRequired, businessRouter);
|
||||||
|
app.use("/api/clients", authRequired, clientsRouter);
|
||||||
|
// Va ANTES que el router de citas: si se monta después, el `/:id` de
|
||||||
|
// appointments se traga la ruta y `/attendance` nunca llega aquí.
|
||||||
|
app.use("/api/appointments/:id/attendance", authRequired, attendanceRouter);
|
||||||
|
app.use("/api/appointments", authRequired, appointmentsRouter);
|
||||||
|
app.use("/api/day-close", authRequired, dayCloseRouter);
|
||||||
|
app.use("/api/admin", authRequired, adminRouter);
|
||||||
|
app.use("/api/crm", authRequired, crmRouter);
|
||||||
|
app.use("/api/messages", authRequired, messagesRouter);
|
||||||
|
|
||||||
|
// Traductor final de errores: sin esto, un rechazo dentro de un handler async
|
||||||
|
// devuelve el HTML de stack de Express y el cliente no puede leer el mensaje.
|
||||||
|
app.use(
|
||||||
|
(
|
||||||
|
e: any,
|
||||||
|
_req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
_next: express.NextFunction
|
||||||
|
) => {
|
||||||
|
console.error("[platform]", e);
|
||||||
|
res.status(e?.status ?? 500).json({ error: e?.error ?? "Error interno del servidor" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoked = process.argv[1]?.replace(/\\/g, "/") ?? "";
|
||||||
|
if (invoked.endsWith("platform/index.ts")) {
|
||||||
|
const port = Number(process.env.PLATFORM_PORT) || 3100;
|
||||||
|
createApp().listen(port, () => console.log(`[platform] escuchando en :${port}`));
|
||||||
|
// Despacha la bandeja hacia el CRM. No se arranca en `createApp()` para que
|
||||||
|
// las pruebas no salgan a la red por su cuenta.
|
||||||
|
arrancarWorker(60_000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { PoolClient } from "pg";
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
businessId: number | null;
|
||||||
|
actorUserId: number | null;
|
||||||
|
entity: string;
|
||||||
|
entityId: number | null;
|
||||||
|
action: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
ip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escribe una fila de auditoría **con el cliente de la transacción en curso**.
|
||||||
|
* Recibe el `PoolClient` a propósito y no usa el pool por su cuenta: si el
|
||||||
|
* cambio se revierte, su rastro tiene que revertirse con él. Una auditoría que
|
||||||
|
* registra cambios que no ocurrieron es peor que no tener auditoría.
|
||||||
|
*/
|
||||||
|
export async function writeAudit(c: PoolClient, e: AuditEntry): Promise<void> {
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO audit_log
|
||||||
|
(business_id, actor_user_id, entity, entity_id, action, before, after, ip)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8)`,
|
||||||
|
[
|
||||||
|
e.businessId,
|
||||||
|
e.actorUserId,
|
||||||
|
e.entity,
|
||||||
|
e.entityId,
|
||||||
|
e.action,
|
||||||
|
e.before === undefined ? null : JSON.stringify(e.before),
|
||||||
|
e.after === undefined ? null : JSON.stringify(e.after),
|
||||||
|
e.ip ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
|
||||||
|
export interface PlatformUser {
|
||||||
|
id: number;
|
||||||
|
business_id: number | null;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: "admin" | "owner" | "employee";
|
||||||
|
employee_id: number | null;
|
||||||
|
avatar_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthedRequest extends Request {
|
||||||
|
user?: PlatformUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEUDA CONOCIDA: el token es el id del usuario en texto plano y la contraseña
|
||||||
|
* se compara sin hashear. Se porta tal cual desde el backend de demo para no
|
||||||
|
* romper `src/lib/api.ts`, el AuthProvider y los .mjs de prueba en el mismo
|
||||||
|
* cambio. Endurecerlo es un entregable propio: bcrypt/Argon2id + sesión real +
|
||||||
|
* los cinco sitios a la vez.
|
||||||
|
*/
|
||||||
|
export async function authRequired(
|
||||||
|
req: AuthedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const header = req.header("authorization") || "";
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id");
|
||||||
|
if (!token) {
|
||||||
|
err(res, 401, "No autorizado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(token);
|
||||||
|
if (!Number.isFinite(userId)) {
|
||||||
|
err(res, 401, "Token inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query<PlatformUser>(
|
||||||
|
`SELECT id, business_id, email, name, role, employee_id, avatar_color
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 401, "Usuario no encontrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = rows[0];
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.role !== "owner") {
|
||||||
|
err(res, 403, "Solo la administradora puede realizar esta acción");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administración de la plataforma: opera todas las cuentas y su `business_id`
|
||||||
|
* es NULL.
|
||||||
|
*
|
||||||
|
* No se confunde con `ownerOnly`, que manda dentro de UN negocio. Son dos
|
||||||
|
* autoridades distintas: la dueña de un spa no debe poder dar de alta cuentas
|
||||||
|
* ajenas ni ver las credenciales de nadie.
|
||||||
|
*/
|
||||||
|
export function adminOnly(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (req.user?.role !== "admin") {
|
||||||
|
err(res, 403, "Solo la administración de la plataforma puede realizar esta acción");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err(res: Response, status: number, message: string) {
|
||||||
|
return res.status(status).json({ error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envuelve un handler async para que un rechazo no cuelgue la petición. */
|
||||||
|
export function h(fn: (req: AuthedRequest, res: Response) => Promise<unknown>) {
|
||||||
|
return (req: AuthedRequest, res: Response, next: NextFunction) => {
|
||||||
|
fn(req, res).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PoolClient } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valores con los que nace un negocio.
|
||||||
|
*
|
||||||
|
* Existe por la misma razón que su gemelo del backend de demo: un negocio sin
|
||||||
|
* `working_hours` no tiene ninguna franja agendable en ninguna fecha, y uno sin
|
||||||
|
* `slug` no tiene página pública. Poner el default en el `INSERT` —y no en una
|
||||||
|
* migración de relleno— es lo único que cubre a las filas creadas después de que
|
||||||
|
* la migración ya corrió.
|
||||||
|
*
|
||||||
|
* NO se importa desde `server/`: los dos backends conviven sin compartir código,
|
||||||
|
* y cruzarlos ataría la evolución de uno a la del otro.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WORKING_HOURS = JSON.stringify({
|
||||||
|
1: { start: "09:00", end: "20:00" },
|
||||||
|
2: { start: "09:00", end: "20:00" },
|
||||||
|
3: { start: "09:00", end: "20:00" },
|
||||||
|
4: { start: "09:00", end: "20:00" },
|
||||||
|
5: { start: "09:00", end: "20:00" },
|
||||||
|
6: null,
|
||||||
|
7: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */
|
||||||
|
export function slugify(s: string): string {
|
||||||
|
return (
|
||||||
|
(s || "negocio")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60) || "negocio"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug único dentro de la plataforma, con sufijo numérico si ya está tomado.
|
||||||
|
*
|
||||||
|
* Recibe el cliente de la transacción, no el pool: comprobar la unicidad en una
|
||||||
|
* conexión y escribir en otra deja una ventana en la que dos altas simultáneas
|
||||||
|
* eligen el mismo slug. La restricción `UNIQUE` de la columna es la red final,
|
||||||
|
* pero conviene no depender de que salte.
|
||||||
|
*/
|
||||||
|
export async function uniqueSlugPg(tx: PoolClient, nombre: string): Promise<string> {
|
||||||
|
const base = slugify(nombre);
|
||||||
|
let slug = base;
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const { rows } = await tx.query(`SELECT 1 FROM businesses WHERE slug = $1`, [slug]);
|
||||||
|
if (!rows.length) return slug;
|
||||||
|
slug = `${base}-${n}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { cifrar, descifrar, huella } from "./crypto.ts";
|
||||||
|
|
||||||
|
const CLAVE = Buffer.alloc(32, 7).toString("base64");
|
||||||
|
|
||||||
|
test("cifrar/descifrar: ida y vuelta devuelve el original", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const token = "pit-abc123def456";
|
||||||
|
assert.equal(descifrar(cifrar(token)), token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cifrar: dos cifrados del mismo texto son distintos (nonce aleatorio)", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const a = cifrar("mismo-token");
|
||||||
|
const b = cifrar("mismo-token");
|
||||||
|
assert.notEqual(a.cipher.toString("hex"), b.cipher.toString("hex"));
|
||||||
|
assert.equal(descifrar(a), descifrar(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("descifrar: un cipher manipulado lanza, no devuelve basura", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const c = cifrar("token-real");
|
||||||
|
c.cipher[0] ^= 0xff;
|
||||||
|
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("descifrar: con otra clave maestra lanza, no devuelve basura", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
const c = cifrar("token-real");
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 9).toString("base64");
|
||||||
|
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("huella: son los 6 últimos caracteres, para distinguir tokens sin exponerlos", () => {
|
||||||
|
assert.equal(huella("pit-abcdef123456"), "123456");
|
||||||
|
assert.equal(huella("corto"), "corto");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una clave que no mide 32 bytes se rechaza con un mensaje que lo dice", () => {
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(16, 1).toString("base64");
|
||||||
|
assert.throws(() => cifrar("x"), /32 bytes/);
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sin CRM_MASTER_KEY se lanza un error que dice qué falta y dónde ponerlo", () => {
|
||||||
|
delete process.env.CRM_MASTER_KEY;
|
||||||
|
assert.throws(() => cifrar("x"), /CRM_MASTER_KEY/);
|
||||||
|
process.env.CRM_MASTER_KEY = CLAVE;
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { loadEnv } from "./env.ts";
|
||||||
|
|
||||||
|
export interface Cifrado {
|
||||||
|
cipher: Buffer;
|
||||||
|
nonce: Buffer;
|
||||||
|
tag: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cifrado de los tokens de subcuenta que se guardan en Postgres.
|
||||||
|
*
|
||||||
|
* AES-256-GCM, es decir cifrado **autenticado**, y eso es la decisión que
|
||||||
|
* importa: si alguien manipula la fila en la base, `descifrar` lanza en vez de
|
||||||
|
* devolver basura. Con un cifrado sin autenticar, una fila corrupta se
|
||||||
|
* convertiría en una petición al CRM con una credencial mal formada, y el fallo
|
||||||
|
* aparecería lejos de su causa.
|
||||||
|
*
|
||||||
|
* La clave maestra vive en el entorno, nunca en la base: quien consiga un
|
||||||
|
* volcado de Postgres no consigue los tokens de los clientes.
|
||||||
|
*/
|
||||||
|
function clave(): Buffer {
|
||||||
|
loadEnv();
|
||||||
|
const b64 = process.env.CRM_MASTER_KEY;
|
||||||
|
if (!b64) {
|
||||||
|
throw new Error(
|
||||||
|
'Falta CRM_MASTER_KEY. Genera una con: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))" y ponla en platform/.env'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const k = Buffer.from(b64, "base64");
|
||||||
|
if (k.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`CRM_MASTER_KEY debe ser de 32 bytes en base64; llegaron ${k.length}. Genera una nueva con randomBytes(32).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cifrar(claro: string): Cifrado {
|
||||||
|
const nonce = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv("aes-256-gcm", clave(), nonce);
|
||||||
|
const cipher = Buffer.concat([c.update(claro, "utf8"), c.final()]);
|
||||||
|
return { cipher, nonce, tag: c.getAuthTag() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descifrar(c: Cifrado): string {
|
||||||
|
// La clave se pide FUERA del try: si falta o mide mal, ese error debe salir
|
||||||
|
// tal cual, no disfrazado de «fila corrupta». Son dos causas distintas y
|
||||||
|
// llevan a dos arreglos distintos.
|
||||||
|
const k = clave();
|
||||||
|
try {
|
||||||
|
const d = crypto.createDecipheriv("aes-256-gcm", k, c.nonce);
|
||||||
|
d.setAuthTag(c.tag);
|
||||||
|
return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8");
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"El token guardado no se pudo descifrar: la clave maestra cambió o la fila está corrupta. Hay que volver a vincular la subcuenta."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Los 6 últimos caracteres del token. Sirve para que la interfaz pueda decir
|
||||||
|
* «termina en …f4a2c1» y para detectar una rotación, sin exponer nunca la
|
||||||
|
* credencial completa ni en la API, ni en los registros, ni en la auditoría.
|
||||||
|
*/
|
||||||
|
export function huella(token: string): string {
|
||||||
|
return token.length <= 6 ? token : token.slice(-6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ENV_PATH = path.resolve(__dirname, "..", ".env");
|
||||||
|
|
||||||
|
let cargado = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee `platform/.env` y lo vuelca en `process.env` sin pisar lo que ya viniera
|
||||||
|
* del entorno — un valor exportado en la terminal gana al archivo, que es lo
|
||||||
|
* que se espera al apuntar a otra subcuenta sin editar nada.
|
||||||
|
*
|
||||||
|
* Sin dependencia externa a propósito: son quince líneas y el archivo lleva
|
||||||
|
* el token del CRM, así que conviene que se vea exactamente qué lo lee.
|
||||||
|
*/
|
||||||
|
export function loadEnv(): void {
|
||||||
|
if (cargado) return;
|
||||||
|
cargado = true;
|
||||||
|
if (!fs.existsSync(ENV_PATH)) return;
|
||||||
|
|
||||||
|
for (const raw of fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
const eq = line.indexOf("=");
|
||||||
|
if (eq < 1) continue;
|
||||||
|
const key = line.slice(0, eq).trim();
|
||||||
|
let value = line.slice(eq + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee una variable obligatoria, con un mensaje que dice qué falta y dónde ponerlo. */
|
||||||
|
export function requireEnv(key: string): string {
|
||||||
|
loadEnv();
|
||||||
|
const v = process.env[key];
|
||||||
|
if (!v) {
|
||||||
|
throw new Error(
|
||||||
|
`Falta ${key}. Defínelo en platform/.env (ver platform/.env.example) o expórtalo en el entorno.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { normalizePhone } from "./phone.ts";
|
||||||
|
|
||||||
|
test("normaliza las formas mexicanas de diez dígitos", () => {
|
||||||
|
assert.equal(normalizePhone("5588887777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("55 8888 7777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("(55) 8888-7777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("55.8888.7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acepta el prefijo de larga distancia 01", () => {
|
||||||
|
assert.equal(normalizePhone("01 55 8888 7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acepta el 52 con y sin más", () => {
|
||||||
|
assert.equal(normalizePhone("+52 55 8888 7777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("525588887777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("0052 55 8888 7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("colapsa el 521 heredado de WhatsApp al formato actual", () => {
|
||||||
|
// El 1 después del 52 era el marcador de móvil; desde 2019 ya no se disca,
|
||||||
|
// pero sigue apareciendo en los identificadores de mensajería.
|
||||||
|
assert.equal(normalizePhone("5215588887777"), "+525588887777");
|
||||||
|
assert.equal(normalizePhone("+52 1 55 8888 7777"), "+525588887777");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respeta un internacional que no es México", () => {
|
||||||
|
assert.equal(normalizePhone("+1 305 555 0134"), "+13055550134");
|
||||||
|
assert.equal(normalizePhone("+34 600 123 456"), "+34600123456");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("devuelve null cuando no se puede normalizar", () => {
|
||||||
|
assert.equal(normalizePhone(null), null);
|
||||||
|
assert.equal(normalizePhone(""), null);
|
||||||
|
assert.equal(normalizePhone(" "), null);
|
||||||
|
assert.equal(normalizePhone("no tengo"), null);
|
||||||
|
assert.equal(normalizePhone("123"), null, "demasiado corto");
|
||||||
|
assert.equal(normalizePhone("12345678901234567"), null, "demasiado largo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("es idempotente sobre su propia salida", () => {
|
||||||
|
const once = normalizePhone("55 8888 7777")!;
|
||||||
|
assert.equal(normalizePhone(once), once);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Normaliza un teléfono a E.164 (`+` seguido de 8 a 15 dígitos).
|
||||||
|
*
|
||||||
|
* Es la clave de identidad de la clienta: sin ella, el mismo número tecleado de
|
||||||
|
* dos formas produce dos fichas, y la auditoría del spa midió que el teléfono es
|
||||||
|
* el único campo con cobertura suficiente para reconciliar canales.
|
||||||
|
*
|
||||||
|
* Devuelve `null` cuando no se puede normalizar con certeza. `null` no es un
|
||||||
|
* error: significa "clienta no contactable", que es un estado legítimo y medido
|
||||||
|
* (40.8 % del histórico). Nunca se inventa un país para rellenarlo.
|
||||||
|
*/
|
||||||
|
export function normalizePhone(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
defaultCountry = "52"
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// Una letra en el campo significa texto libre ("no tengo", "el de su mamá"),
|
||||||
|
// no un teléfono mal escrito. No se intenta rescatar.
|
||||||
|
if (/[a-zA-Z]/.test(trimmed)) return null;
|
||||||
|
|
||||||
|
const explicitIntl = trimmed.startsWith("+") || /^00\d/.test(trimmed);
|
||||||
|
let digits = trimmed.replace(/\D/g, "");
|
||||||
|
if (trimmed.startsWith("00")) digits = digits.slice(2);
|
||||||
|
|
||||||
|
if (!digits) return null;
|
||||||
|
|
||||||
|
if (!explicitIntl) {
|
||||||
|
// "01" es el prefijo mexicano de larga distancia y se quita como unidad, no
|
||||||
|
// como "ceros a la izquierda": si solo se quitara el 0, el 1 restante se
|
||||||
|
// confundiría con el código de país de Estados Unidos.
|
||||||
|
if (digits.length === 12 && digits.startsWith("01")) {
|
||||||
|
digits = digits.slice(2);
|
||||||
|
} else {
|
||||||
|
digits = digits.replace(/^0+/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "52 1 XXXXXXXXXX": el 1 de móvil que WhatsApp sigue arrastrando.
|
||||||
|
if (digits.length === 13 && digits.startsWith(`${defaultCountry}1`)) {
|
||||||
|
digits = defaultCountry + digits.slice(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diez dígitos sueltos = número nacional.
|
||||||
|
if (!explicitIntl && digits.length === 10) {
|
||||||
|
digits = defaultCountry + digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length < 8 || digits.length > 15) return null;
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { adminOnly, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest, CrmError } from "../crm/client.ts";
|
||||||
|
import { DEFAULT_WORKING_HOURS, uniqueSlugPg } from "../lib/businessDefaults.ts";
|
||||||
|
|
||||||
|
export const adminRouter = Router();
|
||||||
|
|
||||||
|
// Todo el router exige rol de plataforma. Se aplica una vez aquí y no ruta por
|
||||||
|
// ruta: olvidarlo en una sola ruta abriría el alta de cuentas a cualquier dueña.
|
||||||
|
adminRouter.use(adminOnly);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Las cuentas de la plataforma, con el estado de su vínculo con Bucéfalo CRM.
|
||||||
|
*
|
||||||
|
* `token_cipher` NO se selecciona siquiera: lo único de la credencial que sale
|
||||||
|
* del servidor es la huella de 6 caracteres.
|
||||||
|
*/
|
||||||
|
adminRouter.get(
|
||||||
|
"/businesses",
|
||||||
|
h(async (_req: AuthedRequest, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT b.id, b.name, b.slug, b.timezone, b.status, b.created_at,
|
||||||
|
c.location_id, c.label AS crm_label, c.token_fingerprint,
|
||||||
|
c.token_updated_at, c.pipeline_id, c.calendar_id,
|
||||||
|
c.allow_real_sends, c.test_email,
|
||||||
|
c.last_sync_at, c.last_sync_status,
|
||||||
|
(SELECT count(*) FROM clients cl
|
||||||
|
WHERE cl.business_id = b.id AND cl.deleted_at IS NULL)::int AS clientes,
|
||||||
|
(SELECT count(*) FROM users u WHERE u.business_id = b.id)::int AS usuarios
|
||||||
|
FROM businesses b
|
||||||
|
LEFT JOIN crm_connections c ON c.business_id = b.id
|
||||||
|
ORDER BY b.created_at DESC`
|
||||||
|
);
|
||||||
|
res.json({ businesses: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Alta de cuenta: el negocio y su dueña, en la misma transacción. */
|
||||||
|
adminRouter.post(
|
||||||
|
"/businesses",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { name, timezone, owner_email, owner_name, owner_password, industry } = req.body ?? {};
|
||||||
|
if (!name || !owner_email || !owner_name || !owner_password) {
|
||||||
|
err(res, 400, "Faltan el nombre del negocio y los datos de la dueña");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(owner_email).trim().toLowerCase();
|
||||||
|
const { rows: ya } = await pool.query(`SELECT 1 FROM users WHERE email = $1`, [email]);
|
||||||
|
if (ya.length) {
|
||||||
|
err(res, 409, `Ya existe una persona con el correo ${email}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const creado = await withTx(async (tx) => {
|
||||||
|
const slug = await uniqueSlugPg(tx, String(name));
|
||||||
|
const { rows: bs } = await tx.query(
|
||||||
|
`INSERT INTO businesses (name, industry, timezone, slug, working_hours)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
|
RETURNING id, name, slug, timezone, status, created_at`,
|
||||||
|
[
|
||||||
|
String(name).trim(),
|
||||||
|
industry || "Estética y Spa",
|
||||||
|
timezone || "America/Mexico_City",
|
||||||
|
slug,
|
||||||
|
DEFAULT_WORKING_HOURS,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const business = bs[0];
|
||||||
|
|
||||||
|
const { rows: us } = await tx.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES ($1, $2, $3, $4, 'owner')
|
||||||
|
RETURNING id, email, name, role`,
|
||||||
|
[business.id, email, owner_password, String(owner_name).trim()]
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeAudit(tx, {
|
||||||
|
businessId: business.id,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "businesses",
|
||||||
|
entityId: business.id,
|
||||||
|
action: "create",
|
||||||
|
after: { name: business.name, slug: business.slug, owner_email: email },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { business, owner: us[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(creado);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "23505") {
|
||||||
|
err(res, 409, "Ya existe una cuenta con ese nombre o ese correo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincula la cuenta con su subcuenta de Bucéfalo CRM.
|
||||||
|
*
|
||||||
|
* Las credenciales se COMPRUEBAN antes de guardarlas. Un token que no se valida
|
||||||
|
* traslada el fallo al primer intento de sincronizar, lejos de donde se cometió,
|
||||||
|
* y con un mensaje que no dice cuál de las dos cosas está mal. La prueba correcta
|
||||||
|
* es leer la propia subcuenta con ese token y comparar identidad contra identidad,
|
||||||
|
* no dar por bueno un 200 genérico.
|
||||||
|
*/
|
||||||
|
adminRouter.put(
|
||||||
|
"/businesses/:id/crm",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const businessId = Number(req.params.id);
|
||||||
|
const { location_id, token, label } = req.body ?? {};
|
||||||
|
if (!Number.isFinite(businessId)) {
|
||||||
|
err(res, 400, "Identificador de cuenta inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!location_id || !token) {
|
||||||
|
err(res, 400, "Hacen falta el identificador de la subcuenta y el token privado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [
|
||||||
|
businessId,
|
||||||
|
]);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "La cuenta no existe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nombreSubcuenta: string | null = null;
|
||||||
|
try {
|
||||||
|
const loc = await crmRequest<any>("GET", `/locations/${location_id}`, {
|
||||||
|
token: String(token),
|
||||||
|
});
|
||||||
|
const devuelto = loc?.location?.id;
|
||||||
|
if (devuelto && devuelto !== location_id) {
|
||||||
|
err(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
"El token pertenece a otra subcuenta distinta de la que indicaste"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nombreSubcuenta = loc?.location?.name ?? null;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof CrmError && e.status === 401) {
|
||||||
|
// MEDIDO: en esta API el 401 es ambiguo — token caducado, sin permiso, o
|
||||||
|
// de otra subcuenta. El mensaje lo dice en vez de afirmar una sola causa.
|
||||||
|
err(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
"Bucéfalo CRM rechazó el token: puede estar caducado, no tener permiso de lectura de la subcuenta, o pertenecer a otra"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e instanceof CrmError && e.status === 404) {
|
||||||
|
err(res, 400, "Ese identificador de subcuenta no existe, o el token no da acceso a ella");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `Bucéfalo CRM no respondió: ${e?.message ?? e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guardarCredencial(
|
||||||
|
businessId,
|
||||||
|
String(location_id),
|
||||||
|
String(token),
|
||||||
|
label || nombreSubcuenta || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "crm_connections",
|
||||||
|
entityId: businessId,
|
||||||
|
action: "link",
|
||||||
|
// El token NO se audita, ni cifrado: el registro de auditoría se lee, se
|
||||||
|
// exporta y se copia, y una credencial ahí dentro acaba donde no debe.
|
||||||
|
after: { location_id, label: label || nombreSubcuenta },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ok: true, location_id, label: label || nombreSubcuenta });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Desvincula. Borra la credencial y conserva todo lo ya sincronizado. */
|
||||||
|
adminRouter.delete(
|
||||||
|
"/businesses/:id/crm",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const businessId = Number(req.params.id);
|
||||||
|
if (!Number.isFinite(businessId)) {
|
||||||
|
err(res, 400, "Identificador de cuenta inválido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await olvidarCredencial(businessId);
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "crm_connections",
|
||||||
|
entityId: businessId,
|
||||||
|
action: "unlink",
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Ajustes de la cuenta que pertenecen a la plataforma, no al negocio. */
|
||||||
|
adminRouter.patch(
|
||||||
|
"/businesses/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const businessId = Number(req.params.id);
|
||||||
|
const { status, name, timezone } = req.body ?? {};
|
||||||
|
if (status && !["active", "suspended"].includes(status)) {
|
||||||
|
err(res, 400, "El estado solo puede ser «active» o «suspended»");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE businesses
|
||||||
|
SET status = COALESCE($2, status),
|
||||||
|
name = COALESCE($3, name),
|
||||||
|
timezone = COALESCE($4, timezone)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, name, slug, timezone, status`,
|
||||||
|
[businessId, status ?? null, name ?? null, timezone ?? null]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "La cuenta no existe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ business: rows[0] });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { encolar } from "../crm/outbox.ts";
|
||||||
|
|
||||||
|
export const appointmentsRouter = Router();
|
||||||
|
|
||||||
|
// `start_at` y `end_at` se serializan a ISO-Z sin milisegundos, que es el
|
||||||
|
// formato que el frontend ya parsea. `to_char` sobre el valor convertido a UTC
|
||||||
|
// evita depender de la zona del proceso de Node.
|
||||||
|
const COLS = `id, business_id, client_id, employee_id, service_id,
|
||||||
|
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||||
|
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||||
|
status, cancelled_by, cancel_reason, price, notes, source_channel, created_at`;
|
||||||
|
|
||||||
|
/** Traduce la violación de exclusión de Postgres a un 409 en español. */
|
||||||
|
function isOverlap(e: any) {
|
||||||
|
return e?.code === "23P01" && String(e?.constraint) === "appointments_no_overlap";
|
||||||
|
}
|
||||||
|
|
||||||
|
const OCUPADO = "Ese horario ya está ocupado para esta especialista";
|
||||||
|
|
||||||
|
appointmentsRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { from, to, employee_id, client_id, status, limit } = req.query as Record<
|
||||||
|
string,
|
||||||
|
string | undefined
|
||||||
|
>;
|
||||||
|
const params: unknown[] = [req.user!.business_id];
|
||||||
|
let sql = `SELECT ${COLS} FROM appointments WHERE business_id = $1`;
|
||||||
|
if (from) {
|
||||||
|
params.push(from);
|
||||||
|
sql += ` AND start_at >= $${params.length}::timestamptz`;
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
params.push(to);
|
||||||
|
sql += ` AND start_at < $${params.length}::timestamptz`;
|
||||||
|
}
|
||||||
|
if (employee_id) {
|
||||||
|
params.push(Number(employee_id));
|
||||||
|
sql += ` AND employee_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
// Sin este filtro, la ficha de una clienta enseñaba las citas de todas: el
|
||||||
|
// cliente lo mandaba y el servidor lo ignoraba en silencio.
|
||||||
|
if (client_id) {
|
||||||
|
params.push(Number(client_id));
|
||||||
|
sql += ` AND client_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
params.push(status);
|
||||||
|
sql += ` AND status = $${params.length}`;
|
||||||
|
}
|
||||||
|
params.push(Math.min(Number(limit) || 500, 1000));
|
||||||
|
sql += ` ORDER BY start_at DESC LIMIT $${params.length}`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, params);
|
||||||
|
res.json({ appointments: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
appointmentsRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { client_id, employee_id, service_id, start_at, notes, source_channel } =
|
||||||
|
req.body ?? {};
|
||||||
|
if (!client_id || !employee_id || !service_id || !start_at) {
|
||||||
|
err(res, 400, "Faltan datos de la cita");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
|
||||||
|
const svc = await pool.query(
|
||||||
|
`SELECT duration_min, price FROM services
|
||||||
|
WHERE id = $1 AND business_id = $2 AND active`,
|
||||||
|
[service_id, bid]
|
||||||
|
);
|
||||||
|
if (!svc.rows[0]) {
|
||||||
|
err(res, 404, "Servicio no encontrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emp = await pool.query(
|
||||||
|
`SELECT 1 FROM employees WHERE id = $1 AND business_id = $2 AND active`,
|
||||||
|
[employee_id, bid]
|
||||||
|
);
|
||||||
|
if (!emp.rows[0]) {
|
||||||
|
err(res, 404, "Especialista no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cli = await pool.query(
|
||||||
|
`SELECT 1 FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[client_id, bid]
|
||||||
|
);
|
||||||
|
if (!cli.rows[0]) {
|
||||||
|
err(res, 404, "Clienta no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointment = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`INSERT INTO appointments
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at,
|
||||||
|
price, notes, source_channel, created_by_user_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5::timestamptz,
|
||||||
|
$5::timestamptz + make_interval(mins => $6::int),
|
||||||
|
$7,$8,$9,$10)
|
||||||
|
RETURNING ${COLS}`,
|
||||||
|
[
|
||||||
|
bid,
|
||||||
|
client_id,
|
||||||
|
employee_id,
|
||||||
|
service_id,
|
||||||
|
start_at,
|
||||||
|
svc.rows[0].duration_min,
|
||||||
|
svc.rows[0].price,
|
||||||
|
notes || null,
|
||||||
|
source_channel || null,
|
||||||
|
req.user!.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, to_status)
|
||||||
|
VALUES ($1,$2,'created','scheduled')`,
|
||||||
|
[rows[0].id, req.user!.id]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: rows[0].id,
|
||||||
|
action: "create",
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
// Se encola en la MISMA transacción: si el proceso muere aquí, la cita
|
||||||
|
// y su intención de sincronizar caen juntas o sobreviven juntas.
|
||||||
|
await encolar(c, {
|
||||||
|
businessId: bid!, entidad: "appointment", entidadId: rows[0].id,
|
||||||
|
operacion: "create", payload: { status: "scheduled" }, secuencia: "create",
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.status(201).json({ appointment });
|
||||||
|
} catch (e) {
|
||||||
|
if (isOverlap(e)) {
|
||||||
|
err(res, 409, OCUPADO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
appointmentsRouter.patch(
|
||||||
|
"/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { start_at, employee_id, notes } = req.body ?? {};
|
||||||
|
|
||||||
|
const cur = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) {
|
||||||
|
err(res, 404, "Cita no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cur.rows[0].status === "cancelled") {
|
||||||
|
err(res, 409, "Una cita cancelada no se puede modificar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointment = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`UPDATE appointments SET
|
||||||
|
start_at = COALESCE($3::timestamptz, start_at),
|
||||||
|
end_at = CASE WHEN $3::timestamptz IS NULL THEN end_at
|
||||||
|
ELSE $3::timestamptz + (end_at - start_at) END,
|
||||||
|
employee_id = COALESCE($4::bigint, employee_id),
|
||||||
|
notes = COALESCE($5::text, notes),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND business_id = $2
|
||||||
|
RETURNING ${COLS}`,
|
||||||
|
[id, bid, start_at ?? null, employee_id ?? null, notes ?? null]
|
||||||
|
);
|
||||||
|
if (start_at || employee_id) {
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, detail)
|
||||||
|
VALUES ($1,$2,'rescheduled',$3::jsonb)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
req.user!.id,
|
||||||
|
JSON.stringify({
|
||||||
|
from: {
|
||||||
|
start_at: cur.rows[0].start_at,
|
||||||
|
employee_id: cur.rows[0].employee_id,
|
||||||
|
},
|
||||||
|
to: { start_at: rows[0].start_at, employee_id: rows[0].employee_id },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: id,
|
||||||
|
action: "update",
|
||||||
|
before: cur.rows[0],
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.json({ appointment });
|
||||||
|
} catch (e) {
|
||||||
|
if (isOverlap(e)) {
|
||||||
|
err(res, 409, OCUPADO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
appointmentsRouter.post(
|
||||||
|
"/:id/cancel",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { cancelled_by, reason } = req.body ?? {};
|
||||||
|
if (cancelled_by !== "client" && cancelled_by !== "business") {
|
||||||
|
err(res, 400, "Indica quién canceló: la clienta o el spa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
if (!cur.rows[0]) {
|
||||||
|
err(res, 404, "Cita no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointment = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`UPDATE appointments
|
||||||
|
SET status = 'cancelled', cancelled_by = $3, cancel_reason = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`,
|
||||||
|
[id, bid, cancelled_by, reason || null]
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events
|
||||||
|
(appointment_id, actor_user_id, action, from_status, to_status, detail)
|
||||||
|
VALUES ($1,$2,'cancelled',$3,'cancelled',$4::jsonb)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
req.user!.id,
|
||||||
|
cur.rows[0].status,
|
||||||
|
JSON.stringify({ cancelled_by, reason: reason || null }),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: id,
|
||||||
|
action: "cancel",
|
||||||
|
before: cur.rows[0],
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
await encolar(c, {
|
||||||
|
businessId: bid!, entidad: "appointment", entidadId: id,
|
||||||
|
operacion: "status", payload: { status: "cancelled", cancelled_by },
|
||||||
|
secuencia: "cancelled",
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.json({ appointment });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { withTx, pool } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { encolar } from "../crm/outbox.ts";
|
||||||
|
|
||||||
|
export const attendanceRouter = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
const APPT_COLS = `id, business_id, client_id, employee_id, service_id,
|
||||||
|
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||||
|
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||||
|
status, cancelled_by, price, notes, created_at`;
|
||||||
|
|
||||||
|
const VALID_PAYMENT = new Set(["cash", "card", "transfer", "other"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El toque de asistencia: un solo POST resuelve la cita.
|
||||||
|
*
|
||||||
|
* La visita se crea **solo** si la clienta vino. Una visita es un hecho con
|
||||||
|
* dinero; el "no vino" es un estado de la cita. Fusionar los dos conceptos es
|
||||||
|
* lo que produce registros que sirven para planear y para cerrar, y que
|
||||||
|
* terminan sin cerrarse nunca.
|
||||||
|
*/
|
||||||
|
attendanceRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { attended, total_charged, payment_method } = req.body ?? {};
|
||||||
|
|
||||||
|
if (typeof attended !== "boolean") {
|
||||||
|
err(res, 400, "Indica si la clienta vino o no vino");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payment_method != null && !VALID_PAYMENT.has(payment_method)) {
|
||||||
|
err(res, 400, "Método de pago no válido");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur = await pool.query(
|
||||||
|
`SELECT ${APPT_COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
const appt = cur.rows[0];
|
||||||
|
if (!appt) {
|
||||||
|
err(res, 404, "Cita no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appt.status === "cancelled") {
|
||||||
|
err(res, 409, "Esta cita está cancelada: no se le puede marcar asistencia");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appt.status === "completed" || appt.status === "no_show") {
|
||||||
|
err(res, 409, "Esta cita ya se resolvió");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Una empleada solo resuelve sus propias citas; la administradora, cualquiera.
|
||||||
|
if (req.user!.role === "employee" && req.user!.employee_id !== appt.employee_id) {
|
||||||
|
err(res, 403, "Solo puedes marcar asistencia en tus propias citas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await withTx(async (c) => {
|
||||||
|
const nextStatus = attended ? "completed" : "no_show";
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`UPDATE appointments SET status = $3, updated_at = now()
|
||||||
|
WHERE id = $1 AND business_id = $2 RETURNING ${APPT_COLS}`,
|
||||||
|
[id, bid, nextStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
let visit = null;
|
||||||
|
if (attended) {
|
||||||
|
const v = await c.query(
|
||||||
|
`INSERT INTO visits
|
||||||
|
(business_id, appointment_id, client_id, employee_id, occurred_at,
|
||||||
|
total_charged, payment_method, recorded_by_user_id)
|
||||||
|
SELECT business_id, id, client_id, employee_id, start_at, $2, $3, $4
|
||||||
|
FROM appointments WHERE id = $1
|
||||||
|
RETURNING id, business_id, appointment_id, client_id, employee_id,
|
||||||
|
to_char(occurred_at AT TIME ZONE 'UTC',
|
||||||
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS occurred_at,
|
||||||
|
total_charged, payment_method, recorded_by_user_id, recorded_at`,
|
||||||
|
[id, total_charged ?? null, payment_method ?? null, req.user!.id]
|
||||||
|
);
|
||||||
|
visit = v.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.query(
|
||||||
|
`INSERT INTO appointment_events
|
||||||
|
(appointment_id, actor_user_id, action, from_status, to_status)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[id, req.user!.id, attended ? "attended" : "no_show", appt.status, nextStatus]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "appointments",
|
||||||
|
entityId: id,
|
||||||
|
action: "attendance",
|
||||||
|
before: { status: appt.status },
|
||||||
|
after: { status: nextStatus, visit_id: visit?.id ?? null },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// La proyección al CRM se ENCOLA en esta misma transacción. Si se hiciera
|
||||||
|
// aquí una llamada de red, un CRM caído impediría marcar la asistencia —
|
||||||
|
// justo el registro que el negocio no puede permitirse perder.
|
||||||
|
await encolar(c, {
|
||||||
|
businessId: bid!,
|
||||||
|
entidad: "appointment",
|
||||||
|
entidadId: id,
|
||||||
|
operacion: "status",
|
||||||
|
payload: { status: nextStatus },
|
||||||
|
secuencia: nextStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { appointment: rows[0], visit };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(out);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { authRequired, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portado tal cual del backend de demo: el token es el id del usuario y la
|
||||||
|
* contraseña se compara en claro. Ver la nota de deuda en lib/auth.ts — se
|
||||||
|
* endurece entero (hash + sesión real + cliente + pruebas) o no se toca.
|
||||||
|
*/
|
||||||
|
authRouter.post(
|
||||||
|
"/login",
|
||||||
|
h(async (req, res) => {
|
||||||
|
const { email, password } = req.body ?? {};
|
||||||
|
if (!email || !password) {
|
||||||
|
err(res, 400, "Faltan credenciales");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, business_id, email, name, role, employee_id, avatar_color, password
|
||||||
|
FROM users WHERE email = $1`,
|
||||||
|
[String(email).toLowerCase().trim()]
|
||||||
|
);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user || user.password !== password) {
|
||||||
|
err(res, 401, "Correo o contraseña incorrectos");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { password: _pw, ...safe } = user;
|
||||||
|
res.json({ token: String(user.id), user: safe });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
authRouter.get("/me", authRequired, (req: AuthedRequest, res) => {
|
||||||
|
res.json({ user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get(
|
||||||
|
"/demo-users",
|
||||||
|
h(async (_req, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT email, name, role, avatar_color FROM users
|
||||||
|
ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name`
|
||||||
|
);
|
||||||
|
res.json({ users: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
|
||||||
|
export const businessRouter = Router();
|
||||||
|
|
||||||
|
businessRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, industry, currency, currency_symbol, phone, address,
|
||||||
|
slug, timezone, working_hours, status
|
||||||
|
FROM businesses WHERE id = $1`,
|
||||||
|
[req.user!.business_id]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "Negocio no encontrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ business: rows[0] });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
|
||||||
|
export const clientsRouter = Router();
|
||||||
|
|
||||||
|
const COLS = `id, business_id, name, email, phone, phone_e164, contactable,
|
||||||
|
birth_date, notes, tags, source_channel, created_at,
|
||||||
|
crm_contact_id, crm_synced_at, crm_source, crm_tags,
|
||||||
|
attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||||
|
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id,
|
||||||
|
attr_referrer`;
|
||||||
|
|
||||||
|
clientsRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const q = (req.query.q as string | undefined)?.trim();
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE business_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY name LIMIT 50`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
res.json({ clients: rows });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se busca por tres vías a la vez: el nombre, el teléfono tal cual se guardó,
|
||||||
|
// y el normalizado. La tercera es la que hace que teclear "5588887777"
|
||||||
|
// encuentre a quien está guardada como "+52 55 8888 7777".
|
||||||
|
const like = `%${q}%`;
|
||||||
|
const e164 = normalizePhone(q);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE business_id = $1 AND deleted_at IS NULL
|
||||||
|
AND (name ILIKE $2 OR phone ILIKE $2 OR email ILIKE $2
|
||||||
|
OR ($3::text IS NOT NULL AND phone_e164 = $3))
|
||||||
|
ORDER BY name LIMIT 50`,
|
||||||
|
[bid, like, e164]
|
||||||
|
);
|
||||||
|
res.json({ clients: rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
clientsRouter.get(
|
||||||
|
"/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[id, bid]
|
||||||
|
);
|
||||||
|
if (!rows[0]) {
|
||||||
|
err(res, 404, "Clienta no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las visitas salen de `visits`, no de las citas: una cita es una
|
||||||
|
// intención y una visita es el hecho consumado. Contar citas como visitas
|
||||||
|
// infla el historial con gente que no vino.
|
||||||
|
const v = await pool.query(
|
||||||
|
`SELECT count(*)::int AS visits,
|
||||||
|
COALESCE(sum(total_charged),0)::float AS total_spent,
|
||||||
|
max(occurred_at) AS last_visit
|
||||||
|
FROM visits WHERE business_id = $1 AND client_id = $2`,
|
||||||
|
[bid, id]
|
||||||
|
);
|
||||||
|
const ns = await pool.query(
|
||||||
|
`SELECT count(*)::int AS c FROM appointments
|
||||||
|
WHERE business_id = $1 AND client_id = $2 AND status = 'no_show'`,
|
||||||
|
[bid, id]
|
||||||
|
);
|
||||||
|
const visits = v.rows[0].visits as number;
|
||||||
|
const total = v.rows[0].total_spent as number;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
client: {
|
||||||
|
...rows[0],
|
||||||
|
stats: {
|
||||||
|
visits,
|
||||||
|
total_spent: Math.round(total * 100) / 100,
|
||||||
|
last_visit: v.rows[0].last_visit,
|
||||||
|
avg_ticket: visits ? Math.round((total / visits) * 100) / 100 : 0,
|
||||||
|
no_show_count: ns.rows[0].c,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
clientsRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { name, phone, email, notes, source_channel } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== "string" || !name.trim()) {
|
||||||
|
err(res, 400, "El nombre es obligatorio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bid = req.user!.business_id;
|
||||||
|
const e164 = normalizePhone(phone);
|
||||||
|
|
||||||
|
// Se pregunta antes de insertar para poder devolver la ficha existente. El
|
||||||
|
// índice único sigue siendo la garantía real: entre esta consulta y el
|
||||||
|
// INSERT cabe otra alta, y por eso abajo también se atrapa el 23505.
|
||||||
|
if (e164) {
|
||||||
|
const dup = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients
|
||||||
|
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||||
|
[bid, e164]
|
||||||
|
);
|
||||||
|
if (dup.rows[0]) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: "Ya existe una clienta con ese teléfono",
|
||||||
|
existing: dup.rows[0],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await withTx(async (c) => {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`INSERT INTO clients
|
||||||
|
(business_id, name, email, phone, phone_e164, notes, source_channel)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING ${COLS}`,
|
||||||
|
[
|
||||||
|
bid,
|
||||||
|
name.trim(),
|
||||||
|
email || null,
|
||||||
|
phone || null,
|
||||||
|
e164,
|
||||||
|
notes || null,
|
||||||
|
source_channel || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "clients",
|
||||||
|
entityId: rows[0].id,
|
||||||
|
action: "create",
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
res.status(201).json({ client });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === "23505") {
|
||||||
|
const dup = await pool.query(
|
||||||
|
`SELECT ${COLS} FROM clients WHERE business_id = $1 AND phone_e164 = $2`,
|
||||||
|
[bid, e164]
|
||||||
|
);
|
||||||
|
res.status(409).json({
|
||||||
|
error: "Ya existe una clienta con ese teléfono",
|
||||||
|
existing: dup.rows[0] ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { err, h, ownerOnly, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { withTx } from "../db/pool.ts";
|
||||||
|
import { obtenerConexion, autoconfigurar } from "../crm/connection.ts";
|
||||||
|
import { ctxDe } from "../crm/ctx.ts";
|
||||||
|
import { sincronizarContactos } from "../crm/syncContacts.ts";
|
||||||
|
import { proyectarCita } from "../crm/syncAppointments.ts";
|
||||||
|
import { despachar, estadoOutbox } from "../crm/outbox.ts";
|
||||||
|
import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts";
|
||||||
|
import { sincronizarConversaciones } from "../crm/syncConversations.ts";
|
||||||
|
|
||||||
|
export const crmRouter = Router();
|
||||||
|
|
||||||
|
/** Estado de la conexión: lo que la pantalla de clientes necesita para el botón. */
|
||||||
|
crmRouter.get(
|
||||||
|
"/status",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const conexion = await obtenerConexion(bid);
|
||||||
|
if (!conexion) {
|
||||||
|
res.json({ connected: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await pool.query(
|
||||||
|
`SELECT count(*)::int AS clientes,
|
||||||
|
count(*) FILTER (WHERE crm_contact_id IS NOT NULL)::int AS sincronizados,
|
||||||
|
count(*) FILTER (WHERE contactable)::int AS contactables,
|
||||||
|
count(*) FILTER (WHERE attr_campaign IS NOT NULL)::int AS con_campana
|
||||||
|
FROM clients WHERE business_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
const ultima = await pool.query(
|
||||||
|
`SELECT id, kind, status, fetched, created, updated, started_at, finished_at, error
|
||||||
|
FROM crm_sync_runs WHERE business_id = $1 ORDER BY id DESC LIMIT 1`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
connected: true,
|
||||||
|
location_id: conexion.location_id,
|
||||||
|
pipeline_id: conexion.pipeline_id,
|
||||||
|
allow_duplicate_opp: conexion.allow_duplicate_opp,
|
||||||
|
last_sync_at: conexion.last_sync_at,
|
||||||
|
last_sync_status: conexion.last_sync_status,
|
||||||
|
stats: stats.rows[0],
|
||||||
|
last_run: ultima.rows[0] ?? null,
|
||||||
|
outbox: await estadoOutbox(bid),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Conecta o reconfigura la subcuenta. El token vive en el entorno, no en el body. */
|
||||||
|
crmRouter.post(
|
||||||
|
"/connect",
|
||||||
|
ownerOnly,
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
// Las credenciales las pone la administración de la plataforma, no el
|
||||||
|
// negocio: son de la subcuenta del cliente y no deben viajar por aquí.
|
||||||
|
// Esta ruta solo redetecta pipeline y etapas de la subcuenta ya vinculada.
|
||||||
|
const ctx = await ctxDe(bid); // lanza 409 si no está vinculado
|
||||||
|
const c = await autoconfigurar(ctx);
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "crm_connections",
|
||||||
|
entityId: c.id,
|
||||||
|
action: "connect",
|
||||||
|
after: { location_id: c.location_id, pipeline_id: c.pipeline_id },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
res.json({ connection: c });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El botón de sincronizar contactos.
|
||||||
|
*
|
||||||
|
* Es una corrida en primer plano y no una tarea de fondo a propósito: 3 200
|
||||||
|
* contactos tardan ~22 s y quien pulsa el botón quiere ver el resultado. Si el
|
||||||
|
* volumen crece hasta molestar, se mueve a la bandeja; hoy sería complejidad
|
||||||
|
* sin problema que resolver.
|
||||||
|
*/
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/contacts",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
try {
|
||||||
|
const r = await sincronizarContactos(bid, {
|
||||||
|
userId: req.user!.id,
|
||||||
|
maxPaginas: Number(req.body?.max_paginas) || 60,
|
||||||
|
});
|
||||||
|
res.json(r);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Empuja una cita concreta al CRM como oportunidad. */
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/appointment/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
try {
|
||||||
|
const r = await proyectarCita(bid, Number(req.params.id));
|
||||||
|
res.json(r);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Vacía la bandeja de salida. */
|
||||||
|
crmRouter.post(
|
||||||
|
"/outbox/flush",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const r = await despachar(bid, Number(req.body?.limite) || 25);
|
||||||
|
res.json(r);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Lo que no se pudo sincronizar, para que alguien pueda mirarlo. */
|
||||||
|
crmRouter.get(
|
||||||
|
"/outbox",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, entity, entity_id, operation, status, attempts, last_error,
|
||||||
|
crm_id, created_at, sent_at
|
||||||
|
FROM crm_outbox
|
||||||
|
WHERE business_id = $1
|
||||||
|
ORDER BY CASE status WHEN 'indeterminado' THEN 0 WHEN 'fallido' THEN 1
|
||||||
|
WHEN 'pendiente' THEN 2 ELSE 3 END, id DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
res.json({ items: rows, resumen: await estadoOutbox(bid) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Espejo de las conversaciones recientes con sus mensajes.
|
||||||
|
*
|
||||||
|
* Va declarada ANTES que `/sync/:entidad/:id` no por ambigüedad —tienen distinto
|
||||||
|
* número de segmentos— sino para que el orden del archivo diga cuál es la ruta
|
||||||
|
* concreta y cuál la genérica.
|
||||||
|
*/
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/conversations",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await sincronizarConversaciones(bid, {
|
||||||
|
limit: Math.min(Number(req.body?.limite) || 50, 200),
|
||||||
|
userId: req.user!.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.error ?? e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza UNA entidad por su identificador.
|
||||||
|
*
|
||||||
|
* Es el punto de entrada único que pedía el encargo. La dirección la decide la
|
||||||
|
* entidad, no quien llama: contactos, conversaciones y mensajes se traen del
|
||||||
|
* CRM; citas y servicios se empujan hacia él. Ver `crm/syncOne.ts`.
|
||||||
|
*/
|
||||||
|
crmRouter.post(
|
||||||
|
"/sync/:entidad/:id",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const { entidad, id } = req.params;
|
||||||
|
if (!esEntidad(entidad)) {
|
||||||
|
err(res, 400, `Entidad no reconocida. Las válidas son: ${ENTIDADES.join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!id || id.length > 64) {
|
||||||
|
err(res, 400, "Identificador ausente o demasiado largo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await sincronizarPorId(req.user!.business_id!, entidad, id));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status) {
|
||||||
|
err(res, e.status, e.error ?? e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../../server/lib/time.ts";
|
||||||
|
|
||||||
|
export const dayCloseRouter = Router();
|
||||||
|
|
||||||
|
const APPT_COLS = `a.id, a.client_id, a.employee_id, a.service_id,
|
||||||
|
to_char(a.start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||||
|
to_char(a.end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||||
|
a.status, a.price, c.name AS client_name, e.name AS employee_name, s.name AS service_name`;
|
||||||
|
|
||||||
|
// `bizDayBoundsIsoFor` devuelve un fin INCLUSIVO (23:59:59 hora local), así que
|
||||||
|
// la comparación es `<=`. Con `<` se perdería la última cita del día.
|
||||||
|
const IN_DAY = `a.start_at >= $2::timestamptz AND a.start_at <= $3::timestamptz`;
|
||||||
|
|
||||||
|
const UNRESOLVED_SQL = `
|
||||||
|
SELECT ${APPT_COLS} FROM appointments a
|
||||||
|
JOIN clients c ON c.id = a.client_id
|
||||||
|
JOIN employees e ON e.id = a.employee_id
|
||||||
|
JOIN services s ON s.id = a.service_id
|
||||||
|
WHERE a.business_id = $1 AND ${IN_DAY} AND a.status = 'scheduled'
|
||||||
|
ORDER BY a.start_at`;
|
||||||
|
|
||||||
|
const COUNTS_SQL = `
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE status = 'completed')::int AS attended,
|
||||||
|
count(*) FILTER (WHERE status = 'no_show')::int AS no_show,
|
||||||
|
count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled
|
||||||
|
FROM appointments
|
||||||
|
WHERE business_id = $1
|
||||||
|
AND start_at >= $2::timestamptz AND start_at <= $3::timestamptz`;
|
||||||
|
|
||||||
|
async function bizTz(businessId: number): Promise<string> {
|
||||||
|
const { rows } = await pool.query(`SELECT timezone FROM businesses WHERE id = $1`, [
|
||||||
|
businessId,
|
||||||
|
]);
|
||||||
|
return rows[0]?.timezone || DEFAULT_TZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayCloseRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const tz = await bizTz(bid);
|
||||||
|
const date = (req.query.date as string | undefined) || bizTodayISO(tz);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
err(res, 400, "Fecha no válida");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||||
|
|
||||||
|
const unresolved = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||||
|
const counts = await pool.query(COUNTS_SQL, [bid, start, end]);
|
||||||
|
const closure = await pool.query(
|
||||||
|
`SELECT closed_at FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||||
|
[bid, date]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
date,
|
||||||
|
closed_at: closure.rows[0]?.closed_at ?? null,
|
||||||
|
unresolved: unresolved.rows,
|
||||||
|
attended: counts.rows[0].attended,
|
||||||
|
no_show: counts.rows[0].no_show,
|
||||||
|
cancelled: counts.rows[0].cancelled,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
dayCloseRouter.post(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const tz = await bizTz(bid);
|
||||||
|
const date = (req.body?.date as string | undefined) || bizTodayISO(tz);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
err(res, 400, "Fecha no válida");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||||
|
|
||||||
|
const ya = await pool.query(
|
||||||
|
`SELECT 1 FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||||
|
[bid, date]
|
||||||
|
);
|
||||||
|
if (ya.rows[0]) {
|
||||||
|
err(res, 409, "Ese día ya está cerrado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La regla que sostiene todo el proyecto: no se puede cerrar el día dejando
|
||||||
|
// citas sin desenlace. Es lo que convierte el registro en el camino más
|
||||||
|
// corto para trabajar, en vez de una tarea añadida al final.
|
||||||
|
const pend = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||||
|
if (pend.rows.length) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: `Quedan ${pend.rows.length} cita(s) sin resolver: marca si vinieron o no antes de cerrar`,
|
||||||
|
unresolved: pend.rows,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closure = await withTx(async (c) => {
|
||||||
|
const counts = await c.query(COUNTS_SQL, [bid, start, end]);
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`INSERT INTO day_closures
|
||||||
|
(business_id, business_date, closed_by_user_id,
|
||||||
|
attended_count, no_show_count, cancelled_count)
|
||||||
|
VALUES ($1,$2::date,$3,$4,$5,$6)
|
||||||
|
RETURNING id, business_id,
|
||||||
|
to_char(business_date, 'YYYY-MM-DD') AS business_date,
|
||||||
|
closed_by_user_id, closed_at,
|
||||||
|
attended_count, no_show_count, cancelled_count`,
|
||||||
|
[
|
||||||
|
bid,
|
||||||
|
date,
|
||||||
|
req.user!.id,
|
||||||
|
counts.rows[0].attended,
|
||||||
|
counts.rows[0].no_show,
|
||||||
|
counts.rows[0].cancelled,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "day_closures",
|
||||||
|
entityId: rows[0].id,
|
||||||
|
action: "close",
|
||||||
|
after: rows[0],
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ closure });
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { obtenerConexion } from "../crm/connection.ts";
|
||||||
|
import { ctxDe } from "../crm/ctx.ts";
|
||||||
|
import { buscarConversaciones, mensajesDeConversacion } from "../crm/conversations.ts";
|
||||||
|
import { enviarCorreo } from "../crm/messages.ts";
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
export const messagesRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MODO PRUEBA — restricción deliberada del MVP.
|
||||||
|
*
|
||||||
|
* Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa
|
||||||
|
* dirección**, sin importar a quién apunte la interfaz. La subcuenta es la de un
|
||||||
|
* cliente real con 3 200 contactos: un bucle mal escrito o un clic de más
|
||||||
|
* escribiría a personas de verdad, y eso no se arregla pidiendo perdón.
|
||||||
|
*
|
||||||
|
* Se quita definiendo `CRM_ALLOW_REAL_SENDS=1`, y esa es una decisión del dueño
|
||||||
|
* del proyecto, no un descuido de configuración.
|
||||||
|
*/
|
||||||
|
function destinoPermitido(deseado: string): { to: string; forzado: boolean } {
|
||||||
|
loadEnv();
|
||||||
|
const prueba = process.env.CRM_TEST_EMAIL;
|
||||||
|
const libre = process.env.CRM_ALLOW_REAL_SENDS === "1";
|
||||||
|
if (prueba && !libre) {
|
||||||
|
return { to: prueba, forzado: prueba.toLowerCase() !== deseado.toLowerCase() };
|
||||||
|
}
|
||||||
|
return { to: deseado, forzado: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandeja: conversaciones del CRM, con la clienta local enlazada si se conoce. */
|
||||||
|
messagesRouter.get(
|
||||||
|
"/",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const conexion = await obtenerConexion(bid);
|
||||||
|
if (!conexion) {
|
||||||
|
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await ctxDe(bid);
|
||||||
|
const { conversations, total } = await buscarConversaciones(ctx, {
|
||||||
|
limit: Number(req.query.limit) || 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se enlazan con las clientas locales por el id del CRM para poder abrir su
|
||||||
|
// ficha desde la bandeja: es el "al lado" que hace útil esta pantalla.
|
||||||
|
const ids = conversations.map((c) => c.contactId).filter(Boolean) as string[];
|
||||||
|
const locales = ids.length
|
||||||
|
? await pool.query(
|
||||||
|
`SELECT id, name, crm_contact_id, phone_e164, contactable
|
||||||
|
FROM clients WHERE business_id = $1 AND crm_contact_id = ANY($2::text[])`,
|
||||||
|
[bid, ids]
|
||||||
|
)
|
||||||
|
: { rows: [] as any[] };
|
||||||
|
const porCrmId = new Map(locales.rows.map((r: any) => [r.crm_contact_id, r]));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total,
|
||||||
|
conversations: conversations.map((c) => ({
|
||||||
|
crm_conversation_id: c.id,
|
||||||
|
crm_contact_id: c.contactId ?? null,
|
||||||
|
contact_name: c.fullName || c.contactName || "Sin nombre",
|
||||||
|
last_message_body: c.lastMessageBody ?? null,
|
||||||
|
last_message_type: c.lastMessageType ?? null,
|
||||||
|
last_message_at: c.lastMessageDate ?? null,
|
||||||
|
unread_count: c.unreadCount ?? 0,
|
||||||
|
client: porCrmId.get(c.contactId ?? "") ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Los mensajes de un hilo. Solo lectura: el CRM es el dueño del histórico. */
|
||||||
|
messagesRouter.get(
|
||||||
|
"/:conversationId",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const conexion = await obtenerConexion(req.user!.business_id!);
|
||||||
|
if (!conexion) {
|
||||||
|
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = await ctxDe(req.user!.business_id!);
|
||||||
|
const { mensajes, hayMas } = await mensajesDeConversacion(ctx, req.params.conversationId, {
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
hay_mas: hayMas,
|
||||||
|
messages: mensajes.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
body: m.body ?? null,
|
||||||
|
direction: m.direction ?? null,
|
||||||
|
channel: m.messageType ?? null,
|
||||||
|
status: m.status ?? null,
|
||||||
|
sent_at: m.dateAdded ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responder por correo.
|
||||||
|
*
|
||||||
|
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||||
|
* canal ejercible hoy, y la respuesta lo dice explícitamente para que la
|
||||||
|
* interfaz no prometa lo que no puede cumplir.
|
||||||
|
*/
|
||||||
|
messagesRouter.post(
|
||||||
|
"/send",
|
||||||
|
h(async (req: AuthedRequest, res) => {
|
||||||
|
const bid = req.user!.business_id!;
|
||||||
|
const { client_id, crm_contact_id, subject, body } = req.body ?? {};
|
||||||
|
if (!subject || !body) {
|
||||||
|
err(res, 400, "El asunto y el mensaje son obligatorios");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conexion = await obtenerConexion(bid);
|
||||||
|
if (!conexion) {
|
||||||
|
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contactId: string | null = crm_contact_id ?? null;
|
||||||
|
let correoDestino: string | null = null;
|
||||||
|
let clienteLocal: any = null;
|
||||||
|
|
||||||
|
if (client_id) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, email, crm_contact_id FROM clients
|
||||||
|
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[Number(client_id), bid]
|
||||||
|
);
|
||||||
|
clienteLocal = rows[0] ?? null;
|
||||||
|
if (!clienteLocal) {
|
||||||
|
err(res, 404, "Clienta no encontrada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contactId = contactId ?? clienteLocal.crm_contact_id;
|
||||||
|
correoDestino = clienteLocal.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contactId) {
|
||||||
|
err(res, 409, "Esta clienta todavía no está sincronizada con el CRM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { to, forzado } = destinoPermitido(correoDestino || "");
|
||||||
|
if (!to) {
|
||||||
|
err(res, 409, "No hay dirección de correo a la que escribir");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await ctxDe(bid);
|
||||||
|
const r = await enviarCorreo(ctx, {
|
||||||
|
contactId,
|
||||||
|
emailTo: to,
|
||||||
|
subject: String(subject).slice(0, 200),
|
||||||
|
html: `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.6">${String(
|
||||||
|
body
|
||||||
|
)
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => `<p>${escaparHtml(l)}</p>`)
|
||||||
|
.join("")}</div>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTx((tx) =>
|
||||||
|
writeAudit(tx, {
|
||||||
|
businessId: bid,
|
||||||
|
actorUserId: req.user!.id,
|
||||||
|
entity: "messages",
|
||||||
|
entityId: clienteLocal?.id ?? null,
|
||||||
|
action: "send_email",
|
||||||
|
after: { to, forzado, crm: r },
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
// El CRM responde "Email queued successfully": es acuse de ENCOLADO, no
|
||||||
|
// de entrega. La interfaz debe decir "en camino", nunca "entregado".
|
||||||
|
queued: true,
|
||||||
|
crm: r,
|
||||||
|
sent_to: to,
|
||||||
|
redirigido: forzado,
|
||||||
|
aviso: forzado
|
||||||
|
? `Modo prueba: el mensaje se envió a ${to}, no a la clienta.`
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function escaparHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Comprueba que la consola de cuentas se pinta de verdad y que el token NO
|
||||||
|
* aparece en ningún sitio del DOM.
|
||||||
|
*
|
||||||
|
* Un typecheck limpio y un build correcto no dicen nada sobre si la pantalla
|
||||||
|
* renderiza: eso hay que mirarlo.
|
||||||
|
*
|
||||||
|
* node platform/scripts/admin-ui-check.mjs
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const BASE = process.env.UI_BASE_URL || "http://localhost:5176";
|
||||||
|
const EMAIL = "[email protected]";
|
||||||
|
const PASS = "demo1234";
|
||||||
|
|
||||||
|
let fallos = 0;
|
||||||
|
const check = (nombre, ok, detalle = "") => {
|
||||||
|
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
|
||||||
|
if (!ok) fallos++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navegador = await chromium.launch();
|
||||||
|
const pagina = await navegador.newPage();
|
||||||
|
const erroresConsola = [];
|
||||||
|
pagina.on("console", (m) => m.type() === "error" && erroresConsola.push(m.text()));
|
||||||
|
pagina.on("pageerror", (e) => erroresConsola.push(String(e)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pagina.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
||||||
|
await pagina.fill('input[type="email"]', EMAIL);
|
||||||
|
await pagina.fill('input[type="password"]', PASS);
|
||||||
|
await pagina.click('button[type="submit"]');
|
||||||
|
await pagina.waitForURL(/\/admin/, { timeout: 15000 });
|
||||||
|
check("entra como administración de plataforma", true, pagina.url());
|
||||||
|
|
||||||
|
await pagina.goto(`${BASE}/admin/cuentas`, { waitUntil: "networkidle" });
|
||||||
|
await pagina.waitForSelector("table", { timeout: 15000 });
|
||||||
|
|
||||||
|
const texto = await pagina.innerText("body");
|
||||||
|
check("se pinta la tabla de cuentas", /Cuentas de la plataforma/.test(texto));
|
||||||
|
check("aparece el negocio real", /Yola Franco Spa/.test(texto));
|
||||||
|
check("muestra la huella del token", /token …f89621|token \.\.\.f89621/.test(texto), "huella visible");
|
||||||
|
|
||||||
|
// Lo que NO puede pasar bajo ningún concepto.
|
||||||
|
const html = await pagina.content();
|
||||||
|
check("el token completo NO está en el DOM", !/pit-/i.test(html) && !html.includes("f89621f"), "");
|
||||||
|
|
||||||
|
// El modal de vínculo: el campo del token debe ser de contraseña y venir vacío.
|
||||||
|
await pagina.click("text=Cambiar token");
|
||||||
|
await pagina.waitForSelector("#cr-tok", { timeout: 8000 });
|
||||||
|
const tipo = await pagina.getAttribute("#cr-tok", "type");
|
||||||
|
const valor = await pagina.inputValue("#cr-tok");
|
||||||
|
check("el campo del token es de contraseña", tipo === "password", `type=${tipo}`);
|
||||||
|
check("y viene vacío: no hay token que traer", valor === "", `valor=«${valor}»`);
|
||||||
|
const locId = await pagina.inputValue("#cr-loc");
|
||||||
|
check("la subcuenta sí se prerrellena", locId === "Pk89Wa23QaxvkOfKgwjZ", locId);
|
||||||
|
|
||||||
|
// Etiquetas asociadas a su control.
|
||||||
|
const sinLabel = await pagina.$$eval("#cr-tok, #cr-loc", (els) =>
|
||||||
|
els.filter((el) => !document.querySelector(`label[for="${el.id}"]`)).map((el) => el.id)
|
||||||
|
);
|
||||||
|
check("cada campo tiene su etiqueta asociada", sinLabel.length === 0, sinLabel.join(", "));
|
||||||
|
|
||||||
|
check("sin errores de consola", erroresConsola.length === 0, erroresConsola.slice(0, 2).join(" | "));
|
||||||
|
|
||||||
|
await pagina.screenshot({ path: "screenshots/admin-cuentas.png", fullPage: true });
|
||||||
|
console.log("\n captura en screenshots/admin-cuentas.png");
|
||||||
|
} finally {
|
||||||
|
await navegador.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${fallos === 0 ? "Todo en verde" : `${fallos} comprobaciones fallaron`}`);
|
||||||
|
process.exit(fallos === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Conecta el negocio de la plataforma con su subcuenta de Bucéfalo CRM y
|
||||||
|
* autodetecta pipeline y etapas.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts [slug]
|
||||||
|
*/
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { loadEnv, requireEnv } from "../lib/env.ts";
|
||||||
|
import { autoconfigurar } from "../crm/connection.ts";
|
||||||
|
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slug = process.argv[2] || "yola-franco";
|
||||||
|
const locationId = requireEnv("CRM_LOCATION_ID");
|
||||||
|
|
||||||
|
const b = await pool.query<{ id: number; name: string }>(
|
||||||
|
`SELECT id, name FROM businesses WHERE slug = $1`,
|
||||||
|
[slug]
|
||||||
|
);
|
||||||
|
if (!b.rows[0]) {
|
||||||
|
console.error(`No existe el negocio con slug «${slug}»`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El script vincula la subcuenta configurada en el entorno: guarda la
|
||||||
|
// credencial cifrada y después autodetecta pipeline y etapas. Antes solo
|
||||||
|
// hacía lo segundo, porque el token era global.
|
||||||
|
await guardarCredencial(b.rows[0].id, locationId, ctxDesdeEnv().token);
|
||||||
|
const c = await autoconfigurar(await ctxDe(b.rows[0].id));
|
||||||
|
console.log(`Conectado «${b.rows[0].name}» ↔ subcuenta ${c.location_id}`);
|
||||||
|
console.log(` pipeline : ${c.pipeline_id}`);
|
||||||
|
console.log(` etapa «en espera» : ${c.stage_open_id}`);
|
||||||
|
console.log(` etapa «ganado» : ${c.stage_won_id}`);
|
||||||
|
console.log(` etapa «perdido» : ${c.stage_lost_id}`);
|
||||||
|
console.log(` permite duplicados : ${c.allow_duplicate_opp}`);
|
||||||
|
console.log(` nota : ${c.last_sync_status}`);
|
||||||
|
if (!c.allow_duplicate_opp) {
|
||||||
|
console.log(
|
||||||
|
`\n >> Con «allow duplicate opportunity» desactivado, cada clienta tiene UNA\n` +
|
||||||
|
` oportunidad que se recicla en cada cita. Para que cada cita estrene la\n` +
|
||||||
|
` suya, hay que activar ese ajuste en la UI del CRM.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Borra del CRM lo que crearon los spikes.
|
||||||
|
*
|
||||||
|
* Los spikes escriben en la subcuenta REAL del cliente. Todo lo que crean lleva
|
||||||
|
* el tag `agendamax:prueba` y el correo autorizado; esto lo busca por ese tag y
|
||||||
|
* lo elimina, para no dejar basura en la base de un negocio en producción.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts (lista)
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar (borra)
|
||||||
|
*/
|
||||||
|
import { loadEnv, requireEnv } from "../lib/env.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest } from "../crm/client.ts";
|
||||||
|
import { oportunidadesDeContacto } from "../crm/opportunities.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
const CORREO = process.env.CRM_TEST_EMAIL || "[email protected]";
|
||||||
|
const BORRAR = process.argv.includes("--borrar");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const r = await crmRequest<any>("GET", "/contacts/", { token: CTX.token,
|
||||||
|
query: { locationId: LOC, query: CORREO, limit: 20 },
|
||||||
|
});
|
||||||
|
const contactos: any[] = (r?.contacts ?? []).filter(
|
||||||
|
(c: any) =>
|
||||||
|
(c.email ?? "").toLowerCase() === CORREO.toLowerCase() ||
|
||||||
|
(c.tags ?? []).includes("agendamax:prueba")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contactos.length) {
|
||||||
|
console.log("No hay contactos de prueba en la subcuenta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of contactos) {
|
||||||
|
console.log(`\nContacto ${c.id} — ${c.contactName ?? c.firstName} <${c.email}>`);
|
||||||
|
console.log(` tags: ${(c.tags ?? []).join(", ") || "(ninguno)"}`);
|
||||||
|
|
||||||
|
const opps = await oportunidadesDeContacto(CTX, c.id);
|
||||||
|
for (const o of opps) {
|
||||||
|
console.log(` oportunidad ${o.id} — «${o.name}» (${o.status})`);
|
||||||
|
if (BORRAR) {
|
||||||
|
await crmRequest("DELETE", `/opportunities/${o.id}`, { token: CTX.token });
|
||||||
|
console.log(" borrada");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BORRAR) {
|
||||||
|
await crmRequest("DELETE", `/contacts/${c.id}`, { token: CTX.token });
|
||||||
|
console.log(" contacto borrado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BORRAR) {
|
||||||
|
console.log("\n(Solo listado. Añade --borrar para eliminarlos de verdad.)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Mueve la credencial de `platform/.env` a la base, cifrada, para un negocio
|
||||||
|
* que ya estaba vinculado.
|
||||||
|
*
|
||||||
|
* Es de un solo uso por negocio: después, las credenciales se ponen desde la
|
||||||
|
* consola de administración (`PUT /api/admin/businesses/:id/crm`).
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
|
||||||
|
*/
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { crmRequest } from "../crm/client.ts";
|
||||||
|
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
|
||||||
|
|
||||||
|
const businessId = Number(process.argv[2]);
|
||||||
|
if (!Number.isFinite(businessId)) {
|
||||||
|
console.error("Uso: node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const desdeEnv = ctxDesdeEnv(businessId);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [businessId]);
|
||||||
|
if (!rows[0]) {
|
||||||
|
console.error(`No existe el negocio ${businessId}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Negocio ${businessId}: ${rows[0].name}`);
|
||||||
|
|
||||||
|
// Se comprueba la credencial ANTES de guardarla, igual que hace la consola.
|
||||||
|
const loc = await crmRequest<any>("GET", `/locations/${desdeEnv.locationId}`, {
|
||||||
|
token: desdeEnv.token,
|
||||||
|
});
|
||||||
|
const nombre = loc?.location?.name ?? null;
|
||||||
|
if (loc?.location?.id && loc.location.id !== desdeEnv.locationId) {
|
||||||
|
console.error("El token pertenece a otra subcuenta distinta de CRM_LOCATION_ID");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Subcuenta verificada: ${nombre} (${desdeEnv.locationId})`);
|
||||||
|
|
||||||
|
await guardarCredencial(businessId, desdeEnv.locationId, desdeEnv.token, nombre ?? undefined);
|
||||||
|
|
||||||
|
// No se acepta el guardado como prueba: se relee de la base, se descifra y se
|
||||||
|
// USA contra el CRM. Es la única forma de saber que el ciclo entero funciona.
|
||||||
|
const ctx = await ctxDe(businessId);
|
||||||
|
const rel = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, { token: ctx.token });
|
||||||
|
|
||||||
|
if (rel?.location?.id === desdeEnv.locationId) {
|
||||||
|
const { rows: f } = await pool.query(
|
||||||
|
`SELECT token_fingerprint, label FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`\n✔ Credencial cifrada, releída de la base y verificada contra el CRM.` +
|
||||||
|
`\n etiqueta: ${f[0].label}` +
|
||||||
|
`\n huella : …${f[0].token_fingerprint}` +
|
||||||
|
`\n\nYa puedes quitar CRM_TOKEN de platform/.env para este negocio.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("✖ La relectura no coincide: la credencial guardada no sirve");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* ¿Se pueden BORRAR citas y servicios? Sin crear nada.
|
||||||
|
*
|
||||||
|
* Se pregunta antes de escribir, no después: si el borrado no existe o el token
|
||||||
|
* no lo tiene, cualquier prueba de escritura dejaría basura permanente en el CRM
|
||||||
|
* de un cliente real. Se manda un DELETE contra un id inventado y se mira el
|
||||||
|
* error:
|
||||||
|
*
|
||||||
|
* 401 «not authorized for this scope» → no hay permiso de borrado
|
||||||
|
* 404 / 422 → el permiso está; solo falta el id real
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts
|
||||||
|
*/
|
||||||
|
import { requireEnv, loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com";
|
||||||
|
const TOKEN = requireEnv("CRM_TOKEN");
|
||||||
|
|
||||||
|
async function sonda(method: string, path: string, version: string) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${TOKEN}`,
|
||||||
|
version,
|
||||||
|
accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const texto = (await res.text()).slice(0, 260);
|
||||||
|
const sinPermiso = res.status === 401;
|
||||||
|
console.log(`\n── ${method} ${path}`);
|
||||||
|
console.log(
|
||||||
|
` ${sinPermiso ? "✖ SIN PERMISO DE BORRADO" : "✔ EL BORRADO EXISTE (falla por el id, no por el token)"}`
|
||||||
|
);
|
||||||
|
console.log(` ${res.status} · ${texto}`);
|
||||||
|
return !sinPermiso;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_FALSO = "idQueNoExisteJamas123";
|
||||||
|
|
||||||
|
const cita = await sonda("DELETE", `/calendars/events/${ID_FALSO}`, "v3");
|
||||||
|
const servicio = await sonda("DELETE", `/calendars/services/catalog/${ID_FALSO}`, "v3");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n────────────\ncitas: ${cita ? "se pueden borrar" : "NO se pueden borrar"} · ` +
|
||||||
|
`servicios: ${servicio ? "se pueden borrar" : "NO se pueden borrar"}`
|
||||||
|
);
|
||||||
|
if (!cita || !servicio) {
|
||||||
|
console.log(
|
||||||
|
"\nNo se debe ejercer la escritura de lo que no se pueda deshacer en la\n" +
|
||||||
|
"subcuenta de un cliente real."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* ¿Hay citas en los calendarios de la subcuenta? Solo lectura.
|
||||||
|
*
|
||||||
|
* El sondeo anterior miró únicamente el PRIMER calendario, que resultó ser uno
|
||||||
|
* personal, y concluir «no hay citas» a partir de eso habría sido un error: el
|
||||||
|
* que importa es «Servicio Spa». Aquí se recorren los SIETE, y se prueban las
|
||||||
|
* dos formas de acotar que admite la API (por calendario y por usuario).
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts
|
||||||
|
*/
|
||||||
|
import { crmRequest, CrmError, VERSION_CALENDARS } from "../crm/client.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { requireEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
const DIA = 86400000;
|
||||||
|
|
||||||
|
async function eventos(query: Record<string, string | number>): Promise<any[] | string> {
|
||||||
|
try {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
|
||||||
|
query,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return r?.events ?? [];
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof CrmError) {
|
||||||
|
const cuerpo = typeof e.body === "string" ? e.body : JSON.stringify(e.body);
|
||||||
|
return `${e.status}: ${String(e.message).slice(0, 120)}${cuerpo ? ` | ${cuerpo.slice(0, 160)}` : ""}`;
|
||||||
|
}
|
||||||
|
return String(e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/", { token: CTX.token,
|
||||||
|
query: { locationId: LOC },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
const cals: any[] = r?.calendars ?? [];
|
||||||
|
console.log(`${cals.length} calendarios en la subcuenta ${LOC}\n`);
|
||||||
|
|
||||||
|
const ahora = Date.now();
|
||||||
|
// Ventana amplia: dos años hacia atrás y uno hacia adelante.
|
||||||
|
const desde = String(ahora - 730 * DIA);
|
||||||
|
const hasta = String(ahora + 365 * DIA);
|
||||||
|
|
||||||
|
let totalEventos = 0;
|
||||||
|
for (const c of cals) {
|
||||||
|
const res = await eventos({
|
||||||
|
locationId: LOC,
|
||||||
|
calendarId: c.id,
|
||||||
|
startTime: desde,
|
||||||
|
endTime: hasta,
|
||||||
|
});
|
||||||
|
const activo = c.isActive === false ? " (inactivo)" : "";
|
||||||
|
if (typeof res === "string") {
|
||||||
|
console.log(` ✖ ${String(c.name).padEnd(42)}${activo} → ${res}`);
|
||||||
|
} else {
|
||||||
|
totalEventos += res.length;
|
||||||
|
const marca = res.length ? "✔" : "·";
|
||||||
|
console.log(` ${marca} ${String(c.name).padEnd(42)}${activo} → ${res.length} citas`);
|
||||||
|
if (res.length) {
|
||||||
|
const e = res[0];
|
||||||
|
console.log(` ejemplo: ${JSON.stringify(e).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTotal de citas en los 7 calendarios (2 años atrás → 1 adelante): ${totalEventos}`);
|
||||||
|
|
||||||
|
// La otra forma de acotar que documenta la API: por usuario en vez de por
|
||||||
|
// calendario. Si por calendario no sale nada, conviene descartar que las
|
||||||
|
// citas cuelguen de un usuario y no de un calendario.
|
||||||
|
console.log("\n── Prueba alterna: acotar por usuario en vez de por calendario");
|
||||||
|
const porUsuario = await eventos({
|
||||||
|
locationId: LOC,
|
||||||
|
userId: "x",
|
||||||
|
startTime: desde,
|
||||||
|
endTime: hasta,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
typeof porUsuario === "string"
|
||||||
|
? ` respuesta: ${porUsuario}`
|
||||||
|
: ` ${porUsuario.length} citas`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n── ¿Y sin acotar por calendario ni usuario?");
|
||||||
|
const sinFiltro = await eventos({ locationId: LOC, startTime: desde, endTime: hasta });
|
||||||
|
console.log(
|
||||||
|
typeof sinFiltro === "string"
|
||||||
|
? ` respuesta: ${sinFiltro}`
|
||||||
|
: ` ${sinFiltro.length} citas`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Se detuvo:", e?.message ?? e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* MEDIDO: el CRM rechaza una segunda oportunidad para el mismo contacto aunque
|
||||||
|
* la primera esté en `won` (400 OPPORTUNITY_NO_DUPLICATE).
|
||||||
|
*
|
||||||
|
* Antes de rediseñar el mapeo hay que saber si eso es un límite duro o un
|
||||||
|
* ajuste de la subcuenta que el cliente puede cambiar. Tres sondeos:
|
||||||
|
* a) ¿el ajuste aparece en la ficha de la subcuenta?
|
||||||
|
* b) ¿el bloqueo es por (contacto, pipeline) o global por contacto?
|
||||||
|
* c) ¿el buscador de oportunidades permite recuperar la existente para
|
||||||
|
* reciclarla? — es el plan B, y tiene que funcionar sí o sí.
|
||||||
|
*/
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest } from "../crm/client.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const LOC = process.env.CRM_LOCATION_ID!;
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
const CONTACTO = "WzBTBaHkNnpmjMb1Avx3";
|
||||||
|
|
||||||
|
const ok = (s: string) => console.log(` ✔ ${s}`);
|
||||||
|
const fail = (s: string) => console.log(` ✗ ${s}`);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("── a) ¿La ficha de la subcuenta expone algún ajuste de duplicados?");
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("GET", `/locations/${LOC}`, { token: CTX.token });
|
||||||
|
const loc = r?.location ?? {};
|
||||||
|
const claves = Object.keys(loc).sort();
|
||||||
|
console.log(" claves:", claves.join(", "));
|
||||||
|
const settings = loc.settings ?? null;
|
||||||
|
console.log(" settings:", JSON.stringify(settings, null, 2));
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── b) ¿Se pueden listar los pipelines y crear uno propio de spa?");
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("POST", "/opportunities/pipelines", { token: CTX.token,
|
||||||
|
body: { locationId: LOC, name: "AgendaMax — Agenda Spa (prueba)" },
|
||||||
|
});
|
||||||
|
ok(`pipeline creado id=${r?.pipeline?.id ?? JSON.stringify(r).slice(0, 200)}`);
|
||||||
|
console.log(" >> Se puede rediseñar el pipeline a etapas de spa desde la plataforma.");
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
|
||||||
|
console.log(" >> El token no tiene `pipelines.create`; el pipeline se rediseña a mano.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── c) PLAN B: recuperar la oportunidad existente para reciclarla");
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("GET", "/opportunities/search", { token: CTX.token,
|
||||||
|
query: { location_id: LOC, contact_id: CONTACTO, limit: 20 },
|
||||||
|
});
|
||||||
|
const opps = r?.opportunities ?? [];
|
||||||
|
ok(`el buscador devuelve ${opps.length} oportunidad(es) del contacto`);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
opps.map((o: any) => ({
|
||||||
|
id: o.id,
|
||||||
|
name: o.name,
|
||||||
|
status: o.status,
|
||||||
|
monetaryValue: o.monetaryValue,
|
||||||
|
pipelineId: o.pipelineId,
|
||||||
|
pipelineStageId: o.pipelineStageId,
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(" >> Con esto se puede localizar y actualizar la existente en vez de crear.");
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── d) ¿El PUT general permite renombrar y cambiar el importe (reciclar)?");
|
||||||
|
const OPP = "IMkYdAkBowggN9aKVbfc";
|
||||||
|
try {
|
||||||
|
const nuevoNombre = `Pedicura — Prueba AgendaMax (reciclada ${Date.now()})`;
|
||||||
|
await crmRequest("PUT", `/opportunities/${OPP}`, { token: CTX.token,
|
||||||
|
body: { pipelineId: "Mrclt4VzRZV1DI4Vbt5c", name: nuevoNombre, monetaryValue: 400 },
|
||||||
|
});
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||||
|
const o = r?.opportunity;
|
||||||
|
if (o?.name === nuevoNombre && o?.monetaryValue === 400) {
|
||||||
|
ok(`releído: nombre e importe reciclados (status sigue «${o.status}»)`);
|
||||||
|
console.log(" >> PLAN B VIABLE: la oportunidad se reutiliza por clienta.");
|
||||||
|
} else {
|
||||||
|
fail(`al releer nombre=${o?.name} importe=${o?.monetaryValue}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── e) ¿Y volver a abrirla (open) tras haberla cerrado?");
|
||||||
|
try {
|
||||||
|
await crmRequest("PUT", `/opportunities/${OPP}/status`, { token: CTX.token, body: { status: "open" } });
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||||
|
if (r?.opportunity?.status === "open") ok("sí: una oportunidad cerrada se puede reabrir");
|
||||||
|
else fail(`al releer status=${r?.opportunity?.status}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Ejerce por primera vez la ESCRITURA de citas y servicios contra el CRM real.
|
||||||
|
*
|
||||||
|
* Ciclo completo y autolimpiable: crear → RELEER → borrar → confirmar que ya no
|
||||||
|
* está. Nada queda en la subcuenta del cliente aunque el script falle a mitad:
|
||||||
|
* lo creado se registra y se borra en el `finally`.
|
||||||
|
*
|
||||||
|
* Usa las funciones de producción (`crearCita`, `obtenerCita`, `publicarServicio`)
|
||||||
|
* y no una implementación paralela: lo que se valida aquí es el código que va a
|
||||||
|
* correr, no un primo suyo.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts
|
||||||
|
*/
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { crmRequest, VERSION_CALENDARS } from "../crm/client.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import {
|
||||||
|
crearCita,
|
||||||
|
obtenerCita,
|
||||||
|
listarCalendarios,
|
||||||
|
listarPersonal,
|
||||||
|
isoConDesplazamiento,
|
||||||
|
} from "../crm/calendars.ts";
|
||||||
|
import { catalogoDelCrm } from "../crm/services.ts";
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv(1);
|
||||||
|
const CONTACTO_PRUEBA = "WzBTBaHkNnpmjMb1Avx3"; // el que ya lleva el tag agendamax:prueba
|
||||||
|
const TZ = "America/Mexico_City";
|
||||||
|
|
||||||
|
const creados: { que: string; id: string; ruta: string }[] = [];
|
||||||
|
let spaId = "";
|
||||||
|
let fallos = 0;
|
||||||
|
|
||||||
|
const check = (nombre: string, ok: boolean, detalle = "") => {
|
||||||
|
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
|
||||||
|
if (!ok) fallos++;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// ── CITA ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log("\n══ ESCRITURA DE CITA AL CALENDARIO ══");
|
||||||
|
|
||||||
|
const cals = await listarCalendarios(CTX);
|
||||||
|
const spa = cals.find((c) => /servicio spa/i.test(c.name)) ?? cals[0];
|
||||||
|
spaId = spa.id;
|
||||||
|
console.log(` calendario: ${spa.name} (${spa.id})`);
|
||||||
|
|
||||||
|
const personal = await listarPersonal(CTX);
|
||||||
|
const quien = personal.find((p) => /recepci/i.test(p.name)) ?? personal[0];
|
||||||
|
console.log(` asignada a: ${quien.name} (${quien.id})`);
|
||||||
|
|
||||||
|
// Una fecha lejana y a una hora inequívoca, para que se distinga de lo real.
|
||||||
|
const inicio = new Date(Date.now() + 120 * 86400000);
|
||||||
|
inicio.setUTCHours(20, 0, 0, 0); // 14:00 en México
|
||||||
|
const fin = new Date(inicio.getTime() + 45 * 60000);
|
||||||
|
|
||||||
|
const startTime = isoConDesplazamiento(inicio, TZ);
|
||||||
|
const endTime = isoConDesplazamiento(fin, TZ);
|
||||||
|
console.log(` ventana : ${startTime} → ${endTime}`);
|
||||||
|
|
||||||
|
const { id: eventoId } = await crearCita(CTX, {
|
||||||
|
calendarId: spa.id,
|
||||||
|
contactId: CONTACTO_PRUEBA,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
title: "[agendamax:prueba] Cita de verificación — BORRAR",
|
||||||
|
assignedUserId: quien.id,
|
||||||
|
appointmentStatus: "confirmed",
|
||||||
|
});
|
||||||
|
creados.push({ que: "cita", id: eventoId, ruta: `/calendars/events/${eventoId}` });
|
||||||
|
check("el CRM aceptó la cita y devolvió id", Boolean(eventoId), eventoId);
|
||||||
|
|
||||||
|
// No se acepta el 200 como prueba: se relee.
|
||||||
|
const releida = await obtenerCita(CTX, eventoId);
|
||||||
|
check("la cita existe al releerla", Boolean(releida));
|
||||||
|
check(
|
||||||
|
"el contacto es el correcto",
|
||||||
|
releida?.contactId === CONTACTO_PRUEBA,
|
||||||
|
releida?.contactId ?? "(sin contacto)"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"la hora de pared coincide con la que escribimos",
|
||||||
|
String(releida?.startTime ?? "").startsWith(startTime.slice(0, 16)),
|
||||||
|
`escrita ${startTime} · releída ${releida?.startTime}`
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"el estado quedó confirmado",
|
||||||
|
releida?.appointmentStatus === "confirmed",
|
||||||
|
String(releida?.appointmentStatus)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SERVICIO ──────────────────────────────────────────────────────────────
|
||||||
|
console.log("\n══ PUBLICACIÓN DE SERVICIO AL CATÁLOGO ══");
|
||||||
|
|
||||||
|
const antes = await catalogoDelCrm(CTX);
|
||||||
|
console.log(` catálogo antes: ${antes.length} servicios`);
|
||||||
|
|
||||||
|
const r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||||
|
token: CTX.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
body: {
|
||||||
|
locationId: CTX.locationId,
|
||||||
|
name: "[agendamax:prueba] Servicio de verificación",
|
||||||
|
slug: `agendamax-prueba-${Date.now()}`,
|
||||||
|
serviceDuration: 45,
|
||||||
|
serviceDurationUnit: "mins",
|
||||||
|
staff: [{ id: quien.id }],
|
||||||
|
variations: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const servicioId = r?.service?.id ?? r?.id;
|
||||||
|
if (servicioId) {
|
||||||
|
creados.push({
|
||||||
|
que: "servicio",
|
||||||
|
id: servicioId,
|
||||||
|
ruta: `/calendars/services/catalog/${servicioId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
check("el CRM aceptó el servicio y devolvió id", Boolean(servicioId), String(servicioId));
|
||||||
|
|
||||||
|
const despues = await catalogoDelCrm(CTX);
|
||||||
|
check(
|
||||||
|
"el servicio aparece al releer el catálogo",
|
||||||
|
despues.some((s) => s.id === servicioId),
|
||||||
|
`${antes.length} → ${despues.length} servicios`
|
||||||
|
);
|
||||||
|
const nuevo = despues.find((s) => s.id === servicioId);
|
||||||
|
check("con la duración que le pusimos", nuevo?.serviceDuration === 45, String(nuevo?.serviceDuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (e: any) {
|
||||||
|
fallos++;
|
||||||
|
console.error("\n✖ El sondeo falló:", e?.error ?? e?.message ?? e);
|
||||||
|
if (e?.body) console.error(" cuerpo:", JSON.stringify(e.body).slice(0, 400));
|
||||||
|
} finally {
|
||||||
|
// ── LIMPIEZA: pase lo que pase, no se deja nada en la subcuenta ───────────
|
||||||
|
console.log("\n══ LIMPIEZA ══");
|
||||||
|
for (const c of creados) {
|
||||||
|
try {
|
||||||
|
await crmRequest("DELETE", c.ruta, { token: CTX.token, version: VERSION_CALENDARS });
|
||||||
|
console.log(` ✔ ${c.que} ${c.id} borrada`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fallos++;
|
||||||
|
console.error(` ✖ NO se pudo borrar ${c.que} ${c.id}: ${e?.message ?? e}`);
|
||||||
|
console.error(` BÓRRALA A MANO en el CRM.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Se confirma el borrado releyendo, no fiándose del 200.
|
||||||
|
for (const c of creados) {
|
||||||
|
if (c.que === "cita") {
|
||||||
|
// MEDIDO: `GET /calendars/events/appointments/{id}` SIGUE devolviendo la
|
||||||
|
// cita después de borrarla — es un borrado lógico. La comprobación fiable
|
||||||
|
// es listar el rango del calendario, donde ya no aparece.
|
||||||
|
const ahora = Date.now();
|
||||||
|
const ev = await crmRequest<any>("GET", "/calendars/events", {
|
||||||
|
token: CTX.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
query: {
|
||||||
|
locationId: CTX.locationId,
|
||||||
|
calendarId: spaId,
|
||||||
|
startTime: String(ahora),
|
||||||
|
endTime: String(ahora + 365 * 86400000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sigue = (ev?.events ?? []).some((e: any) => e.id === c.id);
|
||||||
|
check("la cita ya no aparece en el calendario", !sigue);
|
||||||
|
} else {
|
||||||
|
const cat = await catalogoDelCrm(CTX);
|
||||||
|
check("el servicio ya no está en el catálogo", !cat.some((s) => s.id === c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
console.log(`\n${fallos === 0 ? "Todo en verde y la subcuenta queda limpia" : `${fallos} fallos`}`);
|
||||||
|
process.exit(fallos === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Sondeo de SOLO LECTURA de las rutas que hacen falta para sincronizar por id.
|
||||||
|
*
|
||||||
|
* El objetivo es «traer por id» contactos, conversaciones, mensajes, citas y
|
||||||
|
* servicios. De esas cinco, solo contactos está ejercida hoy (HALLAZGOS 1-23);
|
||||||
|
* el resto está sin medir, y en este proyecto lo medido gana a lo documentado.
|
||||||
|
*
|
||||||
|
* NO escribe nada en la subcuenta. Todas las peticiones son GET.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts
|
||||||
|
*/
|
||||||
|
import { crmRequest, CrmError, VERSION_CALENDARS } from "../crm/client.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { requireEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
let fail = 0;
|
||||||
|
|
||||||
|
async function probe(titulo: string, fn: () => Promise<string>) {
|
||||||
|
process.stdout.write(`\n── ${titulo}\n`);
|
||||||
|
try {
|
||||||
|
const detalle = await fn();
|
||||||
|
ok++;
|
||||||
|
console.log(` ✔ ${detalle}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail++;
|
||||||
|
if (e instanceof CrmError) {
|
||||||
|
const cuerpo = typeof e.body === "string" ? e.body.slice(0, 200) : JSON.stringify(e.body)?.slice(0, 300);
|
||||||
|
console.log(` ✖ ${e.status} — ${e.message}`);
|
||||||
|
if (cuerpo && cuerpo !== "null") console.log(` cuerpo: ${cuerpo}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✖ ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const claves = (o: unknown, n = 14) =>
|
||||||
|
o && typeof o === "object" ? Object.keys(o as object).slice(0, n).join(", ") : String(o);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Subcuenta ${LOC} — sondeo de lectura por id (sin escrituras)`);
|
||||||
|
|
||||||
|
// ── CONVERSACIONES ────────────────────────────────────────────────────────
|
||||||
|
let convId = "";
|
||||||
|
let contactoDeConv = "";
|
||||||
|
await probe("GET /conversations/search — listar para obtener un id real", async () => {
|
||||||
|
const r = await crmRequest<any>("GET", "/conversations/search", { token: CTX.token,
|
||||||
|
query: { locationId: LOC, limit: 3 },
|
||||||
|
});
|
||||||
|
const c = r?.conversations?.[0];
|
||||||
|
if (!c) throw new Error("no devolvió ninguna conversación");
|
||||||
|
convId = c.id;
|
||||||
|
contactoDeConv = c.contactId ?? "";
|
||||||
|
return `total=${r.total} · primera id=${convId} · campos: ${claves(c)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /conversations/{id} — traer UNA conversación por su id", async () => {
|
||||||
|
if (!convId) throw new Error("sin id de conversación");
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/${convId}`, { token: CTX.token });
|
||||||
|
const c = r?.conversation ?? r;
|
||||||
|
return `campos: ${claves(c)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /conversations/search?contactId= — conversaciones de un contacto", async () => {
|
||||||
|
if (!contactoDeConv) throw new Error("la conversación no traía contactId");
|
||||||
|
const r = await crmRequest<any>("GET", "/conversations/search", { token: CTX.token,
|
||||||
|
query: { locationId: LOC, contactId: contactoDeConv, limit: 5 },
|
||||||
|
});
|
||||||
|
return `contacto ${contactoDeConv} → ${r?.conversations?.length ?? 0} conversaciones (total=${r?.total})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MENSAJES ──────────────────────────────────────────────────────────────
|
||||||
|
let msgId = "";
|
||||||
|
await probe("GET /conversations/{id}/messages — mensajes del hilo", async () => {
|
||||||
|
if (!convId) throw new Error("sin id de conversación");
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/${convId}/messages`, { token: CTX.token,
|
||||||
|
query: { limit: 5 },
|
||||||
|
});
|
||||||
|
const lista = r?.messages?.messages ?? r?.messages ?? [];
|
||||||
|
msgId = lista[0]?.id ?? "";
|
||||||
|
return `${lista.length} mensajes · paginación: ${claves(r?.messages)} · campos del mensaje: ${claves(lista[0])}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /conversations/messages/{id} — traer UN mensaje por su id", async () => {
|
||||||
|
if (!msgId) throw new Error("sin id de mensaje");
|
||||||
|
const r = await crmRequest<any>("GET", `/conversations/messages/${msgId}`, { token: CTX.token });
|
||||||
|
return `campos: ${claves(r?.message ?? r)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CALENDARIOS Y CITAS ───────────────────────────────────────────────────
|
||||||
|
let calId = "";
|
||||||
|
await probe("GET /calendars/ — calendarios de la subcuenta", async () => {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/", { token: CTX.token,
|
||||||
|
query: { locationId: LOC },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
const cals: any[] = r?.calendars ?? [];
|
||||||
|
calId = cals[0]?.id ?? "";
|
||||||
|
return `${cals.length} calendarios: ${cals.map((c) => `${c.name}(${c.id})`).join(", ").slice(0, 260)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /calendars/events — citas en un rango de fechas", async () => {
|
||||||
|
if (!calId) throw new Error("sin calendario");
|
||||||
|
const ahora = Date.now();
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
|
||||||
|
query: {
|
||||||
|
locationId: LOC,
|
||||||
|
calendarId: calId,
|
||||||
|
startTime: String(ahora - 90 * 86400000),
|
||||||
|
endTime: String(ahora + 90 * 86400000),
|
||||||
|
},
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
const ev: any[] = r?.events ?? [];
|
||||||
|
return `${ev.length} eventos en ±90 días · campos: ${claves(ev[0])}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /calendars/events/appointments/{id} — traer UNA cita por id", async () => {
|
||||||
|
const ahora = Date.now();
|
||||||
|
if (!calId) throw new Error("sin calendario");
|
||||||
|
const lista = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
|
||||||
|
query: {
|
||||||
|
locationId: LOC,
|
||||||
|
calendarId: calId,
|
||||||
|
startTime: String(ahora - 365 * 86400000),
|
||||||
|
endTime: String(ahora + 365 * 86400000),
|
||||||
|
},
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
const id = lista?.events?.[0]?.id;
|
||||||
|
if (!id) throw new Error("no hay ninguna cita en ±365 días con la que probar");
|
||||||
|
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${id}`, { token: CTX.token,
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return `cita ${id} · campos: ${claves(r?.appointment ?? r)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SERVICIOS ─────────────────────────────────────────────────────────────
|
||||||
|
await probe("GET /calendars/services/catalog — catálogo de servicios", async () => {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/services/catalog", { token: CTX.token,
|
||||||
|
query: { locationId: LOC },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
const s: any[] = r?.services ?? [];
|
||||||
|
return `${s.length} servicios${s.length ? ` · campos: ${claves(s[0])}` : " (vacío, confirma el hallazgo 6)"}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /calendars/groups — agrupaciones de calendarios", async () => {
|
||||||
|
const r = await crmRequest<any>("GET", "/calendars/groups", { token: CTX.token,
|
||||||
|
query: { locationId: LOC },
|
||||||
|
version: VERSION_CALENDARS,
|
||||||
|
});
|
||||||
|
return `${r?.groups?.length ?? 0} grupos · ${claves(r?.groups?.[0])}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CONTACTO POR ID (ya medido; se reconfirma para tener la forma) ────────
|
||||||
|
await probe("GET /contacts/{id} — traer UN contacto por id", async () => {
|
||||||
|
const b = await crmRequest<any>("POST", "/contacts/search", { token: CTX.token,
|
||||||
|
body: { locationId: LOC, pageLimit: 1 },
|
||||||
|
});
|
||||||
|
const id = b?.contacts?.[0]?.id;
|
||||||
|
if (!id) throw new Error("el buscador no devolvió contactos");
|
||||||
|
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: CTX.token });
|
||||||
|
return `contacto ${id} · campos: ${claves(r?.contact, 20)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n────────────\n${ok} rutas responden · ${fail} fallan`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("\nEl sondeo se detuvo:", e?.message ?? e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* La pregunta que decide el mapeo cita → oportunidad:
|
||||||
|
*
|
||||||
|
* MEDIDO: `POST /opportunities/` devuelve 400 OPPORTUNITY_NO_DUPLICATE con
|
||||||
|
* `meta.existingId` cuando el contacto ya tiene una.
|
||||||
|
*
|
||||||
|
* ¿El bloqueo es sobre CUALQUIER oportunidad, o solo sobre las abiertas? De eso
|
||||||
|
* depende todo: una clienta de spa vuelve muchas veces, y si el CRM solo admite
|
||||||
|
* una oportunidad por contacto en toda su vida, entonces "una cita = una
|
||||||
|
* oportunidad" es un modelo imposible y hay que reciclar la misma fila.
|
||||||
|
*/
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest } from "../crm/client.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const LOC = process.env.CRM_LOCATION_ID!;
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
const PIPELINE = "Mrclt4VzRZV1DI4Vbt5c";
|
||||||
|
const ETAPA_PRIMERA = "8063839c-fa73-419a-8b16-9606fe8e64c1";
|
||||||
|
const ETAPA_GANADO = "b91c1653-e785-43a8-8b54-f493205c9b5a";
|
||||||
|
const CONTACTO = "WzBTBaHkNnpmjMb1Avx3"; // el de prueba, ya creado
|
||||||
|
const OPP = "IMkYdAkBowggN9aKVbfc";
|
||||||
|
|
||||||
|
const ok = (s: string) => console.log(` ✔ ${s}`);
|
||||||
|
const fail = (s: string) => console.log(` ✗ ${s}`);
|
||||||
|
|
||||||
|
async function crearOtra(nombre: string) {
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("POST", "/opportunities/", { token: CTX.token,
|
||||||
|
body: {
|
||||||
|
pipelineId: PIPELINE,
|
||||||
|
locationId: LOC,
|
||||||
|
name: nombre,
|
||||||
|
pipelineStageId: ETAPA_PRIMERA,
|
||||||
|
status: "open",
|
||||||
|
contactId: CONTACTO,
|
||||||
|
monetaryValue: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { creada: r?.opportunity?.id as string, error: null as any };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { creada: null, error: e };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("── 1. Cerrar la oportunidad existente como «won» (cita completada)");
|
||||||
|
await crmRequest("PUT", `/opportunities/${OPP}/status`, { token: CTX.token, body: { status: "won" } });
|
||||||
|
let r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||||
|
if (r?.opportunity?.status === "won") ok(`releído status=won, etapa=${r.opportunity.pipelineStageId}`);
|
||||||
|
else fail(`al releer status=${r?.opportunity?.status}`);
|
||||||
|
|
||||||
|
console.log("\n── 2. ¿El PUT general mueve la etapa a «Ganado»?");
|
||||||
|
try {
|
||||||
|
await crmRequest("PUT", `/opportunities/${OPP}`, { token: CTX.token,
|
||||||
|
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_GANADO },
|
||||||
|
});
|
||||||
|
r = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||||
|
if (r?.opportunity?.pipelineStageId === ETAPA_GANADO)
|
||||||
|
ok(`etapa movida a Ganado, status sigue en «${r.opportunity.status}»`);
|
||||||
|
else fail(`al releer etapa=${r?.opportunity?.pipelineStageId}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── 3. LA PREGUNTA: ¿deja crear otra con la anterior ya cerrada?");
|
||||||
|
const segunda = await crearOtra("Manicura — Prueba AgendaMax (segunda visita)");
|
||||||
|
if (segunda.creada) {
|
||||||
|
ok(`SÍ — creada id=${segunda.creada}`);
|
||||||
|
console.log(" >> El bloqueo es solo sobre oportunidades ABIERTAS.");
|
||||||
|
console.log(" >> Modelo viable: una cita = una oportunidad, cerrando la previa.");
|
||||||
|
|
||||||
|
console.log("\n── 4. Y con esta abierta, ¿rechaza una tercera?");
|
||||||
|
const tercera = await crearOtra("Pedicura — Prueba AgendaMax (tercera)");
|
||||||
|
if (tercera.creada) {
|
||||||
|
console.log(` ✔ también la creó (id=${tercera.creada})`);
|
||||||
|
console.log(" >> Entonces el rechazo anterior fue por nombre/importe idénticos.");
|
||||||
|
console.log(` Limpieza extra: DELETE /opportunities/${tercera.creada}`);
|
||||||
|
} else {
|
||||||
|
ok(`rechazada como se esperaba: ${tercera.error?.body?.code ?? tercera.error?.message}`);
|
||||||
|
console.log(" >> CONFIRMADO: máximo UNA oportunidad abierta por contacto.");
|
||||||
|
}
|
||||||
|
console.log(`\n Limpieza: DELETE /opportunities/${segunda.creada}`);
|
||||||
|
} else {
|
||||||
|
fail(`NO — ${segunda.error?.body?.code ?? segunda.error?.message}`);
|
||||||
|
console.log(" >> Grave: un contacto solo puede tener UNA oportunidad en toda su vida.");
|
||||||
|
console.log(" >> Entonces la oportunidad no puede representar una cita, sino la");
|
||||||
|
console.log(" relación con la clienta, y se recicla en cada visita.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── 5. Oportunidades del contacto, tal como las ve el CRM");
|
||||||
|
try {
|
||||||
|
const l: any = await crmRequest("GET", `/contacts/${CONTACTO}/opportunities`, { token: CTX.token });
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
(l?.opportunities ?? []).map((o: any) => ({
|
||||||
|
id: o.id,
|
||||||
|
name: o.name,
|
||||||
|
status: o.status,
|
||||||
|
monetaryValue: o.monetaryValue,
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* ¿Tiene el token permiso de ESCRITURA sobre calendarios y servicios?
|
||||||
|
*
|
||||||
|
* Sin crear nada. El truco: se manda un POST deliberadamente incompleto y se
|
||||||
|
* mira QUÉ error vuelve.
|
||||||
|
*
|
||||||
|
* 401 «not authorized for this scope» → falta el permiso
|
||||||
|
* 400 / 422 sobre campos → el permiso está, lo que falla es el cuerpo
|
||||||
|
*
|
||||||
|
* Distinguir esas dos cosas es justo lo que hace falta para saber si se puede
|
||||||
|
* planificar la escritura de citas al calendario del CRM, y no cuesta un solo
|
||||||
|
* registro basura en la subcuenta del cliente.
|
||||||
|
*
|
||||||
|
* De paso lee las cabeceras X-RateLimit-*, que hoy no lee nadie: el intervalo de
|
||||||
|
* 650 ms del cliente es una estimación observada, no una cuota conocida.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts
|
||||||
|
*/
|
||||||
|
import { requireEnv, loadEnv } from "../lib/env.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com";
|
||||||
|
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||||
|
const TOKEN = requireEnv("CRM_TOKEN");
|
||||||
|
|
||||||
|
async function crudo(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
version: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<{ status: number; texto: string; headers: Record<string, string> }> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${TOKEN}`,
|
||||||
|
version,
|
||||||
|
accept: "application/json",
|
||||||
|
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
res.headers.forEach((v, k) => {
|
||||||
|
if (k.toLowerCase().startsWith("x-ratelimit")) headers[k] = v;
|
||||||
|
});
|
||||||
|
return { status: res.status, texto: await res.text(), headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function veredicto(status: number, texto: string): string {
|
||||||
|
if (status === 401 && /not authorized for this scope/i.test(texto)) {
|
||||||
|
return "✖ FALTA EL PERMISO";
|
||||||
|
}
|
||||||
|
if (status === 401) return "✖ 401 (token rechazado o sin permiso — ambiguo)";
|
||||||
|
if (status === 400 || status === 422) return "✔ EL PERMISO ESTÁ (rechaza por el cuerpo, no por el token)";
|
||||||
|
if (status >= 200 && status < 300) return "⚠ ACEPTÓ LA PETICIÓN — revisa si creó algo";
|
||||||
|
return `? ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Subcuenta ${LOC}\n`);
|
||||||
|
|
||||||
|
console.log("── ¿calendars/events.write? (POST incompleto a propósito)");
|
||||||
|
{
|
||||||
|
// Falta `startTime`, que es obligatorio. Si el permiso está, la API se queja
|
||||||
|
// del campo; si no está, se queja del token antes de mirar el cuerpo.
|
||||||
|
const r = await crudo("POST", "/calendars/events/appointments", "v3", {
|
||||||
|
locationId: LOC,
|
||||||
|
});
|
||||||
|
console.log(` ${veredicto(r.status, r.texto)}`);
|
||||||
|
console.log(` ${r.status} · ${r.texto.slice(0, 320)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── ¿calendars.write? (POST incompleto al catálogo de servicios)");
|
||||||
|
{
|
||||||
|
// Faltan `name`, `slug` y `staff[]`, todos obligatorios.
|
||||||
|
const r = await crudo("POST", "/calendars/services/catalog", "v3", {
|
||||||
|
locationId: LOC,
|
||||||
|
});
|
||||||
|
console.log(` ${veredicto(r.status, r.texto)}`);
|
||||||
|
console.log(` ${r.status} · ${r.texto.slice(0, 320)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── ¿users.readonly? (confirma el hallazgo 14)");
|
||||||
|
{
|
||||||
|
const r = await crudo("GET", `/users/?locationId=${LOC}`, "2021-07-28");
|
||||||
|
let n = 0;
|
||||||
|
try { n = JSON.parse(r.texto || "{}")?.users?.length ?? 0; } catch { n = 0; }
|
||||||
|
console.log(
|
||||||
|
r.status === 200
|
||||||
|
? ` ✔ RESPONDE 200 con ${n} usuarios — el hallazgo 14 (401) ha quedado obsoleto`
|
||||||
|
: ` ✖ ${r.status} · ${r.texto.slice(0, 160)}`
|
||||||
|
);
|
||||||
|
if (r.status === 200 && n) {
|
||||||
|
const us = JSON.parse(r.texto).users.slice(0, 8);
|
||||||
|
for (const u of us) console.log(` ${u.id} ${u.name ?? ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── Citas de un contacto: GET /contacts/{id}/appointments");
|
||||||
|
{
|
||||||
|
const b = await crudo("POST", "/contacts/search", "2021-07-28", {
|
||||||
|
locationId: LOC,
|
||||||
|
pageLimit: 1,
|
||||||
|
});
|
||||||
|
let id: string | undefined;
|
||||||
|
try { id = JSON.parse(b.texto || "{}")?.contacts?.[0]?.id; } catch { id = undefined; }
|
||||||
|
if (!id) {
|
||||||
|
console.log(" (no se pudo obtener un contacto de prueba)");
|
||||||
|
} else {
|
||||||
|
const r = await crudo("GET", `/contacts/${id}/appointments`, "2021-07-28");
|
||||||
|
console.log(` contacto ${id} → ${r.status} · ${r.texto.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n── Cabeceras de límite de tasa (nadie las lee hoy)");
|
||||||
|
{
|
||||||
|
const r = await crudo("GET", `/locations/${LOC}`, "2021-07-28");
|
||||||
|
const hs = Object.entries(r.headers);
|
||||||
|
if (!hs.length) {
|
||||||
|
console.log(" la respuesta no trae ninguna cabecera X-RateLimit-*");
|
||||||
|
} else {
|
||||||
|
for (const [k, v] of hs) console.log(` ${k}: ${v}`);
|
||||||
|
const max = Number(r.headers["x-ratelimit-max"]);
|
||||||
|
const ventana = Number(r.headers["x-ratelimit-interval-milliseconds"]);
|
||||||
|
if (max && ventana) {
|
||||||
|
console.log(
|
||||||
|
` → cuota real: ${max} peticiones / ${ventana} ms = 1 cada ${Math.ceil(ventana / max)} ms`
|
||||||
|
);
|
||||||
|
console.log(` → el cliente usa 650 ms; margen sin aprovechar: ${(650 / (ventana / max)).toFixed(1)}×`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Se detuvo:", e?.message ?? e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Spike de ESCRITURA contra Bucéfalo CRM. Escribe de verdad en la subcuenta del
|
||||||
|
* cliente, así que todo lo que crea lleva el tag `agendamax:prueba` y el correo
|
||||||
|
* autorizado, y al final imprime cómo borrarlo.
|
||||||
|
*
|
||||||
|
* Regla que gobierna este archivo: **un 200 no es prueba de nada**. Cada
|
||||||
|
* escritura se vuelve a leer desde la API antes de darla por buena. El proyecto
|
||||||
|
* hermano pasó meses creyendo que escribía porque los tests estaban escritos
|
||||||
|
* desde la implementación y no contra el contrato real.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts
|
||||||
|
*/
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest } from "../crm/client.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const LOC = process.env.CRM_LOCATION_ID!;
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
const CORREO = process.env.CRM_TEST_EMAIL || "[email protected]";
|
||||||
|
const PIPELINE = "Mrclt4VzRZV1DI4Vbt5c"; // "Standar", medido en el spike de lectura
|
||||||
|
const ETAPA_PRIMERA = "8063839c-fa73-419a-8b16-9606fe8e64c1"; // 1er Contacto
|
||||||
|
const ETAPA_GANADO = "b91c1653-e785-43a8-8b54-f493205c9b5a";
|
||||||
|
const ETAPA_PERDIDO = "04b28d7f-167b-4e97-af24-5a229f56b27f";
|
||||||
|
|
||||||
|
const marca = `agendamax-spike-${Date.now()}`;
|
||||||
|
|
||||||
|
function ok(s: string) {
|
||||||
|
console.log(` ✔ ${s}`);
|
||||||
|
}
|
||||||
|
function fail(s: string) {
|
||||||
|
console.log(` ✗ ${s}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Subcuenta ${LOC} · correo de prueba ${CORREO}\n`);
|
||||||
|
let contactId: string | null = null;
|
||||||
|
let oppId: string | null = null;
|
||||||
|
|
||||||
|
// ── 1. Crear contacto CON atribución UTM ────────────────────────────────
|
||||||
|
console.log("── POST /contacts/ (con attributionSource)");
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("POST", "/contacts/", { token: CTX.token,
|
||||||
|
body: {
|
||||||
|
locationId: LOC,
|
||||||
|
firstName: "Prueba",
|
||||||
|
lastName: "AgendaMax",
|
||||||
|
email: CORREO,
|
||||||
|
phone: "+524451052792",
|
||||||
|
country: "MX",
|
||||||
|
source: "AgendaMax",
|
||||||
|
tags: ["agendamax:prueba"],
|
||||||
|
attributionSource: {
|
||||||
|
sessionSource: "Referral",
|
||||||
|
utmSource: "agendamax",
|
||||||
|
utmMedium: "plataforma",
|
||||||
|
utmCampaign: marca,
|
||||||
|
campaign: marca, // hay que mandar los dos: /contacts/search descarta utmCampaign
|
||||||
|
medium: "form",
|
||||||
|
referrer: "https://agendamax.consultoriae3.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
contactId = r?.contact?.id ?? null;
|
||||||
|
ok(`creado id=${contactId}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status === 400 && e.body?.meta?.contactId) {
|
||||||
|
contactId = e.body.meta.contactId;
|
||||||
|
ok(`ya existía (400 con meta) id=${contactId} · campo=${e.body?.meta?.matchingField}`);
|
||||||
|
console.log(" >> El rechazo de duplicado del CRM funciona: es idempotencia real.");
|
||||||
|
} else {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. RELEER el contacto: ¿persistió la atribución? ────────────────────
|
||||||
|
console.log("\n── GET /contacts/{id} — relectura (¿persistió el UTM?)");
|
||||||
|
if (contactId) {
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("GET", `/contacts/${contactId}`, { token: CTX.token });
|
||||||
|
const c = r?.contact;
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
id: c?.id,
|
||||||
|
email: c?.email,
|
||||||
|
phone: c?.phone,
|
||||||
|
tags: c?.tags,
|
||||||
|
source: c?.source,
|
||||||
|
attributionSource: c?.attributionSource,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const utm = c?.attributionSource?.utmCampaign || c?.attributionSource?.campaign;
|
||||||
|
if (utm === marca) ok("la atribución persistió y se puede releer");
|
||||||
|
else fail(`la atribución NO coincide (esperaba ${marca}, leí ${utm})`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Crear oportunidad ────────────────────────────────────────────────
|
||||||
|
console.log("\n── POST /opportunities/ (cita en espera → status open)");
|
||||||
|
if (contactId) {
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("POST", "/opportunities/", { token: CTX.token,
|
||||||
|
body: {
|
||||||
|
pipelineId: PIPELINE,
|
||||||
|
locationId: LOC,
|
||||||
|
name: "Extensiones de pestañas — Prueba AgendaMax",
|
||||||
|
pipelineStageId: ETAPA_PRIMERA,
|
||||||
|
status: "open",
|
||||||
|
contactId,
|
||||||
|
monetaryValue: 850,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
oppId = r?.opportunity?.id ?? null;
|
||||||
|
ok(`creada id=${oppId}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. RELEER la oportunidad ────────────────────────────────────────────
|
||||||
|
console.log("\n── GET /opportunities/{id} — relectura");
|
||||||
|
if (oppId) {
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||||
|
const o = r?.opportunity;
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
id: o?.id,
|
||||||
|
name: o?.name,
|
||||||
|
status: o?.status,
|
||||||
|
monetaryValue: o?.monetaryValue,
|
||||||
|
pipelineStageId: o?.pipelineStageId,
|
||||||
|
contactId: o?.contact?.id ?? o?.contactId,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (o?.monetaryValue === 850) ok("el importe persistió");
|
||||||
|
else fail(`el importe NO persistió: leí ${o?.monetaryValue}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Cambiar el estado a won (cita completada) ────────────────────────
|
||||||
|
// MEDIDO: /status NO acepta pipelineStageId (422 "should not exist"). Solo status.
|
||||||
|
console.log("\n── PUT /opportunities/{id}/status — cita completada → won (solo status)");
|
||||||
|
if (oppId) {
|
||||||
|
try {
|
||||||
|
await crmRequest("PUT", `/opportunities/${oppId}/status`, { token: CTX.token, body: { status: "won" } });
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||||
|
const o = r?.opportunity;
|
||||||
|
if (o?.status === "won") ok(`releído: status=${o.status}, etapa=${o.pipelineStageId}`);
|
||||||
|
else fail(`devolvió 200 pero al releer status=${o?.status}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5b. ¿Se puede mover la etapa por el PUT general? ─────────────────────
|
||||||
|
console.log("\n── PUT /opportunities/{id} — mover a la etapa «Ganado»");
|
||||||
|
if (oppId) {
|
||||||
|
try {
|
||||||
|
await crmRequest("PUT", `/opportunities/${oppId}`, { token: CTX.token,
|
||||||
|
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_GANADO },
|
||||||
|
});
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||||
|
const o = r?.opportunity;
|
||||||
|
if (o?.pipelineStageId === ETAPA_GANADO) ok(`releído: etapa movida, status=${o.status}`);
|
||||||
|
else fail(`al releer etapa=${o?.pipelineStageId}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Volver a lost (cita cancelada) ───────────────────────────────────
|
||||||
|
console.log("\n── PUT /opportunities/{id}/status — cita cancelada → lost");
|
||||||
|
if (oppId) {
|
||||||
|
try {
|
||||||
|
await crmRequest("PUT", `/opportunities/${oppId}/status`, { token: CTX.token, body: { status: "lost" } });
|
||||||
|
await crmRequest("PUT", `/opportunities/${oppId}`, { token: CTX.token,
|
||||||
|
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_PERDIDO },
|
||||||
|
});
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||||
|
const o = r?.opportunity;
|
||||||
|
if (o?.status === "lost") ok(`releído: status=lost, etapa=${o.pipelineStageId}`);
|
||||||
|
else fail(`al releer status=${o?.status}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Enviar un correo ─────────────────────────────────────────────────
|
||||||
|
console.log("\n── POST /conversations/messages — correo de prueba");
|
||||||
|
if (contactId) {
|
||||||
|
try {
|
||||||
|
const r: any = await crmRequest("POST", "/conversations/messages", { token: CTX.token,
|
||||||
|
body: {
|
||||||
|
type: "Email",
|
||||||
|
contactId,
|
||||||
|
subject: "Prueba de integración AgendaMax ↔ Bucéfalo CRM",
|
||||||
|
html: `<p>Mensaje de prueba enviado desde AgendaMax.</p><p>Marca: <code>${marca}</code></p>`,
|
||||||
|
emailTo: CORREO,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ok(`aceptado: ${JSON.stringify(r).slice(0, 300)}`);
|
||||||
|
console.log(" >> Un 200 aquí NO prueba entrega. Hay que mirar la bandeja real.");
|
||||||
|
} catch (e: any) {
|
||||||
|
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\nLimpieza (marca ${marca}):`);
|
||||||
|
if (oppId) console.log(` DELETE /opportunities/${oppId}`);
|
||||||
|
if (contactId) console.log(` DELETE /contacts/${contactId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Spike de integración contra Bucéfalo CRM. Solo lecturas.
|
||||||
|
*
|
||||||
|
* Existe porque el proyecto hermano ya pagó el precio de descubrir tarde que
|
||||||
|
* ninguna escritura funcionaba: los tests estaban escritos desde la
|
||||||
|
* implementación y no contra el contrato real de la API. Aquí no se da por
|
||||||
|
* buena ninguna capacidad sin haberla ejercido.
|
||||||
|
*
|
||||||
|
* node scripts/run-tsx.mjs platform/scripts/crm-spike.ts
|
||||||
|
*/
|
||||||
|
import { loadEnv } from "../lib/env.ts";
|
||||||
|
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||||
|
import { crmRequest } from "../crm/client.ts";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const LOC = process.env.CRM_LOCATION_ID!;
|
||||||
|
|
||||||
|
const CTX = ctxDesdeEnv();
|
||||||
|
|
||||||
|
async function probe(label: string, fn: () => Promise<unknown>) {
|
||||||
|
process.stdout.write(`\n── ${label}\n`);
|
||||||
|
try {
|
||||||
|
const out = await fn();
|
||||||
|
console.log(JSON.stringify(out, null, 2).slice(0, 1800));
|
||||||
|
return out as any;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(` ✗ ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Subcuenta: ${LOC}`);
|
||||||
|
|
||||||
|
await probe("GET /locations/{id} — ¿el token ve la subcuenta?", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/locations/${LOC}`, { token: CTX.token });
|
||||||
|
return {
|
||||||
|
name: r?.location?.name,
|
||||||
|
timezone: r?.location?.timezone,
|
||||||
|
country: r?.location?.country,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("POST /contacts/search — forma real de un contacto", async () => {
|
||||||
|
const r: any = await crmRequest("POST", `/contacts/search`, { token: CTX.token,
|
||||||
|
body: { locationId: LOC, page: 1, pageLimit: 2 },
|
||||||
|
});
|
||||||
|
const c = r?.contacts?.[0];
|
||||||
|
return {
|
||||||
|
total: r?.total,
|
||||||
|
devueltos: r?.contacts?.length,
|
||||||
|
claves_de_un_contacto: c ? Object.keys(c).sort() : null,
|
||||||
|
attributionSource: c?.attributionSource ?? null,
|
||||||
|
customFields_ejemplo: c?.customFields?.slice(0, 3) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipes = await probe("GET /opportunities/pipelines — pipeline y etapas", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/opportunities/pipelines?locationId=${LOC}`, { token: CTX.token });
|
||||||
|
return (r?.pipelines ?? []).map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
stages: (p.stages ?? []).map((s: any) => ({ id: s.id, name: s.name, position: s.position })),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /locations/{id}/customFields — campos personalizados", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/locations/${LOC}/customFields`, { token: CTX.token });
|
||||||
|
return (r?.customFields ?? []).map((f: any) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
fieldKey: f.fieldKey,
|
||||||
|
dataType: f.dataType,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /users/?locationId — personal del CRM", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/users/?locationId=${LOC}`, { token: CTX.token });
|
||||||
|
return (r?.users ?? []).map((u: any) => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
roles: u.roles?.role,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /calendars/?locationId — ¿EXISTEN calendarios?", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/calendars/?locationId=${LOC}`, { token: CTX.token, version: "v3" });
|
||||||
|
return {
|
||||||
|
cuantos: r?.calendars?.length ?? 0,
|
||||||
|
calendarios: (r?.calendars ?? []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
isActive: c.isActive,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("GET /calendars/services/catalog — ¿hay catálogo de servicios?", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/calendars/services/catalog?locationId=${LOC}`, { token: CTX.token,
|
||||||
|
version: "v3",
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
await probe("POST /conversations/search — conversaciones", async () => {
|
||||||
|
const r: any = await crmRequest("GET", `/conversations/search?locationId=${LOC}&limit=2`, { token: CTX.token });
|
||||||
|
const c = r?.conversations?.[0];
|
||||||
|
return {
|
||||||
|
total: r?.total,
|
||||||
|
claves_de_una_conversacion: c ? Object.keys(c).sort() : null,
|
||||||
|
muestra: c
|
||||||
|
? { id: c.id, contactId: c.contactId, lastMessageType: c.lastMessageType, type: c.type }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipeline = (pipes ?? [])[0];
|
||||||
|
if (pipeline) {
|
||||||
|
console.log(
|
||||||
|
`\n>> Pipeline por defecto: ${pipeline.name} (${pipeline.id}) con ${pipeline.stages.length} etapas`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Siembra la base de desarrollo con el spa, su personal, un catálogo de partida
|
||||||
|
* y unas citas de hoy sin resolver, para poder recorrer el cierre de día.
|
||||||
|
*
|
||||||
|
* ATENCIÓN SOBRE EL CATÁLOGO: los servicios de abajo salen del vocabulario
|
||||||
|
* medido en la muestra anotada de hilos del CRM (`extensiones`, `facial`,
|
||||||
|
* `pedicura`, `pestañas`, `uñas`, `depilación`, `masaje`) — no de la lista de
|
||||||
|
* precios de la dueña. **Las duraciones y los precios son marcadores de
|
||||||
|
* posición**, puestos para que la rejilla tenga algo que dibujar. Hay que
|
||||||
|
* sustituirlos por los reales en una sesión con ella antes de enseñar esto como
|
||||||
|
* catálogo del negocio.
|
||||||
|
*/
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { runMigrations } from "../db/migrate.ts";
|
||||||
|
import { normalizePhone } from "../lib/phone.ts";
|
||||||
|
|
||||||
|
const WORKING_HOURS = JSON.stringify({
|
||||||
|
1: { start: "09:00", end: "20:00" },
|
||||||
|
2: { start: "09:00", end: "20:00" },
|
||||||
|
3: { start: "09:00", end: "20:00" },
|
||||||
|
4: { start: "09:00", end: "20:00" },
|
||||||
|
5: { start: "09:00", end: "20:00" },
|
||||||
|
6: { start: "10:00", end: "18:00" },
|
||||||
|
7: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// nombre, categoría, duración (min), precio — duración y precio SIN VERIFICAR.
|
||||||
|
const SERVICIOS: [string, string, number, number][] = [
|
||||||
|
["Extensiones de pestañas", "pestañas", 120, 850],
|
||||||
|
["Retoque de pestañas", "pestañas", 75, 550],
|
||||||
|
["Limpieza facial profunda", "facial", 60, 700],
|
||||||
|
["Manicura", "uñas", 45, 300],
|
||||||
|
["Pedicura", "uñas", 60, 400],
|
||||||
|
["Uñas acrílicas", "uñas", 90, 600],
|
||||||
|
["Depilación con cera", "depilación", 30, 250],
|
||||||
|
["Masaje relajante", "masaje", 60, 750],
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERSONAL: [string, string][] = [
|
||||||
|
["Karla Ruiz", "[email protected]"],
|
||||||
|
["Brenda Salas", "[email protected]"],
|
||||||
|
["Paola Núñez", "[email protected]"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLIENTAS: [string, string | null][] = [
|
||||||
|
["Mariana López", "55 8888 7777"],
|
||||||
|
["Alejandra Torres", "5544443333"],
|
||||||
|
["Gabriela Méndez", "+52 55 2222 1111"],
|
||||||
|
["Rocío Herrera", null], // sin teléfono: no contactable, y es un caso real y frecuente
|
||||||
|
["Diana Castillo", "01 55 6666 5555"],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
|
const ya = await pool.query(`SELECT id FROM businesses WHERE slug = 'yola-franco'`);
|
||||||
|
if (ya.rows[0]) {
|
||||||
|
console.log("[seed] el negocio ya existe — no se toca nada");
|
||||||
|
await pool.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const biz = await pool.query(
|
||||||
|
`INSERT INTO businesses (name, industry, slug, timezone, working_hours)
|
||||||
|
VALUES ('Yola Franco Spa','Estética y Spa','yola-franco','America/Mexico_City',$1::jsonb)
|
||||||
|
RETURNING id`,
|
||||||
|
[WORKING_HOURS]
|
||||||
|
);
|
||||||
|
const bid = biz.rows[0].id as number;
|
||||||
|
|
||||||
|
const serviceIds: number[] = [];
|
||||||
|
for (const [name, category, duration, price] of SERVICIOS) {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO services (business_id, name, category, duration_min, price)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||||
|
[bid, name, category, duration, price]
|
||||||
|
);
|
||||||
|
serviceIds.push(r.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeIds: number[] = [];
|
||||||
|
for (const [name, email] of PERSONAL) {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO employees (business_id, name, email) VALUES ($1,$2,$3) RETURNING id`,
|
||||||
|
[bid, name, email]
|
||||||
|
);
|
||||||
|
employeeIds.push(r.rows[0].id);
|
||||||
|
// Todo el personal puede dar todos los servicios hasta que la dueña acote
|
||||||
|
// quién hace qué. Es una suposición, y conviene que se note.
|
||||||
|
for (const sid of serviceIds) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
|
||||||
|
[r.rows[0].id, sid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner')`,
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
for (let i = 0; i < PERSONAL.length; i++) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role, employee_id)
|
||||||
|
VALUES ($1,$2,'demo1234',$3,'employee',$4)`,
|
||||||
|
[bid, PERSONAL[i][1], PERSONAL[i][0], employeeIds[i]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIds: number[] = [];
|
||||||
|
for (const [name, phone] of CLIENTAS) {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO clients (business_id, name, phone, phone_e164) VALUES ($1,$2,$3,$4)
|
||||||
|
RETURNING id`,
|
||||||
|
[bid, name, phone, normalizePhone(phone)]
|
||||||
|
);
|
||||||
|
clientIds.push(r.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Citas de HOY, sin resolver, para que el cierre de día tenga trabajo. Las
|
||||||
|
// horas se construyen sobre el día local del proceso, que en desarrollo es el
|
||||||
|
// del spa; el servidor las acota con la zona del negocio de todas formas.
|
||||||
|
const hoy = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const dia = `${hoy.getFullYear()}-${p(hoy.getMonth() + 1)}-${p(hoy.getDate())}`;
|
||||||
|
// Hora local de México → UTC: +6 h. Se escribe explícito para no depender del
|
||||||
|
// reloj del proceso.
|
||||||
|
const citas: [number, number, number, string][] = [
|
||||||
|
[clientIds[0], employeeIds[0], serviceIds[0], `${dia}T16:00:00Z`], // 10:00 local
|
||||||
|
[clientIds[1], employeeIds[1], serviceIds[3], `${dia}T17:00:00Z`], // 11:00
|
||||||
|
[clientIds[2], employeeIds[2], serviceIds[2], `${dia}T18:30:00Z`], // 12:30
|
||||||
|
[clientIds[3], employeeIds[0], serviceIds[6], `${dia}T20:00:00Z`], // 14:00
|
||||||
|
[clientIds[4], employeeIds[1], serviceIds[7], `${dia}T22:00:00Z`], // 16:00
|
||||||
|
];
|
||||||
|
for (const [cid, eid, sid, start] of citas) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO appointments (business_id, client_id, employee_id, service_id,
|
||||||
|
start_at, end_at, price)
|
||||||
|
SELECT $1,$2,$3,$4,$5::timestamptz,
|
||||||
|
$5::timestamptz + make_interval(mins => duration_min), price
|
||||||
|
FROM services WHERE id = $4`,
|
||||||
|
[bid, cid, eid, sid, start]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[seed] Yola Franco Spa creado: ${SERVICIOS.length} servicios, ` +
|
||||||
|
`${PERSONAL.length} especialistas, ${CLIENTAS.length} clientas, ${citas.length} citas de hoy.`
|
||||||
|
);
|
||||||
|
console.log("[seed] Entra con [email protected] / demo1234");
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { createApp } from "../index.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
import { guardarCredencial } from "../crm/ctx.ts";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 5).toString("base64");
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
let adminId: number;
|
||||||
|
let server: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES (NULL,'[email protected]','demo1234','Plataforma','admin') RETURNING id`
|
||||||
|
);
|
||||||
|
adminId = rows[0].id;
|
||||||
|
server = createApp().listen(0);
|
||||||
|
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
server.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function req(path: string, init: RequestInit = {}, userId: number = adminId) {
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${userId}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("una dueña de negocio no puede entrar a la consola de plataforma", async () => {
|
||||||
|
const r = await req("/api/admin/businesses", {}, ids.ownerUserId);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una empleada tampoco", async () => {
|
||||||
|
const r = await req("/api/admin/businesses", {}, ids.employeeUserId);
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el superadministrador da de alta una cuenta con su dueña", async () => {
|
||||||
|
const r = await req("/api/admin/businesses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Spa Nuevo",
|
||||||
|
owner_email: "[email protected]",
|
||||||
|
owner_name: "Dueña",
|
||||||
|
owner_password: "demo1234",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
const b = await r.json();
|
||||||
|
assert.equal(b.business.name, "Spa Nuevo");
|
||||||
|
assert.equal(b.business.slug, "spa-nuevo", "el negocio nace con slug");
|
||||||
|
assert.equal(b.owner.email, "[email protected]", "el correo se normaliza a minúsculas");
|
||||||
|
|
||||||
|
// Sin horario, un negocio nuevo no tiene ninguna franja agendable.
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT working_hours FROM businesses WHERE id = $1`, [b.business.id]
|
||||||
|
);
|
||||||
|
assert.ok(rows[0].working_hours?.["1"], "el negocio nace con horario laboral");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dos cuentas con el mismo nombre no chocan de slug", async () => {
|
||||||
|
const r = await req("/api/admin/businesses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Spa Nuevo", owner_email: "[email protected]",
|
||||||
|
owner_name: "Otra", owner_password: "x",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal((await r.json()).business.slug, "spa-nuevo-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un correo repetido se rechaza con 409, no con un 500 de la base", async () => {
|
||||||
|
const r = await req("/api/admin/businesses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Tercero", owner_email: "[email protected]",
|
||||||
|
owner_name: "X", owner_password: "x",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("faltar datos de la dueña da 400 y lo dice", async () => {
|
||||||
|
const r = await req("/api/admin/businesses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: "Sin dueña" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.match((await r.json()).error, /dueña/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el listado nunca devuelve el token, solo su huella", async () => {
|
||||||
|
await guardarCredencial(ids.businessId, "loc-1", "token-secretisimo", "Yola Franco Spa");
|
||||||
|
const r = await req("/api/admin/businesses");
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const texto = JSON.stringify(await r.json());
|
||||||
|
assert.ok(!texto.includes("token-secretisimo"), "el token no puede salir por la API");
|
||||||
|
assert.ok(texto.includes("etisimo".slice(-6)), "sí debe salir la huella");
|
||||||
|
assert.ok(texto.includes("Yola Franco Spa"), "y la etiqueta de la subcuenta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suspender una cuenta la deja suspendida", async () => {
|
||||||
|
const r = await req(`/api/admin/businesses/${ids.businessId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status: "suspended" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal((await r.json()).business.status, "suspended");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un estado inventado se rechaza", async () => {
|
||||||
|
const r = await req(`/api/admin/businesses/${ids.businessId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status: "lo-que-sea" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desvincular borra la credencial y conserva la subcuenta", async () => {
|
||||||
|
await guardarCredencial(ids.businessId, "loc-9", "token-x");
|
||||||
|
const r = await req(`/api/admin/businesses/${ids.businessId}/crm`, { method: "DELETE" });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT location_id, token_cipher FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[ids.businessId]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].location_id, "loc-9");
|
||||||
|
assert.equal(rows[0].token_cipher, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vincular sin token o sin subcuenta da 400", async () => {
|
||||||
|
const r = await req(`/api/admin/businesses/${ids.businessId}/crm`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ location_id: "loc-1" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { createApp } from "../index.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
let server: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
server = createApp().listen(0);
|
||||||
|
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
server.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${userId}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nueva = (start: string) => ({
|
||||||
|
client_id: ids.clientId,
|
||||||
|
employee_id: ids.employeeId,
|
||||||
|
service_id: ids.serviceId,
|
||||||
|
start_at: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("crea una cita y calcula el fin con la duración del servicio", async () => {
|
||||||
|
const r = await req("/api/appointments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(nueva("2026-09-02T16:00:00Z")),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
const { appointment } = await r.json();
|
||||||
|
// El servicio sembrado dura 90 min.
|
||||||
|
assert.equal(appointment.end_at, "2026-09-02T17:30:00Z");
|
||||||
|
assert.equal(appointment.price, 850);
|
||||||
|
assert.equal(appointment.status, "scheduled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un solape devuelve 409 en español, no un 500", async () => {
|
||||||
|
const r = await req("/api/appointments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(nueva("2026-09-02T17:00:00Z")),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.match(body.error, /ocupad/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reprogramar a un hueco libre funciona", async () => {
|
||||||
|
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
|
||||||
|
const r = await req(`/api/appointments/${rows[0].id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ start_at: "2026-09-02T19:00:00Z" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const { appointment } = await r.json();
|
||||||
|
assert.equal(appointment.start_at, "2026-09-02T19:00:00Z");
|
||||||
|
assert.equal(appointment.end_at, "2026-09-02T20:30:00Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reprogramar encima de otra cita devuelve 409", async () => {
|
||||||
|
await req("/api/appointments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(nueva("2026-09-02T12:00:00Z")),
|
||||||
|
});
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id FROM appointments WHERE start_at = '2026-09-02T12:00:00Z'`
|
||||||
|
);
|
||||||
|
const r = await req(`/api/appointments/${rows[0].id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ start_at: "2026-09-02T19:30:00Z" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelar libera el hueco y registra quién canceló", async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id FROM appointments WHERE start_at = '2026-09-02T19:00:00Z'`
|
||||||
|
);
|
||||||
|
const r = await req(`/api/appointments/${rows[0].id}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ cancelled_by: "client", reason: "Se enfermó" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const { appointment } = await r.json();
|
||||||
|
assert.equal(appointment.status, "cancelled");
|
||||||
|
assert.equal(appointment.cancelled_by, "client");
|
||||||
|
|
||||||
|
const libre = await req("/api/appointments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(nueva("2026-09-02T19:00:00Z")),
|
||||||
|
});
|
||||||
|
assert.equal(libre.status, 201, "el hueco de una cancelada vuelve a estar libre");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cada cambio deja un evento en appointment_events", async () => {
|
||||||
|
const { rows } = await pool.query(`SELECT action FROM appointment_events ORDER BY id`);
|
||||||
|
const acciones = rows.map((r) => r.action);
|
||||||
|
assert.ok(acciones.includes("created"));
|
||||||
|
assert.ok(acciones.includes("rescheduled"));
|
||||||
|
assert.ok(acciones.includes("cancelled"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no se puede tocar una cita de otro negocio", async () => {
|
||||||
|
const other = await pool.query(
|
||||||
|
`INSERT INTO businesses (name, slug, working_hours)
|
||||||
|
VALUES ('Otro Spa 2','otro-2','{}'::jsonb) RETURNING id`
|
||||||
|
);
|
||||||
|
const otherUser = await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES ($1,'[email protected]','x','Otra','owner') RETURNING id`,
|
||||||
|
[other.rows[0].id]
|
||||||
|
);
|
||||||
|
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
|
||||||
|
const r = await req(
|
||||||
|
`/api/appointments/${rows[0].id}`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify({ notes: "intruso" }) },
|
||||||
|
otherUser.rows[0].id
|
||||||
|
);
|
||||||
|
assert.equal(r.status, 404);
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { createApp } from "../index.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
let server: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
server = createApp().listen(0);
|
||||||
|
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
server.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function req(path: string, init: RequestInit = {}, userId = ids.employeeUserId) {
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${userId}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crearCita(start: string): Promise<number> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO appointments
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||||
|
VALUES ($1,$2,$3,$4,$5::timestamptz,$5::timestamptz + interval '90 minutes',850)
|
||||||
|
RETURNING id`,
|
||||||
|
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, start]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("marcar Vino crea la visita y completa la cita", async () => {
|
||||||
|
const id = await crearCita("2026-09-03T16:00:00Z");
|
||||||
|
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: true, total_charged: 900, payment_method: "cash" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const { appointment, visit } = await r.json();
|
||||||
|
assert.equal(appointment.status, "completed");
|
||||||
|
assert.equal(visit.total_charged, 900);
|
||||||
|
assert.equal(visit.payment_method, "cash");
|
||||||
|
assert.equal(visit.recorded_by_user_id, ids.employeeUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marcar No vino no crea visita", async () => {
|
||||||
|
const id = await crearCita("2026-09-03T18:00:00Z");
|
||||||
|
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: false }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const { appointment, visit } = await r.json();
|
||||||
|
assert.equal(appointment.status, "no_show");
|
||||||
|
assert.equal(visit, null);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT count(*)::int c FROM visits WHERE appointment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].c, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marcar dos veces la misma cita devuelve 409", async () => {
|
||||||
|
const id = await crearCita("2026-09-03T20:00:00Z");
|
||||||
|
await req(`/api/appointments/${id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: true }),
|
||||||
|
});
|
||||||
|
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: false }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.match(body.error, /ya se resolvió/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no se puede marcar asistencia en una cita cancelada", async () => {
|
||||||
|
const id = await crearCita("2026-09-04T16:00:00Z");
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE appointments SET status='cancelled', cancelled_by='client' WHERE id=$1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: true }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una empleada no puede resolver la cita de otra", async () => {
|
||||||
|
const otra = await pool.query(
|
||||||
|
`INSERT INTO employees (business_id, name) VALUES ($1,'Otra especialista') RETURNING id`,
|
||||||
|
[ids.businessId]
|
||||||
|
);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO appointments
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||||
|
VALUES ($1,$2,$3,$4,'2026-09-05T16:00:00Z','2026-09-05T17:00:00Z',0)
|
||||||
|
RETURNING id`,
|
||||||
|
[ids.businessId, ids.clientId, otra.rows[0].id, ids.serviceId]
|
||||||
|
);
|
||||||
|
const r = await req(`/api/appointments/${rows[0].id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: true }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403);
|
||||||
|
|
||||||
|
// La administradora sí puede.
|
||||||
|
const rOwner = await req(
|
||||||
|
`/api/appointments/${rows[0].id}/attendance`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ attended: true }) },
|
||||||
|
ids.ownerUserId
|
||||||
|
);
|
||||||
|
assert.equal(rOwner.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el toque deja evento y auditoría", async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT action FROM appointment_events WHERE action IN ('attended','no_show')`
|
||||||
|
);
|
||||||
|
assert.ok(rows.some((r) => r.action === "attended"));
|
||||||
|
assert.ok(rows.some((r) => r.action === "no_show"));
|
||||||
|
|
||||||
|
const audit = await pool.query(
|
||||||
|
`SELECT count(*)::int c FROM audit_log WHERE action = 'attendance'`
|
||||||
|
);
|
||||||
|
assert.ok(audit.rows[0].c >= 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool, withTx } from "../db/pool.ts";
|
||||||
|
import { writeAudit } from "../lib/audit.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writeAudit guarda el antes y el después como jsonb", async () => {
|
||||||
|
await withTx(async (c) => {
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: ids.businessId,
|
||||||
|
actorUserId: ids.ownerUserId,
|
||||||
|
entity: "clients",
|
||||||
|
entityId: ids.clientId,
|
||||||
|
action: "update",
|
||||||
|
before: { name: "Mariana" },
|
||||||
|
after: { name: "Mariana López" },
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT entity, action, before, after, ip FROM audit_log
|
||||||
|
WHERE entity_id = $1 ORDER BY id DESC LIMIT 1`,
|
||||||
|
[ids.clientId]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].entity, "clients");
|
||||||
|
assert.equal(rows[0].action, "update");
|
||||||
|
assert.deepEqual(rows[0].before, { name: "Mariana" });
|
||||||
|
assert.deepEqual(rows[0].after, { name: "Mariana López" });
|
||||||
|
assert.equal(rows[0].ip, "127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writeAudit se apunta a la transacción que lo llama", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
withTx(async (c) => {
|
||||||
|
await writeAudit(c, {
|
||||||
|
businessId: ids.businessId,
|
||||||
|
actorUserId: ids.ownerUserId,
|
||||||
|
entity: "clients",
|
||||||
|
entityId: ids.clientId,
|
||||||
|
action: "delete",
|
||||||
|
});
|
||||||
|
throw new Error("boom");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT count(*)::int c FROM audit_log WHERE action = 'delete'`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].c, 0, "el rollback debe llevarse también la auditoría");
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { createApp } from "../index.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
let server: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
server = createApp().listen(0);
|
||||||
|
const addr = server.address() as { port: number };
|
||||||
|
base = `http://127.0.0.1:${addr.port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
server.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${userId}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("crea una clienta y guarda el teléfono normalizado", async () => {
|
||||||
|
const r = await req("/api/clients", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: "Sofía Ramírez", phone: "(55) 4444-3333" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
const { client } = await r.json();
|
||||||
|
assert.equal(client.phone, "(55) 4444-3333", "conserva lo que tecleó la persona");
|
||||||
|
assert.equal(client.phone_e164, "+525544443333");
|
||||||
|
assert.equal(client.contactable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el segundo alta con el mismo número devuelve 409 con la ficha existente", async () => {
|
||||||
|
const r = await req("/api/clients", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: "Sofia R.", phone: "+52 55 4444 3333" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.match(body.error, /ya existe/i);
|
||||||
|
assert.equal(body.existing.name, "Sofía Ramírez");
|
||||||
|
assert.equal(body.existing.phone_e164, "+525544443333");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permite dar de alta sin teléfono, marcada como no contactable", async () => {
|
||||||
|
const r = await req("/api/clients", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: "Clienta de mostrador" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
const { client } = await r.json();
|
||||||
|
assert.equal(client.phone_e164, null);
|
||||||
|
assert.equal(client.contactable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la búsqueda encuentra por teléfono sin formato", async () => {
|
||||||
|
const r = await req("/api/clients?q=5544443333");
|
||||||
|
const { clients } = await r.json();
|
||||||
|
assert.equal(clients.length, 1);
|
||||||
|
assert.equal(clients[0].name, "Sofía Ramírez");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el alta deja rastro en audit_log", async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT action, actor_user_id FROM audit_log
|
||||||
|
WHERE entity = 'clients' AND action = 'create' ORDER BY id DESC LIMIT 1`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].action, "create");
|
||||||
|
assert.equal(rows[0].actor_user_id, ids.ownerUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no se ven clientas de otro negocio", async () => {
|
||||||
|
const other = await pool.query(
|
||||||
|
`INSERT INTO businesses (name, slug, working_hours)
|
||||||
|
VALUES ('Otro Spa','otro','{}'::jsonb) RETURNING id`
|
||||||
|
);
|
||||||
|
const otherUser = await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES ($1,'[email protected]','x','Otro','owner') RETURNING id`,
|
||||||
|
[other.rows[0].id]
|
||||||
|
);
|
||||||
|
const r = await req("/api/clients", {}, otherUser.rows[0].id);
|
||||||
|
const { clients } = await r.json();
|
||||||
|
assert.equal(clients.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { resetDb, crearNegocio } from "./helpers.ts";
|
||||||
|
import { ctxDe, guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
|
||||||
|
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 3).toString("base64");
|
||||||
|
|
||||||
|
test("dos negocios tienen credenciales distintas y no se cruzan", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio({ name: "Spa A", slug: "spa-a" });
|
||||||
|
const b = await crearNegocio({ name: "Spa B", slug: "spa-b" });
|
||||||
|
|
||||||
|
await guardarCredencial(a.id, "loc-AAA", "token-de-A", "Spa A");
|
||||||
|
await guardarCredencial(b.id, "loc-BBB", "token-de-B", "Spa B");
|
||||||
|
|
||||||
|
const ctxA = await ctxDe(a.id);
|
||||||
|
const ctxB = await ctxDe(b.id);
|
||||||
|
|
||||||
|
assert.equal(ctxA.locationId, "loc-AAA");
|
||||||
|
assert.equal(ctxA.token, "token-de-A");
|
||||||
|
assert.equal(ctxB.locationId, "loc-BBB");
|
||||||
|
assert.equal(ctxB.token, "token-de-B");
|
||||||
|
assert.equal(ctxA.businessId, a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el token no queda en claro en la base", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio();
|
||||||
|
await guardarCredencial(a.id, "loc-1", "token-secretisimo");
|
||||||
|
const { rows } = await pool.query<{ token_cipher: Buffer; token_fingerprint: string }>(
|
||||||
|
`SELECT token_cipher, token_fingerprint FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[a.id]
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!rows[0].token_cipher.toString("utf8").includes("token-secretisimo"),
|
||||||
|
"el cifrado no puede contener el token legible"
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].token_fingerprint, "etisimo".slice(-6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("volver a guardar rota la credencial sin duplicar la fila", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio();
|
||||||
|
await guardarCredencial(a.id, "loc-1", "token-viejo");
|
||||||
|
await guardarCredencial(a.id, "loc-1", "token-nuevo");
|
||||||
|
assert.equal((await ctxDe(a.id)).token, "token-nuevo");
|
||||||
|
const { rows } = await pool.query<{ n: number }>(
|
||||||
|
`SELECT count(*)::int AS n FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[a.id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].n, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rotar la credencial conserva la etiqueta anterior si no se manda otra", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio();
|
||||||
|
await guardarCredencial(a.id, "loc-1", "t1", "Yola Franco Spa");
|
||||||
|
await guardarCredencial(a.id, "loc-1", "t2");
|
||||||
|
const { rows } = await pool.query<{ label: string }>(
|
||||||
|
`SELECT label FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[a.id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].label, "Yola Franco Spa");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un negocio sin conexión da un 409 que dice qué hacer", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio();
|
||||||
|
await assert.rejects(
|
||||||
|
() => ctxDe(a.id),
|
||||||
|
(e: any) => {
|
||||||
|
assert.equal(e.status, 409);
|
||||||
|
assert.match(e.error, /no está vinculado/i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una conexión sin token da un 409 distinto del de sin conexión", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1')`,
|
||||||
|
[a.id]
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => ctxDe(a.id),
|
||||||
|
(e: any) => {
|
||||||
|
assert.equal(e.status, 409);
|
||||||
|
assert.match(e.error, /token/i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("olvidarCredencial borra el token pero conserva la conexión y lo sincronizado", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const a = await crearNegocio();
|
||||||
|
await guardarCredencial(a.id, "loc-1", "token-x", "Etiqueta");
|
||||||
|
await olvidarCredencial(a.id);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT location_id, label, token_cipher, token_fingerprint
|
||||||
|
FROM crm_connections WHERE business_id = $1`,
|
||||||
|
[a.id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].location_id, "loc-1", "la subcuenta se recuerda");
|
||||||
|
assert.equal(rows[0].label, "Etiqueta");
|
||||||
|
assert.equal(rows[0].token_cipher, null);
|
||||||
|
assert.equal(rows[0].token_fingerprint, null);
|
||||||
|
// `ctxDe` lanza `{ status, error }`, no un Error: la forma con expresión
|
||||||
|
// regular compara contra `message`, que un objeto plano no tiene.
|
||||||
|
await assert.rejects(
|
||||||
|
() => ctxDe(a.id),
|
||||||
|
(e: any) => {
|
||||||
|
assert.match(e.error, /token/i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// La zona del proceso es UTC y la del negocio America/Mexico_City: nunca
|
||||||
|
// coinciden, así que una recaída de zona horaria falla aquí y no en producción.
|
||||||
|
process.env.TZ = "UTC";
|
||||||
|
|
||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { createApp } from "../index.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
let server: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
server = createApp().listen(0);
|
||||||
|
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
server.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${userId}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crearCita(startUtc: string): Promise<number> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO appointments
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||||
|
VALUES ($1,$2,$3,$4,$5::timestamptz,$5::timestamptz + interval '60 minutes',850)
|
||||||
|
RETURNING id`,
|
||||||
|
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, startUtc]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("una cita de las 19:00 de México cuenta en su día local, no en el UTC", async () => {
|
||||||
|
// 2026-09-07 19:00 en México (UTC-6) = 2026-09-08 01:00 UTC.
|
||||||
|
await crearCita("2026-09-08T01:00:00Z");
|
||||||
|
const r = await req("/api/day-close?date=2026-09-07");
|
||||||
|
const body = await r.json();
|
||||||
|
assert.equal(body.unresolved.length, 1, "debe contarse en el 7, no en el 8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no deja cerrar el día con citas sin resolver", async () => {
|
||||||
|
const r = await req("/api/day-close", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ date: "2026-09-07" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.equal(body.unresolved.length, 1);
|
||||||
|
assert.match(body.error, /sin resolver/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cierra el día cuando todas están resueltas y guarda el conteo", async () => {
|
||||||
|
const { rows } = await pool.query(`SELECT id FROM appointments WHERE status = 'scheduled'`);
|
||||||
|
await req(`/api/appointments/${rows[0].id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ attended: true, total_charged: 850 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await req("/api/day-close", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ date: "2026-09-07" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const { closure } = await r.json();
|
||||||
|
assert.equal(closure.attended_count, 1);
|
||||||
|
assert.equal(closure.no_show_count, 0);
|
||||||
|
assert.equal(closure.closed_by_user_id, ids.ownerUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cerrar dos veces el mismo día devuelve 409", async () => {
|
||||||
|
const r = await req("/api/day-close", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ date: "2026-09-07" }),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.match(body.error, /ya está cerrado/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el resumen del día muestra la fecha de cierre", async () => {
|
||||||
|
const r = await req("/api/day-close?date=2026-09-07");
|
||||||
|
const body = await r.json();
|
||||||
|
assert.ok(body.closed_at, "un día cerrado reporta cuándo se cerró");
|
||||||
|
assert.equal(body.attended, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { runMigrations } from "../db/migrate.ts";
|
||||||
|
|
||||||
|
export interface SeedIds {
|
||||||
|
businessId: number;
|
||||||
|
ownerUserId: number;
|
||||||
|
employeeUserId: number;
|
||||||
|
employeeId: number;
|
||||||
|
serviceId: number;
|
||||||
|
clientId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKING_HOURS = JSON.stringify({
|
||||||
|
1: { start: "09:00", end: "20:00" },
|
||||||
|
2: { start: "09:00", end: "20:00" },
|
||||||
|
3: { start: "09:00", end: "20:00" },
|
||||||
|
4: { start: "09:00", end: "20:00" },
|
||||||
|
5: { start: "09:00", end: "20:00" },
|
||||||
|
6: { start: "10:00", end: "18:00" },
|
||||||
|
7: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vacía el esquema. La guarda del nombre no es decorativa: este DROP SCHEMA
|
||||||
|
* contra la base de desarrollo se llevaría los datos del spa por delante.
|
||||||
|
*/
|
||||||
|
export async function dropSchema(): Promise<void> {
|
||||||
|
if (!/yola_test/.test(process.env.DATABASE_URL || "")) {
|
||||||
|
throw new Error("dropSchema solo corre contra yola_test — revisa DATABASE_URL");
|
||||||
|
}
|
||||||
|
await pool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deja la base vacía y con el esquema al día. */
|
||||||
|
export async function resetDb(): Promise<void> {
|
||||||
|
await dropSchema();
|
||||||
|
await runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedMinimal(): Promise<SeedIds> {
|
||||||
|
const biz = await pool.query(
|
||||||
|
`INSERT INTO businesses (name, slug, working_hours)
|
||||||
|
VALUES ('Yola Franco Spa', 'yola-franco', $1::jsonb) RETURNING id`,
|
||||||
|
[WORKING_HOURS]
|
||||||
|
);
|
||||||
|
const businessId = biz.rows[0].id as number;
|
||||||
|
|
||||||
|
const emp = await pool.query(
|
||||||
|
`INSERT INTO employees (business_id, name, email)
|
||||||
|
VALUES ($1,'Karla Ruiz','[email protected]') RETURNING id`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const employeeId = emp.rows[0].id as number;
|
||||||
|
|
||||||
|
const svc = await pool.query(
|
||||||
|
`INSERT INTO services (business_id, name, duration_min, price)
|
||||||
|
VALUES ($1,'Extensiones de pestañas',90,850) RETURNING id`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const serviceId = svc.rows[0].id as number;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
|
||||||
|
[employeeId, serviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const owner = await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role)
|
||||||
|
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner') RETURNING id`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
const empUser = await pool.query(
|
||||||
|
`INSERT INTO users (business_id, email, password, name, role, employee_id)
|
||||||
|
VALUES ($1,'[email protected]','demo1234','Karla Ruiz','employee',$2) RETURNING id`,
|
||||||
|
[businessId, employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cli = await pool.query(
|
||||||
|
`INSERT INTO clients (business_id, name, phone, phone_e164)
|
||||||
|
VALUES ($1,'Mariana López','55 8888 7777','+525588887777') RETURNING id`,
|
||||||
|
[businessId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
businessId,
|
||||||
|
ownerUserId: owner.rows[0].id,
|
||||||
|
employeeUserId: empUser.rows[0].id,
|
||||||
|
employeeId,
|
||||||
|
serviceId,
|
||||||
|
clientId: cli.rows[0].id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un negocio suelto, sin catálogo ni personal.
|
||||||
|
*
|
||||||
|
* `seedMinimal` siembra UN negocio completo y sirve para casi todo; esto existe
|
||||||
|
* para las pruebas multi-negocio, donde lo que se comprueba es justamente que
|
||||||
|
* dos cuentas no se pisan y no hace falta el resto del inventario.
|
||||||
|
*/
|
||||||
|
export async function crearNegocio(
|
||||||
|
opts: { name?: string; slug?: string } = {}
|
||||||
|
): Promise<{ id: number; name: string }> {
|
||||||
|
const name = opts.name ?? `Negocio ${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const slug = opts.slug ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO businesses (name, slug, working_hours)
|
||||||
|
VALUES ($1, $2, $3::jsonb) RETURNING id, name`,
|
||||||
|
[name, slug, WORKING_HOURS]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { runMigrations } from "../db/migrate.ts";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { dropSchema } from "./helpers.ts";
|
||||||
|
|
||||||
|
// Parte de un esquema vacío a propósito: la aserción es que el bootstrap se
|
||||||
|
// aplica, y eso solo es cierto sobre una base sin migrar.
|
||||||
|
before(async () => {
|
||||||
|
await dropSchema();
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runMigrations aplica los archivos pendientes y es idempotente", async () => {
|
||||||
|
const first = await runMigrations();
|
||||||
|
assert.ok(first.includes("000_bootstrap.sql"), "debe aplicar el bootstrap");
|
||||||
|
assert.ok(first.includes("001_core.sql"), "debe aplicar el núcleo");
|
||||||
|
|
||||||
|
const second = await runMigrations();
|
||||||
|
assert.deepEqual(second, [], "una segunda corrida no aplica nada");
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT count(*)::int AS c FROM schema_migrations WHERE filename = '000_bootstrap.sql'`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].c, 1, "no debe registrarse dos veces");
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
|
||||||
|
import { resetDb, seedMinimal, crearNegocio } from "./helpers.ts";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
|
||||||
|
await resetDb();
|
||||||
|
|
||||||
|
ids = await seedMinimal();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test("la base rechaza dos citas solapadas de la misma empleada", async () => {
|
||||||
|
|
||||||
|
const ins = `INSERT INTO appointments
|
||||||
|
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||||
|
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,0) RETURNING id`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await pool.query(ins, [
|
||||||
|
|
||||||
|
ids.businessId,
|
||||||
|
|
||||||
|
ids.clientId,
|
||||||
|
|
||||||
|
ids.employeeId,
|
||||||
|
|
||||||
|
ids.serviceId,
|
||||||
|
|
||||||
|
"2026-09-01T16:00:00Z",
|
||||||
|
|
||||||
|
"2026-09-01T17:00:00Z",
|
||||||
|
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
|
||||||
|
() =>
|
||||||
|
|
||||||
|
pool.query(ins, [
|
||||||
|
|
||||||
|
ids.businessId,
|
||||||
|
|
||||||
|
ids.clientId,
|
||||||
|
|
||||||
|
ids.employeeId,
|
||||||
|
|
||||||
|
ids.serviceId,
|
||||||
|
|
||||||
|
"2026-09-01T16:30:00Z",
|
||||||
|
|
||||||
|
"2026-09-01T17:30:00Z",
|
||||||
|
|
||||||
|
]),
|
||||||
|
|
||||||
|
(e: any) => e.code === "23P01",
|
||||||
|
|
||||||
|
"debe ser una violación de exclusión (23P01), no un error cualquiera"
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test("una cita cancelada libera el hueco", async () => {
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
|
||||||
|
`UPDATE appointments SET status = 'cancelled', cancelled_by = 'client'
|
||||||
|
|
||||||
|
WHERE business_id = $1`,
|
||||||
|
|
||||||
|
[ids.businessId]
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
|
||||||
|
`INSERT INTO appointments
|
||||||
|
|
||||||
|
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||||
|
|
||||||
|
VALUES ($1,$2,$3,$4,'2026-09-01T16:15:00Z','2026-09-01T17:15:00Z',0)
|
||||||
|
|
||||||
|
RETURNING id`,
|
||||||
|
|
||||||
|
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId]
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(rows[0].id > 0);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test("dos clientas del mismo negocio no pueden compartir teléfono normalizado", async () => {
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { resetDb, crearNegocio } from "./helpers.ts";
|
||||||
|
import { upsertConversacion, upsertMensaje } from "../crm/syncConversations.ts";
|
||||||
|
|
||||||
|
test("upsertConversacion es idempotente: dos veces no duplica", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
const conv = {
|
||||||
|
id: "conv-1",
|
||||||
|
contactId: "c-1",
|
||||||
|
fullName: "Ana",
|
||||||
|
lastMessageBody: "hola",
|
||||||
|
lastMessageType: "TYPE_SMS",
|
||||||
|
lastMessageDate: 1756000000000,
|
||||||
|
unreadCount: 2,
|
||||||
|
};
|
||||||
|
const id1 = await upsertConversacion(b.id, conv as any);
|
||||||
|
const id2 = await upsertConversacion(b.id, conv as any);
|
||||||
|
assert.equal(id1, id2);
|
||||||
|
const { rows } = await pool.query<{ n: number }>(
|
||||||
|
`SELECT count(*)::int AS n FROM conversations WHERE business_id = $1`,
|
||||||
|
[b.id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].n, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upsertConversacion enlaza con la clienta local por crm_contact_id", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
const { rows: cl } = await pool.query(
|
||||||
|
`INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-9') RETURNING id`,
|
||||||
|
[b.id]
|
||||||
|
);
|
||||||
|
await upsertConversacion(b.id, { id: "conv-9", contactId: "c-9", fullName: "Ana" } as any);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT client_id FROM conversations WHERE business_id = $1 AND crm_conversation_id = 'conv-9'`,
|
||||||
|
[b.id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].client_id, cl[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el enlace con la clienta se rellena después, y no se pierde al resincronizar", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
// Primero llega la conversación, cuando la clienta todavía no existe.
|
||||||
|
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
|
||||||
|
const { rows: sin } = await pool.query(
|
||||||
|
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
|
||||||
|
);
|
||||||
|
assert.equal(sin[0].client_id, null);
|
||||||
|
|
||||||
|
// Luego la sincronización de contactos crea la clienta…
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-x')`,
|
||||||
|
[b.id]
|
||||||
|
);
|
||||||
|
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
|
||||||
|
const { rows: con } = await pool.query(
|
||||||
|
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
|
||||||
|
);
|
||||||
|
assert.ok(con[0].client_id, "al resincronizar debe quedar enlazada");
|
||||||
|
|
||||||
|
// …y una tercera pasada NO puede desenlazarla.
|
||||||
|
await pool.query(`UPDATE clients SET crm_contact_id = NULL WHERE business_id = $1`, [b.id]);
|
||||||
|
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
|
||||||
|
const { rows: sigue } = await pool.query(
|
||||||
|
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
|
||||||
|
);
|
||||||
|
assert.equal(sigue[0].client_id, con[0].client_id, "un enlace resuelto no se borra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upsertMensaje no duplica el mismo crm_message_id", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
const convId = await upsertConversacion(b.id, { id: "conv-2", contactId: "c-2" } as any);
|
||||||
|
const m = { id: "msg-1", body: "hola", direction: "inbound", messageType: "TYPE_SMS" };
|
||||||
|
await upsertMensaje(b.id, convId, m as any);
|
||||||
|
await upsertMensaje(b.id, convId, m as any);
|
||||||
|
const { rows } = await pool.query<{ n: number }>(
|
||||||
|
`SELECT count(*)::int AS n FROM messages WHERE business_id = $1`,
|
||||||
|
[b.id]
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].n, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upsertMensaje guarda el canal normalizado y el crudo", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
const convId = await upsertConversacion(b.id, { id: "conv-3" } as any);
|
||||||
|
await upsertMensaje(b.id, convId, {
|
||||||
|
id: "msg-2", body: "x", direction: "outbound", messageType: "TYPE_EMAIL",
|
||||||
|
} as any);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT channel, channel_raw, direction FROM messages WHERE crm_message_id = 'msg-2'`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].channel, "Email");
|
||||||
|
assert.equal(rows[0].channel_raw, "TYPE_EMAIL", "el valor original se conserva");
|
||||||
|
assert.equal(rows[0].direction, "outbound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un mensaje con dirección desconocida se guarda como entrante, no revienta", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
const convId = await upsertConversacion(b.id, { id: "conv-4" } as any);
|
||||||
|
await upsertMensaje(b.id, convId, { id: "msg-3", body: "x" } as any);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT direction FROM messages WHERE crm_message_id = 'msg-3'`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].direction, "inbound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sincronizar un hilo por id NO degrada el nombre ni el canal ya conocidos", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
// Primero llega desde el buscador, con nombre y canal buenos.
|
||||||
|
await upsertConversacion(b.id, {
|
||||||
|
id: "conv-deg", contactId: "c-d", fullName: "Carmen García",
|
||||||
|
lastMessageType: "TYPE_INSTAGRAM", lastMessageBody: "hola",
|
||||||
|
} as any);
|
||||||
|
// Luego llega por id, que no trae ni nombre ni canal reconocible.
|
||||||
|
await upsertConversacion(b.id, { id: "conv-deg", contactId: "c-d" } as any);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT contact_name, last_message_type, last_message_body
|
||||||
|
FROM conversations WHERE crm_conversation_id = 'conv-deg'`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].contact_name, "Carmen García", "el nombre bueno se conserva");
|
||||||
|
assert.equal(rows[0].last_message_type, "Instagram", "el canal bueno se conserva");
|
||||||
|
assert.equal(rows[0].last_message_body, "hola", "y el último mensaje también");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un nombre nuevo y bueno SÍ reemplaza al anterior", async () => {
|
||||||
|
await resetDb();
|
||||||
|
const b = await crearNegocio();
|
||||||
|
await upsertConversacion(b.id, { id: "conv-n", fullName: "Nombre Viejo" } as any);
|
||||||
|
await upsertConversacion(b.id, { id: "conv-n", fullName: "Nombre Nuevo" } as any);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT contact_name FROM conversations WHERE crm_conversation_id = 'conv-n'`
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].contact_name, "Nombre Nuevo");
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pool } from "../db/pool.ts";
|
||||||
|
import { createApp } from "../index.ts";
|
||||||
|
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||||
|
import { esEntidad, ENTIDADES } from "../crm/syncOne.ts";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 11).toString("base64");
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||||
|
let server: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await resetDb();
|
||||||
|
ids = await seedMinimal();
|
||||||
|
server = createApp().listen(0);
|
||||||
|
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
server.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = (path: string, init: RequestInit = {}) =>
|
||||||
|
fetch(`${base}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${ids.ownerUserId}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("esEntidad acepta solo las cinco entidades del encargo", () => {
|
||||||
|
for (const e of ENTIDADES) assert.ok(esEntidad(e));
|
||||||
|
assert.equal(esEntidad("cliente"), false);
|
||||||
|
assert.equal(esEntidad(""), false);
|
||||||
|
assert.equal(esEntidad("../../etc/passwd"), false);
|
||||||
|
assert.equal(esEntidad("CONTACTO"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ENTIDADES son exactamente las cinco, ni una más", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
[...ENTIDADES].sort(),
|
||||||
|
["cita", "contacto", "conversacion", "mensaje", "servicio"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("una entidad inventada da 400 y dice cuáles valen", async () => {
|
||||||
|
const r = await req("/api/crm/sync/pedido/abc", { method: "POST" });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
const b = await r.json();
|
||||||
|
assert.match(b.error, /contacto/);
|
||||||
|
assert.match(b.error, /servicio/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un identificador desmesurado se rechaza antes de salir a la red", async () => {
|
||||||
|
const r = await req(`/api/crm/sync/contacto/${"x".repeat(200)}`, { method: "POST" });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.match((await r.json()).error, /demasiado largo/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sin vínculo con el CRM, sincronizar por id da 409, no 500", async () => {
|
||||||
|
const r = await req("/api/crm/sync/contacto/abc123", { method: "POST" });
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
assert.match((await r.json()).error, /no está vinculado/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el espejo de conversaciones sin vínculo también da 409", async () => {
|
||||||
|
const r = await req("/api/crm/sync/conversations", { method: "POST" });
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la cita exige un identificador numérico de la plataforma", async () => {
|
||||||
|
// Se vincula con credencial falsa: basta para pasar de `ctxDe` y llegar a la
|
||||||
|
// validación del identificador, que es lo que se prueba aquí.
|
||||||
|
const { guardarCredencial } = await import("../crm/ctx.ts");
|
||||||
|
await guardarCredencial(ids.businessId, "loc-x", "tok-x");
|
||||||
|
const r = await req("/api/crm/sync/cita/no-es-un-numero", { method: "POST" });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.match((await r.json()).error, /numérico/i);
|
||||||
|
});
|
||||||
+216
@@ -83,10 +83,30 @@ export interface Client {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
/** El teléfono normalizado a E.164. `null` = no se pudo normalizar. */
|
||||||
|
phone_e164?: string | null;
|
||||||
|
/** Derivado: hay teléfono normalizado y por tanto se le puede avisar. */
|
||||||
|
contactable?: boolean;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
tags: string | null;
|
tags: string | null;
|
||||||
|
/** whatsapp | facebook | instagram | mostrador | referido */
|
||||||
|
source_channel?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
stats?: ClientStats;
|
stats?: ClientStats;
|
||||||
|
/** Atribución traída del CRM. De solo lectura: allá es inmutable. */
|
||||||
|
crm_contact_id?: string | null;
|
||||||
|
crm_synced_at?: string | null;
|
||||||
|
crm_source?: string | null;
|
||||||
|
crm_tags?: string | null;
|
||||||
|
attr_session_source?: string | null;
|
||||||
|
attr_medium?: string | null;
|
||||||
|
attr_campaign?: string | null;
|
||||||
|
attr_campaign_id?: string | null;
|
||||||
|
attr_utm_source?: string | null;
|
||||||
|
attr_utm_medium?: string | null;
|
||||||
|
attr_utm_content?: string | null;
|
||||||
|
attr_ad_id?: string | null;
|
||||||
|
attr_referrer?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientStats {
|
export interface ClientStats {
|
||||||
@@ -106,6 +126,13 @@ export interface Appointment {
|
|||||||
start_at: string;
|
start_at: string;
|
||||||
end_at: string;
|
end_at: string;
|
||||||
status: AppointmentStatus;
|
status: AppointmentStatus;
|
||||||
|
/**
|
||||||
|
* El CRM aplana "no vino" y "el spa canceló" en un solo estado; para el
|
||||||
|
* negocio son cosas distintas, así que el matiz vive aquí.
|
||||||
|
* Solo tiene valor cuando `status === "cancelled"`.
|
||||||
|
*/
|
||||||
|
cancelled_by?: "client" | "business" | null;
|
||||||
|
cancel_reason?: string | null;
|
||||||
price: number;
|
price: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_by_user_id: number | null;
|
created_by_user_id: number | null;
|
||||||
@@ -202,3 +229,192 @@ export interface BookResponse {
|
|||||||
no_show_count?: number;
|
no_show_count?: number;
|
||||||
risk_flag?: boolean;
|
risk_flag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PaymentMethod = "cash" | "card" | "transfer" | "other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El hecho consumado: la clienta vino. Solo existe si asistió — una cita es una
|
||||||
|
* intención y una visita es un hecho con dinero; fusionarlas produce registros
|
||||||
|
* que nadie cierra nunca.
|
||||||
|
*/
|
||||||
|
export interface Visit {
|
||||||
|
id: number;
|
||||||
|
business_id: number;
|
||||||
|
appointment_id: number | null;
|
||||||
|
client_id: number;
|
||||||
|
employee_id: number;
|
||||||
|
occurred_at: string;
|
||||||
|
total_charged: number | null;
|
||||||
|
payment_method: PaymentMethod | null;
|
||||||
|
recorded_by_user_id: number | null;
|
||||||
|
recorded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceResult {
|
||||||
|
appointment: Appointment;
|
||||||
|
visit: Visit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una cita del día pendiente de desenlace, con los nombres ya resueltos. */
|
||||||
|
export interface UnresolvedAppointment {
|
||||||
|
id: number;
|
||||||
|
client_id: number;
|
||||||
|
employee_id: number;
|
||||||
|
service_id: number;
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
|
status: AppointmentStatus;
|
||||||
|
price: number;
|
||||||
|
client_name: string;
|
||||||
|
employee_name: string;
|
||||||
|
service_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayCloseSummary {
|
||||||
|
date: string;
|
||||||
|
closed_at: string | null;
|
||||||
|
unresolved: UnresolvedAppointment[];
|
||||||
|
attended: number;
|
||||||
|
no_show: number;
|
||||||
|
cancelled: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayClosure {
|
||||||
|
id: number;
|
||||||
|
business_id: number;
|
||||||
|
business_date: string;
|
||||||
|
closed_by_user_id: number;
|
||||||
|
closed_at: string;
|
||||||
|
attended_count: number;
|
||||||
|
no_show_count: number;
|
||||||
|
cancelled_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Integración con Bucéfalo CRM ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** La atribución llega del CRM y es de solo lectura: allá es inmutable. */
|
||||||
|
export interface ClientAttribution {
|
||||||
|
crm_source?: string | null;
|
||||||
|
attr_session_source?: string | null;
|
||||||
|
attr_medium?: string | null;
|
||||||
|
attr_campaign?: string | null;
|
||||||
|
attr_campaign_id?: string | null;
|
||||||
|
attr_utm_source?: string | null;
|
||||||
|
attr_utm_medium?: string | null;
|
||||||
|
attr_utm_content?: string | null;
|
||||||
|
attr_ad_id?: string | null;
|
||||||
|
attr_referrer?: string | null;
|
||||||
|
crm_tags?: string | null;
|
||||||
|
crm_contact_id?: string | null;
|
||||||
|
crm_synced_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmOutboxState {
|
||||||
|
pendiente: number;
|
||||||
|
enviando: number;
|
||||||
|
confirmado: number;
|
||||||
|
fallido: number;
|
||||||
|
indeterminado: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmSyncRun {
|
||||||
|
id: number;
|
||||||
|
kind: string;
|
||||||
|
status: "corriendo" | "ok" | "error";
|
||||||
|
fetched: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmStatus {
|
||||||
|
connected: boolean;
|
||||||
|
location_id?: string;
|
||||||
|
pipeline_id?: string | null;
|
||||||
|
/** Si es false, cada clienta tiene UNA oportunidad que se recicla por cita. */
|
||||||
|
allow_duplicate_opp?: boolean;
|
||||||
|
last_sync_at?: string | null;
|
||||||
|
last_sync_status?: string | null;
|
||||||
|
stats?: {
|
||||||
|
clientes: number;
|
||||||
|
sincronizados: number;
|
||||||
|
contactables: number;
|
||||||
|
con_campana: number;
|
||||||
|
};
|
||||||
|
last_run?: CrmSyncRun | null;
|
||||||
|
outbox?: CrmOutboxState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmSyncResult {
|
||||||
|
runId: number;
|
||||||
|
fetched: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
total_crm: number;
|
||||||
|
status: "ok" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
crm_conversation_id: string;
|
||||||
|
crm_contact_id: string | null;
|
||||||
|
contact_name: string;
|
||||||
|
last_message_body: string | null;
|
||||||
|
last_message_type: string | null;
|
||||||
|
last_message_at: string | number | null;
|
||||||
|
unread_count: number;
|
||||||
|
client: { id: number; name: string; contactable: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationMessage {
|
||||||
|
id: string;
|
||||||
|
body: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
channel: string | null;
|
||||||
|
status: string | null;
|
||||||
|
sent_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResult {
|
||||||
|
/** El CRM acusa ENCOLADO, no entrega. La interfaz no debe decir «entregado». */
|
||||||
|
queued: boolean;
|
||||||
|
sent_to: string;
|
||||||
|
redirigido: boolean;
|
||||||
|
aviso: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una cuenta de la plataforma, vista desde la consola de administración. */
|
||||||
|
export interface PlatformAccount {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
timezone: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
clientes: number;
|
||||||
|
usuarios: number;
|
||||||
|
/** Vínculo con Bucéfalo CRM. `null` si la cuenta no está vinculada. */
|
||||||
|
location_id: string | null;
|
||||||
|
crm_label: string | null;
|
||||||
|
/** Los 6 últimos caracteres del token. El token nunca sale del servidor. */
|
||||||
|
token_fingerprint: string | null;
|
||||||
|
token_updated_at: string | null;
|
||||||
|
pipeline_id: string | null;
|
||||||
|
calendar_id: string | null;
|
||||||
|
allow_real_sends: boolean | null;
|
||||||
|
test_email: string | null;
|
||||||
|
last_sync_at: string | null;
|
||||||
|
last_sync_status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Las cinco entidades que se pueden sincronizar por su identificador. */
|
||||||
|
export type CrmEntidad = "contacto" | "conversacion" | "mensaje" | "cita" | "servicio";
|
||||||
|
|
||||||
|
export interface CrmSyncUnoResult {
|
||||||
|
entidad: CrmEntidad;
|
||||||
|
id: string;
|
||||||
|
accion: string;
|
||||||
|
detalle: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import { ClientsPage } from "./pages/ClientsPage";
|
|||||||
import { TicketsPage } from "./pages/TicketsPage";
|
import { TicketsPage } from "./pages/TicketsPage";
|
||||||
import { ClientDetailPage } from "./pages/ClientDetailPage";
|
import { ClientDetailPage } from "./pages/ClientDetailPage";
|
||||||
import { CashPage } from "./pages/CashPage";
|
import { CashPage } from "./pages/CashPage";
|
||||||
|
import { DayClosePage } from "./pages/DayClosePage";
|
||||||
|
import { MessagesPage } from "./pages/MessagesPage";
|
||||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
||||||
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
||||||
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
||||||
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
||||||
|
import PlatformAccountsPage from "./pages/admin/PlatformAccountsPage";
|
||||||
import BookingPage from "./pages/public/BookingPage";
|
import BookingPage from "./pages/public/BookingPage";
|
||||||
|
|
||||||
// Las dos páginas públicas van aparte del bundle principal por dos razones que
|
// Las dos páginas públicas van aparte del bundle principal por dos razones que
|
||||||
@@ -103,6 +106,7 @@ function AppRoutes() {
|
|||||||
{user && isAdmin && (
|
{user && isAdmin && (
|
||||||
<Route element={<AdminShell />}>
|
<Route element={<AdminShell />}>
|
||||||
<Route path="/admin" element={<AdminOverviewPage />} />
|
<Route path="/admin" element={<AdminOverviewPage />} />
|
||||||
|
<Route path="/admin/cuentas" element={<PlatformAccountsPage />} />
|
||||||
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
|
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
|
||||||
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
|
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
@@ -121,6 +125,11 @@ function AppRoutes() {
|
|||||||
<Route path="/clients" element={<ClientsPage />} />
|
<Route path="/clients" element={<ClientsPage />} />
|
||||||
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
||||||
<Route path="/me" element={<MyPerformancePage />} />
|
<Route path="/me" element={<MyPerformancePage />} />
|
||||||
|
{/* Sin restricción de rol a propósito: el cierre de día es operación,
|
||||||
|
no finanzas, y la empleada tiene que poder resolver sus citas. El
|
||||||
|
servidor devuelve 403 si intenta resolver una cita ajena. */}
|
||||||
|
<Route path="/cierre-dia" element={<DayClosePage />} />
|
||||||
|
<Route path="/mensajes" element={<MessagesPage />} />
|
||||||
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
|
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
|
||||||
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
|
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
|
||||||
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import {
|
import { Link2,
|
||||||
Building2,
|
Building2,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -27,6 +27,9 @@ interface NavItem {
|
|||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: "/admin", label: "Resumen", icon: LayoutDashboard, end: true },
|
{ to: "/admin", label: "Resumen", icon: LayoutDashboard, end: true },
|
||||||
{ to: "/admin/businesses", label: "Negocios", icon: Building2 },
|
{ to: "/admin/businesses", label: "Negocios", icon: Building2 },
|
||||||
|
// Cuentas de la plataforma Postgres, con su vínculo a Bucéfalo CRM. Convive
|
||||||
|
// con «Negocios», que es la consola del backend de demo.
|
||||||
|
{ to: "/admin/cuentas", label: "Cuentas y CRM", icon: Link2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminShell() {
|
export function AdminShell() {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Suspense, useState } from "react";
|
|||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
CalendarCheck,
|
||||||
|
MessageSquare,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
Scissors,
|
Scissors,
|
||||||
@@ -40,6 +42,8 @@ const NAV: NavItem[] = [
|
|||||||
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
|
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
|
||||||
{ to: "/clients", label: "Clientes", icon: Users },
|
{ to: "/clients", label: "Clientes", icon: Users },
|
||||||
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
|
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
|
||||||
|
{ to: "/cierre-dia", label: "Cierre de día", icon: CalendarCheck },
|
||||||
|
{ to: "/mensajes", label: "Mensajes", icon: MessageSquare },
|
||||||
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
|
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
|
||||||
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
|
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
|
||||||
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
|
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw, CheckCircle2, AlertTriangle, Link2, Users } from "lucide-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { Spinner } from "./ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El panel de sincronización con Bucéfalo CRM, para la pantalla de clientes.
|
||||||
|
*
|
||||||
|
* Muestra tres cosas y ninguna es decorativa: cuántas clientas están realmente
|
||||||
|
* ancladas al CRM, **cuántas son contactables** —que es el techo de utilidad de
|
||||||
|
* toda la agenda, y hoy son 6 de cada 10— y qué quedó sin sincronizar.
|
||||||
|
*/
|
||||||
|
export function CrmSyncPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["crm-status"],
|
||||||
|
queryFn: () => api.crm.status(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sync = useMutation({
|
||||||
|
mutationFn: () => api.crm.syncContacts(),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["crm-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["clients"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const conectar = useMutation({
|
||||||
|
mutationFn: () => api.crm.connect(),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["crm-status"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flush = useMutation({
|
||||||
|
mutationFn: () => api.crm.flushOutbox(),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["crm-status"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
if (!data?.connected) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-dashed border-slate-300 bg-white p-4">
|
||||||
|
<Link2 className="h-5 w-5 shrink-0 text-slate-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Sin conectar a Bucéfalo CRM</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Conecta la subcuenta para traer los contactos con su atribución.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary tap-target"
|
||||||
|
disabled={conectar.isPending}
|
||||||
|
onClick={() => conectar.mutate()}
|
||||||
|
>
|
||||||
|
{conectar.isPending ? "Conectando…" : "Conectar"}
|
||||||
|
</button>
|
||||||
|
{conectar.isError && (
|
||||||
|
<p className="basis-full text-sm text-red-600">{(conectar.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = data.stats;
|
||||||
|
const pendientes = (data.outbox?.pendiente ?? 0) + (data.outbox?.indeterminado ?? 0);
|
||||||
|
const fallidos = data.outbox?.fallido ?? 0;
|
||||||
|
const pctContactables = s && s.clientes ? Math.round((s.contactables / s.clientes) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
{/* El botón baja a su propia fila en teléfono: compitiendo por el ancho
|
||||||
|
con el texto se salía de la tarjeta y lo pisaba. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Users className="h-5 w-5 shrink-0 text-brand-600" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Bucéfalo CRM</p>
|
||||||
|
<p className="truncate text-sm text-slate-500">
|
||||||
|
{data.last_sync_at
|
||||||
|
? `Última sincronización: ${new Date(data.last_sync_at).toLocaleString("es-MX")}`
|
||||||
|
: "Todavía no se ha sincronizado"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary tap-target inline-flex basis-full items-center justify-center gap-2 sm:basis-auto"
|
||||||
|
disabled={sync.isPending}
|
||||||
|
onClick={() => sync.mutate()}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${sync.isPending ? "animate-spin" : ""}`} />
|
||||||
|
{sync.isPending ? "Sincronizando…" : "Sincronizar contactos"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{s && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Dato label="Clientas" valor={s.clientes} />
|
||||||
|
<Dato label="Ancladas al CRM" valor={s.sincronizados} />
|
||||||
|
{/* Es el dato incómodo y por eso está a la vista: una agenda no puede
|
||||||
|
avisarle a quien no dejó teléfono. */}
|
||||||
|
<Dato
|
||||||
|
label="Contactables"
|
||||||
|
valor={`${s.contactables} · ${pctContactables}%`}
|
||||||
|
tono={pctContactables < 70 ? "text-amber-600" : "text-emerald-600"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sync.isPending && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<Spinner /> Trayendo contactos del CRM. Con ~3 200 tarda unos 25 segundos.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sync.isSuccess && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-emerald-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{sync.data.created} nuevas y {sync.data.updated} actualizadas, de{" "}
|
||||||
|
{sync.data.fetched} leídas del CRM.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sync.isError && (
|
||||||
|
<p className="flex items-start gap-2 text-sm text-red-600">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
{(sync.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(pendientes > 0 || fallidos > 0) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-amber-50 p-3">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600" />
|
||||||
|
<p className="min-w-0 flex-1 text-sm text-amber-900">
|
||||||
|
{pendientes > 0 && `${pendientes} cambio(s) sin enviar al CRM. `}
|
||||||
|
{fallidos > 0 && `${fallidos} fallido(s).`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tap-target rounded-lg border border-amber-300 px-3 text-sm font-bold text-amber-900 disabled:opacity-50"
|
||||||
|
disabled={flush.isPending}
|
||||||
|
onClick={() => flush.mutate()}
|
||||||
|
>
|
||||||
|
{flush.isPending ? "Enviando…" : "Reintentar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.allow_duplicate_opp === false && (
|
||||||
|
<p className="text-xs leading-relaxed text-slate-500">
|
||||||
|
La subcuenta tiene desactivado «permitir oportunidades duplicadas», así que cada
|
||||||
|
clienta tiene <strong>una</strong> oportunidad que se recicla en cada cita. Para que
|
||||||
|
cada cita estrene la suya hay que activar ese ajuste en el CRM.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dato({
|
||||||
|
label,
|
||||||
|
valor,
|
||||||
|
tono = "text-slate-900",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
valor: number | string;
|
||||||
|
tono?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-slate-50 p-3">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">{label}</div>
|
||||||
|
<div className={`text-lg font-extrabold ${tono}`}>{valor}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+107
@@ -1,4 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
|
PlatformAccount,
|
||||||
|
CrmEntidad,
|
||||||
|
CrmSyncUnoResult,
|
||||||
Appointment,
|
Appointment,
|
||||||
Business,
|
Business,
|
||||||
Client,
|
Client,
|
||||||
@@ -12,6 +15,15 @@ import type {
|
|||||||
Ticket,
|
Ticket,
|
||||||
User,
|
User,
|
||||||
CategorySlice,
|
CategorySlice,
|
||||||
|
AttendanceResult,
|
||||||
|
DayCloseSummary,
|
||||||
|
DayClosure,
|
||||||
|
PaymentMethod,
|
||||||
|
CrmStatus,
|
||||||
|
CrmSyncResult,
|
||||||
|
ConversationSummary,
|
||||||
|
ConversationMessage,
|
||||||
|
SendMessageResult,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
|
||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
@@ -72,6 +84,36 @@ export const api = {
|
|||||||
request<any>(`/admin/businesses/${id}/seed-template`, { method: "POST", body: JSON.stringify(data) }),
|
request<any>(`/admin/businesses/${id}/seed-template`, { method: "POST", body: JSON.stringify(data) }),
|
||||||
resetDemo: (id: number, data: any) =>
|
resetDemo: (id: number, data: any) =>
|
||||||
request<any>(`/admin/businesses/${id}/reset-demo`, { method: "POST", body: JSON.stringify(data) }),
|
request<any>(`/admin/businesses/${id}/reset-demo`, { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// ── Consola de plataforma del backend Postgres ───────────────────────
|
||||||
|
// Conviven con las de arriba a propósito: el frontend habla con el
|
||||||
|
// backend que tenga configurado, y cada uno expone su propia superficie.
|
||||||
|
|
||||||
|
accounts: () => request<{ businesses: PlatformAccount[] }>("/admin/businesses"),
|
||||||
|
createAccount: (b: {
|
||||||
|
name: string;
|
||||||
|
owner_email: string;
|
||||||
|
owner_name: string;
|
||||||
|
owner_password: string;
|
||||||
|
timezone?: string;
|
||||||
|
industry?: string;
|
||||||
|
}) =>
|
||||||
|
request<{ business: PlatformAccount; owner: { id: number; email: string } }>(
|
||||||
|
"/admin/businesses",
|
||||||
|
{ method: "POST", body: JSON.stringify(b) }
|
||||||
|
),
|
||||||
|
linkCrm: (id: number, b: { location_id: string; token: string; label?: string }) =>
|
||||||
|
request<{ ok: true; location_id: string; label: string | null }>(
|
||||||
|
`/admin/businesses/${id}/crm`,
|
||||||
|
{ method: "PUT", body: JSON.stringify(b) }
|
||||||
|
),
|
||||||
|
unlinkCrm: (id: number) =>
|
||||||
|
request<{ ok: true }>(`/admin/businesses/${id}/crm`, { method: "DELETE" }),
|
||||||
|
updateAccount: (id: number, b: { status?: string; name?: string; timezone?: string }) =>
|
||||||
|
request<{ business: PlatformAccount }>(`/admin/businesses/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(b),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
business: {
|
business: {
|
||||||
get: () => request<{ business: Business }>("/business"),
|
get: () => request<{ business: Business }>("/business"),
|
||||||
@@ -97,6 +139,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
clients: {
|
clients: {
|
||||||
list: (q?: string) => request<{ clients: Client[] }>(`/clients${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
list: (q?: string) => request<{ clients: Client[] }>(`/clients${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||||
|
get: (id: number) => request<{ client: Client }>(`/clients/${id}`),
|
||||||
create: (data: any) => request<{ client: Client }>("/clients", { method: "POST", body: JSON.stringify(data) }),
|
create: (data: any) => request<{ client: Client }>("/clients", { method: "POST", body: JSON.stringify(data) }),
|
||||||
update: (id: number, data: any) =>
|
update: (id: number, data: any) =>
|
||||||
request<{ client: Client }>(`/clients/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
request<{ client: Client }>(`/clients/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
@@ -118,6 +161,70 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
|
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
|
||||||
|
/**
|
||||||
|
* El toque de asistencia. Un solo POST resuelve la cita: si vino, además
|
||||||
|
* crea la visita. El importe es opcional a propósito — pedirlo antes de
|
||||||
|
* poder marcar convertiría un toque en un formulario.
|
||||||
|
*/
|
||||||
|
attendance: (
|
||||||
|
id: number,
|
||||||
|
body: { attended: boolean; total_charged?: number; payment_method?: PaymentMethod }
|
||||||
|
) =>
|
||||||
|
request<AttendanceResult>(`/appointments/${id}/attendance`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
cancel: (id: number, body: { cancelled_by: "client" | "business"; reason?: string }) =>
|
||||||
|
request<{ appointment: Appointment }>(`/appointments/${id}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
crm: {
|
||||||
|
status: () => request<CrmStatus>("/crm/status"),
|
||||||
|
connect: () => request<{ connection: unknown }>("/crm/connect", { method: "POST", body: "{}" }),
|
||||||
|
syncContacts: () =>
|
||||||
|
request<CrmSyncResult>("/crm/sync/contacts", { method: "POST", body: "{}" }),
|
||||||
|
flushOutbox: () =>
|
||||||
|
request<{ tomadas: number; confirmadas: number; fallidas: number; indeterminadas: number }>(
|
||||||
|
"/crm/outbox/flush",
|
||||||
|
{ method: "POST", body: "{}" }
|
||||||
|
),
|
||||||
|
outbox: () => request<{ items: any[]; resumen: any }>("/crm/outbox"),
|
||||||
|
/** Sincroniza UNA entidad por su identificador. */
|
||||||
|
syncOne: (entidad: CrmEntidad, id: string) =>
|
||||||
|
request<CrmSyncUnoResult>(`/crm/sync/${entidad}/${encodeURIComponent(id)}`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
/** Espeja las conversaciones recientes con sus mensajes. */
|
||||||
|
syncConversations: (limite = 50) =>
|
||||||
|
request<{ conversaciones: number; mensajes: number }>("/crm/sync/conversations", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ limite }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
messages: {
|
||||||
|
list: (limit = 20) =>
|
||||||
|
request<{ total: number; conversations: ConversationSummary[] }>(
|
||||||
|
`/messages?limit=${limit}`
|
||||||
|
),
|
||||||
|
thread: (conversationId: string) =>
|
||||||
|
request<{ messages: ConversationMessage[] }>(`/messages/${conversationId}`),
|
||||||
|
send: (body: { client_id?: number; crm_contact_id?: string; subject: string; body: string }) =>
|
||||||
|
request<SendMessageResult>("/messages/send", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
dayClose: {
|
||||||
|
get: (date?: string) =>
|
||||||
|
request<DayCloseSummary>(`/day-close${date ? `?date=${date}` : ""}`),
|
||||||
|
close: (date: string) =>
|
||||||
|
request<{ closure: DayClosure }>(`/day-close`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ date }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
overview: (range?: number) =>
|
overview: (range?: number) =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import type { Client } from "../../shared/types";
|
||||||
import { PageHeader, Avatar, Spinner, EmptyState, Badge } from "../components/ui";
|
import { PageHeader, Avatar, Spinner, EmptyState, Badge } from "../components/ui";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import {
|
import {
|
||||||
@@ -28,11 +29,14 @@ export function ClientDetailPage() {
|
|||||||
const clientId = Number(id);
|
const clientId = Number(id);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
// Se pide la ficha por su id y no se busca dentro de la lista: la lista
|
||||||
|
// devuelve 50 filas, y con 3 200 clientas sincronizadas del CRM la ficha
|
||||||
|
// sencillamente no aparecía.
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["clients", clientId],
|
queryKey: ["clients", clientId],
|
||||||
queryFn: () => api.clients.list().then((r) => ({ ...r, target: r.clients.find((c) => c.id === clientId) })),
|
queryFn: () => api.clients.get(clientId),
|
||||||
});
|
});
|
||||||
const client = data?.target;
|
const client = data?.client;
|
||||||
const { data: appts } = useQuery({
|
const { data: appts } = useQuery({
|
||||||
queryKey: ["clients", clientId, "appts"],
|
queryKey: ["clients", clientId, "appts"],
|
||||||
queryFn: () => api.appointments.list({ client_id: clientId, limit: 50 }),
|
queryFn: () => api.appointments.list({ client_id: clientId, limit: 50 }),
|
||||||
@@ -104,6 +108,8 @@ export function ClientDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AtribucionCard client={client} />
|
||||||
|
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">Resumen</h4>
|
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">Resumen</h4>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -241,3 +247,55 @@ function ClientEditModal({ open, client, onClose }: { open: boolean; client: any
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De dónde vino la clienta.
|
||||||
|
*
|
||||||
|
* Es dato del CRM y **de solo lectura**: allá la atribución se escribe en el
|
||||||
|
* alta y es inmutable después, así que un formulario aquí prometería algo que
|
||||||
|
* no se puede cumplir. Solo se pinta si hay algo que contar — una tarjeta vacía
|
||||||
|
* en cada ficha es ruido.
|
||||||
|
*/
|
||||||
|
function AtribucionCard({ client }: { client: Client }) {
|
||||||
|
const filas: [string, string | null | undefined][] = [
|
||||||
|
["Fuente", client.attr_session_source],
|
||||||
|
["Medio", client.attr_medium],
|
||||||
|
["Campaña", client.attr_campaign],
|
||||||
|
["utm_source", client.attr_utm_source],
|
||||||
|
["utm_medium", client.attr_utm_medium],
|
||||||
|
["Contenido", client.attr_utm_content],
|
||||||
|
["Anuncio", client.attr_ad_id],
|
||||||
|
["Origen en el CRM", client.crm_source],
|
||||||
|
];
|
||||||
|
const conValor = filas.filter(([, v]) => v);
|
||||||
|
if (!conValor.length && !client.crm_contact_id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">
|
||||||
|
Adquisición
|
||||||
|
</h4>
|
||||||
|
{conValor.length ? (
|
||||||
|
<dl className="space-y-1.5 text-xs">
|
||||||
|
{conValor.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex gap-2">
|
||||||
|
<dt className="w-28 shrink-0 text-slate-400">{k}</dt>
|
||||||
|
<dd className="min-w-0 break-words font-medium text-slate-700">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
El CRM no registró de dónde vino esta clienta.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{client.crm_synced_at && (
|
||||||
|
<p className="mt-3 border-t border-slate-100 pt-2 text-[11px] text-slate-400">
|
||||||
|
{/* La fecha de sincronización se muestra siempre: presentar dato de un
|
||||||
|
espejo como si fuera de ahora mismo es cómo se pierde la confianza. */}
|
||||||
|
Sincronizado del CRM el {formatDate(client.crm_synced_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Search, Users, Mail, Phone, DollarSign, Calendar, ChevronRight, Sparkle
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
|
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
|
||||||
import { formatCurrency, formatDate, formatRelative } from "../lib/format";
|
import { formatCurrency, formatDate, formatRelative } from "../lib/format";
|
||||||
|
import { CrmSyncPanel } from "../components/CrmSyncPanel";
|
||||||
|
|
||||||
export function ClientsPage() {
|
export function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -31,6 +32,10 @@ export function ClientsPage() {
|
|||||||
subtitle="Busca, edita y revisa el historial de tus clientes."
|
subtitle="Busca, edita y revisa el historial de tus clientes."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="px-5 pt-4 sm:px-7">
|
||||||
|
<CrmSyncPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pt-4 sm:px-7">
|
<div className="px-5 pt-4 sm:px-7">
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CalendarCheck, CheckCircle2, Lock, XCircle } from "lucide-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { PageHeader, EmptyState, Spinner } from "../components/ui";
|
||||||
|
import { formatTime } from "../lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El cierre de día.
|
||||||
|
*
|
||||||
|
* Es la pieza por la que existe el proyecto: hoy el negocio no tiene el dato de
|
||||||
|
* si la clienta vino. La regla de diseño es que registrar sea el camino más
|
||||||
|
* corto para trabajar, no una tarea añadida al final — de ahí que la resolución
|
||||||
|
* sea **un solo toque** por cita, sin diálogo intermedio ni formulario, y que el
|
||||||
|
* importe sea opcional.
|
||||||
|
*/
|
||||||
|
export function DayClosePage() {
|
||||||
|
const [date, setDate] = useState(() => {
|
||||||
|
const now = new Date();
|
||||||
|
// Fecha local del navegador, que es la del spa: el `toISOString()` de aquí
|
||||||
|
// devolvería el día UTC y a partir de las 18:00 mostraría el día siguiente.
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
|
||||||
|
});
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["day-close", date],
|
||||||
|
queryFn: () => api.dayClose.get(date),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendance = useMutation({
|
||||||
|
mutationFn: (v: { id: number; attended: boolean }) =>
|
||||||
|
api.appointments.attendance(v.id, { attended: v.attended }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["day-close", date] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = useMutation({
|
||||||
|
mutationFn: () => api.dayClose.close(date),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["day-close", date] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendientes = data?.unresolved.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title="Cierre de día"
|
||||||
|
subtitle="Marca si cada clienta vino o no vino. Es el dato que hoy no queda registrado en ninguna parte."
|
||||||
|
actions={
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input w-full sm:w-auto"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
aria-label="Día a cerrar"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 pb-8 pt-5 sm:px-7">
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<div className="flex justify-center py-16 text-slate-400">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Tres columnas desde el teléfono, no `sm:grid-cols-3`: apiladas
|
||||||
|
ocupaban media pantalla y empujaban la lista fuera de la vista,
|
||||||
|
que es justo lo que la empleada viene a tocar. */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||||
|
<Metric label="Asistieron" value={data.attended} tone="text-emerald-600" />
|
||||||
|
<Metric label="No asistieron" value={data.no_show} tone="text-amber-600" />
|
||||||
|
<Metric label="Canceladas" value={data.cancelled} tone="text-slate-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendientes === 0 ? (
|
||||||
|
<div className="rounded-2xl bg-white shadow-soft">
|
||||||
|
<EmptyState
|
||||||
|
icon={data.closed_at ? Lock : CalendarCheck}
|
||||||
|
title="No queda ninguna cita sin resolver"
|
||||||
|
description={
|
||||||
|
data.closed_at
|
||||||
|
? "Este día ya está cerrado."
|
||||||
|
: "Ya puedes cerrar el día."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data.unresolved.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-2xl bg-white p-3 shadow-soft"
|
||||||
|
>
|
||||||
|
{/* `basis-full` en teléfono: compitiendo por el ancho con
|
||||||
|
los dos botones, el servicio se truncaba a «Exte…» y la
|
||||||
|
empleada no podía saber a qué cita decía que sí. */}
|
||||||
|
<div className="min-w-0 basis-full sm:flex-1 sm:basis-auto">
|
||||||
|
<p className="truncate text-sm font-bold text-slate-900">{a.client_name}</p>
|
||||||
|
<p className="truncate text-sm text-slate-500">
|
||||||
|
{formatTime(a.start_at)} · {a.service_name} · {a.employee_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 gap-2 sm:flex-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tap-target inline-flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-600 px-3 text-sm font-bold text-white disabled:opacity-50 sm:flex-none"
|
||||||
|
disabled={attendance.isPending}
|
||||||
|
onClick={() => attendance.mutate({ id: a.id, attended: true })}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> Vino
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tap-target inline-flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-slate-300 px-3 text-sm font-bold text-slate-700 disabled:opacity-50 sm:flex-none"
|
||||||
|
disabled={attendance.isPending}
|
||||||
|
onClick={() => attendance.mutate({ id: a.id, attended: false })}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" /> No vino
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attendance.isError && (
|
||||||
|
<p className="text-sm text-red-600">{(attendance.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-slate-200 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary inline-flex items-center gap-2 disabled:opacity-50"
|
||||||
|
disabled={pendientes > 0 || !!data.closed_at || close.isPending}
|
||||||
|
onClick={() => close.mutate()}
|
||||||
|
>
|
||||||
|
{data.closed_at ? <Lock className="h-4 w-4" /> : <CalendarCheck className="h-4 w-4" />}
|
||||||
|
{data.closed_at ? "Día cerrado" : "Cerrar el día"}
|
||||||
|
</button>
|
||||||
|
{/* El botón no se esconde cuando falta trabajo: se explica. */}
|
||||||
|
{pendientes > 0 && (
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
Faltan {pendientes} cita{pendientes === 1 ? "" : "s"} por resolver.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{close.isError && (
|
||||||
|
<span className="text-sm text-red-600">{(close.error as Error).message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">{label}</div>
|
||||||
|
<div className={`text-2xl font-extrabold ${tone}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Send, MessageSquare, Mail, AlertTriangle, ArrowLeft } from "lucide-react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { PageHeader, EmptyState, Spinner } from "../components/ui";
|
||||||
|
import type { ConversationSummary } from "../../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bandeja de mensajes.
|
||||||
|
*
|
||||||
|
* El CRM sigue siendo el sistema de mensajería omnicanal: aquí se lee y se
|
||||||
|
* responde, pero el histórico vive allá. La razón de que esta pantalla exista
|
||||||
|
* es tener la ficha de la clienta y su agenda **al lado** del hilo — ese «al
|
||||||
|
* lado» es lo único que haría que alguien deje de contestar en otra app.
|
||||||
|
*
|
||||||
|
* Hoy solo el correo está conectado en la subcuenta. WhatsApp y SMS no, y la
|
||||||
|
* interfaz lo dice en vez de ofrecer un botón que falla.
|
||||||
|
*/
|
||||||
|
export function MessagesPage() {
|
||||||
|
const [abierta, setAbierta] = useState<ConversationSummary | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["conversations"],
|
||||||
|
queryFn: () => api.messages.list(25),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title="Mensajes"
|
||||||
|
subtitle="Conversaciones del CRM. Hoy solo se puede responder por correo."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 pb-8 pt-5 sm:px-7">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16 text-slate-400">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="rounded-2xl bg-white p-6 shadow-soft">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertTriangle}
|
||||||
|
title="No se pudo leer la bandeja"
|
||||||
|
description={(error as Error).message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : abierta ? (
|
||||||
|
<Hilo conversacion={abierta} onVolver={() => setAbierta(null)} />
|
||||||
|
) : !data?.conversations.length ? (
|
||||||
|
<div className="rounded-2xl bg-white shadow-soft">
|
||||||
|
<EmptyState icon={MessageSquare} title="No hay conversaciones" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data.conversations.map((c) => (
|
||||||
|
<li key={c.crm_conversation_id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl bg-white p-3 text-left shadow-soft"
|
||||||
|
onClick={() => setAbierta(c)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-slate-900">
|
||||||
|
{c.contact_name}
|
||||||
|
{c.unread_count > 0 && (
|
||||||
|
<span className="ml-2 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||||
|
{c.unread_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-slate-500">
|
||||||
|
{c.last_message_body || <em>sin texto</em>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{c.last_message_type && (
|
||||||
|
<span className="shrink-0 rounded-lg bg-slate-100 px-2 py-1 text-[10px] font-bold uppercase text-slate-500">
|
||||||
|
{String(c.last_message_type).replace(/^TYPE_/, "")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hilo({
|
||||||
|
conversacion,
|
||||||
|
onVolver,
|
||||||
|
}: {
|
||||||
|
conversacion: ConversationSummary;
|
||||||
|
onVolver: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [asunto, setAsunto] = useState("");
|
||||||
|
const [cuerpo, setCuerpo] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["thread", conversacion.crm_conversation_id],
|
||||||
|
queryFn: () => api.messages.thread(conversacion.crm_conversation_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const enviar = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.messages.send({
|
||||||
|
client_id: conversacion.client?.id,
|
||||||
|
crm_contact_id: conversacion.crm_contact_id ?? undefined,
|
||||||
|
subject: asunto,
|
||||||
|
body: cuerpo,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setAsunto("");
|
||||||
|
setCuerpo("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["thread", conversacion.crm_conversation_id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tap-target inline-flex items-center gap-2 text-sm font-bold text-slate-600"
|
||||||
|
onClick={onVolver}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Volver a la bandeja
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<p className="text-sm font-bold text-slate-900">{conversacion.contact_name}</p>
|
||||||
|
{conversacion.client ? (
|
||||||
|
<a
|
||||||
|
className="text-sm text-brand-600 underline"
|
||||||
|
href={`/clients/${conversacion.client.id}`}
|
||||||
|
>
|
||||||
|
Ver ficha de la clienta
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Sin ficha local. Sincroniza los contactos para enlazarla.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8 text-slate-400">
|
||||||
|
<Spinner className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(data?.messages ?? []).map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className={`max-w-[85%] rounded-2xl p-3 text-sm shadow-soft ${
|
||||||
|
m.direction === "outbound"
|
||||||
|
? "ml-auto bg-brand-50 text-slate-900"
|
||||||
|
: "bg-white text-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap break-words">{m.body || <em>sin texto</em>}</p>
|
||||||
|
<p className="mt-1 text-[10px] uppercase tracking-wide text-slate-400">
|
||||||
|
{m.channel} {m.status ? `· ${m.status}` : ""}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-2xl bg-white p-4 shadow-soft">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-bold text-slate-900">
|
||||||
|
<Mail className="h-4 w-4" /> Responder por correo
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Asunto"
|
||||||
|
value={asunto}
|
||||||
|
onChange={(e) => setAsunto(e.target.value)}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Escribe tu mensaje…"
|
||||||
|
value={cuerpo}
|
||||||
|
onChange={(e) => setCuerpo(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary tap-target inline-flex items-center gap-2 disabled:opacity-50"
|
||||||
|
disabled={!asunto.trim() || !cuerpo.trim() || enviar.isPending}
|
||||||
|
onClick={() => enviar.mutate()}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{enviar.isPending ? "Enviando…" : "Enviar"}
|
||||||
|
</button>
|
||||||
|
{enviar.isSuccess && (
|
||||||
|
<span className="text-sm text-emerald-700">
|
||||||
|
{/* El CRM acusa encolado, no entrega: la interfaz no promete más. */}
|
||||||
|
En camino a {enviar.data.sent_to}.
|
||||||
|
{enviar.data.aviso && ` ${enviar.data.aviso}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{enviar.isError && (
|
||||||
|
<span className="text-sm text-red-600">{(enviar.error as Error).message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
WhatsApp y SMS todavía no están conectados en esta subcuenta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Building2, Link2, Unlink, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { Spinner } from "../../components/ui";
|
||||||
|
import { Modal } from "../../components/Modal";
|
||||||
|
import { formatDate } from "../../lib/format";
|
||||||
|
import type { PlatformAccount } from "../../../shared/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La consola de cuentas de la plataforma.
|
||||||
|
*
|
||||||
|
* Dos reglas de esta pantalla no son opcionales:
|
||||||
|
*
|
||||||
|
* 1. **El campo del token nunca se rellena con un valor existente**, porque no
|
||||||
|
* hay valor existente que traer: el servidor no lo devuelve nunca. Lo único
|
||||||
|
* que se muestra es la huella de 6 caracteres, para poder distinguir un token
|
||||||
|
* de otro y ver si alguien lo rotó.
|
||||||
|
* 2. **Los errores muestran el mensaje del servidor**, no uno fijo. El servidor
|
||||||
|
* distingue «el token no vale» de «la subcuenta no existe» de «el token es de
|
||||||
|
* otra subcuenta», y esa diferencia es justo lo que necesita quien vincula.
|
||||||
|
*/
|
||||||
|
export default function PlatformAccountsPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [alta, setAlta] = useState(false);
|
||||||
|
const [vinculando, setVinculando] = useState<PlatformAccount | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["admin", "accounts"],
|
||||||
|
queryFn: () => api.admin.accounts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||||
|
<AlertCircle className="h-6 w-6 text-rose-400" />
|
||||||
|
<p className="text-sm font-medium text-slate-700">
|
||||||
|
{error instanceof Error ? error.message : "No pudimos cargar las cuentas."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuentas = data?.businesses ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">Cuentas de la plataforma</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{cuentas.length} {cuentas.length === 1 ? "cuenta" : "cuentas"} ·{" "}
|
||||||
|
{cuentas.filter((c) => c.token_fingerprint).length} vinculadas a Bucéfalo CRM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-primary tap-target" onClick={() => setAlta(true)}>
|
||||||
|
<Plus className="h-4 w-4" /> Nueva cuenta
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
<th className="px-4 py-3">Negocio</th>
|
||||||
|
<th className="px-4 py-3">Clientas</th>
|
||||||
|
<th className="px-4 py-3">Bucéfalo CRM</th>
|
||||||
|
<th className="px-4 py-3">Última sincronización</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cuentas.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-slate-100 last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 font-semibold text-slate-900">
|
||||||
|
<Building2 className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
/{c.slug} · {c.timezone}
|
||||||
|
{c.status !== "active" && (
|
||||||
|
<span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-700">
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 tabular-nums text-slate-700">{c.clientes}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{c.token_fingerprint ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-emerald-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{c.crm_label || c.location_id}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-slate-500">
|
||||||
|
token …{c.token_fingerprint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">Sin vincular</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-slate-500">
|
||||||
|
{c.last_sync_at ? formatDate(c.last_sync_at) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary tap-target"
|
||||||
|
onClick={() => setVinculando(c)}
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
{c.token_fingerprint ? "Cambiar token" : "Vincular CRM"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alta && (
|
||||||
|
<ModalAlta
|
||||||
|
onClose={() => setAlta(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setAlta(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "accounts"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vinculando && (
|
||||||
|
<ModalVinculo
|
||||||
|
cuenta={vinculando}
|
||||||
|
onClose={() => setVinculando(null)}
|
||||||
|
onDone={() => {
|
||||||
|
setVinculando(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "accounts"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalAlta({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
|
||||||
|
const [f, setF] = useState({
|
||||||
|
name: "",
|
||||||
|
owner_name: "",
|
||||||
|
owner_email: "",
|
||||||
|
owner_password: "",
|
||||||
|
timezone: "America/Mexico_City",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mut = useMutation({
|
||||||
|
mutationFn: () => api.admin.createAccount(f),
|
||||||
|
onSuccess: onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listo = f.name && f.owner_name && f.owner_email && f.owner_password;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open title="Nueva cuenta" onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Campo id="ac-name" label="Nombre del negocio">
|
||||||
|
<input
|
||||||
|
id="ac-name"
|
||||||
|
className="input"
|
||||||
|
value={f.name}
|
||||||
|
onChange={(e) => setF({ ...f, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<Campo id="ac-tz" label="Zona horaria">
|
||||||
|
<input
|
||||||
|
id="ac-tz"
|
||||||
|
className="input"
|
||||||
|
value={f.timezone}
|
||||||
|
onChange={(e) => setF({ ...f, timezone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<div className="pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Su administradora
|
||||||
|
</div>
|
||||||
|
<Campo id="ac-oname" label="Nombre">
|
||||||
|
<input
|
||||||
|
id="ac-oname"
|
||||||
|
className="input"
|
||||||
|
value={f.owner_name}
|
||||||
|
onChange={(e) => setF({ ...f, owner_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<Campo id="ac-omail" label="Correo">
|
||||||
|
<input
|
||||||
|
id="ac-omail"
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={f.owner_email}
|
||||||
|
onChange={(e) => setF({ ...f, owner_email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<Campo id="ac-opass" label="Contraseña inicial">
|
||||||
|
<input
|
||||||
|
id="ac-opass"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={f.owner_password}
|
||||||
|
onChange={(e) => setF({ ...f, owner_password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
|
||||||
|
{mut.isError && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-rose-600">
|
||||||
|
{(mut.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!listo || mut.isPending}
|
||||||
|
onClick={() => mut.mutate()}
|
||||||
|
>
|
||||||
|
{mut.isPending ? "Creando…" : "Crear cuenta"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalVinculo({
|
||||||
|
cuenta,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
cuenta: PlatformAccount;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [f, setF] = useState({
|
||||||
|
location_id: cuenta.location_id ?? "",
|
||||||
|
token: "",
|
||||||
|
label: cuenta.crm_label ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const vincular = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.admin.linkCrm(cuenta.id, {
|
||||||
|
location_id: f.location_id.trim(),
|
||||||
|
token: f.token.trim(),
|
||||||
|
label: f.label.trim() || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
const desvincular = useMutation({
|
||||||
|
mutationFn: () => api.admin.unlinkCrm(cuenta.id),
|
||||||
|
onSuccess: onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open title={`Vincular «${cuenta.name}» con Bucéfalo CRM`} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Campo id="cr-loc" label="Identificador de la subcuenta (location id)">
|
||||||
|
<input
|
||||||
|
id="cr-loc"
|
||||||
|
className="input font-mono"
|
||||||
|
value={f.location_id}
|
||||||
|
onChange={(e) => setF({ ...f, location_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
|
||||||
|
<Campo id="cr-tok" label="Token privado de la subcuenta">
|
||||||
|
<input
|
||||||
|
id="cr-tok"
|
||||||
|
type="password"
|
||||||
|
className="input font-mono"
|
||||||
|
autoComplete="off"
|
||||||
|
value={f.token}
|
||||||
|
onChange={(e) => setF({ ...f, token: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{cuenta.token_fingerprint
|
||||||
|
? `Hay un token guardado que termina en …${cuenta.token_fingerprint}. Guardar uno nuevo reemplaza el actual; el anterior no se puede recuperar.`
|
||||||
|
: "Se comprueba contra Bucéfalo CRM antes de guardarse. Se almacena cifrado y no vuelve a salir del servidor."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Campo id="cr-lbl" label="Etiqueta (opcional)">
|
||||||
|
<input
|
||||||
|
id="cr-lbl"
|
||||||
|
className="input"
|
||||||
|
placeholder="Se toma el nombre de la subcuenta si lo dejas vacío"
|
||||||
|
value={f.label}
|
||||||
|
onChange={(e) => setF({ ...f, label: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
|
||||||
|
{vincular.isError && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-rose-600">
|
||||||
|
{(vincular.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-between gap-2 pt-2">
|
||||||
|
{cuenta.token_fingerprint ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary text-rose-600"
|
||||||
|
disabled={desvincular.isPending}
|
||||||
|
onClick={() => desvincular.mutate()}
|
||||||
|
>
|
||||||
|
<Unlink className="h-4 w-4" />
|
||||||
|
{desvincular.isPending ? "Desvinculando…" : "Desvincular"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={!f.location_id.trim() || !f.token.trim() || vincular.isPending}
|
||||||
|
onClick={() => vincular.mutate()}
|
||||||
|
>
|
||||||
|
{vincular.isPending ? "Comprobando…" : "Comprobar y guardar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etiqueta asociada a su control con `htmlFor`. El resto del panel no lo hace
|
||||||
|
* y es una deuda registrada; no la aumentamos aquí. */
|
||||||
|
function Campo({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -22,5 +22,5 @@
|
|||||||
"@server/*": ["server/*"]
|
"@server/*": ["server/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "server", "shared", "vite.config.ts"]
|
"include": ["src", "server", "shared", "platform", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user