diff --git a/AUDIT.md b/AUDIT.md index ef9d21a..fd6915e 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -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. diff --git a/docs/superpowers/plans/2026-08-29-multitenant-credenciales-crm.md b/docs/superpowers/plans/2026-08-29-multitenant-credenciales-crm.md new file mode 100644 index 0000000..de143d0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-multitenant-credenciales-crm.md @@ -0,0 +1,1304 @@ +# Multi-tenancy real: credenciales de Bucéfalo CRM por negocio — Plan de implementación + +> **Para trabajadores agénticos:** SUB-SKILL OBLIGATORIA: usa `superpowers:subagent-driven-development` +> (recomendado) o `superpowers:executing-plans` para ejecutar este plan tarea a tarea. Los pasos usan +> casillas (`- [ ]`) para llevar el control. **Táchalas al completarlas** — el plan hermano de +> 2026-08-29 quedó con 64 casillas sin tachar pese a estar ejecutado, y quien lo retomó sin leer el +> addendum concluyó que no se había hecho nada. + +**Objetivo:** que `platform/` deje de asumir un solo negocio con un solo token, de modo que el +superadministrador pueda dar de alta cuentas y vincular cada una a su subcuenta de Bucéfalo CRM con +su propio `locationId` y su token privado, guardado cifrado. + +**Arquitectura:** hoy el `locationId` ya es por negocio (`crm_connections.business_id UNIQUE`) pero +el token es **una variable de entorno global del proceso** (`platform/crm/client.ts:66`, +`requireEnv("CRM_TOKEN")`). Con dos negocios eso usaría el token del primero contra la subcuenta del +segundo: `401` en el mejor caso, escritura en la subcuenta equivocada en el peor. Este plan +introduce un `CrmCtx { businessId, locationId, token }` que sustituye al `locationId: string` suelto +que hoy viaja por todas las firmas, hace el token **obligatorio en el tipo** para que el compilador +—y no la revisión— impida olvidarlo, y lo guarda cifrado con AES-256-GCM en `crm_connections`. +Encima de eso se añade la superficie de superadministrador. + +**Stack:** TypeScript + Express 4 + `pg` sobre PostgreSQL 16; `node:crypto` para el cifrado (sin +dependencia nueva); `node:test` para las pruebas; React 18 + React Query en el frontend. + +**Especificación de origen:** `platform/crm/HALLAZGOS.md` (32 hallazgos medidos contra la subcuenta +real), `docs/yola-franco-spa-plataforma-propuesta-tecnica.md` §6.1 y +`docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md`. + +**Plan hermano:** la sincronización por id de contactos, conversaciones, mensajes, citas y servicios +va en un plan aparte y **depende de que este esté hecho**, porque consume `CrmCtx`. + +--- + +## Restricciones globales + +- **El token nunca se guarda en claro ni se devuelve nunca por la API.** Ni en `GET /crm/status`, ni + en las rutas de administración, ni en logs, ni en `audit_log`. Lo único que puede salir es la + huella (`token_fingerprint`), que son los 6 últimos caracteres. +- **Node.js >= 22.5.** Ya lo valida `scripts/preflight.mjs`. +- **Todo el texto visible y los mensajes de error de la API van en español.** Es la convención del + repo (`CLAUDE.md`). +- **Al CRM se le llama «Bucéfalo CRM»** en código, comentarios, documentación e interfaz. +- **Toda consulta filtra por `business_id`.** Es la invariante central; ninguna ruta acepta un + `business_id` que venga del cuerpo de la petición, salvo las de superadministrador, que lo toman + de la ruta y exigen rol `admin`. +- **Las pruebas corren contra `yola_test`** con `--test-concurrency=1`: cada archivo hace + `DROP SCHEMA public` y en paralelo se pisan. +- **Nunca se acepta un `200` como prueba.** Toda escritura contra el CRM se verifica releyendo. Es + la regla que produjo los 32 hallazgos y no se relaja aquí. +- **No hacer commits salvo que se pidan explícitamente** (política del repo). La verificación por + tarea es `npm run typecheck` + `npm run test:platform`. + +--- + +## Estructura de archivos + +| Archivo | Responsabilidad | +|---|---| +| `platform/db/migrations/003_multitenant.sql` | **Crear.** Columnas de credencial cifrada y de ajustes por negocio en `crm_connections`. | +| `platform/lib/crypto.ts` | **Crear.** Cifrar/descifrar con AES-256-GCM y derivar la huella. Puro, sin base de datos. | +| `platform/lib/crypto.test.ts` | **Crear.** Ida y vuelta, detección de manipulación, clave ausente. | +| `platform/crm/ctx.ts` | **Crear.** El tipo `CrmCtx` y `ctxDe(businessId)`, que carga la conexión y descifra el token. Único punto donde el token existe en claro. | +| `platform/crm/client.ts` | **Modificar.** `token` obligatorio; estrangulador por token en vez de global. | +| `platform/crm/connection.ts` | **Modificar.** Guardar credenciales cifradas; `autoconfigurar` recibe el token. | +| `platform/crm/contacts.ts` | **Modificar.** Las 4 funciones exportadas pasan a recibir `CrmCtx`. | +| `platform/crm/messages.ts` | **Modificar.** Las 3 funciones exportadas pasan a recibir `CrmCtx`. | +| `platform/crm/opportunities.ts` | **Modificar.** Las funciones que llaman al CRM pasan a recibir `CrmCtx`. | +| `platform/crm/syncContacts.ts`, `syncAppointments.ts`, `outbox.ts` | **Modificar.** Obtienen el `CrmCtx` del negocio y lo propagan. | +| `platform/lib/auth.ts` | **Modificar.** Añadir `adminOnly`. | +| `platform/routes/admin.ts` | **Crear.** Alta y gestión de cuentas y de su vínculo con el CRM. | +| `platform/routes/crm.ts` | **Modificar.** `POST /connect` acepta credenciales; `GET /status` nunca devuelve el token. | +| `platform/routes/messages.ts` | **Modificar.** La red de seguridad de envíos pasa a ser por negocio. | +| `platform/index.ts` | **Modificar.** Montar `/api/admin`. | +| `platform/test/admin.test.ts` | **Crear.** Alta de cuentas, aislamiento por rol, y que el token no se filtre. | +| `platform/test/crmCtx.test.ts` | **Crear.** Que dos negocios usen credenciales distintas. | +| `shared/types.ts` | **Modificar.** Tipos de cuenta y de conexión para el frontend. | +| `src/lib/api.ts` | **Modificar.** Métodos `admin.*`. | +| `src/pages/admin/PlatformAccountsPage.tsx` | **Crear.** Pantalla del superadministrador. | + +--- + +## Tarea 1: Cifrado de credenciales + +**Archivos:** +- Crear: `platform/lib/crypto.ts` +- Crear: `platform/lib/crypto.test.ts` +- Modificar: `platform/.env.example` + +**Interfaces:** +- Consume: nada. +- Produce: `cifrar(claro: string): Cifrado`, `descifrar(c: Cifrado): string`, + `huella(token: string): string`, `interface Cifrado { cipher: Buffer; nonce: Buffer; tag: Buffer }`. + +**Por qué AES-256-GCM y no cifrado simétrico simple:** GCM es autenticado. Si alguien manipula la +fila en la base, el descifrado **falla** en vez de devolver basura que luego se manda como token al +CRM. Es la diferencia entre un error claro y una petición con una credencial corrupta. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/lib/crypto.test.ts +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("huella: son los 6 últimos caracteres, para poder distinguir tokens sin exponerlos", () => { + assert.equal(huella("pit-abcdef123456"), "123456"); + assert.equal(huella("corto"), "corto"); +}); + +test("sin CRM_MASTER_KEY se lanza un error que dice qué falta", () => { + delete process.env.CRM_MASTER_KEY; + assert.throws(() => cifrar("x"), /CRM_MASTER_KEY/); +}); +``` + +- [ ] **Paso 2: Correr la prueba y verificar que falla** + +Ejecuta: `node --import tsx --test platform/lib/crypto.test.ts` +Esperado: FALLA con «Cannot find module './crypto.ts'». + +- [ ] **Paso 3: Implementar** + +```ts +// platform/lib/crypto.ts +import crypto from "node:crypto"; +import { loadEnv } from "./env.ts"; + +export interface Cifrado { + cipher: Buffer; + nonce: Buffer; + tag: Buffer; +} + +/** + * AES-256-GCM: cifrado AUTENTICADO a propósito. Si alguien manipula la fila en + * la base, `descifrar` lanza en vez de devolver basura que acabaríamos mandando + * como token al CRM. Un cifrado sin autenticar convertiría una fila corrupta en + * una petición silenciosamente mal formada. + */ +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}`); + } + 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 { + try { + const d = crypto.createDecipheriv("aes-256-gcm", clave(), c.nonce); + d.setAuthTag(c.tag); + return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8"); + } catch (e: any) { + if (String(e?.message ?? "").includes("CRM_MASTER_KEY")) throw e; + 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. Sirve para distinguir y auditar sin exponer nada. */ +export function huella(token: string): string { + return token.length <= 6 ? token : token.slice(-6); +} +``` + +- [ ] **Paso 4: Correr la prueba y verificar que pasa** + +Ejecuta: `node --import tsx --test platform/lib/crypto.test.ts` +Esperado: 5 pruebas, 5 pass. + +- [ ] **Paso 5: Documentar la variable** + +Añade a `platform/.env.example`, debajo del bloque del CRM: + +```bash +# Clave maestra con la que se cifran los tokens de cada subcuenta en Postgres. +# 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 guardas los secretos, no aquí. +CRM_MASTER_KEY= +``` + +Y en el mismo archivo, marca `CRM_TOKEN` y `CRM_LOCATION_ID` como heredados: + +```bash +# HEREDADAS: solo las usa el script de vinculación inicial para el primer +# negocio. A partir de la Tarea 6 cada negocio guarda las suyas en la base. +``` + +- [ ] **Paso 6: Verificar el conjunto** + +Ejecuta: `npm run typecheck && npm run test:platform` +Esperado: typecheck sin errores; las 49 pruebas existentes siguen pasando. + +--- + +## Tarea 2: Esquema de credenciales por negocio + +**Archivos:** +- Crear: `platform/db/migrations/003_multitenant.sql` +- Modificar: `platform/test/schema.test.ts` + +**Interfaces:** +- Consume: nada. +- Produce: columnas `crm_connections.token_cipher`, `token_nonce`, `token_tag`, `token_fingerprint`, + `token_updated_at`, `calendar_id`, `test_email`, `allow_real_sends`, `label`. + +**Por qué las columnas de la red de seguridad de mensajes se mudan aquí:** hoy `CRM_TEST_EMAIL` es +global (`platform/routes/messages.ts:26-34`). Con varios negocios, un solo interruptor decidiría por +todos: o se abren los envíos reales para todos a la vez, o ninguno puede salir de pruebas. Es una +decisión que pertenece a cada cuenta. + +- [ ] **Paso 1: Escribir la prueba que falla** + +Añade a `platform/test/schema.test.ts`: + +```ts +test("crm_connections guarda la credencial cifrada y los ajustes por negocio", async () => { + await resetDb(); + const cols = await pool.query<{ column_name: string; is_nullable: string }>( + `SELECT column_name, is_nullable FROM information_schema.columns + WHERE table_name = 'crm_connections'` + ); + const nombres = cols.rows.map((r) => r.column_name); + for (const c of [ + "token_cipher", "token_nonce", "token_tag", "token_fingerprint", + "token_updated_at", "calendar_id", "test_email", "allow_real_sends", "label", + ]) { + assert.ok(nombres.includes(c), `falta la columna ${c}`); + } +}); + +test("allow_real_sends nace en false: los envíos reales se abren a propósito", async () => { + await resetDb(); + const b = await crearNegocio(); + const { rows } = await pool.query( + `INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1') + RETURNING allow_real_sends`, + [b.id] + ); + assert.equal(rows[0].allow_real_sends, false); +}); +``` + +> `crearNegocio()` ya existe en `platform/test/helpers.ts`. Si su firma no encaja, usa el mismo +> `INSERT INTO businesses` que usan las demás pruebas de ese archivo. + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `npm run test:platform` +Esperado: FALLA con «falta la columna token_cipher». + +- [ ] **Paso 3: Escribir la migración** + +```sql +-- platform/db/migrations/003_multitenant.sql +-- --------------------------------------------------------------------------- +-- De un negocio con un token global, a N negocios con credencial propia. +-- +-- Hasta aquí el `location_id` era por negocio pero el token vivía en +-- `CRM_TOKEN`, una variable del 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. +-- --------------------------------------------------------------------------- + +ALTER TABLE crm_connections + ADD COLUMN token_cipher bytea, + ADD COLUMN token_nonce bytea, + ADD COLUMN token_tag bytea, + -- Los 6 últimos caracteres. Permite decir en la interfaz «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 las citas reales + -- están en «Servicio Spa». Sin fijar cuál, empujar una cita al calendario del + -- CRM sería adivinar. + ADD COLUMN calendar_id text, + -- La red de seguridad de mensajes pasa a ser POR NEGOCIO. Como variable global + -- decidía por todas las cuentas a la vez. + ADD COLUMN test_email text, + ADD COLUMN allow_real_sends boolean NOT NULL DEFAULT false, + -- Nombre legible de la subcuenta, para que el superadministrador 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.'; +``` + +- [ ] **Paso 4: Aplicar y correr** + +Ejecuta: `npm run pg:migrate && npm run test:platform` +Esperado: `[migrate] aplicada 003_multitenant.sql`; las pruebas nuevas pasan y las 49 anteriores +siguen pasando. + +--- + +## Tarea 3: `CrmCtx` — el contexto de credenciales + +**Archivos:** +- Crear: `platform/crm/ctx.ts` +- Crear: `platform/test/crmCtx.test.ts` +- Modificar: `platform/crm/connection.ts` + +**Interfaces:** +- Consume: `cifrar`, `descifrar`, `huella` de `platform/lib/crypto.ts` (Tarea 1); las columnas de la + Tarea 2. +- Produce: + - `interface CrmCtx { businessId: number; locationId: string; token: string }` + - `ctxDe(businessId: number): Promise` — lanza `{ status: 409, error }` si no hay conexión + o no hay credencial. + - `guardarCredencial(businessId: number, locationId: string, token: string, label?: string): Promise` + - `CrmConnection` gana `token_fingerprint`, `calendar_id`, `test_email`, `allow_real_sends`, `label`. + +**Por qué un objeto y no dos parámetros sueltos:** hoy `locationId: string` viaja por once firmas. +Añadir `token: string` al lado significa once sitios donde se pueden cruzar dos cadenas del mismo +tipo sin que el compilador diga nada. Un objeto con nombres los hace imposibles de intercambiar. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/test/crmCtx.test.ts +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 } 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" }); + const b = await crearNegocio({ name: "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"); +}); + +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( + `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")); + 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"); + const ctx = await ctxDe(a.id); + assert.equal(ctx.token, "token-nuevo"); + const { rows } = await pool.query( + `SELECT count(*)::int AS n FROM crm_connections WHERE business_id = $1`, + [a.id] + ); + assert.equal(rows[0].n, 1); +}); + +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; + }); +}); +``` + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `npm run test:platform` +Esperado: FALLA con «Cannot find module '../crm/ctx.ts'». + +- [ ] **Paso 3: Implementar** + +```ts +// platform/crm/ctx.ts +import { pool } from "../db/pool.ts"; +import { cifrar, descifrar, huella } from "../lib/crypto.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 donde el token existe descifrado, y solo en memoria. + */ +export interface CrmCtx { + businessId: number; + locationId: string; + token: string; +} + +export async function ctxDe(businessId: number): Promise { + const { rows } = await pool.query( + `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) { + 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. Idempotente por negocio. */ +export async function guardarCredencial( + businessId: number, + locationId: string, + token: string, + label?: string +): Promise { + 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] + ); +} +``` + +- [ ] **Paso 4: Correr y verificar que pasa** + +Ejecuta: `npm run test:platform` +Esperado: las 4 pruebas nuevas pasan. + +--- + +## Tarea 4: El token deja de ser global en el cliente HTTP + +**Archivos:** +- Modificar: `platform/crm/client.ts` +- Crear: `platform/crm/client.test.ts` + +**Interfaces:** +- Consume: nada. +- Produce: `CrmOptions.token` pasa de opcional a **obligatorio**; el estrangulador pasa a ser por + token. + +**Por qué obligatorio y no con valor por defecto:** con el `?? requireEnv("CRM_TOKEN")` actual, +olvidar el token no da error — usa el del entorno, que pertenece a otro cliente. Al hacerlo +obligatorio, el olvido es un error de compilación en `npm run typecheck`, que es el gate real de +calidad del repo. Es el cambio de mayor rendimiento de todo el plan. + +**Por qué el estrangulador debe ser por token:** `client.ts:42` guarda `let ultimaPeticion` a nivel +de módulo, con 650 ms de separación. El límite del CRM es **por token**, así que un semáforo único +serializa negocios que podrían ir en paralelo: con diez cuentas, la sincronización de la décima +espera a las nueve anteriores sin ninguna razón. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/crm/client.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { esperaDeToken, registrarPeticion, MIN_INTERVAL_MS } 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); +}); +``` + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `node --import tsx --test platform/crm/client.test.ts` +Esperado: FALLA — esos tres símbolos no se exportan. + +- [ ] **Paso 3: Implementar** + +En `platform/crm/client.ts`, sustituye el bloque del estrangulador (líneas ~13 y ~42-49): + +```ts +/** El CRM estrangula a ~1 petición cada 0.65 s POR TOKEN. */ +export const MIN_INTERVAL_MS = 650; + +// 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. +const ultimaPeticionPorToken = new Map(); + +/** 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, MIN_INTERVAL_MS - (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); +} +``` + +Haz el token obligatorio en las opciones: + +```ts +export interface CrmOptions { + /** Token privado de la subcuenta. Obligatorio: sin valor por defecto, el + * compilador impide mandar la credencial de un cliente a la subcuenta de + * otro. Viene de `CrmCtx.token`. */ + token: string; + body?: unknown; + version?: string; + query?: Record; +} +``` + +Y en `crmRequest`, sustituye la línea del token y la llamada al estrangulador: + +```ts +export async function crmRequest( + method: string, + path: string, + opts: CrmOptions +): Promise { + loadEnv(); + const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT; + const token = opts.token; + // …resto igual, pero: + // await throttle(token); en vez de await throttle(); +``` + +Borra el `import { requireEnv }` si queda sin uso; deja `loadEnv` (sigue leyendo `CRM_BASE_URL`). + +- [ ] **Paso 4: Correr la prueba nueva** + +Ejecuta: `node --import tsx --test platform/crm/client.test.ts` +Esperado: 2 pruebas, 2 pass. + +- [ ] **Paso 5: Ver la superficie que el compilador reclama** + +Ejecuta: `npm run typecheck` +Esperado: **falla a propósito**, con un error por cada una de las 16 llamadas a `crmRequest` que ya +no pasan token, en `connection.ts` (2), `contacts.ts` (4), `messages.ts` (3), `opportunities.ts` (7). +Esa lista es exactamente el trabajo de la Tarea 5. Anótala antes de seguir. + +--- + +## Tarea 5: Propagar `CrmCtx` por los módulos del CRM + +**Archivos:** +- Modificar: `platform/crm/contacts.ts`, `platform/crm/messages.ts`, + `platform/crm/opportunities.ts`, `platform/crm/connection.ts`, + `platform/crm/syncContacts.ts`, `platform/crm/syncAppointments.ts`, `platform/crm/outbox.ts` +- Modificar: `platform/crm/contacts.test.ts`, `platform/crm/opportunities.test.ts` + +**Interfaces:** +- Consume: `CrmCtx` y `ctxDe` (Tarea 3); `CrmOptions.token` obligatorio (Tarea 4). +- Produce, con estas firmas exactas — las demás tareas dependen de ellas: + - `buscarContactos(ctx: CrmCtx, opts?: { pageLimit?: number; searchAfter?: unknown[] })` + - `obtenerContacto(ctx: CrmCtx, id: string)` + - `buscarPorIdentificador(ctx: CrmCtx, q: string)` + - `resolverContacto(ctx: CrmCtx, datos: DatosContacto)` + - `buscarConversaciones(ctx: CrmCtx, opts?: { limit?: number; startAfterDate?: number })` + - `mensajesDeConversacion(ctx: CrmCtx, conversationId: string, limit?: number)` + - `enviarCorreo(ctx: CrmCtx, e: EnvioCorreo)` + - `obtenerOportunidad(ctx: CrmCtx, id: string)` + - `oportunidadesDeContacto(ctx: CrmCtx, contactId: string)` + - `upsertOportunidad(ctx: CrmCtx, args: { … })` + - `autoconfigurar(ctx: CrmCtx): Promise` + +**Regla mecánica:** `ctx` es siempre el **primer** parámetro, y el `locationId` suelto que hoy +reciben algunas de estas funciones **desaparece** — sale de `ctx.locationId`. Toda llamada a +`crmRequest` gana `token: ctx.token`. + +- [ ] **Paso 1: Cambiar `contacts.ts`** + +Cuatro funciones y cuatro llamadas. Ejemplo del patrón, aplícalo a las cuatro: + +```ts +// ANTES +export async function obtenerContacto(id: string): Promise { + const r = await crmRequest("GET", `/contacts/${id}`); + ... + +// DESPUÉS +export async function obtenerContacto(ctx: CrmCtx, id: string): Promise { + const r = await crmRequest("GET", `/contacts/${id}`, { token: ctx.token }); + ... +``` + +En `buscarContactos` y `buscarPorIdentificador`, el `locationId` que hoy es parámetro pasa a ser +`ctx.locationId` dentro del cuerpo. En `resolverContacto`, el `locationId` del cuerpo del +`POST /contacts/` también sale de `ctx.locationId`. + +- [ ] **Paso 2: Cambiar `messages.ts`, `opportunities.ts` y `connection.ts`** + +Mismo patrón. En `connection.ts`, `autoconfigurar(businessId, locationId)` pasa a +`autoconfigurar(ctx: CrmCtx)`, deja de llamar `loadEnv()` y deja de escribir `location_id` (ya lo +escribió `guardarCredencial`): su `INSERT … ON CONFLICT` se reduce a actualizar pipeline, etapas y +`allow_duplicate_opp`. + +- [ ] **Paso 3: Cambiar los tres que orquestan** + +`syncContacts.ts`, `syncAppointments.ts` y `outbox.ts` reciben `businessId` y hoy llaman a las +funciones del CRM sin credencial. Cada uno obtiene el contexto una sola vez, al principio: + +```ts +import { ctxDe } from "./ctx.ts"; + +export async function sincronizarContactos(businessId: number, opts: {...}) { + const ctx = await ctxDe(businessId); + // …y a partir de aquí, `ctx` a cada llamada del CRM +``` + +En `outbox.ts`, `despachar(businessId, limite)` obtiene el `ctx` una vez antes del bucle: pedirlo +por fila descifraría el token en cada iteración sin ganar nada. + +- [ ] **Paso 4: Actualizar las pruebas existentes** + +`platform/crm/contacts.test.ts` y `opportunities.test.ts` prueban funciones puras +(`mapAtribucion`, `nombreDe`, `estadoOportunidad`, `nombreOportunidad`), que **no cambian**. Si +alguna prueba llama a una función de red, dale un ctx de mentira: + +```ts +const CTX = { businessId: 1, locationId: "loc-test", token: "token-test" }; +``` + +- [ ] **Paso 5: Verificar** + +Ejecuta: `npm run typecheck && npm run test:platform` +Esperado: typecheck **limpio** (era el objetivo del paso 5 de la Tarea 4) y las pruebas en verde. + +--- + +## Tarea 6: Superficie de superadministrador + +**Archivos:** +- Modificar: `platform/lib/auth.ts` +- Crear: `platform/routes/admin.ts` +- Modificar: `platform/index.ts` +- Crear: `platform/test/admin.test.ts` + +**Interfaces:** +- Consume: `guardarCredencial`, `ctxDe` (Tarea 3); `crmRequest` (Tarea 4). +- Produce: `adminOnly` middleware; endpoints `GET/POST /api/admin/businesses`, + `PATCH /api/admin/businesses/:id`, `PUT /api/admin/businesses/:id/crm`, + `DELETE /api/admin/businesses/:id/crm`. + +**Por qué se validan las credenciales antes de guardarlas:** guardar un token sin comprobarlo +traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. Se llama +`GET /locations/{locationId}` con el token recibido: si la subcuenta no responde o no coincide, se +rechaza con un mensaje que dice cuál de las dos cosas falló. Es la misma regla de «no aceptar un 200 +como prueba» aplicada al alta. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/test/admin.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resetDb, crearUsuario, peticion } from "./helpers.ts"; + +test("un owner no puede entrar a la consola de administración", async () => { + await resetDb(); + const owner = await crearUsuario({ role: "owner" }); + const r = await peticion("GET", "/api/admin/businesses", { as: owner }); + assert.equal(r.status, 403); +}); + +test("el superadministrador da de alta una cuenta con su dueña", async () => { + await resetDb(); + const admin = await crearUsuario({ role: "admin", business_id: null }); + const r = await peticion("POST", "/api/admin/businesses", { + as: admin, + body: { + name: "Spa Nuevo", + timezone: "America/Mexico_City", + owner_email: "duena@spanuevo.mx", + owner_name: "Dueña", + owner_password: "demo1234", + }, + }); + assert.equal(r.status, 201); + assert.equal(r.body.business.name, "Spa Nuevo"); + assert.ok(r.body.business.slug, "el negocio debe nacer con slug"); + assert.ok(r.body.owner.id); +}); + +test("el listado nunca devuelve el token, solo su huella", async () => { + await resetDb(); + const admin = await crearUsuario({ role: "admin", business_id: null }); + const creada = await peticion("POST", "/api/admin/businesses", { + as: admin, + body: { name: "Spa", owner_email: "a@b.mx", owner_name: "A", owner_password: "x" }, + }); + await guardarCredencial(creada.body.business.id, "loc-1", "token-secretisimo"); + const r = await peticion("GET", "/api/admin/businesses", { as: admin }); + const texto = JSON.stringify(r.body); + 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"); +}); +``` + +> Si `peticion()` y `crearUsuario()` no existen aún en `platform/test/helpers.ts`, escríbelos ahí +> siguiendo el patrón que ya usan `platform/test/clients.test.ts` y `appointments.test.ts` para +> montar la app y autenticarse. + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `npm run test:platform` +Esperado: FALLA con 404 en `/api/admin/businesses`. + +- [ ] **Paso 3: Añadir `adminOnly`** + +En `platform/lib/auth.ts`, junto a `ownerOnly`: + +```ts +/** + * Administradora de plataforma: opera todas las cuentas y su `business_id` es + * NULL. No se confunde con `owner`, que manda dentro de UN negocio. + */ +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(); +} +``` + +- [ ] **Paso 4: Escribir el router** + +```ts +// platform/routes/admin.ts +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 } from "../crm/ctx.ts"; +import { crmRequest } from "../crm/client.ts"; +import { DEFAULT_WORKING_HOURS, uniqueSlugPg } from "../lib/businessDefaults.ts"; + +export const adminRouter = Router(); +adminRouter.use(adminOnly); + +/** Las cuentas de la plataforma, con el estado de su vínculo con Bucéfalo CRM. */ +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.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 + FROM businesses b + LEFT JOIN crm_connections c ON c.business_id = b.id + ORDER BY b.created_at DESC` + ); + // `token_fingerprint` sale; `token_cipher` no se selecciona siquiera. + res.json({ businesses: rows }); + }) +); + +/** Alta de cuenta: negocio + su dueña, en una 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 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) RETURNING *`, + [name, 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, String(owner_email).toLowerCase(), owner_password, owner_name] + ); + await writeAudit(tx, { + businessId: business.id, + actorUserId: req.user!.id, + entity: "businesses", + entityId: business.id, + action: "create", + after: { name: business.name, slug: business.slug }, + ip: req.ip ?? null, + }); + return { business, owner: us[0] }; + }); + + res.status(201).json(creado); + }) +); + +/** + * Vincula la cuenta a su subcuenta de Bucéfalo CRM. + * + * Se COMPRUEBAN las credenciales antes de guardarlas: un token que no se valida + * traslada el fallo al primer intento de sincronizar, lejos de donde se cometió. + */ +adminRouter.put( + "/businesses/:id/crm", + h(async (req: AuthedRequest, res) => { + const businessId = Number(req.params.id); + const { location_id, token, label } = req.body ?? {}; + 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 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("GET", `/locations/${location_id}`, { token }); + nombreSubcuenta = loc?.location?.name ?? null; + } catch (e: any) { + if (e?.status === 401) { + err(res, 400, "El token no es válido para Bucéfalo CRM o ha caducado"); + return; + } + if (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", + // Deliberadamente NO se audita el token, ni cifrado. + after: { location_id, label: label || nombreSubcuenta }, + ip: req.ip ?? null, + }) + ); + + res.json({ ok: true, location_id, label: label || nombreSubcuenta }); + }) +); + +/** Desvincula: borra la credencial pero conserva los datos ya sincronizados. */ +adminRouter.delete( + "/businesses/:id/crm", + h(async (req: AuthedRequest, res) => { + const businessId = Number(req.params.id); + 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] + ); + res.json({ ok: true }); + }) +); +``` + +> `DEFAULT_WORKING_HOURS` y `uniqueSlugPg` no existen todavía en `platform/`. Crea +> `platform/lib/businessDefaults.ts` con la misma constante de horario que +> `server/lib/businessDefaults.ts` y un `uniqueSlugPg(tx, nombre)` que use el mismo `slugify` pero +> consulte Postgres. **No importes desde `server/`**: los dos backends no comparten código a +> propósito. + +- [ ] **Paso 5: Montar el router** + +En `platform/index.ts`, junto a los demás: + +```ts +import { adminRouter } from "./routes/admin.ts"; +// … +app.use("/api/admin", authRequired, adminRouter); +``` + +- [ ] **Paso 6: Verificar** + +Ejecuta: `npm run typecheck && npm run test:platform` +Esperado: todo en verde, incluidas las 3 pruebas nuevas de administración. + +--- + +## Tarea 7: Las rutas de negocio dejan de leer el entorno + +**Archivos:** +- Modificar: `platform/routes/crm.ts` +- Modificar: `platform/routes/messages.ts` + +**Interfaces:** +- Consume: `ctxDe` (Tarea 3), `autoconfigurar(ctx)` (Tarea 5). +- Produce: `GET /api/crm/status` gana `token_fingerprint`, `label` y `allow_real_sends`; nunca + devuelve el token. + +- [ ] **Paso 1: `POST /api/crm/connect` deja de usar `process.env`** + +En `platform/routes/crm.ts`, la ruta `/connect` (línea ~54) hoy toma +`req.body?.location_id || process.env.CRM_LOCATION_ID`. Cámbiala para que **exija** que la cuenta ya +esté vinculada por el superadministrador, y se limite a redetectar pipeline y etapas: + +```ts +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í. + const ctx = await ctxDe(bid); // lanza 409 si no está vinculado + const c = await autoconfigurar(ctx); + // …auditoría igual que antes + res.json({ connection: { ...c, token_cipher: undefined } }); + }) +); +``` + +- [ ] **Paso 2: `GET /api/crm/status` expone la huella, nunca el token** + +Añade al `res.json` de `/status`: + +```ts + label: conexion.label, + token_fingerprint: conexion.token_fingerprint, + token_updated_at: conexion.token_updated_at, + allow_real_sends: conexion.allow_real_sends, +``` + +Y **revisa** que `obtenerConexion` no siga haciendo `SELECT *`: cámbialo a una lista explícita de +columnas que **excluya** `token_cipher`, `token_nonce` y `token_tag`. `ctxDe` es el único que los +lee. + +- [ ] **Paso 3: La red de seguridad de envíos pasa a ser por negocio** + +En `platform/routes/messages.ts`, sustituye `destinoPermitido(deseado)` (línea ~26) por una versión +que reciba la conexión: + +```ts +/** + * MODO PRUEBA — por negocio, no global. + * + * Mientras la conexión tenga `test_email` y `allow_real_sends` en false, el + * servidor solo escribe a esa dirección. Como variable de entorno global esto + * decidía por todas las cuentas a la vez: o se abrían los envíos reales para + * todas, o ninguna salía de pruebas. + */ +function destinoPermitido( + conexion: { test_email: string | null; allow_real_sends: boolean }, + deseado: string +): { to: string; forzado: boolean } { + if (conexion.test_email && !conexion.allow_real_sends) { + return { + to: conexion.test_email, + forzado: conexion.test_email.toLowerCase() !== deseado.toLowerCase(), + }; + } + return { to: deseado, forzado: false }; +} +``` + +Y en las tres rutas de ese archivo, sustituye `buscarConversaciones(conexion.location_id, …)`, +`mensajesDeConversacion(…)` y `enviarCorreo(…)` por sus versiones con `ctx` (Tarea 5). + +- [ ] **Paso 4: Verificar** + +Ejecuta: `npm run typecheck && npm run test:platform` +Esperado: verde. + +- [ ] **Paso 5: Comprobar que el token no se filtra por ninguna ruta** + +Ejecuta: + +```bash +grep -rn "token_cipher\|token_nonce\|token_tag" platform/routes/ platform/crm/syncContacts.ts platform/crm/syncAppointments.ts +``` + +Esperado: **cero resultados**. Esas tres columnas solo pueden aparecer en `platform/crm/ctx.ts` y en +la migración. + +--- + +## Tarea 8: Migrar el negocio existente y verificar contra la subcuenta real + +**Archivos:** +- Crear: `platform/scripts/crm-migrar-credencial.ts` +- Modificar: `platform/scripts/crm-conectar.ts` + +**Interfaces:** +- Consume: `guardarCredencial` (Tarea 3), `ctxDe`, `autoconfigurar(ctx)` (Tarea 5). +- Produce: nada que otras tareas consuman. + +- [ ] **Paso 1: Escribir el script de migración** + +```ts +// platform/scripts/crm-migrar-credencial.ts +/** + * Mueve la credencial de `platform/.env` a la base, cifrada, para el negocio + * que ya estaba vinculado. Es de un solo uso: después, las credenciales se + * ponen desde la consola de administración. + * + * node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts + */ +import { pool } from "../db/pool.ts"; +import { requireEnv } from "../lib/env.ts"; +import { guardarCredencial, ctxDe } from "../crm/ctx.ts"; +import { crmRequest } from "../crm/client.ts"; + +const businessId = Number(process.argv[2]); +if (!Number.isFinite(businessId)) { + console.error("Uso: … crm-migrar-credencial.ts "); + process.exit(1); +} + +const locationId = requireEnv("CRM_LOCATION_ID"); +const token = requireEnv("CRM_TOKEN"); + +const loc = await crmRequest("GET", `/locations/${locationId}`, { token }); +const nombre = loc?.location?.name ?? null; +console.log(`Subcuenta: ${nombre} (${locationId})`); + +await guardarCredencial(businessId, locationId, token, nombre ?? undefined); + +// No se acepta el guardado como prueba: se relee y se usa. +const ctx = await ctxDe(businessId); +const rel = await crmRequest("GET", `/locations/${ctx.locationId}`, { token: ctx.token }); +console.log( + rel?.location?.id === locationId + ? `✔ Credencial guardada y verificada releyendo para el negocio ${businessId}` + : `✖ La relectura no coincide` +); +await pool.end(); +``` + +- [ ] **Paso 2: Correr contra el entorno real** + +Ejecuta: +```bash +node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts 1 +``` +Esperado: imprime el nombre de la subcuenta y `✔ Credencial guardada y verificada releyendo`. + +- [ ] **Paso 3: Comprobar que el token ya no está en claro en la base** + +```bash +docker exec yola-postgres psql -U yola -d yola -c "SELECT business_id, location_id, token_fingerprint, length(token_cipher) FROM crm_connections;" +``` +Esperado: una fila con `token_fingerprint` de 6 caracteres y `length` distinto de 0. La columna del +token en claro no existe. + +- [ ] **Paso 4: Sincronizar de verdad, con la credencial de la base** + +Con el servidor levantado (`npm run platform`), entra como la dueña y pulsa el botón de sincronizar +contactos, o llama a `POST /api/crm/sync/contacts`. +Esperado: la corrida termina en `ok` con ~3 210 contactos. Es la prueba de que el token descifrado +funciona de punta a punta. + +- [ ] **Paso 5: Verificar el aislamiento con dos cuentas** + +Crea una segunda cuenta desde `POST /api/admin/businesses` y vincúlala con un `location_id` +**inventado** y un token cualquiera. Esperado: la vinculación se **rechaza** con «Ese identificador +de subcuenta no existe, o el token no da acceso a ella». Es la comprobación de que la validación +previa de la Tarea 6 hace su trabajo. + +--- + +## Tarea 9: La consola del superadministrador en el frontend + +**Archivos:** +- Modificar: `shared/types.ts` +- Modificar: `src/lib/api.ts` +- Crear: `src/pages/admin/PlatformAccountsPage.tsx` +- Modificar: `src/App.tsx` + +**Interfaces:** +- Consume: los endpoints de la Tarea 6. +- Produce: ruta `/admin/cuentas`. + +- [ ] **Paso 1: Tipos compartidos** + +En `shared/types.ts`: + +```ts +/** 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; + /** 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; + last_sync_at: string | null; + last_sync_status: string | null; +} +``` + +- [ ] **Paso 2: Métodos de API** + +En `src/lib/api.ts`, junto a los grupos existentes: + +```ts + admin: { + 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: { id: number }; owner: { id: number } }>("/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" }), + }, +``` + +- [ ] **Paso 3: La pantalla** + +Crea `src/pages/admin/PlatformAccountsPage.tsx` con: una tabla de cuentas (nombre, clientas, +estado del vínculo con el CRM mostrando `crm_label` y «token …{token_fingerprint}», última +sincronización), un botón «Nueva cuenta» que abra un modal con los cinco campos del alta, y por fila +un botón «Vincular CRM» que abra un modal con `location_id`, `token` y `label`. + +Tres reglas de interfaz que no son opcionales: +1. El campo del token es `type="password"` y **nunca se rellena** con un valor existente: no hay + valor existente que traer, el servidor no lo devuelve. Cuando ya hay vínculo, el modal dice + «Guardar un token nuevo reemplaza el actual». +2. El estado de error muestra el mensaje que devuelve el servidor, no uno fijo: los mensajes de la + Tarea 6 distinguen «token inválido» de «subcuenta inexistente», y esa diferencia es justo lo que + quien vincula necesita saber. +3. Etiqueta `htmlFor` en los cinco campos, con su `id` en el control. El resto del panel no lo hace + y es una deuda registrada; no la aumentes. + +- [ ] **Paso 4: Ruta** + +En `src/App.tsx`, dentro del `AdminShell`: + +```tsx +} /> +``` + +- [ ] **Paso 5: Verificar** + +Ejecuta: `npm run typecheck && npm run build` +Esperado: sin errores. + +Y a mano, contra `npm run platform` con el frontend apuntado a `:3100`: entra como +administración de plataforma, da de alta una cuenta, vincúlala con las credenciales reales, y +comprueba que la tabla muestra la huella del token y **nunca** el token. + +--- + +## Autorrevisión + +**Cobertura del objetivo.** El encargo pedía cuatro cosas. Alta de cuentas por el superadministrador +→ Tarea 6 (endpoints) y Tarea 9 (pantalla). Vincular por `locationId` y token privado → Tareas 1-3 +(cifrado y almacenamiento), Tarea 6 (validación previa contra el CRM). Que cada negocio use lo suyo +→ Tareas 4-5, con el compilador como garantía. Migrar lo que ya existe sin romperlo → Tarea 8. +**La sincronización por id de las cinco entidades no está aquí a propósito**: es el plan hermano, y +depende de `CrmCtx`, que produce este. + +**Placeholders.** Revisado: no hay «TBD», ni «añadir manejo de errores», ni «similar a la Tarea N». +Los mensajes de error están escritos literalmente. La única indirección deliberada es el Paso 3 de +la Tarea 9, que describe una pantalla en vez de dictarla: el repo ya tiene cuatro pantallas de +administración cuyo patrón hay que seguir, y copiarlo aquí produciría divergencia. + +**Consistencia de tipos.** `CrmCtx` se define en la Tarea 3 y se consume con el mismo nombre y la +misma forma en las Tareas 4, 5, 7 y 8. `guardarCredencial(businessId, locationId, token, label?)` +tiene la misma firma en las Tareas 3, 6 y 8. `token_fingerprint` se llama igual en la migración +(Tarea 2), la consulta (Tarea 6), el tipo compartido y la pantalla (Tarea 9). + +**Un hueco que dejo dicho, no escondido:** la autenticación sigue siendo el token trivial —el id del +usuario en claro— también para el rol `admin`. Este plan añade una consola que crea cuentas y guarda +credenciales de clientes **encima de esa base**. Endurecer la sesión es un entregable propio y +declarado en la deuda del repo; mientras no se haga, esta consola no debe quedar expuesta a +internet. Conviene decidirlo antes de desplegar, no después. diff --git a/docs/superpowers/plans/2026-08-29-sincronizacion-por-id.md b/docs/superpowers/plans/2026-08-29-sincronizacion-por-id.md new file mode 100644 index 0000000..4c44f53 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-sincronizacion-por-id.md @@ -0,0 +1,1249 @@ +# Sincronización por id de las cinco entidades — Plan de implementación + +> **Para trabajadores agénticos:** SUB-SKILL OBLIGATORIA: usa `superpowers:subagent-driven-development` +> (recomendado) o `superpowers:executing-plans`. **Tacha las casillas al completarlas.** + +**Objetivo:** poder traer o empujar una entidad concreta de Bucéfalo CRM **por su identificador** — +contactos, conversaciones, mensajes, citas y servicios— en vez de depender solo del arrastre masivo +de contactos que existe hoy. + +**Arquitectura:** hoy la única sincronización es `POST /api/crm/sync/contacts`, que trae los 3 210 +contactos enteros (~22 s). Este plan añade un punto de entrada único +`POST /api/crm/sync/:entidad/:id` que resuelve una sola entidad, y el espejo persistido de +conversaciones y mensajes —cuyas tablas existen desde `002_crm.sql` y **nadie escribe**, de modo que +hoy la bandeja consulta el CRM en vivo en cada carga. Para citas y servicios la dirección útil es la +contraria: **empujar**, porque el calendario del CRM está prácticamente vacío y su catálogo de +servicios lo está del todo. + +**Stack:** TypeScript + Express 4 + `pg` sobre PostgreSQL 16; `node:test`; React 18 + React Query. + +**Depende de:** `docs/superpowers/plans/2026-08-29-multitenant-credenciales-crm.md`. **No empieces +este plan sin aquel terminado**: todas las funciones de aquí reciben `CrmCtx`, que produce la Tarea 3 +de aquel plan. + +**Especificación de origen:** `platform/crm/HALLAZGOS.md`, hallazgos **1-37**. Los del 24 al 37 se +midieron específicamente para este plan, con dos sondeos de solo lectura +(`crm-spike-lectura-id.ts`, `crm-spike-calendarios.ts`) y uno de permisos que no crea nada +(`crm-spike-permisos.ts`). + +--- + +## Restricciones globales + +- **Lo medido gana a lo documentado.** Es la regla que produjo los 37 hallazgos. Donde la + documentación oficial y la observación chocan, manda la observación, y el choque se anota. +- **Nunca se acepta un `200` como prueba.** Toda escritura se verifica releyendo. +- **Cabecera `Version`:** `2021-07-28` para contactos, conversaciones, mensajes, oportunidades y + subcuentas; **`v3` para todo `/calendars/`**. Equivocarla es `400`. Ya lo resuelve `client.ts` + automáticamente por el prefijo de la ruta; no lo cambies «para alinearlo con la documentación», + que dice otra cosa y está desactualizada. +- **Tres convenciones de paginación distintas en la misma API**, y confundirlas devuelve resultados + incompletos sin error: contactos usan `searchAfter` (tomado del **último elemento** de la página, + no de la raíz); conversaciones usan `startAfterDate`; mensajes usan `lastMessageId` + `nextPage`. +- **Toda consulta filtra por `business_id`.** +- **Texto visible y errores de la API en español.** Al CRM se le llama «Bucéfalo CRM». +- **No hacer commits salvo que se pidan.** Verificación por tarea: `npm run typecheck` + + `npm run test:platform`. + +--- + +## Estructura de archivos + +| Archivo | Responsabilidad | +|---|---| +| `platform/crm/client.ts` | **Modificar.** Leer las cabeceras `X-RateLimit-*` y ajustar el estrangulador con dato real. | +| `platform/crm/conversations.ts` | **Crear.** Leer conversaciones y mensajes por id. Sale de `messages.ts`, que se queda con el envío. | +| `platform/crm/calendars.ts` | **Crear.** Calendarios, y citas por id y por rango. | +| `platform/crm/services.ts` | **Crear.** Catálogo de servicios y personal de la subcuenta. | +| `platform/crm/syncConversations.ts` | **Crear.** Espejo persistido de conversaciones y mensajes. | +| `platform/crm/syncOne.ts` | **Crear.** El despachador: entidad + id → función correspondiente. | +| `platform/routes/crm.ts` | **Modificar.** `POST /sync/:entidad/:id` y `POST /sync/conversations`. | +| `platform/db/migrations/004_sync_por_id.sql` | **Crear.** Índices y columnas que faltan para el espejo. | +| `platform/test/syncOne.test.ts` | **Crear.** | +| `platform/test/syncConversations.test.ts` | **Crear.** | +| `src/pages/MessagesPage.tsx` | **Modificar.** Leer del espejo. | +| `src/components/CrmSyncPanel.tsx` | **Modificar.** Buscar por id. | + +--- + +## Tarea 1: Estrangulador con dato real, no con estimación + +**Archivos:** +- Modificar: `platform/crm/client.ts` +- Modificar: `platform/crm/client.test.ts` + +**Interfaces:** +- Consume: `esperaDeToken`, `registrarPeticion` (Tarea 4 del plan de multi-tenancy). +- Produce: `limitesDe(token: string): Limites | null` con `{ max, ventanaMs, restantes, diarioRestante }`. + +**Por qué:** el comentario de `client.ts` dice «~1 petición cada 0.65 s por token» y fija +`MIN_INTERVAL_MS = 650`. **Medido (hallazgo 37)**, las cabeceras reales dicen +`x-ratelimit-max: 100` en `x-ratelimit-interval-milliseconds: 10000`, o sea **1 cada 100 ms**. El +cliente va **6,5 veces por debajo** de lo permitido. Con la sincronización de contactos en primer +plano, eso son 22 s que podrían ser ~4. Y hasta ahora nadie leía esas cabeceras: el 650 era una +estimación observada, no una cuota conocida. + +**Decisión deliberada:** no se baja a 100 ms de golpe. Se baja a **150 ms** —un 50 % de margen +sobre la cuota— y se **aprende de las cabeceras**: si el CRM dice otra cosa, gana el CRM. Fijar el +valor teórico exacto deja el sistema sin colchón para las peticiones que el propio worker hace en +paralelo. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// añadir a platform/crm/client.test.ts +import { anotarLimites, limitesDe, intervaloDe } from "./client.ts"; + +test("las cabeceras del CRM mandan sobre el valor por defecto", () => { + anotarLimites("tok-1", new Headers({ + "x-ratelimit-max": "100", + "x-ratelimit-interval-milliseconds": "10000", + "x-ratelimit-remaining": "94", + "x-ratelimit-daily-remaining": "199970", + })); + const l = limitesDe("tok-1"); + assert.equal(l?.max, 100); + assert.equal(l?.ventanaMs, 10000); + // 10000/100 = 100 ms teóricos, +50 % de margen = 150 + assert.equal(intervaloDe("tok-1"), 150); +}); + +test("sin cabeceras se usa el intervalo conservador por defecto", () => { + assert.equal(intervaloDe("tok-sin-datos"), 650); +}); + +test("cuando quedan pocas peticiones en la ventana, se frena", () => { + anotarLimites("tok-2", new Headers({ + "x-ratelimit-max": "100", + "x-ratelimit-interval-milliseconds": "10000", + "x-ratelimit-remaining": "3", + })); + assert.ok(intervaloDe("tok-2") > 150, "con la ventana casi agotada hay que espaciar más"); +}); +``` + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `node --import tsx --test platform/crm/client.test.ts` +Esperado: FALLA — esos tres símbolos no existen. + +- [ ] **Paso 3: Implementar** + +```ts +// en platform/crm/client.ts +export interface Limites { + max: number; + ventanaMs: number; + restantes: number; + diarioRestante: number | null; +} + +const limitesPorToken = new Map(); + +/** Intervalo conservador mientras el CRM no diga la cuota real. */ +export const MIN_INTERVAL_MS = 650; +/** Margen sobre la cuota: no se corre al límite exacto. */ +const MARGEN = 1.5; + +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. + * + * MEDIDO (hallazgo 37): la cuota real es 100 peticiones por 10 s, o sea 1 cada + * 100 ms — 6,5× más de lo que el cliente asumía. Se toma esa cuota con un 50 % + * de margen, no al límite: el worker y una sincronización manual pueden coincidir. + * Si la ventana está casi agotada se espacia hasta que se renueve, que es 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; +} +``` + +Cambia `esperaDeToken` para que use `intervaloDe(token)` en lugar de la constante, y en +`crmRequest`, después de recibir la respuesta, añade `anotarLimites(token, res.headers);` **antes** +de leer el cuerpo. + +- [ ] **Paso 4: Correr y verificar que pasa** + +Ejecuta: `node --import tsx --test platform/crm/client.test.ts` +Esperado: las 3 pruebas nuevas pasan y las 2 anteriores siguen pasando. + +- [ ] **Paso 5: Medir la mejora contra el CRM real** + +Ejecuta: `node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts` +Esperado: sigue imprimiendo las cabeceras. Después, con el servidor arriba, lanza la sincronización +de contactos y compara: **antes ~22 s**. Anota el nuevo tiempo en `HALLAZGOS.md`. Si no baja de +forma apreciable, el cuello de botella no era el estrangulador y hay que decirlo. + +--- + +## Tarea 2: Lectura por id de conversaciones y mensajes + +**Archivos:** +- Crear: `platform/crm/conversations.ts` +- Modificar: `platform/crm/messages.ts` (se queda solo con el envío) +- Crear: `platform/crm/conversations.test.ts` + +**Interfaces:** +- Consume: `CrmCtx`, `crmRequest`. +- Produce: + - `obtenerConversacion(ctx: CrmCtx, id: string): Promise` + - `conversacionesDeContacto(ctx: CrmCtx, contactId: string): Promise` + - `buscarConversaciones(ctx: CrmCtx, opts?: { limit?: number; startAfterDate?: number }): Promise<{ conversations: CrmConversation[]; total: number }>` + - `mensajesDeConversacion(ctx: CrmCtx, conversationId: string, opts?: { limit?: number; lastMessageId?: string }): Promise<{ mensajes: CrmMessage[]; lastMessageId: string | null; hayMas: boolean }>` + - `obtenerMensaje(ctx: CrmCtx, id: string): Promise` + +**Medido, y hay que respetarlo:** +- `GET /conversations/{id}` devuelve los campos **en la raíz**, sin envoltorio (hallazgo 24). +- `GET /conversations/search` sí admite `contactId` **y** `id` como filtros (hallazgo 25). +- `GET /conversations/{id}/messages` devuelve **`{ messages: { messages: [...], lastMessageId, nextPage } }`** — anidado dos niveles (hallazgo 26). +- `GET /conversations/messages/{id}` funciona y devuelve el mensaje en la raíz (hallazgo 27). +- **`type` es numérico en `/conversations/{id}` y una cadena `TYPE_SMS` en el buscador.** Es la misma información con dos formas según el endpoint. Normalízalo al leer. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/crm/conversations.test.ts +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", () => { + 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"); +}); + +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); +}); +``` + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `npm run test:platform` +Esperado: FALLA — no existe `./conversations.ts`. + +- [ ] **Paso 3: Implementar** + +```ts +// platform/crm/conversations.ts +import { crmRequest } 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 viene como cadena (`TYPE_SMS`) desde el buscador y como número desde + * `GET /conversations/{id}`. Es la misma información con dos formas, y cruzarlas + * produce una bandeja que etiqueta mal los hilos. + */ +const POR_NUMERO: Record = { + 1: "Phone", 2: "Email", 3: "FB", 4: "Review", 5: "SMS", +}; +export function normalizarTipo(t: string | number | undefined): string { + if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido"; + if (typeof t === "string" && t) return t.replace(/^TYPE_/, "").replace(/_/g, " "); + return "Desconocido"; +} + +/** MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...] } }`. */ +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 }; +} + +export async function obtenerConversacion( + ctx: CrmCtx, + id: string +): Promise { + try { + // MEDIDO (hallazgo 24): los campos vienen en la raíz, sin envoltorio. + return await crmRequest("GET", `/conversations/${id}`, { token: ctx.token }); + } catch (e: any) { + if (e?.status === 404) return null; + throw e; + } +} + +export async function conversacionesDeContacto( + ctx: CrmCtx, + contactId: string +): Promise { + const r = await crmRequest("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("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("GET", `/conversations/${conversationId}/messages`, { + token: ctx.token, + query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId }, + }); + return formaDeMensajes(r); +} + +export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise { + try { + const r = await crmRequest("GET", `/conversations/messages/${id}`, { token: ctx.token }); + return (r?.message ?? r) as CrmMessage; + } catch (e: any) { + if (e?.status === 404) return null; + throw e; + } +} +``` + +Deja en `platform/crm/messages.ts` **solo** `enviarCorreo`, y actualiza los imports de +`platform/routes/messages.ts`. + +- [ ] **Paso 4: Correr y verificar que pasa** + +Ejecuta: `npm run typecheck && npm run test:platform` + +- [ ] **Paso 5: Comprobar contra el CRM real** + +Ejecuta: `node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts` +Esperado: sigue dando 10 rutas en verde. Ese sondeo ejerce exactamente estas rutas. + +--- + +## Tarea 3: Espejo persistido de conversaciones y mensajes + +**Archivos:** +- Crear: `platform/db/migrations/004_sync_por_id.sql` +- Crear: `platform/crm/syncConversations.ts` +- Crear: `platform/test/syncConversations.test.ts` + +**Interfaces:** +- Consume: `obtenerConversacion`, `buscarConversaciones`, `mensajesDeConversacion` (Tarea 2). +- Produce: + - `sincronizarConversaciones(businessId: number, opts?: { limit?: number; userId?: number }): Promise` + - `sincronizarConversacion(businessId: number, crmConversationId: string): Promise<{ conversacion: number; mensajes: number }>` + +**Por qué esto existe:** las tablas `conversations` y `messages` se declararon en `002_crm.sql` y +**nadie escribe en ellas**. La bandeja consulta el CRM en vivo en cada carga, lo que significa que +sin conexión no hay bandeja, que cada visita gasta cuota, y que no se puede buscar ni cruzar un hilo +con una clienta sin volver a salir a la red. El espejo lo arregla, y las tablas ya estaban pensadas +para él. + +- [ ] **Paso 1: Escribir la migración** + +```sql +-- platform/db/migrations/004_sync_por_id.sql +-- El espejo de conversaciones ya tenía tablas (002_crm.sql) pero nada que las +-- escribiera. Aquí se añade lo que faltaba para poder llenarlas y consultarlas. + +-- De qué contacto del CRM es cada mensaje, para poder cruzarlo con la clienta +-- sin pasar por la conversación. +ALTER TABLE messages + ADD COLUMN crm_contact_id text, + -- Cuerpo normalizado del canal: la API lo devuelve como número o como cadena + -- según el endpoint (hallazgo 24 vs buscador). + 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 poder continuar +-- donde se quedó en vez de releer las 3 213 cada vez. +ALTER TABLE crm_connections + ADD COLUMN conv_cursor_date bigint; + +-- `crm_sync_runs.kind` gana dos valores; la columna es text sin CHECK, así que +-- no hace falta migrar nada: se documenta y ya. +COMMENT ON COLUMN crm_sync_runs.kind IS + 'contacts | appointments | conversations | one — "one" es la sincronización de una sola entidad por id'; +``` + +- [ ] **Paso 2: Escribir la prueba que falla** + +```ts +// platform/test/syncConversations.test.ts +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( + `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("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( + `SELECT count(*)::int AS n FROM messages WHERE business_id = $1`, [b.id] + ); + assert.equal(rows[0].n, 1); +}); +``` + +- [ ] **Paso 3: Correr y verificar que falla** + +Ejecuta: `npm run pg:migrate && npm run test:platform` +Esperado: la migración se aplica; las pruebas fallan por falta de `syncConversations.ts`. + +- [ ] **Paso 4: Implementar** + +```ts +// platform/crm/syncConversations.ts +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 → timestamptz. Acepta ISO y epoch en ms, que la API mezcla. */ +function fecha(v: string | number | undefined | null): Date | null { + if (v == null) return null; + const d = typeof v === "number" ? new Date(v) : new Date(v); + return isNaN(d.getTime()) ? null : d; +} + +export async function upsertConversacion( + businessId: number, + c: CrmConversation +): Promise { + 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 = EXCLUDED.crm_contact_id, + contact_name = EXCLUDED.contact_name, + last_message_type = EXCLUDED.last_message_type, + last_message_body = EXCLUDED.last_message_body, + last_message_at = EXCLUDED.last_message_at, + unread_count = EXCLUDED.unread_count, + -- El enlace con la clienta solo se rellena, nunca se borra: si la + -- sincronización de contactos aún no ha corrido, `client_id` es NULL y + -- pisarlo con NULL más tarde perdería 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 { + 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 dueño del histórico: aquí se reescribe, nunca se edita. + 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 ResumenSync { + conversaciones: number; + mensajes: number; + runId: number; +} + +/** Trae una conversación concreta con todos sus mensajes. */ +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" }; + + const convId = await upsertConversacion(businessId, { ...c, id: crmConversationId }); + 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) break; + cursor = lastMessageId; + } + return { conversacion: convId, mensajes: total }; +} + +/** Trae las conversaciones más recientes y sus mensajes. */ +export async function sincronizarConversaciones( + businessId: number, + opts: { limit?: number; userId?: number } = {} +): Promise { + 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?.message ?? e).slice(0, 500)] + ); + throw e; + } +} +``` + +- [ ] **Paso 5: Correr y verificar que pasa** + +Ejecuta: `npm run typecheck && npm run test:platform` +Esperado: las 3 pruebas nuevas pasan. + +--- + +## Tarea 4: El despachador «sincroniza esto por su id» + +**Archivos:** +- Crear: `platform/crm/syncOne.ts` +- Crear: `platform/test/syncOne.test.ts` +- Modificar: `platform/routes/crm.ts` + +**Interfaces:** +- Consume: `obtenerContacto` (contacts.ts), `sincronizarConversacion` (Tarea 3), + `obtenerMensaje` (Tarea 2), `upsertClienteDesdeCrm` (syncContacts.ts). +- Produce: `sincronizarPorId(businessId, entidad: Entidad, id: string)` con + `type Entidad = "contacto" | "conversacion" | "mensaje" | "cita" | "servicio"`. +- Produce: `POST /api/crm/sync/:entidad/:id`. + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/test/syncOne.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { esEntidad, ENTIDADES } from "../crm/syncOne.ts"; + +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); +}); + +test("ENTIDADES son exactamente las cinco, ni una más", () => { + assert.deepEqual([...ENTIDADES].sort(), + ["cita", "contacto", "conversacion", "mensaje", "servicio"]); +}); +``` + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `npm run test:platform` + +- [ ] **Paso 3: Implementar el despachador** + +```ts +// platform/crm/syncOne.ts +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; +} + +/** + * Sincroniza UNA entidad por su identificador. + * + * La dirección no es la misma para todas, y no es un capricho: + * - contacto, conversación y mensaje se TRAEN: el CRM es su dueño. + * - cita y servicio se EMPUJAN. MEDIDO (hallazgos 29 y 32): el calendario del + * CRM tiene una sola cita en dos años y el catálogo de servicios está vacío, + * así que no hay nada que arrastrar. La agenda y el catálogo nacen aquí. + */ +export async function sincronizarPorId( + businessId: number, + entidad: Entidad, + id: string +): Promise { + const ctx = await ctxDe(businessId); + + switch (entidad) { + case "contacto": { + 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.creado ? "creado" : "actualizado", detalle: { clientId: r.clientId } }; + } + case "conversacion": { + const r = await sincronizarConversacion(businessId, id); + return { entidad, id, accion: "espejada", detalle: r }; + } + case "mensaje": { + 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" }; + } + // Se sincroniza el hilo entero: un mensaje suelto sin su conversación no + // se puede guardar, porque `messages.conversation_id` es obligatorio. + const r = await sincronizarConversacion(businessId, m.conversationId); + return { entidad, id, accion: "espejado con su hilo", detalle: r }; + } + case "cita": { + const r = await proyectarCita(businessId, Number(id)); + return { entidad, id, accion: "empujada", detalle: r as Record }; + } + case "servicio": { + const r = await publicarServicio(businessId, Number(id)); + return { entidad, id, accion: "publicado", detalle: r as Record }; + } + } +} +``` + +- [ ] **Paso 4: Añadir la ruta** + +En `platform/routes/crm.ts`: + +```ts +import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts"; +import { sincronizarConversaciones } from "../crm/syncConversations.ts"; + +/** Sincroniza UNA entidad por su id. */ +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}`); + } + }) +); + +/** Espejo de las conversaciones recientes. */ +crmRouter.post( + "/sync/conversations", + h(async (req: AuthedRequest, res) => { + try { + res.json(await sincronizarConversaciones(req.user!.business_id!, { + limit: Number(req.body?.limite) || 50, + 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}`); + } + }) +); +``` + +> **Ojo con el orden de las rutas.** `POST /sync/:entidad/:id` tiene dos segmentos y +> `POST /sync/conversations` tiene uno, así que no chocan. Pero `POST /sync/contacts` (el masivo, ya +> existente) también tiene uno: **déjalo declarado antes** que el genérico para que no haya +> ambigüedad si alguien añade mañana un `/sync/:algo` de un solo segmento. + +- [ ] **Paso 5: Verificar** + +Ejecuta: `npm run typecheck && npm run test:platform` + +--- + +## Tarea 5: Empujar citas al calendario del CRM + +**Archivos:** +- Crear: `platform/crm/calendars.ts` +- Crear: `platform/crm/calendars.test.ts` +- Modificar: `platform/crm/syncAppointments.ts` + +**Interfaces:** +- Consume: `CrmCtx`; `crm_connections.calendar_id` (plan de multi-tenancy, Tarea 2). +- Produce: + - `listarCalendarios(ctx: CrmCtx): Promise` + - `listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]>` + - `obtenerCita(ctx: CrmCtx, eventId: string): Promise` + - `citasEnRango(ctx: CrmCtx, calendarId: string, desdeMs: number, hastaMs: number): Promise` + - `crearCita(ctx: CrmCtx, args: AltaCita): Promise<{ id: string }>` + - `isoConDesplazamiento(d: Date, tz: string): string` + +**Medido, y cada punto es una trampa distinta:** +- **`calendars/events.write` sí está** (hallazgo 33). Esto ya no es una incógnita. +- `GET /calendars/events` **exige** uno de `calendarId`, `userId` o `groupId`; sin ellos es `422` (hallazgo 28). +- La **entrada** del rango va en **milisegundos epoch** y la **salida** viene en **ISO con + desplazamiento** (`2026-09-03T11:00:00-06:00`). Es asimétrico. +- **`POST` acepta `locationId`; el `PUT` lo rechaza con `422`.** Es la misma asimetría ya medida en + contactos (hallazgo de cabeceras y trampas). No recicles el cuerpo del alta para actualizar. +- El evento devuelve `appointmentStatus` **y** `appoinmentStatus` —con la errata, del lado del + CRM— con el mismo valor (hallazgo 30). + +- [ ] **Paso 1: Escribir la prueba que falla** + +```ts +// platform/crm/calendars.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isoConDesplazamiento, estadoCitaCrm } from "./calendars.ts"; + +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"); +}); + +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"); +}); +``` + +- [ ] **Paso 2: Correr y verificar que falla** + +Ejecuta: `node --import tsx --test platform/crm/calendars.test.ts` + +- [ ] **Paso 3: Implementar** + +```ts +// platform/crm/calendars.ts +import { crmRequest, 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; + startTime: string; // ISO con desplazamiento + endTime: string; + title: string; + assignedUserId?: string; + appointmentStatus?: string; +} + +/** + * ISO con el desplazamiento de la zona del NEGOCIO. + * + * El CRM acepta `2026-09-03T11:00:00-06:00` y NO milisegundos, al revés que el + * filtro de rango de `/calendars/events`. Y no vale `toISOString()`: eso da 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 no 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 p = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, 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: tz, 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"; + return `${g("year")}-${g("month")}-${g("day")}T${g("hour")}:${g("minute")}:${g("second")}${desp}`; +} + +/** MEDIDO: en la petición el enum es new|confirmed|cancelled|showed|noshow|invalid. */ +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 { + const r = await crmRequest("GET", "/calendars/", { + token: ctx.token, query: { locationId: ctx.locationId }, version: VERSION_CALENDARS, + }); + return r?.calendars ?? []; +} + +/** MEDIDO (hallazgo 35): esta ruta volvió a estar disponible. Da los ids que + * `staff[]` exige al crear servicios y `assignedUserId` al crear citas. */ +export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> { + const r = await crmRequest("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 { + try { + const r = await crmRequest("GET", `/calendars/events/appointments/${eventId}`, { + token: ctx.token, version: VERSION_CALENDARS, + }); + return (r?.event ?? r?.appointment ?? r) as CrmEvent; + } catch (e: any) { + if (e?.status === 404) return null; + throw e; + } +} + +/** MEDIDO (hallazgo 28): sin `calendarId` esto es 422. Y el rango va en ms epoch. */ +export async function citasEnRango( + ctx: CrmCtx, calendarId: string, desdeMs: number, hastaMs: number +): Promise { + const r = await crmRequest("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("POST", "/calendars/events/appointments", { + token: ctx.token, version: VERSION_CALENDARS, + body: { + locationId: ctx.locationId, // va en el POST y ROMPE el PUT: no reciclar + 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 sus + // automatizaciones encima 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. Que el CRM no rechace por su propia idea de disponibilidad. + ignoreFreeSlotValidation: true, + }, + }); + const id = r?.id ?? r?.event?.id; + if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador"); + return { id }; +} +``` + +- [ ] **Paso 4: Correr y verificar que pasa** + +Ejecuta: `node --import tsx --test platform/crm/calendars.test.ts` + +- [ ] **Paso 5: Ejercerlo contra el CRM real, y limpiar** + +Escribe `platform/scripts/crm-spike-cita.ts` que: cree una cita en el calendario `Servicio Spa` +para el contacto de prueba `WzBTBaHkNnpmjMb1Avx3`, **la relea** con `obtenerCita` para confirmar que +existe y que la hora de pared coincide, y la borre con +`DELETE /calendars/events/{eventId}`. +Ejecuta: `node scripts/run-tsx.mjs platform/scripts/crm-spike-cita.ts` +Esperado: crea, relee con la misma hora, borra. **Anota el resultado en `HALLAZGOS.md` como +hallazgo 38** — es la primera escritura de calendario del proyecto. + +--- + +## Tarea 6: Publicar servicios al catálogo del CRM + +**Archivos:** +- Crear: `platform/crm/services.ts` +- Modificar: `platform/routes/crm.ts` + +**Interfaces:** +- Consume: `listarPersonal` (Tarea 5), `CrmCtx`. +- Produce: + - `catalogoDelCrm(ctx: CrmCtx): Promise` + - `publicarServicio(businessId: number, serviceId: number): Promise<{ crmServiceId: string }>` + +**La decisión que hay que entender antes de escribir código:** «sincronizar servicios» **no puede +significar traerlos**. Medido dos veces (hallazgos 6 y 32), `GET /calendars/services/catalog` +devuelve `services: []`: el catálogo del CRM está vacío, y la duración y el precio los define el +negocio en la plataforma. Lo único con sentido es **publicar** hacia allá. Y eso ahora es posible: +`calendars.write` está (hallazgo 34) y `staff[]` —que era el impedimento— se puede rellenar desde +que `GET /users/` volvió a responder (hallazgo 35). + +- [ ] **Paso 1: Añadir la columna de anclaje** + +Añade a `platform/db/migrations/004_sync_por_id.sql`: + +```sql +-- El servicio de la plataforma, una vez publicado en el catálogo del CRM. +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; +``` + +- [ ] **Paso 2: Implementar** + +```ts +// platform/crm/services.ts +import { pool } from "../db/pool.ts"; +import { crmRequest, VERSION_CALENDARS } from "./client.ts"; +import { ctxDe } from "./ctx.ts"; +import { listarPersonal } from "./calendars.ts"; +import type { CrmCtx } from "./ctx.ts"; + +export interface CrmService { + id: string; name: string; slug: string; + serviceDuration?: number; serviceDurationUnit?: string; +} + +export async function catalogoDelCrm(ctx: CrmCtx): Promise { + const r = await crmRequest("GET", "/calendars/services/catalog", { + token: ctx.token, query: { locationId: ctx.locationId }, version: VERSION_CALENDARS, + }); + return r?.services ?? []; +} + +function slugify(s: string): string { + return (s || "servicio").toLowerCase().normalize("NFD") + .replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "").slice(0, 60) || "servicio"; +} + +/** + * Publica un servicio de la plataforma en el catálogo del CRM. + * + * MEDIDO (hallazgo 34): `staff[]` con al menos un miembro es obligatorio, y el + * `422` lo dice explícitamente. Se toma el primer usuario de la subcuenta si no + * hay uno mejor: el catálogo no admite un servicio sin quien lo preste. + */ +export async function publicarServicio( + businessId: number, + serviceId: number +): Promise<{ crmServiceId: string }> { + 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" }; + + const personal = await listarPersonal(ctx); + 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 r = await crmRequest("POST", "/calendars/services/catalog", { + token: ctx.token, version: VERSION_CALENDARS, + body: { + locationId: ctx.locationId, + name: s.name, + slug: slugify(s.name), + description: s.description ?? undefined, + eventColor: s.color ?? undefined, + serviceDuration: Number(s.duration_min), + serviceDurationUnit: "mins", + staff: personal.slice(0, 1).map((p) => ({ id: p.id })), + variations: [], + }, + }); + + 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"); + } + + await pool.query( + `UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`, + [serviceId, crmServiceId] + ); + return { crmServiceId }; +} +``` + +- [ ] **Paso 3: Verificar contra el CRM real** + +Con el servidor arriba: `POST /api/crm/sync/servicio/`. +Esperado: `201`/`200` con el `crmServiceId`, y `GET /calendars/services/catalog` deja de devolver +vacío. **Anótalo como hallazgo 39** — sería la primera escritura al catálogo. + +Si falla con `422` sobre `staff`, comprueba con `listarPersonal` que los ids se están mandando como +`[{ id: "..." }]` y no como `["..."]`: el `422` medido dice *«staff must be an array»* sin precisar +la forma de sus elementos, y esa ambigüedad es exactamente donde se pierde una tarde. + +- [ ] **Paso 4: Verificar el conjunto** + +Ejecuta: `npm run typecheck && npm run test:platform` + +--- + +## Tarea 7: La interfaz + +**Archivos:** +- Modificar: `src/lib/api.ts`, `src/components/CrmSyncPanel.tsx`, `src/pages/MessagesPage.tsx` + +- [ ] **Paso 1: Métodos de API** + +```ts + syncOne: (entidad: string, id: string) => + request<{ entidad: string; id: string; accion: string; detalle: Record }>( + `/crm/sync/${entidad}/${encodeURIComponent(id)}`, { method: "POST" } + ), + syncConversations: (limite = 50) => + request<{ conversaciones: number; mensajes: number }>("/crm/sync/conversations", { + method: "POST", body: JSON.stringify({ limite }), + }), +``` + +- [ ] **Paso 2: Buscar por id en el panel** + +En `CrmSyncPanel.tsx`, añade un bloque «Traer una ficha concreta» con un ` setDate(e.target.value)} + /> + +
+ + + +
+ + {pendientes === 0 ? ( + + ) : ( +
    + {data.unresolved.map((a) => ( +
  • +
    +

    {a.client_name}

    +

    + {formatTime(a.start_at)} · {a.service_name} · {a.employee_name} +

    +
    +
    + + +
    +
  • + ))} +
+ )} + + {attendance.isError && ( +

{(attendance.error as Error).message}

+ )} + +
+ + {pendientes > 0 && ( + + Faltan {pendientes} cita{pendientes === 1 ? "" : "s"} por resolver. + + )} + {close.isError && ( + {(close.error as Error).message} + )} +
+ + ); +} + +function Metric({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} +``` + +- [ ] **Step 2: Registrar la ruta en `src/App.tsx`** + +Junto a las otras rutas del panel. No va con `React.lazy`: la página no importa +`recharts` ni FullCalendar, así que no hay motivo medible para diferirla. + +```tsx +import DayClosePage from "@/pages/DayClosePage"; +// … + } /> +``` + +- [ ] **Step 3: Añadir la entrada de menú en `src/components/AppShell.tsx`** + +En el arreglo de navegación, siguiendo el patrón `sidebarContent(mini)` existente +—cada entrada conserva su `title` para no perder el nombre accesible en modo mini. +Importar `CalendarCheck` de `lucide-react`: + +```tsx + { to: "/cierre-dia", label: "Cierre de día", icon: CalendarCheck }, +``` + +- [ ] **Step 4: Correr el typecheck y verificar que pasa** + +Run: `npm.cmd run typecheck` +Expected: sin errores. + +- [ ] **Step 5: Verificación manual** + +```bash +npm.cmd run pg:up +npm.cmd run pg:migrate +node scripts/run-tsx.mjs platform/index.ts +# en otra terminal: +API_URL=http://127.0.0.1:3100 npm.cmd run dev:web +``` + +Abrir `http://127.0.0.1:5173/cierre-dia` a 390px de ancho y comprobar: +1. Los botones *Vino* / *No vino* miden al menos 40px de alto. +2. Al marcar, la cita desaparece de la lista y el contador de arriba sube. +3. Con citas pendientes, «Cerrar el día» está deshabilitado y dice cuántas faltan. +4. Con cero pendientes, cierra y el botón pasa a «Día cerrado». +5. La página no desplaza horizontalmente. + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/DayClosePage.tsx src/App.tsx src/components/AppShell.tsx +git commit -m "feat(web): pantalla de cierre de día con toque de asistencia" +``` + +--- + +## Verificación final del entregable + +Corre las tres cosas y pega la salida real; no des nada por bueno sin verla: + +```bash +npm.cmd run typecheck +npm.cmd run test:platform +node --import tsx --test platform/lib/phone.test.ts +``` + +Criterios de aceptación de este entregable, tomados de la §12 de la propuesta y de la §7 del caso Yola: + +| Criterio | Cómo se comprueba | +|---|---| +| La búsqueda por teléfono encuentra a la clienta existente sin crear duplicado | `platform/test/clients.test.ts`, casos 2 y 4 | +| Dos usuarios no pueden reservar el mismo horario para la misma especialista | `platform/test/schema.test.ts` caso 1 (código `23P01`) y `appointments.test.ts` caso 2 (409) | +| Cada cambio relevante deja registro de usuario, fecha y acción | `audit.test.ts`, más los asertos de `audit_log` en clientes, citas y asistencia | +| Existe el dato «vino / no vino» | `attendance.test.ts` completo | +| El cierre de día no deja citas sin desenlace | `dayClose.test.ts` caso 2 | +| Una empleada no resuelve citas ajenas | Guarda de rol en `attendance.ts`; añadir el caso de prueba si el revisor lo pide | + +## Lo que este entregable deliberadamente NO hace + +- **No endurece la autenticación.** Token trivial y contraseña sin hashear, portados tal cual. Es deuda anotada, no un descuido. +- **No toca Bucéfalo CRM.** Ni bandeja de salida, ni webhooks, ni conversaciones. La columna `crm_contact_id` queda declarada y vacía. +- **No migra los datos del backend SQLite.** `server/` sigue en pie y sin cambios. +- **No añade multi-servicio por cita, buffers, salas ni bloqueos.** Es el alcance «cerrar huecos de agenda». +- **No hay reserva pública ni recordatorios** sobre este backend todavía. + +## Antes de dar por bueno el diseño, hay que validar tres cosas con el cliente + +Vienen del cierre del documento del caso Yola y ninguna es código: + +1. Que existan calendarios configurados en la subcuenta del CRM. Si no hay ninguno, la Fase 2 cambia de forma. +2. Que la dueña confirme el catálogo de servicios y sus duraciones. El que circula sale de 38 menciones en una muestra de 150 hilos, no de su lista de precios. +3. Que el spa acepte crear citas **solo** en la plataforma. De eso depende que la exclusión por rango sirva de algo: la base no puede impedir una cita creada fuera. + +--- + +## Addendum de ejecución — 2026-08-29 + +El plan se ejecutó completo. Seis cosas se desviaron de lo escrito, y esta es la +razón de cada una: + +1. **Puerto 5434, no 5433.** El 5433 lo ocupaba `analytics-pg-local`. El 5432 + también está tomado (`andamios-postgres-dev`). +2. **`--test-concurrency=1` en `test:platform`.** El corredor de `node:test` + lanza un proceso por archivo en paralelo y cada archivo hace `DROP SCHEMA + public`: se pisaban entre sí y fallaban con `relation "schema_migrations" + does not exist`. Serializados, los 38 pasan. +3. **`migrate.test.ts` parte de un esquema vacío.** Con la suite completa corría + después de otros archivos y su aserción («el bootstrap se aplica») era falsa. + Se añadió `dropSchema()` a los helpers y un `before` que lo llama. +4. **La acotación del día usa `<=`, no `<`.** `bizDayBoundsIsoFor` devuelve un + fin **inclusivo** (23:59:59 hora local). Con `<` se perdía la última cita. +5. **Se añadieron `platform/routes/auth.ts` y `business.ts`, y + `platform/scripts/seed.ts`.** No estaban en el plan y sin ellos el entregable + no se puede abrir en un navegador: el frontend pide `/api/auth/login`, + `/api/auth/me` y `/api/business` antes de pintar nada. +6. **`platform` se añadió a `include` de `tsconfig.json`.** Sin eso el + `typecheck` pasaba sin haber mirado una sola línea del backend nuevo. + Verificado con `tsc --listFiles`: 19 archivos de `platform/` compilan. + +### Dos defectos de interfaz encontrados en la verificación a 390px + +Ninguno lo habría detectado un test de API; salieron de mirar la captura: + +- Las tres métricas apiladas (`grid-cols-1 sm:grid-cols-3`) ocupaban media + pantalla y empujaban la lista fuera de la vista, que es justo lo que la + empleada viene a tocar. Ahora son tres columnas desde el teléfono. +- El nombre del servicio se truncaba a «Exte…» porque competía por el ancho con + los dos botones. El bloque de texto pasa a `basis-full` en teléfono y los + botones ocupan su propia fila a ancho completo. + +### Evidencia de la verificación + +``` +npm run typecheck → sin errores +npm run test:platform → 38 pruebas, 38 pass, 0 fail +npm run test:unit → 43 pruebas, 43 pass, 0 fail (no se rompió nada existente) +``` + +WebKit a 390×844: 5 citas pendientes pintadas, botones de 40px de alto, 0px de +desborde horizontal, «Cerrar el día» deshabilitado con el aviso de cuántas +faltan, y al marcar una la lista baja a 4 y el contador de asistidas sube a 1. +Sin errores de consola. + +Flujo completo contra la API real (`curl`): cerrar con 5 pendientes → 409 con el +mensaje en español; resolver las 5 → 200 cada una; cerrar → `attended_count: 4`, +`no_show_count: 1`. diff --git a/docs/yola-franco-spa-plataforma-propuesta-tecnica.md b/docs/yola-franco-spa-plataforma-propuesta-tecnica.md new file mode 100644 index 0000000..63ebbde --- /dev/null +++ b/docs/yola-franco-spa-plataforma-propuesta-tecnica.md @@ -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. diff --git a/package-lock.json b/package-lock.json index aee2780..2c8259e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 77ea506..d4d843a 100644 --- a/package.json +++ b/package.json @@ -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:yola_dev@127.0.0.1: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", diff --git a/platform/.env.example b/platform/.env.example new file mode 100644 index 0000000..3a90be6 --- /dev/null +++ b/platform/.env.example @@ -0,0 +1,31 @@ +# Base de datos de la plataforma +DATABASE_URL=postgres://yola:yola_dev@127.0.0.1:5434/yola +TEST_DATABASE_URL=postgres://yola:yola_dev@127.0.0.1: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=urieljareth@grupo-e3.com + +# 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 diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 0000000..211ba8b --- /dev/null +++ b/platform/README.md @@ -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: `yola@yolafranco.mx` / `demo1234`. El personal entra con +`karla@`, `brenda@` y `paola@yolafranco.mx`, 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 +``` + +## 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 # 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. diff --git a/platform/crm/API.md b/platform/crm/API.md new file mode 100644 index 0000000..1804795 --- /dev/null +++ b/platform/crm/API.md @@ -0,0 +1,288 @@ +# Referencia de la API de Bucéfalo CRM + +Host base: `https://services.leadconnectorhq.com` · Autenticación: `Authorization: Bearer `. + +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. diff --git a/platform/crm/HALLAZGOS.md b/platform/crm/HALLAZGOS.md new file mode 100644 index 0000000..b27a833 --- /dev/null +++ b/platform/crm/HALLAZGOS.md @@ -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 | diff --git a/platform/crm/calendars.test.ts b/platform/crm/calendars.test.ts new file mode 100644 index 0000000..f88561f --- /dev/null +++ b/platform/crm/calendars.test.ts @@ -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"); +}); diff --git a/platform/crm/calendars.ts b/platform/crm/calendars.ts new file mode 100644 index 0000000..bf59571 --- /dev/null +++ b/platform/crm/calendars.ts @@ -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 { + const r = await crmRequest("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("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 { + try { + const r = await crmRequest("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 { + const r = await crmRequest("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("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> +): Promise { + 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, + }, + }); +} diff --git a/platform/crm/client.test.ts b/platform/crm/client.test.ts new file mode 100644 index 0000000..9abdd0d --- /dev/null +++ b/platform/crm/client.test.ts @@ -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); +}); diff --git a/platform/crm/client.ts b/platform/crm/client.ts new file mode 100644 index 0000000..8e671ac --- /dev/null +++ b/platform/crm/client.ts @@ -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(); + +/** 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(); + +/** 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; +} + +export async function crmRequest( + method: string, + path: string, + opts: CrmOptions +): Promise { + 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; + } +} diff --git a/platform/crm/connection.ts b/platform/crm/connection.ts new file mode 100644 index 0000000..2afd9e4 --- /dev/null +++ b/platform/crm/connection.ts @@ -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 { + const { rows } = await pool.query( + `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 { + const r = await crmRequest("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("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( + `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]; +} diff --git a/platform/crm/contacts.test.ts b/platform/crm/contacts.test.ts new file mode 100644 index 0000000..bbe6af1 --- /dev/null +++ b/platform/crm/contacts.test.ts @@ -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: "ana@x.mx" }), "ana@x.mx"); + assert.equal(nombreDe({ id: "5" }), "Sin nombre"); +}); diff --git a/platform/crm/contacts.ts b/platform/crm/contacts.ts new file mode 100644 index 0000000..c58a748 --- /dev/null +++ b/platform/crm/contacts.ts @@ -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 | 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 { + const body: Record = { + locationId: ctx.locationId, + pageLimit: opts.pageLimit ?? 100, + }; + if (opts.searchAfter) body.searchAfter = opts.searchAfter; + + const r = await crmRequest("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 { + try { + const r = await crmRequest("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 { + const r = await crmRequest("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; +} + +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; + } +): Promise { + // 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 = { + // 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("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; + } +} diff --git a/platform/crm/conversations.test.ts b/platform/crm/conversations.test.ts new file mode 100644 index 0000000..0e38412 --- /dev/null +++ b/platform/crm/conversations.test.ts @@ -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"); +}); diff --git a/platform/crm/conversations.ts b/platform/crm/conversations.ts new file mode 100644 index 0000000..89d78e5 --- /dev/null +++ b/platform/crm/conversations.ts @@ -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 = { + 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 = { + 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 { + try { + return await crmRequest("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 { + const r = await crmRequest("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("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("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 { + try { + const r = await crmRequest("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; + } +} diff --git a/platform/crm/ctx.ts b/platform/crm/ctx.ts new file mode 100644 index 0000000..d116f03 --- /dev/null +++ b/platform/crm/ctx.ts @@ -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 { + const { rows } = await pool.query( + `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 { + 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 { + 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 }; +} diff --git a/platform/crm/messages.ts b/platform/crm/messages.ts new file mode 100644 index 0000000..d98deb0 --- /dev/null +++ b/platform/crm/messages.ts @@ -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 { + return crmRequest("POST", "/conversations/messages", { + token: ctx.token, + body: { + type: "Email", + contactId: e.contactId, + subject: e.subject, + html: e.html, + emailTo: e.emailTo, + }, + }); +} diff --git a/platform/crm/opportunities.test.ts b/platform/crm/opportunities.test.ts new file mode 100644 index 0000000..b607764 --- /dev/null +++ b/platform/crm/opportunities.test.ts @@ -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); +}); diff --git a/platform/crm/opportunities.ts b/platform/crm/opportunities.ts new file mode 100644 index 0000000..4550035 --- /dev/null +++ b/platform/crm/opportunities.ts @@ -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 { + try { + const r = await crmRequest("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 { + const r = await crmRequest("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 { + 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 { + 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 = { + 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("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; + } +} diff --git a/platform/crm/outbox.ts b/platform/crm/outbox.ts new file mode 100644 index 0000000..5c14898 --- /dev/null +++ b/platform/crm/outbox.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/platform/crm/services.ts b/platform/crm/services.ts new file mode 100644 index 0000000..c9bfdfe --- /dev/null +++ b/platform/crm/services.ts @@ -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 { + const r = await crmRequest("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 { + 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("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("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 }; +} diff --git a/platform/crm/syncAppointments.ts b/platform/crm/syncAppointments.ts new file mode 100644 index 0000000..806d78b --- /dev/null +++ b/platform/crm/syncAppointments.ts @@ -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 { + 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}`, + }; +} diff --git a/platform/crm/syncContacts.ts b/platform/crm/syncContacts.ts new file mode 100644 index 0000000..a0dc7a5 --- /dev/null +++ b/platform/crm/syncContacts.ts @@ -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 { + // 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 { + 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; + } + }); +} diff --git a/platform/crm/syncConversations.ts b/platform/crm/syncConversations.ts new file mode 100644 index 0000000..8e2142d --- /dev/null +++ b/platform/crm/syncConversations.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/platform/crm/syncOne.ts b/platform/crm/syncOne.ts new file mode 100644 index 0000000..76504b7 --- /dev/null +++ b/platform/crm/syncOne.ts @@ -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; +} + +/** + * 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 { + 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 }; + } + + 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, + }; + } + } +} diff --git a/platform/crm/worker.ts b/platform/crm/worker.ts new file mode 100644 index 0000000..f61db92 --- /dev/null +++ b/platform/crm/worker.ts @@ -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; +} diff --git a/platform/db/migrate.ts b/platform/db/migrate.ts new file mode 100644 index 0000000..f2f1445 --- /dev/null +++ b/platform/db/migrate.ts @@ -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 { + 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); + }); +} diff --git a/platform/db/migrations/000_bootstrap.sql b/platform/db/migrations/000_bootstrap.sql new file mode 100644 index 0000000..4356bc2 --- /dev/null +++ b/platform/db/migrations/000_bootstrap.sql @@ -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; diff --git a/platform/db/migrations/001_core.sql b/platform/db/migrations/001_core.sql new file mode 100644 index 0000000..aa4906f --- /dev/null +++ b/platform/db/migrations/001_core.sql @@ -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) +); diff --git a/platform/db/migrations/002_crm.sql b/platform/db/migrations/002_crm.sql new file mode 100644 index 0000000..83af72f --- /dev/null +++ b/platform/db/migrations/002_crm.sql @@ -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); diff --git a/platform/db/migrations/003_multitenant.sql b/platform/db/migrations/003_multitenant.sql new file mode 100644 index 0000000..235602b --- /dev/null +++ b/platform/db/migrations/003_multitenant.sql @@ -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.'; diff --git a/platform/db/migrations/004_sync_por_id.sql b/platform/db/migrations/004_sync_por_id.sql new file mode 100644 index 0000000..2df09c2 --- /dev/null +++ b/platform/db/migrations/004_sync_por_id.sql @@ -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'; diff --git a/platform/db/pool.ts b/platform/db/pool.ts new file mode 100644 index 0000000..9915055 --- /dev/null +++ b/platform/db/pool.ts @@ -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:yola_dev@127.0.0.1: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(fn: (c: pg.PoolClient) => Promise): Promise { + 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(); + } +} diff --git a/platform/docker-compose.yml b/platform/docker-compose.yml new file mode 100644 index 0000000..73d1a00 --- /dev/null +++ b/platform/docker-compose.yml @@ -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: diff --git a/platform/index.ts b/platform/index.ts new file mode 100644 index 0000000..09b7515 --- /dev/null +++ b/platform/index.ts @@ -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); +} diff --git a/platform/lib/audit.ts b/platform/lib/audit.ts new file mode 100644 index 0000000..89b5506 --- /dev/null +++ b/platform/lib/audit.ts @@ -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 { + 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, + ] + ); +} diff --git a/platform/lib/auth.ts b/platform/lib/auth.ts new file mode 100644 index 0000000..d54f219 --- /dev/null +++ b/platform/lib/auth.ts @@ -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( + `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) { + return (req: AuthedRequest, res: Response, next: NextFunction) => { + fn(req, res).catch(next); + }; +} diff --git a/platform/lib/businessDefaults.ts b/platform/lib/businessDefaults.ts new file mode 100644 index 0000000..80069a4 --- /dev/null +++ b/platform/lib/businessDefaults.ts @@ -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 { + 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}`; + } +} diff --git a/platform/lib/crypto.test.ts b/platform/lib/crypto.test.ts new file mode 100644 index 0000000..2bd271f --- /dev/null +++ b/platform/lib/crypto.test.ts @@ -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; +}); diff --git a/platform/lib/crypto.ts b/platform/lib/crypto.ts new file mode 100644 index 0000000..d2eabf4 --- /dev/null +++ b/platform/lib/crypto.ts @@ -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); +} diff --git a/platform/lib/env.ts b/platform/lib/env.ts new file mode 100644 index 0000000..1e097dd --- /dev/null +++ b/platform/lib/env.ts @@ -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; +} diff --git a/platform/lib/phone.test.ts b/platform/lib/phone.test.ts new file mode 100644 index 0000000..2f8cec1 --- /dev/null +++ b/platform/lib/phone.test.ts @@ -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); +}); diff --git a/platform/lib/phone.ts b/platform/lib/phone.ts new file mode 100644 index 0000000..ac33e10 --- /dev/null +++ b/platform/lib/phone.ts @@ -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}`; +} diff --git a/platform/routes/admin.ts b/platform/routes/admin.ts new file mode 100644 index 0000000..71f8ae0 --- /dev/null +++ b/platform/routes/admin.ts @@ -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("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] }); + }) +); diff --git a/platform/routes/appointments.ts b/platform/routes/appointments.ts new file mode 100644 index 0000000..439ca13 --- /dev/null +++ b/platform/routes/appointments.ts @@ -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 }); + }) +); diff --git a/platform/routes/attendance.ts b/platform/routes/attendance.ts new file mode 100644 index 0000000..b1f5e91 --- /dev/null +++ b/platform/routes/attendance.ts @@ -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); + }) +); diff --git a/platform/routes/auth.ts b/platform/routes/auth.ts new file mode 100644 index 0000000..edc0778 --- /dev/null +++ b/platform/routes/auth.ts @@ -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 }); + }) +); diff --git a/platform/routes/business.ts b/platform/routes/business.ts new file mode 100644 index 0000000..df0e510 --- /dev/null +++ b/platform/routes/business.ts @@ -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] }); + }) +); diff --git a/platform/routes/clients.ts b/platform/routes/clients.ts new file mode 100644 index 0000000..561a6ed --- /dev/null +++ b/platform/routes/clients.ts @@ -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; + } + }) +); diff --git a/platform/routes/crm.ts b/platform/routes/crm.ts new file mode 100644 index 0000000..f1198e0 --- /dev/null +++ b/platform/routes/crm.ts @@ -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}`); + } + }) +); diff --git a/platform/routes/dayClose.ts b/platform/routes/dayClose.ts new file mode 100644 index 0000000..d12e627 --- /dev/null +++ b/platform/routes/dayClose.ts @@ -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 { + 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 }); + }) +); diff --git a/platform/routes/messages.ts b/platform/routes/messages.ts new file mode 100644 index 0000000..44c9cc0 --- /dev/null +++ b/platform/routes/messages.ts @@ -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: `
${String( + body + ) + .split("\n") + .map((l) => `

${escaparHtml(l)}

`) + .join("")}
`, + }); + + 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, """); +} diff --git a/platform/scripts/admin-ui-check.mjs b/platform/scripts/admin-ui-check.mjs new file mode 100644 index 0000000..a589345 --- /dev/null +++ b/platform/scripts/admin-ui-check.mjs @@ -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 = "plataforma@agendamax.mx"; +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); diff --git a/platform/scripts/crm-conectar.ts b/platform/scripts/crm-conectar.ts new file mode 100644 index 0000000..21969ae --- /dev/null +++ b/platform/scripts/crm-conectar.ts @@ -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); +}); diff --git a/platform/scripts/crm-limpiar-pruebas.ts b/platform/scripts/crm-limpiar-pruebas.ts new file mode 100644 index 0000000..a0d6530 --- /dev/null +++ b/platform/scripts/crm-limpiar-pruebas.ts @@ -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 || "urieljareth@grupo-e3.com"; +const BORRAR = process.argv.includes("--borrar"); + +async function main() { + const r = await crmRequest("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); +}); diff --git a/platform/scripts/crm-migrar-credencial.ts b/platform/scripts/crm-migrar-credencial.ts new file mode 100644 index 0000000..b26f173 --- /dev/null +++ b/platform/scripts/crm-migrar-credencial.ts @@ -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 + */ +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 "); + 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("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("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(); diff --git a/platform/scripts/crm-spike-borrado.ts b/platform/scripts/crm-spike-borrado.ts new file mode 100644 index 0000000..23c9f09 --- /dev/null +++ b/platform/scripts/crm-spike-borrado.ts @@ -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." + ); +} diff --git a/platform/scripts/crm-spike-calendarios.ts b/platform/scripts/crm-spike-calendarios.ts new file mode 100644 index 0000000..7a0fc7e --- /dev/null +++ b/platform/scripts/crm-spike-calendarios.ts @@ -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): Promise { + try { + const r = await crmRequest("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("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); +}); diff --git a/platform/scripts/crm-spike-dup.ts b/platform/scripts/crm-spike-dup.ts new file mode 100644 index 0000000..35f0994 --- /dev/null +++ b/platform/scripts/crm-spike-dup.ts @@ -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); +}); diff --git a/platform/scripts/crm-spike-escritura-cita-servicio.ts b/platform/scripts/crm-spike-escritura-cita-servicio.ts new file mode 100644 index 0000000..9bcd078 --- /dev/null +++ b/platform/scripts/crm-spike-escritura-cita-servicio.ts @@ -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("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("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); +} diff --git a/platform/scripts/crm-spike-lectura-id.ts b/platform/scripts/crm-spike-lectura-id.ts new file mode 100644 index 0000000..ef902d3 --- /dev/null +++ b/platform/scripts/crm-spike-lectura-id.ts @@ -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) { + 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("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("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("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("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("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("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("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("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("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("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("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("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("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); +}); diff --git a/platform/scripts/crm-spike-opps.ts b/platform/scripts/crm-spike-opps.ts new file mode 100644 index 0000000..a124b18 --- /dev/null +++ b/platform/scripts/crm-spike-opps.ts @@ -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); +}); diff --git a/platform/scripts/crm-spike-permisos.ts b/platform/scripts/crm-spike-permisos.ts new file mode 100644 index 0000000..70a9d10 --- /dev/null +++ b/platform/scripts/crm-spike-permisos.ts @@ -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 }> { + 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 = {}; + 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); +}); diff --git a/platform/scripts/crm-spike-write.ts b/platform/scripts/crm-spike-write.ts new file mode 100644 index 0000000..a426f31 --- /dev/null +++ b/platform/scripts/crm-spike-write.ts @@ -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 || "urieljareth@grupo-e3.com"; +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: `

Mensaje de prueba enviado desde AgendaMax.

Marca: ${marca}

`, + 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); +}); diff --git a/platform/scripts/crm-spike.ts b/platform/scripts/crm-spike.ts new file mode 100644 index 0000000..2f868cd --- /dev/null +++ b/platform/scripts/crm-spike.ts @@ -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) { + 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); +}); diff --git a/platform/scripts/seed.ts b/platform/scripts/seed.ts new file mode 100644 index 0000000..fe24988 --- /dev/null +++ b/platform/scripts/seed.ts @@ -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", "karla@yolafranco.mx"], + ["Brenda Salas", "brenda@yolafranco.mx"], + ["Paola Núñez", "paola@yolafranco.mx"], +]; + +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,'yola@yolafranco.mx','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 yola@yolafranco.mx / demo1234"); + await pool.end(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/platform/test/admin.test.ts b/platform/test/admin.test.ts new file mode 100644 index 0000000..1e751bf --- /dev/null +++ b/platform/test/admin.test.ts @@ -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>; +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,'plataforma@agendamax.mx','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: "Duena@SpaNuevo.MX", + 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, "duena@spanuevo.mx", "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: "otra@spa.mx", + 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: "duena@spanuevo.mx", + 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); +}); diff --git a/platform/test/appointments.test.ts b/platform/test/appointments.test.ts new file mode 100644 index 0000000..e3562ff --- /dev/null +++ b/platform/test/appointments.test.ts @@ -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>; +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,'otro2@otro.mx','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); +}); diff --git a/platform/test/attendance.test.ts b/platform/test/attendance.test.ts new file mode 100644 index 0000000..37dad03 --- /dev/null +++ b/platform/test/attendance.test.ts @@ -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>; +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 { + 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); +}); diff --git a/platform/test/audit.test.ts b/platform/test/audit.test.ts new file mode 100644 index 0000000..778c57e --- /dev/null +++ b/platform/test/audit.test.ts @@ -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>; +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"); +}); diff --git a/platform/test/clients.test.ts b/platform/test/clients.test.ts new file mode 100644 index 0000000..684cd0e --- /dev/null +++ b/platform/test/clients.test.ts @@ -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>; +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,'otro@otro.mx','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); +}); diff --git a/platform/test/crmCtx.test.ts b/platform/test/crmCtx.test.ts new file mode 100644 index 0000000..55a2ec0 --- /dev/null +++ b/platform/test/crmCtx.test.ts @@ -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; + } + ); +}); diff --git a/platform/test/dayClose.test.ts b/platform/test/dayClose.test.ts new file mode 100644 index 0000000..1363502 --- /dev/null +++ b/platform/test/dayClose.test.ts @@ -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>; +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 { + 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); +}); diff --git a/platform/test/helpers.ts b/platform/test/helpers.ts new file mode 100644 index 0000000..55b4381 --- /dev/null +++ b/platform/test/helpers.ts @@ -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 { + 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 { + await dropSchema(); + await runMigrations(); +} + +export async function seedMinimal(): Promise { + 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','karla@yola.mx') 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,'yola@yola.mx','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,'karla@yola.mx','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]; +} diff --git a/platform/test/migrate.test.ts b/platform/test/migrate.test.ts new file mode 100644 index 0000000..85ffce9 --- /dev/null +++ b/platform/test/migrate.test.ts @@ -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"); +}); diff --git a/platform/test/schema.test.ts b/platform/test/schema.test.ts new file mode 100644 index 0000000..194890c --- /dev/null +++ b/platform/test/schema.test.ts @@ -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>; + +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 () => { + const ins = `INSERT INTO clients (business_id, name, phone, phone_e164) + VALUES ($1,$2,$3,$4) RETURNING id`; + await pool.query(ins, [ids.businessId, "Ana", "55 1111 2222", "+525511112222"]); + await assert.rejects( + () => pool.query(ins, [ids.businessId, "Ana (dup)", "5511112222", "+525511112222"]), + (e: any) => e.code === "23505" + ); +}); + +test("dos clientas sin teléfono sí pueden coexistir", async () => { + const ins = `INSERT INTO clients (business_id, name, phone, phone_e164) + VALUES ($1,$2,NULL,NULL) RETURNING id, contactable`; + const a = await pool.query(ins, [ids.businessId, "Sin teléfono 1"]); + const b = await pool.query(ins, [ids.businessId, "Sin teléfono 2"]); + assert.ok(a.rows[0].id !== b.rows[0].id); + assert.equal(a.rows[0].contactable, false, "sin teléfono = no contactable"); +}); + +test("crm_connections guarda la credencial cifrada y los ajustes por negocio", async () => { + await resetDb(); + const { rows } = await pool.query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns WHERE table_name = 'crm_connections'` + ); + const nombres = rows.map((r) => r.column_name); + for (const c of [ + "token_cipher", "token_nonce", "token_tag", "token_fingerprint", + "token_updated_at", "calendar_id", "test_email", "allow_real_sends", "label", + ]) { + assert.ok(nombres.includes(c), `falta la columna ${c}`); + } +}); + +test("allow_real_sends nace en false: los envíos reales se abren a propósito", async () => { + await resetDb(); + const b = await crearNegocio(); + const { rows } = await pool.query( + `INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1') + RETURNING allow_real_sends`, + [b.id] + ); + assert.equal(rows[0].allow_real_sends, false); +}); + +test("media credencial se rechaza: va completa o no va", async () => { + await resetDb(); + const b = await crearNegocio(); + await assert.rejects( + () => + pool.query( + // El bytea va como PARÁMETRO, no como literal: escrito a mano dentro + // de una plantilla, la secuencia de escape de un byte nulo se convierte + // en ese byte de verdad y rompe el protocolo de Postgres antes de que + // la restricción llegue a opinar. + `INSERT INTO crm_connections (business_id, location_id, token_cipher) + VALUES ($1, 'loc-1', $2)`, + [b.id, Buffer.from([1, 2, 3])] + ), + /crm_connections_credencial_completa/ + ); +}); diff --git a/platform/test/syncConversations.test.ts b/platform/test/syncConversations.test.ts new file mode 100644 index 0000000..e43b2fa --- /dev/null +++ b/platform/test/syncConversations.test.ts @@ -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"); +}); diff --git a/platform/test/syncOne.test.ts b/platform/test/syncOne.test.ts new file mode 100644 index 0000000..7723843 --- /dev/null +++ b/platform/test/syncOne.test.ts @@ -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>; +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); +}); diff --git a/shared/types.ts b/shared/types.ts index 4eef999..143b07e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 9ba5a20..a16ba22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 && ( }> } /> + } /> } /> } /> } /> @@ -121,6 +125,11 @@ function AppRoutes() { } /> } /> } /> + {/* 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. */} + } /> + } /> {user.role === "owner" && } />} {user.role === "owner" && } />} {user.role === "owner" && } />} diff --git a/src/components/AdminShell.tsx b/src/components/AdminShell.tsx index 36de99e..0411142 100644 --- a/src/components/AdminShell.tsx +++ b/src/components/AdminShell.tsx @@ -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() { diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index e20883c..1f3d7ca 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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 }, diff --git a/src/components/CrmSyncPanel.tsx b/src/components/CrmSyncPanel.tsx new file mode 100644 index 0000000..bb91257 --- /dev/null +++ b/src/components/CrmSyncPanel.tsx @@ -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 ( +
+ +
+

Sin conectar a Bucéfalo CRM

+

+ Conecta la subcuenta para traer los contactos con su atribución. +

+
+ + {conectar.isError && ( +

{(conectar.error as Error).message}

+ )} +
+ ); + } + + 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 ( +
+ {/* 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. */} +
+ +
+

Bucéfalo CRM

+

+ {data.last_sync_at + ? `Última sincronización: ${new Date(data.last_sync_at).toLocaleString("es-MX")}` + : "Todavía no se ha sincronizado"} +

+
+ +
+ + {s && ( +
+ + + {/* Es el dato incómodo y por eso está a la vista: una agenda no puede + avisarle a quien no dejó teléfono. */} + +
+ )} + + {sync.isPending && ( +

+ Trayendo contactos del CRM. Con ~3 200 tarda unos 25 segundos. +

+ )} + + {sync.isSuccess && ( +

+ + {sync.data.created} nuevas y {sync.data.updated} actualizadas, de{" "} + {sync.data.fetched} leídas del CRM. +

+ )} + {sync.isError && ( +

+ + {(sync.error as Error).message} +

+ )} + + {(pendientes > 0 || fallidos > 0) && ( +
+ +

+ {pendientes > 0 && `${pendientes} cambio(s) sin enviar al CRM. `} + {fallidos > 0 && `${fallidos} fallido(s).`} +

+ +
+ )} + + {data.allow_duplicate_opp === false && ( +

+ La subcuenta tiene desactivado «permitir oportunidades duplicadas», así que cada + clienta tiene una oportunidad que se recicla en cada cita. Para que + cada cita estrene la suya hay que activar ese ajuste en el CRM. +

+ )} +
+ ); +} + +function Dato({ + label, + valor, + tono = "text-slate-900", +}: { + label: string; + valor: number | string; + tono?: string; +}) { + return ( +
+
{label}
+
{valor}
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b4ce798..183c191 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(`/admin/businesses/${id}/seed-template`, { method: "POST", body: JSON.stringify(data) }), resetDemo: (id: number, data: any) => request(`/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(`/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("/crm/status"), + connect: () => request<{ connection: unknown }>("/crm/connect", { method: "POST", body: "{}" }), + syncContacts: () => + request("/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(`/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("/messages/send", { + method: "POST", + body: JSON.stringify(body), + }), + }, + dayClose: { + get: (date?: string) => + request(`/day-close${date ? `?date=${date}` : ""}`), + close: (date: string) => + request<{ closure: DayClosure }>(`/day-close`, { + method: "POST", + body: JSON.stringify({ date }), + }), }, dashboard: { overview: (range?: number) => diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index dbfc701..5ecd96e 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -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() { )} + +

Resumen

@@ -241,3 +247,55 @@ function ClientEditModal({ open, client, onClose }: { open: boolean; client: any ); } + +/** + * 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 ( +
+

+ Adquisición +

+ {conValor.length ? ( +
+ {conValor.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) : ( +

+ El CRM no registró de dónde vino esta clienta. +

+ )} + {client.crm_synced_at && ( +

+ {/* 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)} +

+ )} +
+ ); +} diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx index 01b2a1f..004c5bd 100644 --- a/src/pages/ClientsPage.tsx +++ b/src/pages/ClientsPage.tsx @@ -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." /> +
+ +
+
diff --git a/src/pages/DayClosePage.tsx b/src/pages/DayClosePage.tsx new file mode 100644 index 0000000..caab013 --- /dev/null +++ b/src/pages/DayClosePage.tsx @@ -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 ( +
+ setDate(e.target.value)} + aria-label="Día a cerrar" + /> + } + /> + +
+ {isLoading || !data ? ( +
+ +
+ ) : ( +
+ {/* 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. */} +
+ + + +
+ + {pendientes === 0 ? ( +
+ +
+ ) : ( +
    + {data.unresolved.map((a) => ( +
  • + {/* `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í. */} +
    +

    {a.client_name}

    +

    + {formatTime(a.start_at)} · {a.service_name} · {a.employee_name} +

    +
    +
    + + +
    +
  • + ))} +
+ )} + + {attendance.isError && ( +

{(attendance.error as Error).message}

+ )} + +
+ + {/* El botón no se esconde cuando falta trabajo: se explica. */} + {pendientes > 0 && ( + + Faltan {pendientes} cita{pendientes === 1 ? "" : "s"} por resolver. + + )} + {close.isError && ( + {(close.error as Error).message} + )} +
+
+ )} +
+
+ ); +} + +function Metric({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/pages/MessagesPage.tsx b/src/pages/MessagesPage.tsx new file mode 100644 index 0000000..7fcd028 --- /dev/null +++ b/src/pages/MessagesPage.tsx @@ -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(null); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["conversations"], + queryFn: () => api.messages.list(25), + }); + + return ( +
+ + +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ +
+ ) : abierta ? ( + setAbierta(null)} /> + ) : !data?.conversations.length ? ( +
+ +
+ ) : ( +
    + {data.conversations.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + +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 ( +
+ + +
+

{conversacion.contact_name}

+ {conversacion.client ? ( + + Ver ficha de la clienta + + ) : ( +

+ Sin ficha local. Sincroniza los contactos para enlazarla. +

+ )} +
+ + {isLoading ? ( +
+ +
+ ) : ( +
    + {(data?.messages ?? []).map((m) => ( +
  • +

    {m.body || sin texto}

    +

    + {m.channel} {m.status ? `· ${m.status}` : ""} +

    +
  • + ))} +
+ )} + +
+

+ Responder por correo +

+ setAsunto(e.target.value)} + /> +