feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.
## Multi-tenancy
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.
- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
acabaríamos mandando como credencial al CRM. La clave maestra vive en
`CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
`locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.
- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
de esa base: no debe quedar expuesta a internet hasta endurecerla.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcbf750c09
commit
6d67b23e55
@@ -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. **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.**
|
||||
## 1. El producto que emulamos
|
||||
|
||||
## Pain points reales de los usuarios (del blog/casos)
|
||||
- No-shows = pérdida directa de ingresos (un salón perdía 15 citas/mes).
|
||||
- Cierre de caja con descuadre ("menos dinero del que debería haber").
|
||||
- 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.
|
||||
AgendaPro es el software de agendamiento líder en Latinoamérica: **+20.000 negocios**, presencia en
|
||||
**+100 países**, foco en México, Colombia, Argentina y Chile. Su promesa comercial es *«el único
|
||||
software que ordena tu negocio y acelera su crecimiento un 82%»*.
|
||||
|
||||
## Priorización (impacto × factibilidad) — lo que implementaremos
|
||||
- **P0 Política de cancelación + no-show** (company) — resuelve el dolor #1.
|
||||
- **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).
|
||||
**Verticales:** salones de belleza, spas, barberías, peluquerías, centros de estética, clínicas,
|
||||
psicólogos, nutricionistas, fisioterapeutas, podólogos y bienestar en general.
|
||||
|
||||
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.
|
||||
|
||||
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",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.451.0",
|
||||
"pg": "^8.23.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
@@ -33,6 +34,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
@@ -1815,6 +1817,18 @@
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -4196,6 +4210,95 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4446,6 +4549,45 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
@@ -5082,6 +5224,15 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -6041,6 +6192,15 @@
|
||||
"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": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
+8
-1
@@ -26,7 +26,12 @@
|
||||
"audit:visual": "node visual-audit.mjs",
|
||||
"audit:responsive": "node responsive-audit.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": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
@@ -43,6 +48,7 @@
|
||||
"express": "^4.21.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.451.0",
|
||||
"pg": "^8.23.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
@@ -54,6 +60,7 @@
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@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;
|
||||
email: 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;
|
||||
tags: string | null;
|
||||
/** whatsapp | facebook | instagram | mostrador | referido */
|
||||
source_channel?: string | null;
|
||||
created_at: string;
|
||||
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 {
|
||||
@@ -106,6 +126,13 @@ export interface Appointment {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
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;
|
||||
notes: string | null;
|
||||
created_by_user_id: number | null;
|
||||
@@ -202,3 +229,192 @@ export interface BookResponse {
|
||||
no_show_count?: number;
|
||||
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 { ClientDetailPage } from "./pages/ClientDetailPage";
|
||||
import { CashPage } from "./pages/CashPage";
|
||||
import { DayClosePage } from "./pages/DayClosePage";
|
||||
import { MessagesPage } from "./pages/MessagesPage";
|
||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
||||
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
||||
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
||||
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
||||
import PlatformAccountsPage from "./pages/admin/PlatformAccountsPage";
|
||||
import BookingPage from "./pages/public/BookingPage";
|
||||
|
||||
// Las dos páginas públicas van aparte del bundle principal por dos razones que
|
||||
@@ -103,6 +106,7 @@ function AppRoutes() {
|
||||
{user && isAdmin && (
|
||||
<Route element={<AdminShell />}>
|
||||
<Route path="/admin" element={<AdminOverviewPage />} />
|
||||
<Route path="/admin/cuentas" element={<PlatformAccountsPage />} />
|
||||
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
|
||||
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
@@ -121,6 +125,11 @@ function AppRoutes() {
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
||||
<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="/services" element={<ServicesPage />} />}
|
||||
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import {
|
||||
import { Link2,
|
||||
Building2,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
@@ -27,6 +27,9 @@ interface NavItem {
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/admin", label: "Resumen", icon: LayoutDashboard, end: true },
|
||||
{ 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() {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Suspense, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import {
|
||||
CalendarDays,
|
||||
CalendarCheck,
|
||||
MessageSquare,
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Scissors,
|
||||
@@ -40,6 +42,8 @@ const NAV: NavItem[] = [
|
||||
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
|
||||
{ to: "/clients", label: "Clientes", icon: Users },
|
||||
{ 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: "/services", label: "Servicios", icon: Scissors, 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 {
|
||||
PlatformAccount,
|
||||
CrmEntidad,
|
||||
CrmSyncUnoResult,
|
||||
Appointment,
|
||||
Business,
|
||||
Client,
|
||||
@@ -12,6 +15,15 @@ import type {
|
||||
Ticket,
|
||||
User,
|
||||
CategorySlice,
|
||||
AttendanceResult,
|
||||
DayCloseSummary,
|
||||
DayClosure,
|
||||
PaymentMethod,
|
||||
CrmStatus,
|
||||
CrmSyncResult,
|
||||
ConversationSummary,
|
||||
ConversationMessage,
|
||||
SendMessageResult,
|
||||
} from "../../shared/types";
|
||||
|
||||
const BASE = "/api";
|
||||
@@ -72,6 +84,36 @@ export const api = {
|
||||
request<any>(`/admin/businesses/${id}/seed-template`, { method: "POST", body: JSON.stringify(data) }),
|
||||
resetDemo: (id: number, data: any) =>
|
||||
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: {
|
||||
get: () => request<{ business: Business }>("/business"),
|
||||
@@ -97,6 +139,7 @@ export const api = {
|
||||
},
|
||||
clients: {
|
||||
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) }),
|
||||
update: (id: number, data: any) =>
|
||||
request<{ client: Client }>(`/clients/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
@@ -118,6 +161,70 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
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: {
|
||||
overview: (range?: number) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { Client } from "../../shared/types";
|
||||
import { PageHeader, Avatar, Spinner, EmptyState, Badge } from "../components/ui";
|
||||
import { Modal } from "../components/Modal";
|
||||
import {
|
||||
@@ -28,11 +29,14 @@ export function ClientDetailPage() {
|
||||
const clientId = Number(id);
|
||||
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({
|
||||
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({
|
||||
queryKey: ["clients", clientId, "appts"],
|
||||
queryFn: () => api.appointments.list({ client_id: clientId, limit: 50 }),
|
||||
@@ -104,6 +108,8 @@ export function ClientDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AtribucionCard client={client} />
|
||||
|
||||
<div className="card p-5">
|
||||
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">Resumen</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -241,3 +247,55 @@ function ClientEditModal({ open, client, onClose }: { open: boolean; client: any
|
||||
</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 { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
|
||||
import { formatCurrency, formatDate, formatRelative } from "../lib/format";
|
||||
import { CrmSyncPanel } from "../components/CrmSyncPanel";
|
||||
|
||||
export function ClientsPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -31,6 +32,10 @@ export function ClientsPage() {
|
||||
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="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" />
|
||||
|
||||
@@ -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/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "server", "shared", "vite.config.ts"]
|
||||
"include": ["src", "server", "shared", "platform", "vite.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user