feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.
## Multi-tenancy
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.
- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
acabaríamos mandando como credencial al CRM. La clave maestra vive en
`CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
`locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.
- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
de esa base: no debe quedar expuesta a internet hasta endurecerla.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcbf750c09
commit
6d67b23e55
@@ -0,0 +1,31 @@
|
||||
# Base de datos de la plataforma
|
||||
DATABASE_URL=postgres://yola:[email protected]:5434/yola
|
||||
TEST_DATABASE_URL=postgres://yola:[email protected]:5434/yola_test
|
||||
PLATFORM_PORT=3100
|
||||
|
||||
# ── Bucéfalo CRM ────────────────────────────────────────────────────────────
|
||||
# El token es de SUBCUENTA (PIT). No lo subas al repo: platform/.env está
|
||||
# gitignorado. Si sospechas que se filtró, regenéralo en el CRM.
|
||||
CRM_BASE_URL=https://services.leadconnectorhq.com
|
||||
|
||||
# Clave maestra con la que se cifran en Postgres los tokens de cada subcuenta.
|
||||
# 32 bytes en base64. Genérala UNA vez con:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
# Si la pierdes, los tokens guardados dejan de descifrarse y hay que volver a
|
||||
# vincular cada subcuenta a mano. Guárdala donde guardes los secretos.
|
||||
CRM_MASTER_KEY=
|
||||
|
||||
# HEREDADAS: a partir del multi-tenant, cada negocio guarda sus credenciales
|
||||
# cifradas en la base y se ponen desde la consola de administración. Estas dos
|
||||
# solo las usa el script de migración de la credencial del primer negocio.
|
||||
CRM_LOCATION_ID=
|
||||
CRM_TOKEN=
|
||||
|
||||
# Mientras esta variable tenga valor, el servidor SOLO envía mensajes a esta
|
||||
# dirección, sin importar a quién apunte la interfaz. Es la red de seguridad
|
||||
# que impide escribirle a los 3 200 contactos reales del cliente por accidente.
|
||||
CRM_TEST_EMAIL=[email protected]
|
||||
|
||||
# Descomenta para permitir envíos a las clientas de verdad. Es una decisión
|
||||
# deliberada del dueño del proyecto, no un ajuste de configuración.
|
||||
# CRM_ALLOW_REAL_SENDS=1
|
||||
@@ -0,0 +1,259 @@
|
||||
# `platform/` — backend Postgres de Yola Franco Spa
|
||||
|
||||
Backend nuevo sobre PostgreSQL 16 para la plataforma del spa. Habla los mismos
|
||||
contratos `/api` que el frontend de este repo, así que la SPA de `src/` funciona
|
||||
contra él sin cambios de stack.
|
||||
|
||||
**El `server/` de SQLite sigue en pie y sin tocar**: es la demo de AgendaPro y el
|
||||
punto de comparación. Los dos backends no se hablan ni comparten base.
|
||||
|
||||
Plan e historia de las decisiones:
|
||||
[`docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md`](../docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md).
|
||||
|
||||
## Arranque
|
||||
|
||||
```bash
|
||||
npm run pg:up # Postgres 16 en Docker, puerto 5434
|
||||
npm run pg:migrate # aplica las migraciones pendientes
|
||||
node scripts/run-tsx.mjs platform/scripts/seed.ts # siembra el spa y citas de hoy
|
||||
npm run platform # API en :3100
|
||||
|
||||
# el frontend contra este backend:
|
||||
API_URL=http://127.0.0.1:3100 npx vite --port 5175
|
||||
```
|
||||
|
||||
Cuenta de la dueña: `[email protected]` / `demo1234`. El personal entra con
|
||||
`karla@`, `brenda@` y `[email protected]`, misma contraseña.
|
||||
|
||||
**Puerto 5434 y no 5432/5433:** los dos están ocupados por contenedores de otros
|
||||
proyectos en esta máquina.
|
||||
|
||||
## Pruebas
|
||||
|
||||
```bash
|
||||
npm run test:platform # 38 pruebas
|
||||
node --import tsx --test platform/lib/phone.test.ts # solo las puras
|
||||
```
|
||||
|
||||
Corren contra la base `yola_test`, que se crea una vez:
|
||||
|
||||
```bash
|
||||
docker exec yola-postgres psql -U yola -d postgres -c "CREATE DATABASE yola_test OWNER yola"
|
||||
```
|
||||
|
||||
`--test-concurrency=1` en el script **no es cosmético**: cada archivo de prueba
|
||||
hace `DROP SCHEMA public` y en paralelo se pisan entre sí.
|
||||
`resetDb()` se niega a correr si `DATABASE_URL` no apunta a `yola_test`.
|
||||
|
||||
## Las tres decisiones que sostienen el diseño
|
||||
|
||||
1. **La doble reserva la impide el motor, no un `if`.** `appointments` lleva una
|
||||
restricción `EXCLUDE USING gist (employee_id WITH =, during WITH &&)` sobre un
|
||||
`tstzrange` generado. Postgres rechaza la fila con `23P01` y el router lo
|
||||
traduce a un 409 en español. Requiere la extensión `btree_gist`, que aplica
|
||||
`000_bootstrap.sql`.
|
||||
2. **`visits` está separada de `appointments`.** Una cita es una intención; una
|
||||
visita es un hecho con dinero. Un solo registro que sirve para planear y para
|
||||
cerrar termina sin cerrarse nunca — es exactamente lo que dejó 3 002
|
||||
oportunidades congeladas en el CRM del spa. Por eso el "no vino" es un estado
|
||||
de la cita y **no** crea una visita vacía.
|
||||
3. **`clients.phone_e164` es la clave de identidad.** Índice único parcial por
|
||||
`(business_id, phone_e164)`, parcial porque el 40.8 % del histórico medido no
|
||||
tiene teléfono y esas clientas tienen que poder existir: quedan marcadas
|
||||
`contactable = false`.
|
||||
|
||||
## Deuda conocida, dicha sin rodeos
|
||||
|
||||
- **La autenticación no se endureció.** El token es el id del usuario en texto
|
||||
plano y la contraseña se compara sin hashear, portado tal cual del backend de
|
||||
demo. Arreglarlo es un entregable propio: bcrypt/Argon2id + sesión real +
|
||||
`src/lib/api.ts` + el `AuthProvider` + las pruebas, todo a la vez. A medias
|
||||
rompe el login.
|
||||
- **Sin rate limiting y con `cors()` abierto.** Igual que el backend de demo.
|
||||
- **La validación de entrada es manual.** `zod` está en `dependencies` y sigue
|
||||
sin importarse en ningún archivo.
|
||||
- **De Bucéfalo CRM falta lo de entrada.** Lo que hay está en la segunda mitad de
|
||||
este documento; lo que no: webhooks (exigen OAuth y este token es un PIT), el
|
||||
espejo persistido de conversaciones —las tablas existen y nadie las escribe—, y
|
||||
el arrastre de citas.
|
||||
- **El catálogo sembrado no es el del negocio.** Los nombres salen del
|
||||
vocabulario medido en los hilos del CRM; **las duraciones y los precios son
|
||||
marcadores de posición** y hay que sustituirlos por los reales antes de
|
||||
enseñar esto como catálogo del spa.
|
||||
- **Falta parte de la superficie de `/api`.** Hoy están `auth`, `business`,
|
||||
`clients`, `appointments`, `attendance`, `day-close`, `crm` y `messages`.
|
||||
Servicios, empleados, tablero, caja, tickets y recordatorios siguen solo en el
|
||||
backend de SQLite.
|
||||
|
||||
---
|
||||
|
||||
# Integración con Bucéfalo CRM
|
||||
|
||||
Subcuenta **Yola Franco Spa**. Todo el diseño sale de hallazgos **medidos** contra el CRM real,
|
||||
no de la especificación. Dos documentos, y conviene no confundirlos:
|
||||
|
||||
- [`crm/HALLAZGOS.md`](crm/HALLAZGOS.md) — los **47 hallazgos empíricos**: qué se ejerció, contra qué
|
||||
y con qué resultado. Es la fuente de verdad y manda sobre la documentación oficial.
|
||||
- [`crm/API.md`](crm/API.md) — la **referencia de endpoints**: rutas, parámetros, scopes, límites de
|
||||
tasa, y la lista explícita de lo que la documentación oficial dice mal o no dice.
|
||||
|
||||
Los spikes que produjeron los hallazgos se pueden volver a correr.
|
||||
|
||||
## Puesta en marcha
|
||||
|
||||
```bash
|
||||
# 1. Clave maestra del cifrado de credenciales (una vez por instalación):
|
||||
cp platform/.env.example platform/.env
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
# → pégala en CRM_MASTER_KEY
|
||||
|
||||
# 2. Vincula la subcuenta desde la consola de administración:
|
||||
# PUT /api/admin/businesses/:id/crm { location_id, token, label }
|
||||
# Las credenciales se COMPRUEBAN contra el CRM antes de guardarse.
|
||||
|
||||
# 3. Trae los contactos (o pulsa el botón en la pantalla de Clientes)
|
||||
```
|
||||
|
||||
Para migrar un negocio que ya estaba vinculado por variables de entorno:
|
||||
|
||||
```bash
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
|
||||
```
|
||||
|
||||
## Qué hace hoy
|
||||
|
||||
| Pieza | Estado |
|
||||
|---|---|
|
||||
| **Traer contactos con su atribución UTM** | ✅ 3 210 en ~22 s, idempotente, con botón en Clientes |
|
||||
| **Deduplicar por id → teléfono → correo** | ✅ Misma cadena que el CRM aplica. Cero duplicados sobre datos reales |
|
||||
| **Proyectar citas como oportunidades** | ✅ `SERVICIO — CLIENTA`, importe del servicio, `open`/`won`/`lost` |
|
||||
| **Bandeja de salida con reintentos** | ✅ Encolada en la misma transacción del cambio, despachada cada minuto |
|
||||
| **Leer conversaciones y responder** | ✅ Solo correo: WhatsApp y SMS no están conectados en la subcuenta |
|
||||
| **Ver la atribución en la ficha** | ✅ Fuente, medio, campaña, UTM, anuncio y fecha de sincronización |
|
||||
|
||||
## Multi-tenancy: una credencial por negocio
|
||||
|
||||
**Cada negocio guarda su propio `locationId` y su token privado, cifrado en la
|
||||
base.** Antes el `locationId` era por negocio pero el token era una variable de
|
||||
entorno global: con dos cuentas, el servidor usaba el token de la primera contra
|
||||
la subcuenta de la segunda —401 en el mejor caso, escritura en la subcuenta
|
||||
equivocada en el peor—. Tres piezas lo sostienen y hay que tocarlas juntas:
|
||||
|
||||
- **`CrmOptions.token` es obligatorio** ([crm/client.ts](crm/client.ts)). No tiene
|
||||
valor por defecto a propósito: olvidarlo es un error de compilación, no una
|
||||
petición con la credencial de otro cliente.
|
||||
- **`CrmCtx { businessId, locationId, token }`** ([crm/ctx.ts](crm/ctx.ts)) sustituye
|
||||
al `locationId: string` suelto que antes viajaba por once firmas. Es un objeto y
|
||||
no dos parámetros porque dos `string` seguidos se cruzan sin que el compilador
|
||||
diga nada. `ctxDe(businessId)` es el **único** sitio donde el token existe
|
||||
descifrado, y solo en memoria.
|
||||
- **El token se cifra con AES-256-GCM** ([lib/crypto.ts](lib/crypto.ts)), autenticado
|
||||
a propósito: una fila manipulada hace que el descifrado **falle**, en vez de
|
||||
devolver basura que acabaríamos mandando como credencial. La clave maestra vive
|
||||
en `CRM_MASTER_KEY`, fuera de la base.
|
||||
|
||||
Lo único de la credencial que sale del servidor es `token_fingerprint`, los 6
|
||||
últimos caracteres. Ni la API, ni los registros, ni `audit_log` ven el token.
|
||||
|
||||
El estrangulador también es **por token** y aprende la cuota de las cabeceras
|
||||
`x-ratelimit-*` que el CRM devuelve: son 100 peticiones por 10 s, no la estimación
|
||||
de 1 cada 650 ms con la que se escribió el cliente.
|
||||
|
||||
## La consola de administración de plataforma
|
||||
|
||||
`/api/admin`, solo para el rol `admin` (cuyo `business_id` es NULL):
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `GET /api/admin/businesses` | Las cuentas, con el estado de su vínculo. Nunca devuelve el token |
|
||||
| `POST /api/admin/businesses` | Alta de cuenta y su dueña, en una transacción. El negocio nace con slug y horario |
|
||||
| `PUT /api/admin/businesses/:id/crm` | Vincula la subcuenta. **Comprueba las credenciales contra el CRM antes de guardarlas** |
|
||||
| `DELETE /api/admin/businesses/:id/crm` | Desvincula. Borra la credencial y conserva lo sincronizado |
|
||||
| `PATCH /api/admin/businesses/:id` | Suspender o reactivar, renombrar, cambiar zona horaria |
|
||||
|
||||
## Sincronización por identificador
|
||||
|
||||
`POST /api/crm/sync/:entidad/:id` resuelve **una** entidad. Las cinco están ejercidas contra la
|
||||
subcuenta real. La dirección la decide la entidad, no quien llama:
|
||||
|
||||
| Entidad | Dirección | Por qué |
|
||||
|---|---|---|
|
||||
| `contacto` | ← del CRM | Es su dueño: ahí viven la deduplicación y las automatizaciones |
|
||||
| `conversacion` | ← del CRM | Se espeja con todos sus mensajes |
|
||||
| `mensaje` | ← del CRM | Se espeja **con su hilo**: `messages.conversation_id` es obligatorio |
|
||||
| `cita` | → al CRM | MEDIDO: el calendario del CRM tiene **una** cita en dos años |
|
||||
| `servicio` | → al CRM | MEDIDO: su catálogo está **vacío** |
|
||||
|
||||
`POST /api/crm/sync/conversations` espeja las conversaciones recientes con sus mensajes.
|
||||
|
||||
**Si el spa empieza a agendar dentro del CRM, la premisa de las dos últimas se cae** y habrá que
|
||||
decidir cuál de los dos manda cuando difieran. Conviene decidirlo antes de que pase.
|
||||
|
||||
## Las tres decisiones que no son obvias
|
||||
|
||||
**1. La sincronización de contactos va en una sola dirección: del CRM hacia aquí.**
|
||||
El contacto es del CRM —es su llave de deduplicación y donde viven las automatizaciones—, así que
|
||||
esta sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar pasa por la
|
||||
bandeja de salida, que es otra cosa y tiene otras garantías.
|
||||
|
||||
**2. La oportunidad se recicla, no se duplica.** La subcuenta tiene
|
||||
`allowDuplicateOpportunity: false`, y eso hace que el CRM rechace una segunda oportunidad por
|
||||
contacto **aunque la primera esté cerrada**. La plataforma intenta crear y, si recibe ese rechazo,
|
||||
reutiliza la existente con el nombre, el importe y el estado de la cita nueva. Si alguien activa
|
||||
ese ajuste en el CRM, pasa a «una cita = una oportunidad» sin tocar código.
|
||||
|
||||
**3. Un fallo de transporte no se reintenta.** Un `5xx` es una respuesta: el servidor habló. Un
|
||||
timeout no dice nada sobre si la escritura entró, y reenviarlo es fabricar la doble creación. Esas
|
||||
filas quedan en `indeterminado` y se resuelven **leyendo**.
|
||||
|
||||
## Modo prueba de mensajes
|
||||
|
||||
Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa dirección**, sin importar a
|
||||
quién apunte la interfaz. La subcuenta es la de un cliente real con 3 200 contactos: un bucle mal
|
||||
escrito escribiría a personas de verdad. Se levanta con `CRM_ALLOW_REAL_SENDS=1`, y esa es una
|
||||
decisión deliberada, no un descuido de configuración.
|
||||
|
||||
## Lo que NO hace, y conviene tener presente
|
||||
|
||||
- **La bandeja de mensajes todavía lee en vivo del CRM**, no del espejo. Las tablas `conversations`
|
||||
y `messages` ya se llenan (`POST /api/crm/sync/conversations`), pero `MessagesPage` sigue sin
|
||||
apuntar a ellas.
|
||||
- **No recibe webhooks.** Exigen OAuth y este token es un PIT. La entrada es por sondeo: el botón.
|
||||
- **No confirma entrega de correo.** El CRM acusa «encolado». La interfaz dice «en camino» a
|
||||
propósito, y no «entregado».
|
||||
- **No trae el catálogo de servicios**, porque el del CRM está vacío. Duración y precio viven aquí,
|
||||
y lo que sí se puede es **publicarlos** hacia el CRM.
|
||||
- **4 de cada 10 clientas no tienen teléfono.** El panel lo enseña en ámbar. Es el techo de
|
||||
utilidad de cualquier recordatorio, y se arregla pidiendo el teléfono al agendar, no con código.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike.ts # sondeo de lectura
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts # escrituras, con relectura
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-opps.ts # regla de duplicados
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-dup.ts # ajustes de la subcuenta
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts # conectar y autodetectar
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts # listar basura de pruebas
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar
|
||||
```
|
||||
|
||||
Los spikes **escriben en la subcuenta real del cliente**. Todo lo que crean lleva el tag
|
||||
`agendamax:prueba` y el correo autorizado, y `crm-limpiar-pruebas.ts` los borra.
|
||||
|
||||
## Sondeos contra el CRM
|
||||
|
||||
```bash
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts # lectura por id (solo lectura)
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts # los 7 calendarios (solo lectura)
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts # qué permisos tiene el token, sin crear nada
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts # ¿se puede deshacer?, sin crear nada
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts # ESCRIBE: crea, relee y borra
|
||||
node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <id> # mueve la credencial del .env a la base, cifrada
|
||||
node platform/scripts/admin-ui-check.mjs # la consola de cuentas, en navegador
|
||||
```
|
||||
|
||||
Los tres primeros **no escriben nada**. El de escritura crea, relee y **borra en un `finally`**, así
|
||||
que no deja rastro aunque falle a mitad — y antes de escribir se comprobó con `crm-spike-borrado.ts`
|
||||
que el borrado existe. Preguntar si se puede deshacer **antes** de tocar el CRM de un cliente, no
|
||||
después.
|
||||
@@ -0,0 +1,288 @@
|
||||
# Referencia de la API de Bucéfalo CRM
|
||||
|
||||
Host base: `https://services.leadconnectorhq.com` · Autenticación: `Authorization: Bearer <token privado de subcuenta>`.
|
||||
|
||||
Este documento es la **referencia de endpoints**. Los hallazgos empíricos —lo que se ha ejercido
|
||||
contra la subcuenta real y con qué resultado— viven en [`HALLAZGOS.md`](HALLAZGOS.md), y **mandan
|
||||
sobre lo que diga aquí**: la documentación oficial de esta API está incompleta o desactualizada en
|
||||
varios puntos concretos, todos marcados abajo.
|
||||
|
||||
Cada entrada lleva su origen:
|
||||
**[DOC]** de la especificación OpenAPI pública del proveedor · **[MEDIDO]** ejercido contra la
|
||||
subcuenta real de este proyecto · **[INFERENCIA]** deducción no verificada, que **no debe usarse como
|
||||
base para escribir código sin comprobarla antes**.
|
||||
|
||||
---
|
||||
|
||||
## La cabecera `Version`: donde más chocan la documentación y la realidad
|
||||
|
||||
| Familia | Según la documentación | Lo que funciona [MEDIDO] |
|
||||
|---|---|---|
|
||||
| `/contacts/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||
| `/locations/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||
| `/conversations/` | `2021-04-15` | **`2021-07-28`** ⚠ |
|
||||
| `/calendars/` | `2021-04-15` | **`v3`** ⚠ |
|
||||
| `/opportunities/` | `2021-07-28` | `2021-07-28` ✅ |
|
||||
|
||||
`client.ts` la elige sola por el prefijo de la ruta. **No la cambies «para alinearla con la
|
||||
documentación»**: con estos valores se ejercieron los 3 210 contactos, los 3 213 hilos y los 7
|
||||
calendarios. Equivocarla es un `400`.
|
||||
|
||||
---
|
||||
|
||||
## Tres convenciones de paginación distintas en la misma API
|
||||
|
||||
Confundirlas devuelve listas **incompletas sin ningún error**, que es la peor forma de fallar.
|
||||
|
||||
| Qué se pagina | Cómo | Detalle |
|
||||
|---|---|---|
|
||||
| Contactos | `searchAfter` | El cursor sale del **último elemento** del array, no de la raíz de la respuesta. Con `page` se topa un techo de profundidad antes de los 3 200 |
|
||||
| Conversaciones | `startAfterDate` | El valor de ordenación del último hilo de la página |
|
||||
| Mensajes | `lastMessageId` + `nextPage` | El id del último mensaje, y un booleano que dice si hay más |
|
||||
|
||||
---
|
||||
|
||||
## A · Contactos
|
||||
|
||||
### `GET /contacts/{contactId}`
|
||||
Scope `contacts.readonly`. Devuelve el contacto **envuelto en `contact`**.
|
||||
**[MEDIDO]** El `404` existe aunque la documentación no lo liste.
|
||||
**[MEDIDO]** `attributionSource` trae `sessionSource` y `adId` poblados, que el esquema oficial **no
|
||||
declara**. `mapAtribucion()` es más fiel que el esquema.
|
||||
|
||||
### `POST /contacts/search`
|
||||
Scope `contacts.readonly`. **El esquema del cuerpo NO está documentado**: el `$ref` del OpenAPI
|
||||
apunta a un objeto vacío. Todo lo que sabemos es medido:
|
||||
|
||||
```json
|
||||
{ "locationId": "…", "pageLimit": 100, "searchAfter": [1724900000000, "seD4Pf…"] }
|
||||
```
|
||||
|
||||
**[MEDIDO]** 3 210 contactos en ~22 s, idempotente. **[INFERENCIA]** 100 es probablemente el máximo
|
||||
de `pageLimit`; no está escrito en ningún sitio.
|
||||
**[MEDIDO]** El buscador descarta `utmCampaign` y conserva `campaign`. Al dar de alta hay que mandar
|
||||
**los dos**.
|
||||
|
||||
**Filtrar por id, teléfono o correo dentro de este buscador no está documentado en ninguna parte.**
|
||||
Lo que funciona: por id → `GET /contacts/{id}`; por teléfono o correo → `GET /contacts/?query=`.
|
||||
|
||||
### `GET /contacts/`
|
||||
**Marcado DEPRECADO** por la propia documentación. `limit` máximo 100, default 20. Útil solo para
|
||||
búsquedas puntuales de 1-5 resultados, que es como lo usa `buscarPorIdentificador`.
|
||||
|
||||
**Trampa de forma:** aquí la atribución se llama **`attributions` (array)**; en `GET /contacts/{id}`
|
||||
se llama **`attributionSource` (objeto)**. Misma información, dos formas.
|
||||
|
||||
### `POST /contacts/`
|
||||
Scope `contacts.write`.
|
||||
**[MEDIDO]** `locationId` va en el cuerpo del POST y **rompe el PUT** con `422 property locationId
|
||||
should not exist`.
|
||||
**[MEDIDO]** Un duplicado responde `400` **con `meta.contactId`**: es idempotencia regalada por el
|
||||
servidor, y mejor que un `upsert`, cuya rama de actualización descarta la atribución.
|
||||
**[MEDIDO]** La atribución **solo se escribe en el alta**; un PUT posterior devuelve `200` sin
|
||||
guardar nada.
|
||||
|
||||
---
|
||||
|
||||
## B · Conversaciones y mensajes
|
||||
|
||||
### `GET /conversations/search`
|
||||
Scope `conversations.readonly`. Filtros documentados: `locationId` (obligatorio), **`contactId`**,
|
||||
**`id`**, `assignedTo`, `followers`, `mentions`, `query`, `sort`, `sortBy`, `status`,
|
||||
`lastMessageType` (43 valores), `lastMessageDirection`, `lastMessageAction`, `startDate`/`endDate`
|
||||
(epoch ms), `startAfterDate`, `limit`.
|
||||
|
||||
### `GET /conversations/{conversationId}`
|
||||
**[MEDIDO]** Devuelve los campos **en la raíz**, sin envoltorio.
|
||||
**[MEDIDO, hallazgo 38] NO devuelve el nombre del contacto ni un canal reconocible** — eso solo
|
||||
viene del buscador. Sincronizar un hilo por su id degradaba el nombre a «Sin nombre»; ver
|
||||
`syncConversations.ts`, que ya no deja que un dato pobre pise a uno bueno.
|
||||
**Trampa de forma:** aquí `type` es un **número**; en el buscador es la cadena `TYPE_PHONE`.
|
||||
|
||||
### `GET /conversations/{conversationId}/messages`
|
||||
Scope **`conversations/message.readonly`** — distinto de `conversations.readonly`. Tener uno no da
|
||||
el otro.
|
||||
**[MEDIDO]** La respuesta viene **anidada dos niveles**: `{ messages: { messages: [...],
|
||||
lastMessageId, nextPage } }`.
|
||||
|
||||
### `GET /conversations/messages/{id}`
|
||||
**[MEDIDO]** Funciona y devuelve el mensaje en la raíz. El parámetro de ruta no está declarado en la
|
||||
especificación — es un fallo de la documentación, no de la API.
|
||||
|
||||
### `POST /conversations/messages`
|
||||
Scope `conversations/message.write`. Tipos: `SMS`, `RCS`, `Email`, `WhatsApp`, `IG`, `FB`, `Custom`,
|
||||
`Live_Chat`, `TIKTOK`.
|
||||
**[CONTRADICCIÓN]** La documentación marca `subType` y `status` como obligatorios. `enviarCorreo()`
|
||||
**no manda ninguno** y responde `200`. Manda lo medido; no los añadas «por cumplir el esquema» sin
|
||||
un sondeo, porque cualquier clave inesperada es `422`.
|
||||
**[MEDIDO]** La respuesta es `Email queued successfully`: acuse de **encolado**, no de entrega. Y al
|
||||
releer el hilo, el `status` del mensaje propio viene `null` (hallazgo 23). **No se puede afirmar que
|
||||
llegó.**
|
||||
|
||||
---
|
||||
|
||||
## C · Calendarios, citas y servicios
|
||||
|
||||
### `GET /calendars/`
|
||||
Scope `calendars.readonly`, `Version: v3`. **[MEDIDO]** 7 calendarios en la subcuenta, uno
|
||||
`Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`).
|
||||
|
||||
### `GET /calendars/events`
|
||||
Scope `calendars/events.readonly`.
|
||||
**[MEDIDO, hallazgo 28]** Exige uno de `calendarId`, `userId` o `groupId`; sin ellos es
|
||||
`422 Either of userId, calendarId or groupId is required`. **No existe «todas las citas de la
|
||||
subcuenta»**: hay que iterar los calendarios.
|
||||
**Asimetría de formato:** la **entrada** (`startTime`/`endTime` del query) va en **milisegundos
|
||||
epoch**; la **salida** viene en **ISO con desplazamiento** (`2026-12-28T14:00:00-06:00`).
|
||||
|
||||
### `GET /calendars/events/appointments/{eventId}`
|
||||
**[MEDIDO, hallazgo 43] Sigue devolviendo la cita después de borrarla** — es un borrado lógico.
|
||||
Para comprobar si una cita existe, **lista el rango del calendario**; por id da un falso positivo.
|
||||
|
||||
### `POST /calendars/events/appointments`
|
||||
Scope `calendars/events.write` — **[MEDIDO, hallazgo 33] el token lo tiene**.
|
||||
Obligatorios: `calendarId`, `locationId`, `contactId`, `startTime`.
|
||||
`startTime`/`endTime` en **ISO con desplazamiento**, no en epoch — al revés que el filtro de arriba.
|
||||
Palancas que este proyecto usa a propósito:
|
||||
- `toNotify: false` — la plataforma ya avisó a la clienta; que el CRM no lo haga otra vez.
|
||||
- `ignoreFreeSlotValidation: true` — AgendaMax es la fuente de verdad del horario y su base ya
|
||||
impide el solape.
|
||||
|
||||
**[MEDIDO, hallazgo 42]** Verificado de punta a punta: creada, releída con la **hora de pared
|
||||
idéntica** a la escrita, y borrada.
|
||||
|
||||
### `PUT /calendars/events/appointments/{eventId}`
|
||||
**No admite `locationId` ni `contactId`.** Misma trampa que en contactos: no recicles el cuerpo del
|
||||
alta.
|
||||
|
||||
### `DELETE /calendars/events/{eventId}`
|
||||
**[MEDIDO]** Existe y el token lo tiene. Borrado lógico (ver hallazgo 43).
|
||||
|
||||
### Servicios: `GET`/`POST /calendars/services/catalog`
|
||||
Scopes `calendars.readonly` / **`calendars.write`** — **[MEDIDO, hallazgo 34] el token lo tiene**.
|
||||
Un «servicio» es una prestación vendible con duración, precio, categoría, personal y variaciones:
|
||||
literalmente el modelo de un spa.
|
||||
|
||||
**[MEDIDO, hallazgos 6 y 32] El catálogo de esta subcuenta está VACÍO.** Por eso la duración y el
|
||||
precio viven en la plataforma y «sincronizar servicios» solo puede significar **publicar**.
|
||||
|
||||
Obligatorios: `locationId`, `name`, `slug`, **`staff[]` con al menos un miembro** (como
|
||||
`[{ id: "…" }]`, no como `["…"]`).
|
||||
|
||||
**[MEDIDO, hallazgos 44-45] La primera publicación en una subcuenta sin catálogo falla** con
|
||||
`400 No default service category found for this location` — y **ese mismo intento hace que el CRM
|
||||
cree la categoría por defecto**. El reintento entra sin cambiar nada. `publicarServicio` reintenta
|
||||
una vez y **solo** ante ese mensaje.
|
||||
|
||||
### `GET /calendars/service-categories`
|
||||
**[MEDIDO, hallazgo 46]** Esta es la ruta correcta. `/calendars/services/categories` cae en el
|
||||
comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID`, que se lee como «no
|
||||
existe el recurso» cuando en realidad significa «no existe la ruta».
|
||||
|
||||
### `DELETE /calendars/services/catalog/{serviceId}`
|
||||
**[MEDIDO]** Existe y el token lo tiene.
|
||||
|
||||
---
|
||||
|
||||
## D · Subcuentas
|
||||
|
||||
### `GET /locations/{locationId}`
|
||||
Scope `locations.readonly`. **Un token privado de subcuenta basta**; no hace falta token de agencia.
|
||||
|
||||
**[MEDIDO] `settings` trae una quinta clave que la documentación no declara:**
|
||||
|
||||
```json
|
||||
"contactUniqueIdentifiers": ["email", "phone"]
|
||||
```
|
||||
|
||||
No es un detalle: es la cadena de identidad que el CRM aplica para deduplicar, y la justificación de
|
||||
que la del proyecto (id → teléfono → correo) **coincida con la suya** en vez de pelearse con ella.
|
||||
|
||||
`allowDuplicateOpportunity: false` es la causa del `400 OPPORTUNITY_NO_DUPLICATE`, y es **un
|
||||
interruptor de la interfaz**, no un límite duro.
|
||||
|
||||
### Cómo validar que un token corresponde a una subcuenta
|
||||
**No hay endpoint de introspección de token.** El método correcto, y el que usa
|
||||
`PUT /api/admin/businesses/:id/crm`, es leer `GET /locations/{locationId}` con ese token y **comparar
|
||||
`location.id` con el esperado** — identidad contra identidad, no un `200` genérico.
|
||||
|
||||
**[MEDIDO] El `401` de esta API es ambiguo**: significa a la vez «token caducado», «al token le falta
|
||||
el scope» y (probablemente) «el token es de otra subcuenta». Por eso el mensaje de error de la
|
||||
consola nombra las tres posibilidades en vez de afirmar una.
|
||||
|
||||
---
|
||||
|
||||
## E · Límites de tasa
|
||||
|
||||
**[MEDIDO, hallazgo 37]** Cabeceras reales de la subcuenta:
|
||||
|
||||
| Cabecera | Valor observado |
|
||||
|---|---|
|
||||
| `x-ratelimit-max` | `100` |
|
||||
| `x-ratelimit-interval-milliseconds` | `10000` |
|
||||
| `x-ratelimit-limit-daily` | `200000` |
|
||||
| `x-ratelimit-remaining` | va bajando en la ventana |
|
||||
| `x-ratelimit-daily-remaining` | `199970` |
|
||||
|
||||
O sea **1 petición cada 100 ms**, no cada 650 como asumía el cliente originalmente. `client.ts` lee
|
||||
esas cabeceras en cada respuesta y ajusta el intervalo con un 50 % de margen; el 650 ms queda solo
|
||||
como respaldo para la primera petición de un token, antes de haber visto ninguna cabecera.
|
||||
|
||||
La cuota es **por aplicación y por subcuenta**: añadir cuentas no reparte el límite, cada una tiene
|
||||
el suyo. Por eso el estrangulador es **por token** y no global.
|
||||
|
||||
---
|
||||
|
||||
## F · Scopes
|
||||
|
||||
```
|
||||
contacts.readonly contacts.write
|
||||
conversations.readonly conversations.write
|
||||
conversations/message.readonly conversations/message.write
|
||||
calendars.readonly calendars.write
|
||||
calendars/events.readonly calendars/events.write
|
||||
calendars/groups.readonly calendars/groups.write
|
||||
locations.readonly locations.write
|
||||
locations/customFields.* locations/customValues.* locations/tags.*
|
||||
```
|
||||
|
||||
**Dos parejas que se confunden fácil y dan `401` donde no se espera:**
|
||||
`conversations.readonly` **no** incluye `conversations/message.readonly`, y `calendars.readonly`
|
||||
**no** incluye `calendars/events.readonly`.
|
||||
|
||||
**[MEDIDO] Estado del token de esta subcuenta**, al 2026-08-29:
|
||||
|
||||
| Scope | Estado |
|
||||
|---|---|
|
||||
| contactos, conversaciones, mensajes, subcuenta | ✔ |
|
||||
| `calendars/events.write` | ✔ (hallazgo 33) |
|
||||
| `calendars.write` | ✔ (hallazgo 34) |
|
||||
| usuarios | ✔ **hoy** — daba `401` cuando se midió por primera vez (hallazgos 14 → 35) |
|
||||
| crear pipelines | ✖ `401 The token is not authorized for this scope` |
|
||||
|
||||
**Un permiso medido una vez no queda medido para siempre.** El de usuarios cambió entre dos
|
||||
mediciones porque alguien tocó el token. Conviene comprobar los permisos al vincular una subcuenta y
|
||||
volver a hacerlo cuando algo falle con `401`, en vez de fiarse de una tabla escrita en el pasado.
|
||||
|
||||
---
|
||||
|
||||
## Lo que NO está documentado y no debe inventarse
|
||||
|
||||
1. El esquema del cuerpo de `POST /contacts/search`: filtros, operadores y campos filtrables. El
|
||||
OpenAPI lo declara como objeto vacío.
|
||||
2. El máximo real de `pageLimit`.
|
||||
3. El valor del techo de profundidad al paginar por número de página. Que existe está medido; cuánto
|
||||
es, no.
|
||||
4. El cuerpo de la respuesta `429` y la política de reintento recomendada.
|
||||
5. Si los tokens privados tienen cuota distinta de las aplicaciones de marketplace.
|
||||
6. Un endpoint de introspección de token.
|
||||
7. El código exacto que devuelve un token de **otra** subcuenta. Se infiere `401`; comprobarlo exige
|
||||
un segundo token y este proyecto solo tiene uno.
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
**Exigen OAuth.** Un token privado de subcuenta no puede suscribirse, así que **la entrada de datos
|
||||
es por sondeo**: los botones de sincronización y `POST /api/crm/sync/:entidad/:id`. Si algún día se
|
||||
quiere tiempo real, hay que pasar por OAuth, y eso es un entregable propio.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Hallazgos medidos contra Bucéfalo CRM
|
||||
|
||||
Subcuenta **Yola Franco Spa** (`Pk89Wa23QaxvkOfKgwjZ`) · **2026-08-29** · token PIT de subcuenta.
|
||||
|
||||
Todo lo de aquí se ejerció contra el CRM real y **se verificó releyendo**, nunca aceptando un
|
||||
`200` como prueba. Los spikes que lo produjeron están en `platform/scripts/crm-spike*.ts` y se
|
||||
pueden volver a correr.
|
||||
|
||||
---
|
||||
|
||||
## Lo que quedó confirmado
|
||||
|
||||
| # | Hallazgo | Consecuencia |
|
||||
|---|---|---|
|
||||
| 1 | La subcuenta responde: `Yola Franco Spa`, tz `America/Mexico_City`, país `MX` | El token y el `locationId` son correctos |
|
||||
| 2 | **3 209 contactos** y **3 210 conversaciones** | Hay material real que sincronizar |
|
||||
| 3 | `attributionSource` viene **poblado** con datos reales (`sessionSource`, `medium`, `campaign`, `campaignId`, `adId`, `utmMedium`, `utmContent`) | La atribución UTM que pide el proyecto **existe y se puede traer** |
|
||||
| 4 | Pipeline único: **`Standar`** `Mrclt4VzRZV1DI4Vbt5c`, 9 etapas, con **`Ganado`** (`b91c1653-…`) y **`Perdido`** (`04b28d7f-…`) | Hay dónde aterrizar `won` y `lost` sin inventar nada |
|
||||
| 5 | **SÍ existen 7 calendarios**, uno de ellos `Servicio Spa` (`LXZIuRPYa3uCPlUlsqY7`) | Resuelve la pregunta que el análisis previo marcaba como bloqueante |
|
||||
| 6 | **`GET /calendars/services/catalog` devuelve `services: []`** | El catálogo de servicios del CRM está **vacío**: la duración y el precio tienen que vivir en la plataforma. Confirma la sospecha previa |
|
||||
| 7 | `POST /contacts/` con `attributionSource` → **persiste íntegro** (verificado releyendo) | Se puede dar de alta con UTM completo |
|
||||
| 8 | `POST /contacts/` duplicado → **`400` con `meta.contactId` y `meta.matchingField`** | **Idempotencia real y gratuita.** Es mejor que `upsert`, cuya rama *actualizar* descarta la atribución |
|
||||
| 9 | `POST /opportunities/` → crea y **el importe persiste** | El valor del servicio llega al CRM |
|
||||
| 10 | `POST /conversations/messages` con `type: "Email"` → `200` `Email queued successfully` con `conversationId`, `messageId`, `threadId` | Hay canal de vuelta para probar mensajes |
|
||||
|
||||
## Lo que NO funciona como uno esperaría
|
||||
|
||||
| # | Hallazgo | Cómo se sortea |
|
||||
|---|---|---|
|
||||
| 11 | **`PUT /opportunities/{id}/status` rechaza `pipelineStageId`** con `422 property pipelineStageId should not exist` | El estado y la etapa se cambian en **dos llamadas**: `/status` con solo `status`, y `PUT /opportunities/{id}` con `pipelineId` + `pipelineStageId` |
|
||||
| 12 | **`POST /opportunities/` rechaza una segunda oportunidad del mismo contacto aunque la primera esté en `won`** (`400 OPPORTUNITY_NO_DUPLICATE` con `meta.existingId`) | Ver «La decisión de las oportunidades» abajo |
|
||||
| 13 | La causa es el ajuste **`settings.allowDuplicateOpportunity: false`** de la subcuenta | **Es un ajuste, no un límite duro.** El cliente puede activarlo |
|
||||
| 14 | ~~`GET /users/?locationId` → **`401` fuera de scope**~~ **OBSOLETO — ver hallazgo 35: hoy responde `200`** | El token PIT no listaba personal. Volvió a medirse el 2026-08-29 y sí lo lista |
|
||||
| 15 | `POST /opportunities/pipelines` → **`401` `The token is not authorized for this scope`** | `pipelines.create` no está en el token. Rediseñar el pipeline a etapas de spa es trabajo de UI, no de código |
|
||||
| 16 | `GET /contacts/{id}/opportunities` → **`404`, la ruta no existe** | Se usa `GET /opportunities/search?location_id=&contact_id=`, que sí funciona |
|
||||
|
||||
## Lo que el CRM usa para deduplicar, y coincide con lo pedido
|
||||
|
||||
`GET /locations/{id}` devuelve:
|
||||
|
||||
```json
|
||||
"settings": {
|
||||
"allowDuplicateContact": false,
|
||||
"allowDuplicateOpportunity": false,
|
||||
"contactUniqueIdentifiers": ["email", "phone"]
|
||||
}
|
||||
```
|
||||
|
||||
La cadena de identidad pedida para el proyecto —**id de contacto → teléfono → correo**— es
|
||||
exactamente la que el CRM aplica. Con `allowDuplicateContact: false`, el propio CRM devuelve el
|
||||
`contactId` existente en el `400`: la deduplicación no hay que construirla, hay que **leerla del
|
||||
rechazo**.
|
||||
|
||||
## La decisión de las oportunidades
|
||||
|
||||
El encargo es «una cita = una oportunidad», con el nombre `SERVICIO + NOMBRE CONTACTO`, el importe
|
||||
del servicio, y `open` / `won` / `lost` según el estado. El hallazgo 12 lo impide **hoy**: con
|
||||
`allowDuplicateOpportunity: false`, una clienta que vuelve por segunda vez no puede estrenar
|
||||
oportunidad, y una clienta de spa vuelve muchas veces.
|
||||
|
||||
Se implementan los dos caminos y la plataforma elige solo, sin configuración:
|
||||
|
||||
1. **Intenta crear.** Si el CRM la acepta, una cita = una oportunidad, tal como se pidió.
|
||||
2. **Si responde `400 OPPORTUNITY_NO_DUPLICATE`**, toma el `meta.existingId` y **recicla esa
|
||||
oportunidad**: le pone el nombre de la cita nueva, su importe y su estado. Verificado que se
|
||||
puede renombrar, cambiar el importe, cerrar y **reabrir** una ya cerrada.
|
||||
|
||||
Con el ajuste desactivado, la oportunidad representa *la cita vigente de la clienta* y el histórico
|
||||
completo vive en AgendaMax. Con el ajuste activado, el modelo pasa a ser el pedido **sin tocar una
|
||||
línea de código**.
|
||||
|
||||
> **Para que sea «una cita = una oportunidad» hace falta que alguien active
|
||||
> _Allow Duplicate Opportunity_ en los ajustes de la subcuenta.** Es un interruptor de la UI del
|
||||
> CRM; el token no puede cambiarlo. Mientras tanto el MVP funciona reciclando.
|
||||
|
||||
## Cabeceras y trampas
|
||||
|
||||
- `Version: 2021-07-28` para contactos, oportunidades y conversaciones; **`Version: v3` para todo
|
||||
`/calendars/`**. Equivocarla es `400`.
|
||||
- `locationId` **va en el cuerpo del `POST /contacts/`** y **rompe el `PUT`** (`422 property
|
||||
locationId should not exist`). Es una asimetría fácil de cruzar reciclando código.
|
||||
- Hay lista blanca de propiedades: cualquier clave desconocida es `422` y no crea nada. Es un fallo
|
||||
seguro y sirve de herramienta de descubrimiento.
|
||||
- La atribución **es de una sola oportunidad**: se escribe en el alta y un `PUT` posterior devuelve
|
||||
`200` sin guardar nada.
|
||||
- `campaign` hay que mandarlo **además** de `utmCampaign`: el buscador de contactos descarta
|
||||
`utmCampaign` y conserva `campaign`.
|
||||
|
||||
## Hallazgos posteriores, ya con la integración escrita
|
||||
|
||||
| # | Hallazgo | Consecuencia |
|
||||
|---|---|---|
|
||||
| 17 | **`PUT .../status` mueve la etapa por su cuenta.** Con la etapa escrita primero y el estado después, el CRM la devolvió de «Ganado» a «Cotización Aceptada» | El orden correcto es **estado primero, etapa después** |
|
||||
| 18 | Ese movimiento es **asíncrono y gana igualmente en `won`**: se reescribió y releyó tres veces y el CRM la volvió a mover después de que la relectura ya confirmaba la nuestra. En `lost` sí respeta «Perdido» | Hay una regla del lado del CRM que gobierna la etapa en `won`. **No se pelea con ella**: lo que el negocio pidió mapear es el `status`, y ese sí queda estable |
|
||||
| 19 | El buscador de contactos pagina con **`searchAfter`**, tomado del último contacto de la página anterior | Con `page` se topa un techo de profundidad mucho antes de los 3 200 |
|
||||
| 20 | Sincronización completa medida: **3 210 contactos en 22 s**, idempotente (segunda corrida: 0 creados, 3 210 actualizados) | El botón puede correr en primer plano sin tarea de fondo |
|
||||
| 21 | Calidad real de los contactos traídos: **59,4 % con teléfono normalizable**, **8 con correo** de 3 215, 801 con campaña, 824 con anuncio | Coincide con la auditoría independiente previa (59,8 % y 0,2 %). **Cuatro de cada diez clientas no son contactables** |
|
||||
| 22 | El prefijo `521` heredado de mensajería aparece en los teléfonos reales (`+5215656592254`) y se colapsa bien a `+525656592254`. **Cero duplicados** por teléfono tras sincronizar 3 210 | La deduplicación por E.164 funciona sobre datos reales |
|
||||
| 23 | El mensaje enviado **aparece al releer el hilo**, pero su `status` viene `null` | El CRM no expone el estado de entrega ahí: sigue sin poder afirmarse que llegó |
|
||||
|
||||
## Lo que sigue sin verificarse
|
||||
|
||||
- **Que el correo se entregue.** `Email queued successfully` es acuse de encolado, no de entrega.
|
||||
Exige mirar una bandeja real.
|
||||
- **WhatsApp y SMS**: no están conectados en la subcuenta. Fuera del MVP.
|
||||
- **Escritura de citas al calendario del CRM** (`POST /calendars/events/appointments`): no se ha
|
||||
ejercido. El MVP proyecta las citas como oportunidades, no como eventos de calendario.
|
||||
- **Webhooks**: exigen OAuth, que este token no es. La sincronización de entrada es por sondeo.
|
||||
|
||||
## Datos de prueba creados en la subcuenta real
|
||||
|
||||
Contacto `WzBTBaHkNnpmjMb1Avx3` (`urieljareth@grupo-e3.com`, tag `agendamax:prueba`) y su
|
||||
oportunidad `IMkYdAkBowggN9aKVbfc`. Se limpian con
|
||||
`node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Sondeo de lectura por id — 2026-08-29
|
||||
|
||||
Medido con `crm-spike-lectura-id.ts` y `crm-spike-calendarios.ts`, ambos **solo lectura**. Cubre
|
||||
las cinco entidades que pide la sincronización por id: contactos, conversaciones, mensajes, citas
|
||||
y servicios.
|
||||
|
||||
| # | Hallazgo | Consecuencia |
|
||||
|---|---|---|
|
||||
| 24 | `GET /conversations/{id}` **funciona** y trae `contactId`, `messageTypes`, `unreadCount`, `firstUnreadInboundMessageId` | Se puede anclar una conversación por su id sin recorrer la lista |
|
||||
| 25 | `GET /conversations/search?contactId=…` **filtra por contacto** | Es el camino para «las conversaciones de esta clienta» sin traerse las 3 213 |
|
||||
| 26 | `GET /conversations/{id}/messages` pagina con **`lastMessageId` + `nextPage`**, no con `page` ni `searchAfter` | Tercera convención de paginación distinta en la misma API. No reciclar la de contactos |
|
||||
| 27 | **`GET /conversations/messages/{id}` funciona**: trae un mensaje suelto por su id, con `from`, `messageType`, `contentType`, `meta` | Permite reconciliar un mensaje concreto sin releer el hilo entero |
|
||||
| 28 | `GET /calendars/events` **exige** uno de `userId`, `calendarId` o `groupId`; sin ellos es `422`. Con un `userId` inexistente es `400 User with id … not found` | No hay forma de pedir «todas las citas de la subcuenta» en una llamada: hay que iterar los calendarios |
|
||||
| 29 | **La subcuenta tiene 1 sola cita en total** en los 7 calendarios, en una ventana de 2 años atrás y 1 adelante, y está en **`Servicio Spa`** (`LXZIuRPYa3uCPlUlsqY7`) | El calendario del CRM está prácticamente sin usar. La agenda real no vive ahí: nace en la plataforma. Confirma que la sincronización de citas es **empuje**, no arrastre |
|
||||
| 30 | Forma del evento: `appointmentStatus`, `assignedUserId`, `calendarId`, `contactId`, `startTime`, `endTime`, `dateAdded`, `address`. Devuelve **además** `appoinmentStatus` — con la errata — con el mismo valor | Si se lee el estado, leer `appointmentStatus` y tolerar la errata: es del CRM, no nuestra |
|
||||
| 31 | `GET /calendars/groups` → 0 grupos | No hay agrupación que aprovechar |
|
||||
| 32 | `GET /calendars/services/catalog` → `services: []` **reconfirmado** | El catálogo de servicios del CRM sigue vacío. Duración y precio viven en la plataforma, y «sincronizar servicios» no puede significar traerlos de allá |
|
||||
|
||||
### Lo que esto decide
|
||||
|
||||
- **Contactos, conversaciones y mensajes**: los tres se pueden traer por id concreto. La
|
||||
sincronización selectiva que pide el encargo es viable tal cual, sin rodeos.
|
||||
- **Citas**: no hay nada que arrastrar. La dirección útil es empujar la cita de la plataforma al
|
||||
calendario del CRM (`POST /calendars/events/appointments`, **aún sin ejercer**) además de la
|
||||
oportunidad que ya se proyecta.
|
||||
- **Servicios**: no hay catálogo en el CRM que sincronizar. Lo único con sentido es publicar hacia
|
||||
allá los de la plataforma, y eso exige comprobar antes si el token tiene permiso de escritura
|
||||
sobre `/calendars/services` — no se ha probado.
|
||||
|
||||
---
|
||||
|
||||
## Sondeo de permisos y de límites — 2026-08-29
|
||||
|
||||
Medido con `crm-spike-permisos.ts`. La técnica no crea nada: se manda un `POST` **deliberadamente
|
||||
incompleto** y se mira qué error vuelve. Un `401 not authorized for this scope` significa que falta
|
||||
el permiso; un `422` sobre los campos significa que el permiso está y lo que falla es el cuerpo.
|
||||
Distinguir esas dos cosas era lo único que faltaba para saber si se puede planificar escritura, y
|
||||
no costó un solo registro basura en la subcuenta del cliente.
|
||||
|
||||
| # | Hallazgo | Consecuencia |
|
||||
|---|---|---|
|
||||
| 33 | **`calendars/events.write` SÍ está.** `POST /calendars/events/appointments` responde `422 calendarId should not be empty · startTime must be a valid ISO 8601 date string`, no `401` | **Se pueden escribir citas al calendario del CRM.** Deja de ser una incógnita: el MVP puede proyectar la cita como evento además de como oportunidad |
|
||||
| 34 | **`calendars.write` SÍ está.** `POST /calendars/services/catalog` responde `422 name should not be empty · At least one staff member is required` | **Se puede poblar el catálogo de servicios del CRM.** Que esté vacío (hallazgo 6) no es un límite de la API: es que nadie lo llenó |
|
||||
| 35 | **`GET /users/?locationId` responde `200` con 6 usuarios.** Contradice el hallazgo 14, medido semanas antes | El token tiene ahora permiso de personal. Esto **desbloquea el `staff[]` obligatorio** del alta de servicios, que era el impedimento práctico del hallazgo 34. Ids disponibles, entre ellos `6HCOVjDvvdUlv1bjbR7W` (Yola Spa Recepción), que es el `assignedUserId` de la única cita real |
|
||||
| 36 | `GET /contacts/{id}/appointments` responde `200` con `{"events":[]}` | Hay una ruta directa para «las citas de esta clienta» sin recorrer calendarios. Devuelve vacío porque la subcuenta casi no tiene citas (hallazgo 29) |
|
||||
| 37 | **Cabeceras de límite reales**: `x-ratelimit-max: 100`, `x-ratelimit-interval-milliseconds: 10000`, `x-ratelimit-limit-daily: 200000` | La cuota es **1 petición cada 100 ms**, no cada 650. El cliente estrangula **6,5× por debajo** de lo permitido. Bajar `MIN_INTERVAL_MS` acortaría la sincronización de 22 s a ~4 s. Hasta hoy nadie leía esas cabeceras: el 650 ms era una estimación observada, no una cuota conocida |
|
||||
|
||||
### Lo que esto cambia
|
||||
|
||||
- **Las cinco entidades del encargo son viables.** Contactos, conversaciones y mensajes se leen por
|
||||
id (24-27); las citas se pueden **escribir** al calendario (33) además de proyectarse como
|
||||
oportunidad; y los servicios se pueden **publicar** al catálogo (34) ahora que hay ids de personal
|
||||
(35). Ninguna queda bloqueada por permisos.
|
||||
- **«Sincronizar servicios» solo puede significar empujar**, nunca traer: el catálogo del CRM está
|
||||
vacío y la duración y el precio los define el negocio en la plataforma.
|
||||
- **Un permiso medido una vez no queda medido para siempre.** El hallazgo 14 era cierto cuando se
|
||||
midió y hoy es falso, porque alguien cambió el token o sus permisos. Conviene que la plataforma
|
||||
compruebe los permisos al vincular una subcuenta y lo vuelva a hacer cuando algo falle con `401`,
|
||||
en vez de fiarse de una tabla escrita en el pasado.
|
||||
|
||||
---
|
||||
|
||||
## Ejercido con la sincronización por id escrita — 2026-08-29
|
||||
|
||||
| # | Hallazgo | Consecuencia |
|
||||
|---|---|---|
|
||||
| 38 | **`GET /conversations/{id}` no devuelve el nombre del contacto ni un canal reconocible.** Solo el buscador los trae | Sincronizar un hilo por su id **degradaba** un nombre bueno a «Sin nombre» y el canal a «Desconocido». El upsert ya no deja que un dato pobre pise a uno que ya se tenía, y el nombre se toma de la clienta enlazada |
|
||||
| 39 | El canal del hilo **se puede deducir de su último mensaje**, que sí lo trae | Evita el «Desconocido» sin gastar una petición más. Se excluyen los `TYPE_ACTIVITY_*`, que son notas del propio CRM y no un canal por el que hablar con la clienta |
|
||||
| 40 | Los hilos reales traen **`TYPE_INSTAGRAM`** y **`TYPE_ACTIVITY_OPPORTUNITY`**, que no estaban en ningún mapa | Instagram es un canal de verdad de este negocio; las actividades no lo son y conviene distinguirlas en la bandeja |
|
||||
| 41 | Sincronización por id verificada de punta a punta contra la subcuenta real: contacto (`creado`), conversación (`espejada`, 3 mensajes) y mensaje suelto (`espejado con su hilo`) | Las tres entidades que el CRM posee se pueden traer una a una. La conversación quedó enlazada con la clienta local por `crm_contact_id` |
|
||||
|
||||
---
|
||||
|
||||
## Primera escritura de citas y servicios al CRM — 2026-08-29
|
||||
|
||||
Ejercida con `crm-spike-escritura-cita-servicio.ts`, que crea, **relee**, borra y
|
||||
**confirma el borrado** en la misma corrida. Nada quedó en la subcuenta: la limpieza va en un
|
||||
`finally`, así que se ejecuta aunque el sondeo falle a mitad. Antes se comprobó con
|
||||
`crm-spike-borrado.ts` que el borrado existe — preguntar si se puede deshacer **antes** de escribir
|
||||
en el CRM de un cliente real, no después.
|
||||
|
||||
| # | Hallazgo | Consecuencia |
|
||||
|---|---|---|
|
||||
| 42 | **Escribir una cita al calendario funciona.** `POST /calendars/events/appointments` la crea, se relee con el contacto correcto, el estado `confirmed`, y **la hora de pared idéntica a la escrita**: se mandó `2026-12-28T14:00:00-06:00` y se releyó igual | La proyección de citas al calendario deja de ser una incógnita. Y confirma que `isoConDesplazamiento` acierta: escribir con `toISOString()` habría movido la hora que el CRM enseña |
|
||||
| 43 | **`GET /calendars/events/appointments/{id}` SIGUE devolviendo la cita después de borrarla.** El listado del rango sí deja de incluirla | Es un borrado lógico. Comprobar existencia por id da un falso positivo: **la comprobación fiable es listar el calendario** |
|
||||
| 44 | **La primera publicación de un servicio falla** con `400 No default service category found for this location`, aunque `staff`, `name` y `slug` sean correctos | La documentación marca `serviceCategoryId` como opcional. No lo es cuando la subcuenta no tiene ninguna categoría |
|
||||
| 45 | **Ese mismo intento fallido hace que el CRM cree la categoría por defecto** (`GET /calendars/service-categories` la devuelve con `isSystemGenerated: true` y fecha del segundo del fallo). El reintento entra sin cambiar nada | `publicarServicio` reintenta **una vez** y **solo** ante ese mensaje. Reintentar un POST a ciegas fabrica duplicados |
|
||||
| 46 | La ruta de categorías es **`/calendars/service-categories`**, no `/calendars/services/categories` — esta última cae en el comodín `/{serviceId}` y devuelve `404 Please provide a valid service ID` | Un 404 con ese texto significa «la ruta no existe», no «el recurso no existe». Es fácil de leer al revés |
|
||||
| 47 | Publicar servicio verificado de punta a punta: catálogo **0 → 1**, con la duración correcta, y borrado después dejándolo en 0 | Las cinco entidades del encargo quedan ejercidas contra el CRM real |
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isoConDesplazamiento, estadoCitaCrm } from "./calendars.ts";
|
||||
|
||||
// La suite corre con la zona del proceso FIJADA a otra distinta de la del
|
||||
// negocio, para que un cálculo que se ancle a la del proceso falle aquí.
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
test("isoConDesplazamiento escribe la hora de pared del negocio con su desplazamiento", () => {
|
||||
// 2026-09-03 11:00 en México = 17:00Z
|
||||
const d = new Date("2026-09-03T17:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T11:00:00-06:00");
|
||||
});
|
||||
|
||||
test("isoConDesplazamiento no usa la zona del proceso", () => {
|
||||
const d = new Date("2026-09-03T17:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "UTC"), "2026-09-03T17:00:00+00:00");
|
||||
// Misma entrada, dos zonas, dos horas de pared distintas.
|
||||
assert.notEqual(
|
||||
isoConDesplazamiento(d, "UTC"),
|
||||
isoConDesplazamiento(d, "America/Mexico_City")
|
||||
);
|
||||
});
|
||||
|
||||
test("isoConDesplazamiento: la medianoche se escribe 00, no 24", () => {
|
||||
// 00:00 del 4 de septiembre en México = 06:00Z
|
||||
const d = new Date("2026-09-04T06:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-04T00:00:00-06:00");
|
||||
});
|
||||
|
||||
test("isoConDesplazamiento: una cita de la tarde cae en el día correcto", () => {
|
||||
// 19:00 de México del día 3 = 01:00Z del día 4. El día de pared es el 3.
|
||||
const d = new Date("2026-09-04T01:00:00Z");
|
||||
assert.equal(isoConDesplazamiento(d, "America/Mexico_City"), "2026-09-03T19:00:00-06:00");
|
||||
});
|
||||
|
||||
test("estadoCitaCrm traduce los estados de la plataforma a los del CRM", () => {
|
||||
assert.equal(estadoCitaCrm("scheduled"), "confirmed");
|
||||
assert.equal(estadoCitaCrm("completed"), "showed");
|
||||
assert.equal(estadoCitaCrm("no_show"), "noshow");
|
||||
assert.equal(estadoCitaCrm("cancelled"), "cancelled");
|
||||
});
|
||||
|
||||
test("estadoCitaCrm: un estado que no conocemos no inventa, cae en confirmed", () => {
|
||||
assert.equal(estadoCitaCrm("lo-que-sea"), "confirmed");
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { crmRequest, CrmError, VERSION_CALENDARS } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
export interface CrmCalendar {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
calendarType?: string;
|
||||
}
|
||||
|
||||
export interface CrmEvent {
|
||||
id: string;
|
||||
calendarId: string;
|
||||
contactId?: string;
|
||||
title?: string;
|
||||
appointmentStatus?: string;
|
||||
assignedUserId?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface AltaCita {
|
||||
calendarId: string;
|
||||
contactId: string;
|
||||
/** ISO con desplazamiento, no epoch. Ver `isoConDesplazamiento`. */
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
title: string;
|
||||
assignedUserId?: string;
|
||||
appointmentStatus?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO con el desplazamiento horario del NEGOCIO.
|
||||
*
|
||||
* El CRM acepta `2026-09-03T11:00:00-06:00` en el alta de citas, y **no**
|
||||
* milisegundos — al revés que el filtro de rango de `/calendars/events`, que sí
|
||||
* los exige. Esa asimetría es de la API, no nuestra.
|
||||
*
|
||||
* Y no vale `toISOString()`: devuelve UTC con `Z`, y aunque el instante sea el
|
||||
* mismo, la hora de pared que el CRM enseña en su interfaz sale de lo que se
|
||||
* escribe aquí. Se construye con `Intl` y nunca con `new Date(y, m, d, …)`, que
|
||||
* resuelve el reloj en la zona del proceso — el error que ya costó un fallo de
|
||||
* producción en este repo (ver la sección de zonas horarias de CLAUDE.md).
|
||||
*/
|
||||
export function isoConDesplazamiento(d: Date, tz: string): string {
|
||||
const zona = tz || "America/Mexico_City";
|
||||
const p = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: zona,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const g = (t: string) => p.find((x) => x.type === t)!.value;
|
||||
|
||||
const off = new Intl.DateTimeFormat("en-US", { timeZone: zona, timeZoneName: "longOffset" })
|
||||
.formatToParts(d)
|
||||
.find((x) => x.type === "timeZoneName")!.value;
|
||||
const m = off.match(/GMT([+-])(\d{2}):(\d{2})/);
|
||||
const desp = m ? `${m[1]}${m[2]}:${m[3]}` : "+00:00";
|
||||
|
||||
// `en-CA` con hour12:false puede rendir la medianoche como 24; el CRM espera 00.
|
||||
const hora = g("hour") === "24" ? "00" : g("hour");
|
||||
return `${g("year")}-${g("month")}-${g("day")}T${hora}:${g("minute")}:${g("second")}${desp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de la cita de la plataforma → estado del CRM.
|
||||
*
|
||||
* En la PETICIÓN el enum admite `new|confirmed|cancelled|showed|noshow|invalid`.
|
||||
* En la respuesta hay dos más (`active`, `completed`) que el CRM asigna por su
|
||||
* cuenta y no se pueden escribir.
|
||||
*/
|
||||
export function estadoCitaCrm(estado: string): string {
|
||||
switch (estado) {
|
||||
case "completed":
|
||||
return "showed";
|
||||
case "no_show":
|
||||
return "noshow";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
default:
|
||||
return "confirmed";
|
||||
}
|
||||
}
|
||||
|
||||
export async function listarCalendarios(ctx: CrmCtx): Promise<CrmCalendar[]> {
|
||||
const r = await crmRequest<any>("GET", "/calendars/", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return r?.calendars ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* El personal de la subcuenta.
|
||||
*
|
||||
* MEDIDO (hallazgo 35): esta ruta devolvía `401` cuando se midió por primera vez
|
||||
* y hoy responde `200` con 6 usuarios. Da los ids que `staff[]` exige al crear
|
||||
* servicios y `assignedUserId` al crear citas. Si vuelve a dar 401, quien llame
|
||||
* debe poder seguir sin ella, no romperse.
|
||||
*/
|
||||
export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> {
|
||||
const r = await crmRequest<any>("GET", "/users/", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
});
|
||||
return (r?.users ?? []).map((u: any) => ({ id: u.id, name: u.name ?? "" }));
|
||||
}
|
||||
|
||||
export async function obtenerCita(ctx: CrmCtx, eventId: string): Promise<CrmEvent | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${eventId}`, {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return (r?.event ?? r?.appointment ?? r) as CrmEvent;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Citas de un calendario en un rango.
|
||||
*
|
||||
* MEDIDO (hallazgo 28): sin `calendarId`, `userId` o `groupId` la API responde
|
||||
* `422 Either of userId, calendarId or groupId is required`. No existe «dame
|
||||
* todas las citas de la subcuenta»: hay que iterar los calendarios.
|
||||
*
|
||||
* El rango va en **milisegundos epoch**, al revés que el alta.
|
||||
*/
|
||||
export async function citasEnRango(
|
||||
ctx: CrmCtx,
|
||||
calendarId: string,
|
||||
desdeMs: number,
|
||||
hastaMs: number
|
||||
): Promise<CrmEvent[]> {
|
||||
const r = await crmRequest<any>("GET", "/calendars/events", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
query: {
|
||||
locationId: ctx.locationId,
|
||||
calendarId,
|
||||
startTime: String(desdeMs),
|
||||
endTime: String(hastaMs),
|
||||
},
|
||||
});
|
||||
return r?.events ?? [];
|
||||
}
|
||||
|
||||
export async function crearCita(ctx: CrmCtx, a: AltaCita): Promise<{ id: string }> {
|
||||
const r = await crmRequest<any>("POST", "/calendars/events/appointments", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: {
|
||||
// `locationId` va en el POST y ROMPE el PUT con 422. No reciclar el cuerpo
|
||||
// del alta para actualizar: es la misma trampa ya medida en contactos.
|
||||
locationId: ctx.locationId,
|
||||
calendarId: a.calendarId,
|
||||
contactId: a.contactId,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
title: a.title,
|
||||
appointmentStatus: a.appointmentStatus ?? "confirmed",
|
||||
...(a.assignedUserId ? { assignedUserId: a.assignedUserId } : {}),
|
||||
// La plataforma ya avisó a la clienta: que el CRM no dispare además sus
|
||||
// automatizaciones y le llegue el mismo aviso dos veces.
|
||||
toNotify: false,
|
||||
// AgendaMax es la fuente de verdad del horario, y su base ya impide el
|
||||
// solape con una restricción de exclusión. Que el CRM no rechace por su
|
||||
// propia idea de disponibilidad, que no conoce la agenda real.
|
||||
ignoreFreeSlotValidation: true,
|
||||
},
|
||||
});
|
||||
const id = r?.id ?? r?.event?.id ?? r?.appointment?.id;
|
||||
if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador");
|
||||
return { id };
|
||||
}
|
||||
|
||||
/** Actualiza una cita ya escrita. Sin `locationId` ni `contactId`: el PUT los rechaza. */
|
||||
export async function actualizarCita(
|
||||
ctx: CrmCtx,
|
||||
eventId: string,
|
||||
cambios: Partial<Omit<AltaCita, "contactId">>
|
||||
): Promise<void> {
|
||||
await crmRequest("PUT", `/calendars/events/appointments/${eventId}`, {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: {
|
||||
...(cambios.calendarId ? { calendarId: cambios.calendarId } : {}),
|
||||
...(cambios.startTime ? { startTime: cambios.startTime } : {}),
|
||||
...(cambios.endTime ? { endTime: cambios.endTime } : {}),
|
||||
...(cambios.title ? { title: cambios.title } : {}),
|
||||
...(cambios.appointmentStatus
|
||||
? { appointmentStatus: cambios.appointmentStatus }
|
||||
: {}),
|
||||
toNotify: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
esperaDeToken,
|
||||
registrarPeticion,
|
||||
MIN_INTERVAL_MS,
|
||||
anotarLimites,
|
||||
limitesDe,
|
||||
intervaloDe,
|
||||
} from "./client.ts";
|
||||
|
||||
test("el estrangulador cuenta por token, no globalmente", () => {
|
||||
const ahora = 1_000_000;
|
||||
registrarPeticion("token-A", ahora);
|
||||
// El mismo token tiene que esperar…
|
||||
assert.ok(esperaDeToken("token-A", ahora + 10) > 0);
|
||||
// …pero otro token no espera nada: su límite es independiente.
|
||||
assert.equal(esperaDeToken("token-B", ahora + 10), 0);
|
||||
});
|
||||
|
||||
test("pasado el intervalo, el mismo token deja de esperar", () => {
|
||||
const ahora = 2_000_000;
|
||||
registrarPeticion("token-C", ahora);
|
||||
assert.equal(esperaDeToken("token-C", ahora + MIN_INTERVAL_MS + 1), 0);
|
||||
});
|
||||
|
||||
test("sin cabeceras se usa el intervalo conservador por defecto", () => {
|
||||
assert.equal(intervaloDe("token-sin-datos"), MIN_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("las cabeceras del CRM mandan sobre el valor por defecto", () => {
|
||||
// MEDIDO (hallazgo 37): la cuota real de la subcuenta.
|
||||
anotarLimites(
|
||||
"token-D",
|
||||
new Headers({
|
||||
"x-ratelimit-max": "100",
|
||||
"x-ratelimit-interval-milliseconds": "10000",
|
||||
"x-ratelimit-remaining": "94",
|
||||
"x-ratelimit-daily-remaining": "199970",
|
||||
})
|
||||
);
|
||||
const l = limitesDe("token-D");
|
||||
assert.equal(l?.max, 100);
|
||||
assert.equal(l?.ventanaMs, 10000);
|
||||
assert.equal(l?.diarioRestante, 199970);
|
||||
// 10000/100 = 100 ms teóricos, con 50 % de margen = 150
|
||||
assert.equal(intervaloDe("token-D"), 150);
|
||||
});
|
||||
|
||||
test("con la ventana casi agotada se espacia más, para no comerse un 429", () => {
|
||||
anotarLimites(
|
||||
"token-E",
|
||||
new Headers({
|
||||
"x-ratelimit-max": "100",
|
||||
"x-ratelimit-interval-milliseconds": "10000",
|
||||
"x-ratelimit-remaining": "3",
|
||||
})
|
||||
);
|
||||
assert.ok(intervaloDe("token-E") > 150);
|
||||
});
|
||||
|
||||
test("una respuesta sin cabeceras de límite no borra lo que ya se sabía", () => {
|
||||
anotarLimites(
|
||||
"token-F",
|
||||
new Headers({ "x-ratelimit-max": "100", "x-ratelimit-interval-milliseconds": "10000" })
|
||||
);
|
||||
anotarLimites("token-F", new Headers({}));
|
||||
assert.equal(limitesDe("token-F")?.max, 100);
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
|
||||
const BASE_URL_DEFAULT = "https://services.leadconnectorhq.com";
|
||||
|
||||
/**
|
||||
* La cabecera `Version` no es opcional y no es una sola: la familia de
|
||||
* calendarios exige `v3` y el resto `2021-07-28`. Omitirla o equivocarla es un
|
||||
* 400, y es el error más fácil de cometer al reciclar código entre dominios.
|
||||
*/
|
||||
export const VERSION_DEFAULT = "2021-07-28";
|
||||
export const VERSION_CALENDARS = "v3";
|
||||
|
||||
/**
|
||||
* Intervalo conservador mientras el CRM no diga su cuota real.
|
||||
*
|
||||
* MEDIDO (hallazgo 37): las cabeceras `x-ratelimit-*` declaran 100 peticiones
|
||||
* por 10 s, o sea 1 cada 100 ms — 6,5 veces más de lo que este valor asume. Se
|
||||
* mantiene como respaldo para la primera petición de un token, antes de haber
|
||||
* visto ninguna cabecera; a partir de ahí manda `intervaloDe()`.
|
||||
*/
|
||||
export const MIN_INTERVAL_MS = 650;
|
||||
/** Margen sobre la cuota declarada: no se corre al límite exacto. */
|
||||
const MARGEN = 1.5;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export interface Limites {
|
||||
max: number;
|
||||
ventanaMs: number;
|
||||
restantes: number;
|
||||
diarioRestante: number | null;
|
||||
}
|
||||
|
||||
const limitesPorToken = new Map<string, Limites>();
|
||||
|
||||
/** Registra lo que el CRM dice de su propia cuota. Una respuesta sin cabeceras
|
||||
* no borra lo ya sabido: no todas las rutas las devuelven. */
|
||||
export function anotarLimites(token: string, h: Headers): void {
|
||||
const max = Number(h.get("x-ratelimit-max"));
|
||||
const ventanaMs = Number(h.get("x-ratelimit-interval-milliseconds"));
|
||||
if (!max || !ventanaMs) return;
|
||||
limitesPorToken.set(token, {
|
||||
max,
|
||||
ventanaMs,
|
||||
restantes: Number(h.get("x-ratelimit-remaining") ?? max),
|
||||
diarioRestante: h.get("x-ratelimit-daily-remaining")
|
||||
? Number(h.get("x-ratelimit-daily-remaining"))
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function limitesDe(token: string): Limites | null {
|
||||
return limitesPorToken.get(token) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuánto esperar entre peticiones de ESTE token.
|
||||
*
|
||||
* Se toma la cuota que el CRM declara, con un 50 % de margen y no al límite
|
||||
* exacto: el worker de la bandeja y una sincronización manual pueden coincidir.
|
||||
* Si la ventana está casi agotada se espacia hasta que se renueve, que sale más
|
||||
* barato que comerse un 429 y su espera lineal de 5, 10 y 15 s.
|
||||
*/
|
||||
export function intervaloDe(token: string): number {
|
||||
const l = limitesPorToken.get(token);
|
||||
if (!l) return MIN_INTERVAL_MS;
|
||||
const base = Math.ceil((l.ventanaMs / l.max) * MARGEN);
|
||||
if (l.restantes <= 5) {
|
||||
return Math.max(base, Math.ceil(l.ventanaMs / Math.max(1, l.restantes)));
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export class CrmError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
readonly body?: unknown
|
||||
) {
|
||||
super(`CRM ${status}: ${message}`);
|
||||
this.name = "CrmError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un fallo de transporte no es una respuesta: el servidor no habló, así que no
|
||||
* se sabe si la escritura entró. Reenviarlo es fabricar la doble creación. Se
|
||||
* marca aparte para que la bandeja de salida lo deje en `indeterminado` y lo
|
||||
* resuelva **leyendo**, nunca reintentando.
|
||||
*/
|
||||
export class CrmTransportError extends Error {
|
||||
readonly indeterminate = true;
|
||||
constructor(message: string) {
|
||||
super(`CRM sin respuesta: ${message}`);
|
||||
this.name = "CrmTransportError";
|
||||
}
|
||||
}
|
||||
|
||||
// Un reloj por token, no uno global: el límite del CRM es por credencial, así
|
||||
// que un semáforo único serializaría negocios que pueden ir en paralelo. Con
|
||||
// diez cuentas, la décima esperaría a las nueve anteriores sin ninguna razón.
|
||||
const ultimaPeticionPorToken = new Map<string, number>();
|
||||
|
||||
/** Milisegundos que este token debe esperar antes de su próxima petición. */
|
||||
export function esperaDeToken(token: string, ahora = Date.now()): number {
|
||||
const ultima = ultimaPeticionPorToken.get(token) ?? 0;
|
||||
return Math.max(0, intervaloDe(token) - (ahora - ultima));
|
||||
}
|
||||
|
||||
export function registrarPeticion(token: string, ahora = Date.now()): void {
|
||||
ultimaPeticionPorToken.set(token, ahora);
|
||||
}
|
||||
|
||||
async function throttle(token: string) {
|
||||
const espera = esperaDeToken(token);
|
||||
if (espera > 0) await new Promise((r) => setTimeout(r, espera));
|
||||
registrarPeticion(token);
|
||||
}
|
||||
|
||||
export interface CrmOptions {
|
||||
/**
|
||||
* Token privado de la subcuenta. **Obligatorio y sin valor por defecto.**
|
||||
*
|
||||
* Antes caía a `requireEnv("CRM_TOKEN")`, una variable global del proceso: con
|
||||
* dos negocios, olvidar el token no daba error — usaba el del primero contra
|
||||
* la subcuenta del segundo. Al hacerlo obligatorio, ese olvido pasa a ser un
|
||||
* error de compilación, que es el gate real de calidad de este repo.
|
||||
*
|
||||
* Sale siempre de `CrmCtx.token` (ver platform/crm/ctx.ts).
|
||||
*/
|
||||
token: string;
|
||||
body?: unknown;
|
||||
version?: string;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
export async function crmRequest<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: CrmOptions
|
||||
): Promise<T> {
|
||||
loadEnv();
|
||||
const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT;
|
||||
const token = opts.token;
|
||||
|
||||
let url = `${base}${path}`;
|
||||
if (opts.query) {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query)) {
|
||||
if (v !== undefined) q.set(k, String(v));
|
||||
}
|
||||
const s = q.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
|
||||
const version =
|
||||
opts.version ?? (path.startsWith("/calendars/") ? VERSION_CALENDARS : VERSION_DEFAULT);
|
||||
|
||||
let intento = 0;
|
||||
for (;;) {
|
||||
await throttle(token);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
version,
|
||||
accept: "application/json",
|
||||
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Timeout / conexión caída: no hubo respuesta. Se reintenta el transporte
|
||||
// solo en GET, que es idempotente por naturaleza; en escrituras se
|
||||
// propaga para que arriba se resuelva leyendo.
|
||||
if (method === "GET" && intento < MAX_RETRIES) {
|
||||
await new Promise((r) => setTimeout(r, 2000 * 2 ** intento));
|
||||
intento++;
|
||||
continue;
|
||||
}
|
||||
throw new CrmTransportError(`${e?.name ?? "Error"}: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
// El CRM declara su propia cuota en cada respuesta. Leerla es la única
|
||||
// forma de no ir a ciegas: el intervalo por defecto era una estimación.
|
||||
anotarLimites(token, res.headers);
|
||||
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
if (intento < MAX_RETRIES) {
|
||||
// 429 lineal (5/10/15 s), 5xx exponencial: es la política ya medida en
|
||||
// el proyecto hermano.
|
||||
const espera = res.status === 429 ? 5000 * (intento + 1) : 2000 * 2 ** intento;
|
||||
await new Promise((r) => setTimeout(r, espera));
|
||||
intento++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const texto = await res.text();
|
||||
let cuerpo: any = null;
|
||||
try {
|
||||
cuerpo = texto ? JSON.parse(texto) : null;
|
||||
} catch {
|
||||
cuerpo = texto;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// Rotar el token es trabajo humano: no se reintenta y se dice claro.
|
||||
throw new CrmError(401, "Token rechazado — hay que regenerarlo en el CRM", cuerpo);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(Array.isArray(cuerpo?.message) ? cuerpo.message.join("; ") : cuerpo?.message) ||
|
||||
cuerpo?.error ||
|
||||
res.statusText;
|
||||
throw new CrmError(res.status, String(msg), cuerpo);
|
||||
}
|
||||
return cuerpo as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { crmRequest } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
import type { EtapasPipeline } from "./opportunities.ts";
|
||||
|
||||
export interface CrmConnection {
|
||||
id: number;
|
||||
business_id: number;
|
||||
location_id: string;
|
||||
pipeline_id: string | null;
|
||||
stage_open_id: string | null;
|
||||
stage_won_id: string | null;
|
||||
stage_lost_id: string | null;
|
||||
allow_duplicate_opp: boolean;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string | null;
|
||||
}
|
||||
|
||||
export async function obtenerConexion(businessId: number): Promise<CrmConnection | null> {
|
||||
const { rows } = await pool.query<CrmConnection>(
|
||||
`SELECT * FROM crm_connections WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export function etapasDe(c: CrmConnection): EtapasPipeline {
|
||||
if (!c.pipeline_id) throw new Error("La conexión con el CRM no tiene pipeline configurado");
|
||||
return {
|
||||
pipelineId: c.pipeline_id,
|
||||
open: c.stage_open_id,
|
||||
won: c.stage_won_id,
|
||||
lost: c.stage_lost_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta el pipeline y las etapas de la subcuenta y las guarda.
|
||||
*
|
||||
* Las etapas se eligen por nombre porque sus identificadores son opacos y
|
||||
* distintos en cada subcuenta. Se busca «ganado» y «perdido»; si no aparecen,
|
||||
* se cae a la primera y la última por posición, que es lo que un embudo suele
|
||||
* significar. Ese respaldo se registra en `last_sync_status` para que no pase
|
||||
* inadvertido.
|
||||
*/
|
||||
export async function autoconfigurar(ctx: CrmCtx): Promise<CrmConnection> {
|
||||
const r = await crmRequest<any>("GET", "/opportunities/pipelines", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
});
|
||||
const pipelines: any[] = r?.pipelines ?? [];
|
||||
if (!pipelines.length) {
|
||||
throw new Error("La subcuenta del CRM no tiene ningún pipeline");
|
||||
}
|
||||
const pipe = pipelines[0];
|
||||
const stages: any[] = [...(pipe.stages ?? [])].sort(
|
||||
(a, b) => (a.position ?? 0) - (b.position ?? 0)
|
||||
);
|
||||
|
||||
const porNombre = (...palabras: string[]) =>
|
||||
stages.find((s) => {
|
||||
const n = String(s.name ?? "").toLowerCase();
|
||||
return palabras.some((p) => n.includes(p));
|
||||
})?.id ?? null;
|
||||
|
||||
const won = porNombre("ganado", "won", "asisti", "complet");
|
||||
const lost = porNombre("perdido", "lost", "cancel", "no asis");
|
||||
const open = stages[0]?.id ?? null;
|
||||
|
||||
// MEDIDO: la subcuenta expone el ajuste que decide si una cita puede estrenar
|
||||
// su propia oportunidad o hay que reciclar la de la clienta.
|
||||
let permiteDuplicados = false;
|
||||
try {
|
||||
const loc = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, {
|
||||
token: ctx.token,
|
||||
});
|
||||
permiteDuplicados = Boolean(loc?.location?.settings?.allowDuplicateOpportunity);
|
||||
} catch {
|
||||
// Si no se puede leer, se asume el caso restrictivo: reciclar nunca rompe,
|
||||
// crear a ciegas sí.
|
||||
}
|
||||
|
||||
const nota =
|
||||
won && lost
|
||||
? `pipeline «${pipe.name}»; etapas detectadas por nombre`
|
||||
: `pipeline «${pipe.name}»; OJO: no se hallaron etapas de ganado/perdido por nombre`;
|
||||
|
||||
// `location_id` NO se escribe aquí: lo puso `guardarCredencial` junto al token,
|
||||
// y son la misma decisión. Escribirlo desde dos sitios permite que se separen.
|
||||
const { rows } = await pool.query<CrmConnection>(
|
||||
`INSERT INTO crm_connections
|
||||
(business_id, location_id, pipeline_id, stage_open_id, stage_won_id, stage_lost_id,
|
||||
allow_duplicate_opp, last_sync_status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (business_id) DO UPDATE SET
|
||||
pipeline_id = EXCLUDED.pipeline_id,
|
||||
stage_open_id = EXCLUDED.stage_open_id,
|
||||
stage_won_id = EXCLUDED.stage_won_id,
|
||||
stage_lost_id = EXCLUDED.stage_lost_id,
|
||||
allow_duplicate_opp = EXCLUDED.allow_duplicate_opp,
|
||||
last_sync_status = EXCLUDED.last_sync_status
|
||||
RETURNING *`,
|
||||
[ctx.businessId, ctx.locationId, pipe.id, open, won, lost, permiteDuplicados, nota]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapAtribucion, nombreDe } from "./contacts.ts";
|
||||
|
||||
test("aplana la atribución real que devuelve el CRM", () => {
|
||||
// Este objeto es una captura literal de un contacto real de la subcuenta.
|
||||
const a = mapAtribucion({
|
||||
id: "x",
|
||||
source: "WhatsApp",
|
||||
attributionSource: {
|
||||
sessionSource: "Paid Social",
|
||||
medium: "instagram",
|
||||
mediumId: "28379457221686971",
|
||||
campaign: "Servicios-Interaccion-WhatsApp",
|
||||
utmMedium: "Uñas-Interaccion-WA",
|
||||
utmContent: "Post - Uñas - WA",
|
||||
campaignId: "120249003827580681",
|
||||
adId: "120249003827530681",
|
||||
},
|
||||
});
|
||||
assert.equal(a.crm_source, "WhatsApp");
|
||||
assert.equal(a.attr_session_source, "Paid Social");
|
||||
assert.equal(a.attr_medium, "instagram");
|
||||
assert.equal(a.attr_campaign, "Servicios-Interaccion-WhatsApp");
|
||||
assert.equal(a.attr_campaign_id, "120249003827580681");
|
||||
assert.equal(a.attr_ad_id, "120249003827530681");
|
||||
});
|
||||
|
||||
test("«campaign» gana a «utmCampaign»", () => {
|
||||
// No es un capricho de orden: el buscador de contactos del CRM descarta
|
||||
// `utmCampaign` y conserva `campaign`. Leerlos al revés deja la campaña
|
||||
// vacía en la mitad de los contactos.
|
||||
const a = mapAtribucion({
|
||||
id: "x",
|
||||
attributionSource: { campaign: "la_buena", utmCampaign: "la_descartada" },
|
||||
});
|
||||
assert.equal(a.attr_campaign, "la_buena");
|
||||
});
|
||||
|
||||
test("cae a utmCampaign cuando campaign no viene", () => {
|
||||
const a = mapAtribucion({ id: "x", attributionSource: { utmCampaign: "solo_esta" } });
|
||||
assert.equal(a.attr_campaign, "solo_esta");
|
||||
});
|
||||
|
||||
test("una atribución vacía no inventa valores", () => {
|
||||
const a = mapAtribucion({ id: "x" });
|
||||
assert.equal(a.attr_campaign, null);
|
||||
assert.equal(a.attr_session_source, null);
|
||||
assert.equal(a.crm_source, null);
|
||||
});
|
||||
|
||||
test("las cadenas vacías cuentan como ausencia, no como valor", () => {
|
||||
const a = mapAtribucion({ id: "x", attributionSource: { campaign: "", utmCampaign: "buena" } });
|
||||
assert.equal(a.attr_campaign, "buena");
|
||||
});
|
||||
|
||||
test("el nombre sale de los tres orígenes que trae el CRM, en orden", () => {
|
||||
assert.equal(nombreDe({ id: "1", firstName: "Ana", lastName: "Ruiz" }), "Ana Ruiz");
|
||||
assert.equal(nombreDe({ id: "2", firstName: "Ana" }), "Ana");
|
||||
assert.equal(nombreDe({ id: "3", contactName: "Ana R." }), "Ana R.");
|
||||
assert.equal(nombreDe({ id: "4", email: "[email protected]" }), "[email protected]");
|
||||
assert.equal(nombreDe({ id: "5" }), "Sin nombre");
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
|
||||
/** Lo que el CRM devuelve de un contacto, en la forma que nos interesa. */
|
||||
export interface CrmContact {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
contactName?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
source?: string | null;
|
||||
tags?: string[] | null;
|
||||
dateAdded?: string | null;
|
||||
dateOfBirth?: string | null;
|
||||
attributionSource?: Record<string, string | null> | null;
|
||||
customFields?: { id: string; value: unknown }[] | null;
|
||||
}
|
||||
|
||||
/** La atribución, aplanada a las columnas de `clients`. */
|
||||
export interface Atribucion {
|
||||
crm_source: string | null;
|
||||
attr_session_source: string | null;
|
||||
attr_medium: string | null;
|
||||
attr_campaign: string | null;
|
||||
attr_campaign_id: string | null;
|
||||
attr_utm_source: string | null;
|
||||
attr_utm_medium: string | null;
|
||||
attr_utm_content: string | null;
|
||||
attr_ad_id: string | null;
|
||||
attr_referrer: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplana `attributionSource`.
|
||||
*
|
||||
* `campaign` se lee ANTES que `utmCampaign` a propósito: el buscador de
|
||||
* contactos del CRM descarta `utmCampaign` y conserva `campaign`, así que
|
||||
* leerlos al revés deja la campaña vacía en la mitad de los contactos.
|
||||
*/
|
||||
export function mapAtribucion(c: CrmContact): Atribucion {
|
||||
const a = c.attributionSource ?? {};
|
||||
const g = (...claves: string[]) => {
|
||||
for (const k of claves) {
|
||||
const v = (a as any)[k];
|
||||
if (v !== undefined && v !== null && v !== "") return String(v);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
crm_source: c.source ?? null,
|
||||
attr_session_source: g("sessionSource"),
|
||||
attr_medium: g("medium"),
|
||||
attr_campaign: g("campaign", "utmCampaign"),
|
||||
attr_campaign_id: g("campaignId"),
|
||||
attr_utm_source: g("utmSource"),
|
||||
attr_utm_medium: g("utmMedium"),
|
||||
attr_utm_content: g("utmContent"),
|
||||
attr_ad_id: g("adId"),
|
||||
attr_referrer: g("referrer", "url"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Nombre presentable, con los tres orígenes que trae el CRM. */
|
||||
export function nombreDe(c: CrmContact): string {
|
||||
const compuesto = [c.firstName, c.lastName].filter(Boolean).join(" ").trim();
|
||||
return compuesto || (c.contactName ?? "").trim() || (c.email ?? "").trim() || "Sin nombre";
|
||||
}
|
||||
|
||||
/** Una página del buscador de contactos. */
|
||||
export interface PaginaContactos {
|
||||
contacts: CrmContact[];
|
||||
total: number;
|
||||
searchAfter?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorre los contactos de la subcuenta.
|
||||
*
|
||||
* Pagina con `searchAfter` y no con `page`: el buscador tiene un techo de
|
||||
* profundidad por número de página, y con 3 209 contactos se alcanza. El cursor
|
||||
* sale del ÚLTIMO contacto de la página anterior.
|
||||
*/
|
||||
export async function buscarContactos(
|
||||
ctx: CrmCtx,
|
||||
opts: { pageLimit?: number; searchAfter?: unknown } = {}
|
||||
): Promise<PaginaContactos> {
|
||||
const body: Record<string, unknown> = {
|
||||
locationId: ctx.locationId,
|
||||
pageLimit: opts.pageLimit ?? 100,
|
||||
};
|
||||
if (opts.searchAfter) body.searchAfter = opts.searchAfter;
|
||||
|
||||
const r = await crmRequest<any>("POST", "/contacts/search", { token: ctx.token, body });
|
||||
return {
|
||||
contacts: r?.contacts ?? [],
|
||||
total: r?.total ?? 0,
|
||||
searchAfter: r?.contacts?.length ? r.contacts[r.contacts.length - 1]?.searchAfter : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function obtenerContacto(ctx: CrmCtx, id: string): Promise<CrmContact | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: ctx.token });
|
||||
return r?.contact ?? null;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Busca por un identificador natural. El CRM deduplica por email y teléfono. */
|
||||
export async function buscarPorIdentificador(
|
||||
ctx: CrmCtx,
|
||||
q: string
|
||||
): Promise<CrmContact | null> {
|
||||
const r = await crmRequest<any>("GET", "/contacts/", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId, query: q, limit: 5 },
|
||||
});
|
||||
return r?.contacts?.[0] ?? null;
|
||||
}
|
||||
|
||||
export interface AltaContacto {
|
||||
locationId: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
name?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
source?: string;
|
||||
tags?: string[];
|
||||
attribution?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ResultadoResolucion {
|
||||
contact: CrmContact;
|
||||
/** Cómo se llegó a él: importa para la auditoría y para depurar duplicados. */
|
||||
via: "crm_id" | "telefono" | "correo" | "creado" | "duplicado_400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve el contacto en el CRM siguiendo la cadena de identidad acordada:
|
||||
* **id de contacto → teléfono → correo**, y si no existe, lo crea.
|
||||
*
|
||||
* Es la misma cadena que el CRM aplica por su cuenta
|
||||
* (`contactUniqueIdentifiers: ["email","phone"]`), así que las dos coinciden y
|
||||
* no se pelean.
|
||||
*
|
||||
* El caso interesante es el último: si el alta choca con un duplicado, el CRM
|
||||
* responde `400` **con el `contactId` existente en `meta`**. Eso es idempotencia
|
||||
* de verdad, regalada por el servidor, y es mejor que `upsert` — cuya rama
|
||||
* *actualizar* descarta la atribución en silencio.
|
||||
*/
|
||||
export async function resolverContacto(
|
||||
ctx: CrmCtx,
|
||||
datos: {
|
||||
crmContactId?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
name: string;
|
||||
source?: string;
|
||||
tags?: string[];
|
||||
attribution?: Record<string, string | undefined>;
|
||||
}
|
||||
): Promise<ResultadoResolucion> {
|
||||
// 1. Por id del CRM, si ya lo teníamos anclado.
|
||||
if (datos.crmContactId) {
|
||||
const c = await obtenerContacto(ctx, datos.crmContactId);
|
||||
if (c) return { contact: c, via: "crm_id" };
|
||||
// El id guardado ya no resuelve: el contacto se borró en el CRM. Se sigue
|
||||
// por los fallbacks en vez de fallar.
|
||||
}
|
||||
|
||||
// 2. Por teléfono normalizado.
|
||||
const tel = normalizePhone(datos.phone);
|
||||
if (tel) {
|
||||
const c = await buscarPorIdentificador(ctx, tel);
|
||||
if (c) return { contact: c, via: "telefono" };
|
||||
}
|
||||
|
||||
// 3. Por correo.
|
||||
if (datos.email) {
|
||||
const c = await buscarPorIdentificador(ctx, datos.email);
|
||||
if (c) return { contact: c, via: "correo" };
|
||||
}
|
||||
|
||||
// 4. Crear. La atribución solo entra AQUÍ: después es inmutable.
|
||||
const partes = datos.name.trim().split(/\s+/);
|
||||
const body: Record<string, unknown> = {
|
||||
// MEDIDO: `locationId` va en el POST de alta y ROMPE el PUT con
|
||||
// `422 property locationId should not exist`. No reciclar este cuerpo.
|
||||
locationId: ctx.locationId,
|
||||
firstName: partes[0] || datos.name,
|
||||
lastName: partes.slice(1).join(" ") || undefined,
|
||||
country: "MX",
|
||||
source: datos.source ?? "AgendaMax",
|
||||
};
|
||||
if (tel) body.phone = tel;
|
||||
if (datos.email) body.email = datos.email;
|
||||
if (datos.tags?.length) body.tags = datos.tags;
|
||||
if (datos.attribution && Object.keys(datos.attribution).length) {
|
||||
body.attributionSource = datos.attribution;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await crmRequest<any>("POST", "/contacts/", { token: ctx.token, body });
|
||||
return { contact: r.contact, via: "creado" };
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 400) {
|
||||
const existente = (e.body as any)?.meta?.contactId;
|
||||
if (existente) {
|
||||
const c = await obtenerContacto(ctx, existente);
|
||||
if (c) return { contact: c, via: "duplicado_400" };
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizarTipo, formaDeMensajes } from "./conversations.ts";
|
||||
|
||||
test("normalizarTipo: la API devuelve número o cadena según el endpoint", () => {
|
||||
// MEDIDO (hallazgo 24): `/conversations/{id}` da un número y el buscador una
|
||||
// cadena `TYPE_SMS`. Es la misma información con dos formas.
|
||||
assert.equal(normalizarTipo("TYPE_SMS"), "SMS");
|
||||
assert.equal(normalizarTipo("TYPE_EMAIL"), "Email");
|
||||
assert.equal(normalizarTipo(1), "Phone");
|
||||
assert.equal(normalizarTipo(2), "Email");
|
||||
assert.equal(normalizarTipo(3), "FB");
|
||||
assert.equal(normalizarTipo(undefined), "Desconocido");
|
||||
assert.equal(normalizarTipo(""), "Desconocido");
|
||||
});
|
||||
|
||||
test("normalizarTipo: un tipo desconocido no se traga, se ve", () => {
|
||||
assert.equal(normalizarTipo("TYPE_TIKTOK"), "TIKTOK");
|
||||
assert.equal(normalizarTipo(99), "Desconocido");
|
||||
});
|
||||
|
||||
test("formaDeMensajes: desanida la respuesta real, que trae messages.messages", () => {
|
||||
const r = formaDeMensajes({
|
||||
messages: { messages: [{ id: "m1" }], lastMessageId: "m1", nextPage: true },
|
||||
});
|
||||
assert.equal(r.mensajes.length, 1);
|
||||
assert.equal(r.lastMessageId, "m1");
|
||||
assert.equal(r.hayMas, true);
|
||||
});
|
||||
|
||||
test("formaDeMensajes: tolera la forma plana por si la API cambia", () => {
|
||||
const r = formaDeMensajes({ messages: [{ id: "m1" }] });
|
||||
assert.equal(r.mensajes.length, 1);
|
||||
assert.equal(r.hayMas, false);
|
||||
assert.equal(r.lastMessageId, null);
|
||||
});
|
||||
|
||||
test("formaDeMensajes: una respuesta vacía no revienta", () => {
|
||||
const r = formaDeMensajes({});
|
||||
assert.deepEqual(r.mensajes, []);
|
||||
assert.equal(r.hayMas, false);
|
||||
});
|
||||
|
||||
test("normalizarTipo reconoce los canales reales de la subcuenta", () => {
|
||||
// MEDIDO: los hilos reales traen TYPE_INSTAGRAM y TYPE_ACTIVITY_OPPORTUNITY.
|
||||
assert.equal(normalizarTipo("TYPE_INSTAGRAM"), "Instagram");
|
||||
assert.equal(normalizarTipo("TYPE_ACTIVITY_OPPORTUNITY"), "Actividad");
|
||||
assert.equal(normalizarTipo("TYPE_WHATSAPP"), "WhatsApp");
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
export interface CrmConversation {
|
||||
id: string;
|
||||
contactId?: string;
|
||||
fullName?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
lastMessageBody?: string;
|
||||
lastMessageType?: string;
|
||||
lastMessageDate?: string | number;
|
||||
unreadCount?: number;
|
||||
type?: string | number;
|
||||
}
|
||||
|
||||
export interface CrmMessage {
|
||||
id: string;
|
||||
body?: string;
|
||||
direction?: "inbound" | "outbound";
|
||||
messageType?: string;
|
||||
status?: string | null;
|
||||
dateAdded?: string;
|
||||
contactId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* El canal llega como cadena (`TYPE_SMS`) desde el buscador y como número desde
|
||||
* `GET /conversations/{id}`.
|
||||
*
|
||||
* MEDIDO (hallazgo 24). Es la misma información con dos formas, y mezclarlas
|
||||
* produce una bandeja que etiqueta mal los hilos. Un tipo que no se reconozca se
|
||||
* deja pasar tal cual en vez de esconderlo: si el CRM añade un canal, se verá.
|
||||
*/
|
||||
const POR_NUMERO: Record<number, string> = {
|
||||
1: "Phone",
|
||||
2: "Email",
|
||||
3: "FB",
|
||||
4: "Review",
|
||||
5: "SMS",
|
||||
};
|
||||
|
||||
/** Los canales conocidos se rinden con su nombre propio; el resto pasa tal cual. */
|
||||
const POR_NOMBRE: Record<string, string> = {
|
||||
SMS: "SMS",
|
||||
EMAIL: "Email",
|
||||
CALL: "Llamada",
|
||||
VOICEMAIL: "Buzón de voz",
|
||||
WHATSAPP: "WhatsApp",
|
||||
FB: "Facebook",
|
||||
IG: "Instagram",
|
||||
INSTAGRAM: "Instagram",
|
||||
FACEBOOK: "Facebook",
|
||||
GMB: "Google Business",
|
||||
WEBCHAT: "Chat web",
|
||||
// Los TYPE_ACTIVITY_* no son mensajes de la clienta: son notas que el propio
|
||||
// CRM escribe en el hilo cuando pasa algo (se creó una oportunidad, se agendó
|
||||
// una cita). Se etiquetan como actividad para poder distinguirlos en la
|
||||
// bandeja en vez de mostrarlos como si alguien los hubiera escrito.
|
||||
ACTIVITY_OPPORTUNITY: "Actividad",
|
||||
ACTIVITY_APPOINTMENT: "Actividad",
|
||||
ACTIVITY_CONTACT: "Actividad",
|
||||
ACTIVITY: "Actividad",
|
||||
REVIEW: "Reseña",
|
||||
LIVE_CHAT: "Chat en vivo",
|
||||
CUSTOM: "Otro",
|
||||
};
|
||||
|
||||
export function normalizarTipo(t: string | number | undefined | null): string {
|
||||
if (typeof t === "number") return POR_NUMERO[t] ?? "Desconocido";
|
||||
if (typeof t === "string" && t) {
|
||||
const crudo = t.replace(/^TYPE_/, "");
|
||||
return POR_NOMBRE[crudo] ?? crudo.replace(/_/g, " ");
|
||||
}
|
||||
return "Desconocido";
|
||||
}
|
||||
|
||||
/**
|
||||
* MEDIDO (hallazgo 26): la respuesta real es `{ messages: { messages: [...],
|
||||
* lastMessageId, nextPage } }` — anidada dos niveles.
|
||||
*
|
||||
* Esta es la TERCERA convención de paginación de la misma API: contactos usan
|
||||
* `searchAfter`, conversaciones `startAfterDate`, y los mensajes `lastMessageId`
|
||||
* con un booleano `nextPage`. Reciclar una por otra devuelve listas incompletas
|
||||
* sin dar ningún error.
|
||||
*/
|
||||
export function formaDeMensajes(r: any): {
|
||||
mensajes: CrmMessage[];
|
||||
lastMessageId: string | null;
|
||||
hayMas: boolean;
|
||||
} {
|
||||
const anidado = r?.messages?.messages;
|
||||
if (Array.isArray(anidado)) {
|
||||
return {
|
||||
mensajes: anidado,
|
||||
lastMessageId: r.messages.lastMessageId ?? null,
|
||||
hayMas: Boolean(r.messages.nextPage),
|
||||
};
|
||||
}
|
||||
const plano = Array.isArray(r?.messages) ? r.messages : [];
|
||||
return { mensajes: plano, lastMessageId: null, hayMas: false };
|
||||
}
|
||||
|
||||
/** Una conversación por su id. MEDIDO: los campos vienen en la raíz, sin envoltorio. */
|
||||
export async function obtenerConversacion(
|
||||
ctx: CrmCtx,
|
||||
id: string
|
||||
): Promise<CrmConversation | null> {
|
||||
try {
|
||||
return await crmRequest<CrmConversation>("GET", `/conversations/${id}`, {
|
||||
token: ctx.token,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Las conversaciones de un contacto. MEDIDO (hallazgo 25): `contactId` es filtro. */
|
||||
export async function conversacionesDeContacto(
|
||||
ctx: CrmCtx,
|
||||
contactId: string
|
||||
): Promise<CrmConversation[]> {
|
||||
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId, contactId, limit: 50 },
|
||||
});
|
||||
return r?.conversations ?? [];
|
||||
}
|
||||
|
||||
export async function buscarConversaciones(
|
||||
ctx: CrmCtx,
|
||||
opts: { limit?: number; startAfterDate?: number } = {}
|
||||
): Promise<{ conversations: CrmConversation[]; total: number }> {
|
||||
const r = await crmRequest<any>("GET", "/conversations/search", {
|
||||
token: ctx.token,
|
||||
query: {
|
||||
locationId: ctx.locationId,
|
||||
limit: opts.limit ?? 20,
|
||||
sortBy: "last_message_date",
|
||||
sort: "desc",
|
||||
startAfterDate: opts.startAfterDate,
|
||||
},
|
||||
});
|
||||
return { conversations: r?.conversations ?? [], total: r?.total ?? 0 };
|
||||
}
|
||||
|
||||
export async function mensajesDeConversacion(
|
||||
ctx: CrmCtx,
|
||||
conversationId: string,
|
||||
opts: { limit?: number; lastMessageId?: string } = {}
|
||||
) {
|
||||
const r = await crmRequest<any>("GET", `/conversations/${conversationId}/messages`, {
|
||||
token: ctx.token,
|
||||
query: { limit: opts.limit ?? 50, lastMessageId: opts.lastMessageId },
|
||||
});
|
||||
return formaDeMensajes(r);
|
||||
}
|
||||
|
||||
/** Un mensaje suelto por su id. MEDIDO (hallazgo 27): funciona y viene en la raíz. */
|
||||
export async function obtenerMensaje(ctx: CrmCtx, id: string): Promise<CrmMessage | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/conversations/messages/${id}`, {
|
||||
token: ctx.token,
|
||||
});
|
||||
return (r?.message ?? r) as CrmMessage;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { cifrar, descifrar, huella } from "../lib/crypto.ts";
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
|
||||
/**
|
||||
* Todo lo que hace falta para hablar con la subcuenta de UN negocio.
|
||||
*
|
||||
* Sustituye al `locationId: string` suelto que antes viajaba por once firmas.
|
||||
* Es un objeto y no dos parámetros a propósito: dos `string` seguidos se pueden
|
||||
* cruzar sin que el compilador diga nada, y cruzarlos aquí significa mandar el
|
||||
* token de un cliente a la subcuenta de otro.
|
||||
*
|
||||
* Es el ÚNICO sitio del código donde el token existe descifrado, y solo en
|
||||
* memoria. Ni se registra, ni se audita, ni sale por la API.
|
||||
*/
|
||||
export interface CrmCtx {
|
||||
businessId: number;
|
||||
locationId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface FilaCredencial {
|
||||
location_id: string;
|
||||
token_cipher: Buffer | null;
|
||||
token_nonce: Buffer | null;
|
||||
token_tag: Buffer | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga la credencial del negocio y la descifra.
|
||||
*
|
||||
* Distingue «no vinculado» de «vinculado sin token» a propósito: son dos
|
||||
* situaciones con dos arreglos distintos, y un solo mensaje para las dos manda
|
||||
* a quien lo lea a mirar donde no es.
|
||||
*/
|
||||
export async function ctxDe(businessId: number): Promise<CrmCtx> {
|
||||
const { rows } = await pool.query<FilaCredencial>(
|
||||
`SELECT location_id, token_cipher, token_nonce, token_tag
|
||||
FROM crm_connections WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
const c = rows[0];
|
||||
if (!c) {
|
||||
throw { status: 409, error: "Este negocio no está vinculado a Bucéfalo CRM" };
|
||||
}
|
||||
if (!c.token_cipher || !c.token_nonce || !c.token_tag) {
|
||||
throw {
|
||||
status: 409,
|
||||
error:
|
||||
"Este negocio no tiene token de Bucéfalo CRM. Vincúlalo desde la consola de administración.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
businessId,
|
||||
locationId: c.location_id,
|
||||
token: descifrar({ cipher: c.token_cipher, nonce: c.token_nonce, tag: c.token_tag }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda o rota la credencial de un negocio. Idempotente.
|
||||
*
|
||||
* La etiqueta se conserva si no se manda otra: al rotar un token caducado nadie
|
||||
* quiere volver a teclear el nombre de la subcuenta, y perderlo en silencio
|
||||
* dejaría la consola llena de cuentas sin identificar.
|
||||
*/
|
||||
export async function guardarCredencial(
|
||||
businessId: number,
|
||||
locationId: string,
|
||||
token: string,
|
||||
label?: string
|
||||
): Promise<void> {
|
||||
const c = cifrar(token);
|
||||
await pool.query(
|
||||
`INSERT INTO crm_connections
|
||||
(business_id, location_id, token_cipher, token_nonce, token_tag,
|
||||
token_fingerprint, token_updated_at, label)
|
||||
VALUES ($1,$2,$3,$4,$5,$6, now(), $7)
|
||||
ON CONFLICT (business_id) DO UPDATE SET
|
||||
location_id = EXCLUDED.location_id,
|
||||
token_cipher = EXCLUDED.token_cipher,
|
||||
token_nonce = EXCLUDED.token_nonce,
|
||||
token_tag = EXCLUDED.token_tag,
|
||||
token_fingerprint = EXCLUDED.token_fingerprint,
|
||||
token_updated_at = now(),
|
||||
label = COALESCE(EXCLUDED.label, crm_connections.label)`,
|
||||
[businessId, locationId, c.cipher, c.nonce, c.tag, huella(token), label ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desvincula: borra la credencial y **conserva** la conexión y todo lo ya
|
||||
* sincronizado. Quitar el token no es motivo para tirar 3 200 contactos, sus
|
||||
* conversaciones y la atribución que costó traer.
|
||||
*/
|
||||
export async function olvidarCredencial(businessId: number): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE crm_connections
|
||||
SET token_cipher = NULL, token_nonce = NULL, token_tag = NULL,
|
||||
token_fingerprint = NULL, token_updated_at = NULL
|
||||
WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexto construido desde el entorno, **solo para los scripts de sondeo**.
|
||||
*
|
||||
* El servidor nunca debe usar esto: sus credenciales salen de la base, por
|
||||
* negocio, vía `ctxDe`. Aquí existe porque los spikes se lanzan a mano contra
|
||||
* la subcuenta que esté configurada en `platform/.env`, antes incluso de que
|
||||
* exista una fila en `crm_connections`.
|
||||
*/
|
||||
export function ctxDesdeEnv(businessId = 0): CrmCtx {
|
||||
// Carga el .env explícitamente: depender de que otro import lo haya hecho
|
||||
// antes funciona por casualidad y se rompe al reordenar los imports.
|
||||
loadEnv();
|
||||
const locationId = process.env.CRM_LOCATION_ID;
|
||||
const token = process.env.CRM_TOKEN;
|
||||
if (!locationId || !token) {
|
||||
throw new Error(
|
||||
"Faltan CRM_LOCATION_ID y/o CRM_TOKEN en platform/.env — este script los necesita para hablar con la subcuenta."
|
||||
);
|
||||
}
|
||||
return { businessId, locationId, token };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { crmRequest } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
/**
|
||||
* Envío de mensajes hacia Bucéfalo CRM.
|
||||
*
|
||||
* La LECTURA de conversaciones y mensajes vive en `conversations.ts`: son dos
|
||||
* responsabilidades distintas y la de lectura creció con la sincronización por
|
||||
* id. Aquí queda solo lo que escribe.
|
||||
*/
|
||||
|
||||
export interface EnvioCorreo {
|
||||
contactId: string;
|
||||
emailTo: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface ResultadoEnvio {
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
emailMessageId?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un correo por el CRM.
|
||||
*
|
||||
* **Un `200` aquí es acuse de encolado, no de entrega** — la respuesta literal
|
||||
* es `Email queued successfully`. No se puede afirmar que el mensaje llegó sin
|
||||
* mirar una bandeja real, y la interfaz no debe decir «enviado» como si fuera
|
||||
* un hecho confirmado.
|
||||
*
|
||||
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||
* canal ejercible hoy.
|
||||
*/
|
||||
export async function enviarCorreo(ctx: CrmCtx, e: EnvioCorreo): Promise<ResultadoEnvio> {
|
||||
return crmRequest<ResultadoEnvio>("POST", "/conversations/messages", {
|
||||
token: ctx.token,
|
||||
body: {
|
||||
type: "Email",
|
||||
contactId: e.contactId,
|
||||
subject: e.subject,
|
||||
html: e.html,
|
||||
emailTo: e.emailTo,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { estadoOportunidad, nombreOportunidad } from "./opportunities.ts";
|
||||
|
||||
test("el estado de la cita se mapea al de la oportunidad", () => {
|
||||
assert.equal(estadoOportunidad("scheduled"), "open", "en espera → open");
|
||||
assert.equal(estadoOportunidad("completed"), "won", "completada → won");
|
||||
assert.equal(estadoOportunidad("cancelled"), "lost", "cancelada → lost");
|
||||
});
|
||||
|
||||
test("no vino también es una pérdida para el embudo", () => {
|
||||
// El matiz de POR QUÉ se perdió (no vino / canceló la clienta / canceló el
|
||||
// spa) vive en AgendaMax: el CRM aplana los tres en `lost`.
|
||||
assert.equal(estadoOportunidad("no_show"), "lost");
|
||||
});
|
||||
|
||||
test("un estado desconocido no cierra la oportunidad", () => {
|
||||
// Cerrar por error es peor que dejar abierto: `won` mete ingreso inventado en
|
||||
// los reportes del CRM y `lost` mata una cita viva.
|
||||
assert.equal(estadoOportunidad("cualquier_cosa"), "open");
|
||||
});
|
||||
|
||||
test("el nombre de la oportunidad es SERVICIO + CLIENTA", () => {
|
||||
assert.equal(
|
||||
nombreOportunidad("Extensiones de pestañas", "Mariana López"),
|
||||
"Extensiones de pestañas — Mariana López"
|
||||
);
|
||||
});
|
||||
|
||||
test("el nombre se recorta para no romper el límite del CRM", () => {
|
||||
const n = nombreOportunidad("S".repeat(200), "C".repeat(200));
|
||||
assert.equal(n.length, 255);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
/** El enum de la API. La cita en espera es `open`, completada `won`, cancelada `lost`. */
|
||||
export type CrmOppStatus = "open" | "won" | "lost" | "abandoned";
|
||||
|
||||
export interface CrmOpportunity {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: CrmOppStatus;
|
||||
monetaryValue?: number;
|
||||
pipelineId?: string;
|
||||
pipelineStageId?: string;
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
/** Estado de la cita en AgendaMax → estado de la oportunidad en el CRM. */
|
||||
export function estadoOportunidad(estadoCita: string): CrmOppStatus {
|
||||
switch (estadoCita) {
|
||||
case "completed":
|
||||
return "won";
|
||||
case "cancelled":
|
||||
case "no_show":
|
||||
// Una cita a la que no vino la clienta tampoco produjo ingreso: para el
|
||||
// embudo del CRM es una pérdida. El matiz de POR QUÉ se perdió (no vino,
|
||||
// canceló ella, canceló el spa) vive en AgendaMax, que sí lo distingue.
|
||||
return "lost";
|
||||
default:
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* El nombre de la oportunidad, con el formato acordado: SERVICIO + NOMBRE.
|
||||
* Se recorta a 255 porque el CRM no documenta el límite y un nombre largo
|
||||
* es la clase de cosa que falla en producción y no en pruebas.
|
||||
*/
|
||||
export function nombreOportunidad(servicio: string, cliente: string): string {
|
||||
return `${servicio} — ${cliente}`.slice(0, 255);
|
||||
}
|
||||
|
||||
export interface EtapasPipeline {
|
||||
pipelineId: string;
|
||||
open?: string | null;
|
||||
won?: string | null;
|
||||
lost?: string | null;
|
||||
}
|
||||
|
||||
function etapaPara(estado: CrmOppStatus, etapas: EtapasPipeline): string | null {
|
||||
if (estado === "won") return etapas.won ?? null;
|
||||
if (estado === "lost") return etapas.lost ?? null;
|
||||
return etapas.open ?? null;
|
||||
}
|
||||
|
||||
export async function obtenerOportunidad(
|
||||
ctx: CrmCtx,
|
||||
id: string
|
||||
): Promise<CrmOpportunity | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/opportunities/${id}`, { token: ctx.token });
|
||||
return r?.opportunity ?? null;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Las oportunidades de un contacto. `GET /contacts/{id}/opportunities` NO existe (404). */
|
||||
export async function oportunidadesDeContacto(
|
||||
ctx: CrmCtx,
|
||||
contactId: string
|
||||
): Promise<CrmOpportunity[]> {
|
||||
const r = await crmRequest<any>("GET", "/opportunities/search", {
|
||||
token: ctx.token,
|
||||
query: { location_id: ctx.locationId, contact_id: contactId, limit: 20 },
|
||||
});
|
||||
return r?.opportunities ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambia el estado y la etapa.
|
||||
*
|
||||
* Tres cosas medidas gobiernan esta función, y las tres son contraintuitivas:
|
||||
*
|
||||
* 1. Son **dos llamadas**, no una: `PUT /opportunities/{id}/status` rechaza
|
||||
* `pipelineStageId` con `422 property pipelineStageId should not exist`.
|
||||
* 2. **El orden importa**: el `/status` mueve la etapa por su cuenta, así que
|
||||
* va primero y la etapa deseada se escribe después. Al revés, el `/status`
|
||||
* pisa la etapa recién puesta (medido: de «Ganado» a «Cotización Aceptada»).
|
||||
* 3. En `won`, la subcuenta acaba imponiendo **su** etapa igualmente: se probó
|
||||
* reescribir y releer tres veces y el CRM la devuelve a «Cotización
|
||||
* Aceptada» de forma asíncrona, después de que la relectura ya confirmó la
|
||||
* nuestra. Hay una regla del lado del CRM que gobierna eso, y pelearse con
|
||||
* ella sería un bucle que nunca gana. En `lost` sí respeta «Perdido».
|
||||
*
|
||||
* Se escribe la etapa una vez y no se insiste. Lo que el negocio pidió mapear
|
||||
* es el **estado** —`open` / `won` / `lost`—, y ese sí queda estable y
|
||||
* verificado; la etapa es presentación y la manda el CRM.
|
||||
*/
|
||||
async function aplicarEstado(
|
||||
ctx: CrmCtx,
|
||||
id: string,
|
||||
estado: CrmOppStatus,
|
||||
etapas: EtapasPipeline
|
||||
): Promise<void> {
|
||||
await crmRequest("PUT", `/opportunities/${id}/status`, {
|
||||
token: ctx.token,
|
||||
body: { status: estado },
|
||||
});
|
||||
|
||||
const etapa = etapaPara(estado, etapas);
|
||||
if (!etapa) return;
|
||||
|
||||
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, pipelineStageId: etapa },
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResultadoOportunidad {
|
||||
opportunity: CrmOpportunity;
|
||||
via: "creada" | "reciclada" | "actualizada";
|
||||
}
|
||||
|
||||
/**
|
||||
* Proyecta una cita al CRM como oportunidad.
|
||||
*
|
||||
* Dos caminos, y la plataforma elige sola sin configuración:
|
||||
*
|
||||
* 1. **Crear.** Si la subcuenta permite duplicados, cada cita estrena su
|
||||
* oportunidad — que es el modelo pedido.
|
||||
* 2. **Reciclar.** Si responde `400 OPPORTUNITY_NO_DUPLICATE`, el CRM entrega
|
||||
* en `meta.existingId` la que ya existe, y se le pone el nombre, el importe
|
||||
* y el estado de esta cita.
|
||||
*
|
||||
* El camino 2 es el que corre hoy en Yola: la subcuenta tiene
|
||||
* `allowDuplicateOpportunity: false`. Ahí la oportunidad representa *la cita
|
||||
* vigente de la clienta*, y el histórico completo vive en AgendaMax. Si alguien
|
||||
* activa el ajuste en el CRM, esta misma función pasa al camino 1 sin cambios.
|
||||
*/
|
||||
export async function upsertOportunidad(
|
||||
ctx: CrmCtx,
|
||||
args: {
|
||||
contactId: string;
|
||||
nombre: string;
|
||||
importe: number;
|
||||
estado: CrmOppStatus;
|
||||
etapas: EtapasPipeline;
|
||||
/** Si ya la teníamos anclada, se actualiza directamente. */
|
||||
oportunidadId?: string | null;
|
||||
}
|
||||
): Promise<ResultadoOportunidad> {
|
||||
const { contactId, nombre, importe, estado, etapas } = args;
|
||||
|
||||
// Ya anclada: actualizar en sitio.
|
||||
if (args.oportunidadId) {
|
||||
const existente = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||
if (existente) {
|
||||
await crmRequest("PUT", `/opportunities/${args.oportunidadId}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||
});
|
||||
await aplicarEstado(ctx, args.oportunidadId, estado, etapas);
|
||||
const releida = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||
return { opportunity: releida ?? existente, via: "actualizada" };
|
||||
}
|
||||
// El id guardado ya no resuelve; se sigue por el camino normal.
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
pipelineId: etapas.pipelineId,
|
||||
locationId: ctx.locationId,
|
||||
name: nombre,
|
||||
status: estado,
|
||||
contactId,
|
||||
monetaryValue: importe,
|
||||
};
|
||||
const etapa = etapaPara(estado, etapas);
|
||||
if (etapa) body.pipelineStageId = etapa;
|
||||
|
||||
try {
|
||||
const r = await crmRequest<any>("POST", "/opportunities/", { token: ctx.token, body });
|
||||
const creada = r?.opportunity;
|
||||
// Se RELEE antes de dar el id por bueno: la respuesta de creación de esta
|
||||
// API refleja lo que mandaste, no necesariamente lo que persistió.
|
||||
const releida = creada?.id ? await obtenerOportunidad(ctx, creada.id) : null;
|
||||
return { opportunity: releida ?? creada, via: "creada" };
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 400) {
|
||||
const cuerpo = e.body as any;
|
||||
const existenteId =
|
||||
cuerpo?.meta?.existingId ??
|
||||
(cuerpo?.code === "OPPORTUNITY_NO_DUPLICATE" ? cuerpo?.meta?.id : null);
|
||||
|
||||
let id: string | null = existenteId ?? null;
|
||||
if (!id) {
|
||||
// El 400 no trajo el id: se busca. Es el tercer mecanismo, el más débil,
|
||||
// pero aquí la llave natural (contacto + subcuenta) es exacta.
|
||||
const previas = await oportunidadesDeContacto(ctx, contactId);
|
||||
id = previas[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||
});
|
||||
await aplicarEstado(ctx, id, estado, etapas);
|
||||
const releida = await obtenerOportunidad(ctx, id);
|
||||
if (releida) return { opportunity: releida, via: "reciclada" };
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { CrmTransportError } from "./client.ts";
|
||||
import { proyectarCita } from "./syncAppointments.ts";
|
||||
|
||||
export type EntidadOutbox = "appointment" | "client" | "message";
|
||||
|
||||
/**
|
||||
* Clave de deduplicación **propia y estable**. Nunca se deriva del contenido:
|
||||
* dos ediciones que dejan el mismo valor son dos intenciones distintas y las
|
||||
* dos tienen que salir.
|
||||
*/
|
||||
export function claveDedup(
|
||||
businessId: number,
|
||||
entidad: EntidadOutbox,
|
||||
entidadId: number,
|
||||
operacion: string,
|
||||
secuencia: number | string
|
||||
): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update([businessId, entidad, entidadId, operacion, secuencia].join("|"))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encola un cambio para el CRM **dentro de la transacción que lo produjo**.
|
||||
*
|
||||
* Recibe el `PoolClient` a propósito: el cambio local y su fila de bandeja se
|
||||
* escriben juntos o no se escriben. Sin eso aparece la escritura perdida — el
|
||||
* usuario ve «guardado», el proceso muere antes de encolar, y nadie lo reclama
|
||||
* nunca.
|
||||
*/
|
||||
export async function encolar(
|
||||
tx: PoolClient,
|
||||
args: {
|
||||
businessId: number;
|
||||
entidad: EntidadOutbox;
|
||||
entidadId: number;
|
||||
operacion: string;
|
||||
payload: unknown;
|
||||
secuencia?: number | string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const secuencia = args.secuencia ?? Date.now();
|
||||
const dedup = claveDedup(
|
||||
args.businessId,
|
||||
args.entidad,
|
||||
args.entidadId,
|
||||
args.operacion,
|
||||
secuencia
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO crm_outbox (business_id, entity, entity_id, operation, payload, dedup_key)
|
||||
VALUES ($1,$2,$3,$4,$5::jsonb,$6)
|
||||
ON CONFLICT (dedup_key) DO NOTHING`,
|
||||
[
|
||||
args.businessId,
|
||||
args.entidad,
|
||||
args.entidadId,
|
||||
args.operacion,
|
||||
JSON.stringify(args.payload ?? {}),
|
||||
dedup,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResumenDespacho {
|
||||
tomadas: number;
|
||||
confirmadas: number;
|
||||
fallidas: number;
|
||||
indeterminadas: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Despacha la bandeja de salida de un negocio.
|
||||
*
|
||||
* FIFO estricto y **una sola escritura en vuelo por registro**: el CRM
|
||||
* estrangula por token y dos escrituras concurrentes sobre la misma cita
|
||||
* corren contra una base que ya cambió.
|
||||
*/
|
||||
export async function despachar(
|
||||
businessId: number,
|
||||
limite = 25
|
||||
): Promise<ResumenDespacho> {
|
||||
const resumen: ResumenDespacho = {
|
||||
tomadas: 0,
|
||||
confirmadas: 0,
|
||||
fallidas: 0,
|
||||
indeterminadas: 0,
|
||||
};
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, entity, entity_id, operation, attempts
|
||||
FROM crm_outbox
|
||||
WHERE business_id = $1 AND status IN ('pendiente','indeterminado')
|
||||
ORDER BY id
|
||||
LIMIT $2`,
|
||||
[businessId, limite]
|
||||
);
|
||||
resumen.tomadas = rows.length;
|
||||
|
||||
for (const fila of rows) {
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox SET status = 'enviando', attempts = attempts + 1 WHERE id = $1`,
|
||||
[fila.id]
|
||||
);
|
||||
try {
|
||||
let crmId: string | null = null;
|
||||
|
||||
if (fila.entity === "appointment") {
|
||||
const r = await proyectarCita(businessId, fila.entity_id);
|
||||
crmId = r.crmOpportunityId;
|
||||
} else {
|
||||
// Todavía no hay más entidades salientes; se descarta explícitamente
|
||||
// en vez de dejarla girando en la cola para siempre.
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox
|
||||
SET status = 'fallido', last_error = 'entidad no soportada todavía'
|
||||
WHERE id = $1`,
|
||||
[fila.id]
|
||||
);
|
||||
resumen.fallidas++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox
|
||||
SET status = 'confirmado', crm_id = $2, evidence = 'relectura', sent_at = now(),
|
||||
last_error = NULL
|
||||
WHERE id = $1`,
|
||||
[fila.id, crmId]
|
||||
);
|
||||
resumen.confirmadas++;
|
||||
} catch (e: any) {
|
||||
// Un fallo de transporte NO se reintenta: el servidor no habló, así que
|
||||
// no se sabe si la escritura entró, y reenviar es fabricar el duplicado.
|
||||
// Queda en `indeterminado` para resolverlo LEYENDO.
|
||||
const indeterminado = e instanceof CrmTransportError || e?.indeterminate === true;
|
||||
await pool.query(
|
||||
`UPDATE crm_outbox SET status = $2, last_error = $3 WHERE id = $1`,
|
||||
[
|
||||
fila.id,
|
||||
indeterminado ? "indeterminado" : fila.attempts >= 4 ? "fallido" : "pendiente",
|
||||
String(e?.message ?? e).slice(0, 500),
|
||||
]
|
||||
);
|
||||
if (indeterminado) resumen.indeterminadas++;
|
||||
else resumen.fallidas++;
|
||||
}
|
||||
}
|
||||
|
||||
return resumen;
|
||||
}
|
||||
|
||||
export interface EstadoOutbox {
|
||||
pendiente: number;
|
||||
enviando: number;
|
||||
confirmado: number;
|
||||
fallido: number;
|
||||
indeterminado: number;
|
||||
}
|
||||
|
||||
export async function estadoOutbox(businessId: number): Promise<EstadoOutbox> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT status, count(*)::int AS c FROM crm_outbox WHERE business_id = $1 GROUP BY status`,
|
||||
[businessId]
|
||||
);
|
||||
const base: EstadoOutbox = {
|
||||
pendiente: 0,
|
||||
enviando: 0,
|
||||
confirmado: 0,
|
||||
fallido: 0,
|
||||
indeterminado: 0,
|
||||
};
|
||||
for (const r of rows) (base as any)[r.status] = r.c;
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { crmRequest, VERSION_CALENDARS } from "./client.ts";
|
||||
import { ctxDe, type CrmCtx } from "./ctx.ts";
|
||||
import { listarPersonal } from "./calendars.ts";
|
||||
import { slugify } from "../lib/businessDefaults.ts";
|
||||
|
||||
export interface CrmService {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
serviceDuration?: number;
|
||||
serviceDurationUnit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* El catálogo de servicios de la subcuenta.
|
||||
*
|
||||
* MEDIDO dos veces (hallazgos 6 y 32): devuelve `services: []`. El catálogo del
|
||||
* CRM está VACÍO, no ausente — el modelo existe y admite duración, precio,
|
||||
* categoría y variaciones. Simplemente nadie lo ha poblado.
|
||||
*
|
||||
* De ahí la dirección: «sincronizar servicios» no puede significar traerlos. La
|
||||
* duración y el precio los define el negocio en la plataforma, y lo único con
|
||||
* sentido es publicarlos hacia allá.
|
||||
*/
|
||||
export async function catalogoDelCrm(ctx: CrmCtx): Promise<CrmService[]> {
|
||||
const r = await crmRequest<any>("GET", "/calendars/services/catalog", {
|
||||
token: ctx.token,
|
||||
query: { locationId: ctx.locationId },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return r?.services ?? [];
|
||||
}
|
||||
|
||||
export interface ResultadoPublicacion {
|
||||
crmServiceId: string;
|
||||
nombre: string;
|
||||
yaEstaba: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publica un servicio de la plataforma en el catálogo del CRM.
|
||||
*
|
||||
* MEDIDO (hallazgo 34): `calendars.write` está y `staff[]` con al menos un
|
||||
* miembro es obligatorio — el `422` lo dice literalmente. Los ids de personal
|
||||
* salen de `GET /users/`, que volvió a estar disponible (hallazgo 35).
|
||||
*/
|
||||
export async function publicarServicio(
|
||||
businessId: number,
|
||||
serviceId: number
|
||||
): Promise<ResultadoPublicacion> {
|
||||
const ctx = await ctxDe(businessId);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, description, duration_min, price, color, crm_service_id
|
||||
FROM services WHERE id = $1 AND business_id = $2 AND active = true`,
|
||||
[serviceId, businessId]
|
||||
);
|
||||
const s = rows[0];
|
||||
if (!s) throw { status: 404, error: "Ese servicio no existe en este negocio, o está inactivo" };
|
||||
|
||||
if (s.crm_service_id) {
|
||||
// Ya publicado. Se comprueba que siga existiendo antes de darlo por bueno:
|
||||
// alguien pudo borrarlo desde la interfaz del CRM.
|
||||
const catalogo = await catalogoDelCrm(ctx);
|
||||
if (catalogo.some((x) => x.id === s.crm_service_id)) {
|
||||
return { crmServiceId: s.crm_service_id, nombre: s.name, yaEstaba: true };
|
||||
}
|
||||
}
|
||||
|
||||
let personal: { id: string; name: string }[] = [];
|
||||
try {
|
||||
personal = await listarPersonal(ctx);
|
||||
} catch (e: any) {
|
||||
// El permiso de personal se ha visto ir y venir (hallazgo 14 → 35). Si no
|
||||
// está, se dice qué falta en vez de fallar con el 422 del catálogo.
|
||||
throw {
|
||||
status: 409,
|
||||
error:
|
||||
"No se pudo leer el personal de la subcuenta, y el catálogo exige al menos una persona por servicio. Revisa que el token tenga permiso de usuarios.",
|
||||
};
|
||||
}
|
||||
if (!personal.length) {
|
||||
throw {
|
||||
status: 409,
|
||||
error:
|
||||
"La subcuenta de Bucéfalo CRM no tiene personal, y el catálogo exige al menos una persona por servicio",
|
||||
};
|
||||
}
|
||||
|
||||
const cuerpo = {
|
||||
locationId: ctx.locationId,
|
||||
name: s.name,
|
||||
slug: slugify(s.name),
|
||||
...(s.description ? { description: s.description } : {}),
|
||||
...(s.color ? { eventColor: s.color } : {}),
|
||||
serviceDuration: Number(s.duration_min),
|
||||
serviceDurationUnit: "mins",
|
||||
staff: personal.slice(0, 1).map((p) => ({ id: p.id })),
|
||||
variations: [],
|
||||
};
|
||||
|
||||
let r: any;
|
||||
try {
|
||||
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: cuerpo,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// MEDIDO: la PRIMERA publicación en una subcuenta que nunca ha tenido
|
||||
// catálogo falla con `400 No default service category found for this
|
||||
// location` — y ese mismo intento hace que el CRM cree la categoría por
|
||||
// defecto (`isSystemGenerated: true`). El reintento sí entra.
|
||||
//
|
||||
// Se reintenta UNA vez y solo ante ese mensaje concreto: reintentar a ciegas
|
||||
// un POST es fabricar duplicados.
|
||||
const msg = String(e?.message ?? "");
|
||||
if (e?.status === 400 && /default service category/i.test(msg)) {
|
||||
r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||
token: ctx.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: cuerpo,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const crmServiceId = r?.service?.id ?? r?.id;
|
||||
if (!crmServiceId) {
|
||||
throw new Error("El CRM aceptó el servicio pero no devolvió su identificador");
|
||||
}
|
||||
|
||||
// No se acepta el 200 como prueba: se relee el catálogo y se busca.
|
||||
const catalogo = await catalogoDelCrm(ctx);
|
||||
if (!catalogo.some((x) => x.id === crmServiceId)) {
|
||||
throw new Error(
|
||||
"El servicio no aparece al releer el catálogo del CRM: la escritura no persistió"
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||
[serviceId, crmServiceId]
|
||||
);
|
||||
return { crmServiceId, nombre: s.name, yaEstaba: false };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import { obtenerConexion, etapasDe } from "./connection.ts";
|
||||
import { resolverContacto } from "./contacts.ts";
|
||||
import {
|
||||
upsertOportunidad,
|
||||
estadoOportunidad,
|
||||
nombreOportunidad,
|
||||
} from "./opportunities.ts";
|
||||
|
||||
export interface ResultadoProyeccion {
|
||||
appointmentId: number;
|
||||
crmContactId: string;
|
||||
crmOpportunityId: string;
|
||||
status: string;
|
||||
via: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proyecta UNA cita al CRM como oportunidad.
|
||||
*
|
||||
* Nombre `SERVICIO — CLIENTA`, importe el del servicio, y estado según la cita:
|
||||
* en espera → `open`, completada → `won`, cancelada o no asistió → `lost`.
|
||||
*
|
||||
* Resuelve primero el contacto: `POST /calendars/...` y `POST /opportunities/`
|
||||
* exigen `contactId`, así que una clienta nacida en la plataforma tiene que
|
||||
* existir en el CRM antes de que su cita pueda salir.
|
||||
*/
|
||||
export async function proyectarCita(
|
||||
businessId: number,
|
||||
appointmentId: number
|
||||
): Promise<ResultadoProyeccion> {
|
||||
const conexion = await obtenerConexion(businessId);
|
||||
if (!conexion) {
|
||||
throw Object.assign(new Error("Este negocio no tiene conexión con Bucéfalo CRM"), {
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
// La conexión da pipeline y etapas; el contexto da la credencial. Son dos
|
||||
// cosas distintas y por eso se piden por separado.
|
||||
const ctx = await ctxDe(businessId);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, a.status, a.price, a.crm_opportunity_id,
|
||||
c.id AS client_id, c.name AS client_name, c.phone, c.email,
|
||||
c.crm_contact_id, s.name AS service_name
|
||||
FROM appointments a
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
JOIN services s ON s.id = a.service_id
|
||||
WHERE a.id = $1 AND a.business_id = $2`,
|
||||
[appointmentId, businessId]
|
||||
);
|
||||
const cita = rows[0];
|
||||
if (!cita) {
|
||||
throw Object.assign(new Error("Cita no encontrada"), { status: 404 });
|
||||
}
|
||||
|
||||
const contacto = await resolverContacto(ctx, {
|
||||
crmContactId: cita.crm_contact_id,
|
||||
phone: cita.phone,
|
||||
email: cita.email,
|
||||
name: cita.client_name,
|
||||
source: "AgendaMax",
|
||||
tags: ["agendamax"],
|
||||
});
|
||||
|
||||
// Se ancla el contacto en cuanto se conoce: si la oportunidad falla después,
|
||||
// al menos la clienta ya no se volverá a crear duplicada.
|
||||
if (contacto.contact.id !== cita.crm_contact_id) {
|
||||
await pool.query(
|
||||
`UPDATE clients SET crm_contact_id = $2, crm_synced_at = now() WHERE id = $1`,
|
||||
[cita.client_id, contacto.contact.id]
|
||||
);
|
||||
}
|
||||
|
||||
const estado = estadoOportunidad(cita.status);
|
||||
const oportunidad = await upsertOportunidad(ctx, {
|
||||
contactId: contacto.contact.id,
|
||||
nombre: nombreOportunidad(cita.service_name, cita.client_name),
|
||||
importe: Number(cita.price) || 0,
|
||||
estado,
|
||||
etapas: etapasDe(conexion),
|
||||
oportunidadId: cita.crm_opportunity_id,
|
||||
});
|
||||
|
||||
await pool.query(
|
||||
`UPDATE appointments
|
||||
SET crm_opportunity_id = $2, crm_status = $3, crm_synced_at = now()
|
||||
WHERE id = $1`,
|
||||
[appointmentId, oportunidad.opportunity.id, estado]
|
||||
);
|
||||
|
||||
return {
|
||||
appointmentId,
|
||||
crmContactId: contacto.contact.id,
|
||||
crmOpportunityId: oportunidad.opportunity.id,
|
||||
status: estado,
|
||||
via: `contacto:${contacto.via} · oportunidad:${oportunidad.via}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
import { buscarContactos, mapAtribucion, nombreDe, type CrmContact } from "./contacts.ts";
|
||||
|
||||
export interface ResumenSync {
|
||||
runId: number;
|
||||
fetched: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total_crm: number;
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae los contactos del CRM a la plataforma, con su atribución.
|
||||
*
|
||||
* Dirección: **una sola, del CRM hacia aquí.** El contacto es del CRM —es su
|
||||
* llave de deduplicación y donde viven las automatizaciones—, así que esta
|
||||
* sincronización nunca escribe hacia allá. Lo que la plataforma quiere empujar
|
||||
* pasa por la bandeja de salida, que es otra cosa.
|
||||
*
|
||||
* Reconciliación, en el mismo orden que la cadena de identidad acordada:
|
||||
* 1. `crm_contact_id` — si ya está anclado, es él y no se busca más.
|
||||
* 2. teléfono normalizado a E.164.
|
||||
* 3. correo.
|
||||
* Si ninguno encaja, se crea la clienta.
|
||||
*
|
||||
* La atribución se sobrescribe siempre desde el CRM: es dato del CRM y él es su
|
||||
* único dueño. El nombre, en cambio, **no pisa** uno editado en la plataforma
|
||||
* si el CRM no trae nada mejor.
|
||||
*/
|
||||
export async function sincronizarContactos(
|
||||
businessId: number,
|
||||
opts: { userId?: number | null; maxPaginas?: number } = {}
|
||||
): Promise<ResumenSync> {
|
||||
// El contexto se pide UNA vez, al principio: descifra el token y ya no se
|
||||
// vuelve a tocar la base para eso en toda la corrida.
|
||||
const ctx = await ctxDe(businessId);
|
||||
|
||||
const run = await pool.query<{ id: number }>(
|
||||
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||
VALUES ($1,'contacts','pull',$2) RETURNING id`,
|
||||
[businessId, opts.userId ?? null]
|
||||
);
|
||||
const runId = run.rows[0].id;
|
||||
|
||||
let fetched = 0;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let total = 0;
|
||||
|
||||
try {
|
||||
let cursor: unknown = undefined;
|
||||
const maxPaginas = opts.maxPaginas ?? 60; // 60 × 100 = 6 000 contactos por corrida
|
||||
|
||||
for (let pagina = 0; pagina < maxPaginas; pagina++) {
|
||||
const p = await buscarContactos(ctx, {
|
||||
pageLimit: 100,
|
||||
searchAfter: cursor,
|
||||
});
|
||||
total = p.total;
|
||||
if (!p.contacts.length) break;
|
||||
fetched += p.contacts.length;
|
||||
|
||||
for (const c of p.contacts) {
|
||||
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||
if (r === "created") created++;
|
||||
else if (r === "updated") updated++;
|
||||
else skipped++;
|
||||
}
|
||||
|
||||
if (!p.searchAfter) break;
|
||||
cursor = p.searchAfter;
|
||||
if (fetched >= total) break;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs
|
||||
SET finished_at = now(), status = 'ok',
|
||||
fetched = $2, created = $3, updated = $4, skipped = $5
|
||||
WHERE id = $1`,
|
||||
[runId, fetched, created, updated, skipped]
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE crm_connections
|
||||
SET last_sync_at = now(),
|
||||
last_sync_status = $2
|
||||
WHERE business_id = $1`,
|
||||
[businessId, `${created} nuevas, ${updated} actualizadas de ${fetched} leídas`]
|
||||
);
|
||||
|
||||
return { runId, fetched, created, updated, skipped, total_crm: total, status: "ok" };
|
||||
} catch (e: any) {
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs
|
||||
SET finished_at = now(), status = 'error', error = $2,
|
||||
fetched = $3, created = $4, updated = $5, skipped = $6
|
||||
WHERE id = $1`,
|
||||
[runId, String(e?.message ?? e).slice(0, 500), fetched, created, updated, skipped]
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE crm_connections SET last_sync_status = $2 WHERE business_id = $1`,
|
||||
[businessId, `error: ${String(e?.message ?? e).slice(0, 200)}`]
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
type Resultado = "created" | "updated" | "skipped";
|
||||
|
||||
export async function upsertClienteDesdeCrm(
|
||||
businessId: number,
|
||||
c: CrmContact
|
||||
): Promise<Resultado> {
|
||||
const tel = normalizePhone(c.phone);
|
||||
const email = (c.email ?? "").trim().toLowerCase() || null;
|
||||
const nombre = nombreDe(c);
|
||||
const attr = mapAtribucion(c);
|
||||
const tags = Array.isArray(c.tags) ? c.tags.join(",") : null;
|
||||
|
||||
return withTx(async (tx) => {
|
||||
// Cadena de identidad: id anclado → teléfono → correo.
|
||||
let existente: { id: number; name: string } | null = null;
|
||||
|
||||
const porId = await tx.query(
|
||||
`SELECT id, name FROM clients WHERE business_id = $1 AND crm_contact_id = $2`,
|
||||
[businessId, c.id]
|
||||
);
|
||||
existente = porId.rows[0] ?? null;
|
||||
|
||||
if (!existente && tel) {
|
||||
const porTel = await tx.query(
|
||||
`SELECT id, name FROM clients
|
||||
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||
[businessId, tel]
|
||||
);
|
||||
existente = porTel.rows[0] ?? null;
|
||||
}
|
||||
if (!existente && email) {
|
||||
const porMail = await tx.query(
|
||||
`SELECT id, name FROM clients
|
||||
WHERE business_id = $1 AND lower(email) = $2 AND deleted_at IS NULL`,
|
||||
[businessId, email]
|
||||
);
|
||||
existente = porMail.rows[0] ?? null;
|
||||
}
|
||||
|
||||
const cols = [
|
||||
attr.crm_source,
|
||||
attr.attr_session_source,
|
||||
attr.attr_medium,
|
||||
attr.attr_campaign,
|
||||
attr.attr_campaign_id,
|
||||
attr.attr_utm_source,
|
||||
attr.attr_utm_medium,
|
||||
attr.attr_utm_content,
|
||||
attr.attr_ad_id,
|
||||
attr.attr_referrer,
|
||||
tags,
|
||||
c.dateAdded ?? null,
|
||||
];
|
||||
|
||||
if (existente) {
|
||||
await tx.query(
|
||||
`UPDATE clients SET
|
||||
crm_contact_id = $2,
|
||||
crm_synced_at = now(),
|
||||
-- El teléfono y el correo solo se rellenan si aquí faltaban: son
|
||||
-- las llaves de identidad y pisarlas puede fusionar dos personas.
|
||||
phone = COALESCE(phone, $3),
|
||||
phone_e164 = COALESCE(phone_e164, $4),
|
||||
email = COALESCE(email, $5),
|
||||
-- El nombre solo se completa si el de aquí está vacío: alguien pudo
|
||||
-- corregirlo en la plataforma y el CRM trae 56 % de apellidos.
|
||||
name = CASE WHEN btrim(name) = '' THEN $6 ELSE name END,
|
||||
crm_source = $7, attr_session_source = $8, attr_medium = $9,
|
||||
attr_campaign = $10, attr_campaign_id = $11, attr_utm_source = $12,
|
||||
attr_utm_medium = $13, attr_utm_content = $14, attr_ad_id = $15,
|
||||
attr_referrer = $16, crm_tags = $17, crm_date_added = $18::timestamptz
|
||||
WHERE id = $1`,
|
||||
[existente.id, c.id, c.phone ?? null, tel, email, nombre, ...cols]
|
||||
);
|
||||
return "updated";
|
||||
}
|
||||
|
||||
try {
|
||||
await tx.query(
|
||||
`INSERT INTO clients
|
||||
(business_id, name, email, phone, phone_e164, crm_contact_id, crm_synced_at,
|
||||
crm_source, attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id, attr_referrer,
|
||||
crm_tags, crm_date_added, birth_date)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,now(),
|
||||
$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18::timestamptz,$19::date)`,
|
||||
[
|
||||
businessId,
|
||||
nombre,
|
||||
email,
|
||||
c.phone ?? null,
|
||||
tel,
|
||||
c.id,
|
||||
...cols,
|
||||
c.dateOfBirth ?? null,
|
||||
]
|
||||
);
|
||||
return "created";
|
||||
} catch (e: any) {
|
||||
// El índice único de teléfono ganó una carrera: otra clienta con el mismo
|
||||
// número entró entre la consulta y este INSERT. Se ancla al existente en
|
||||
// vez de perder el contacto.
|
||||
if (e.code === "23505" && tel) {
|
||||
await tx.query(
|
||||
`UPDATE clients SET crm_contact_id = $3, crm_synced_at = now()
|
||||
WHERE business_id = $1 AND phone_e164 = $2 AND crm_contact_id IS NULL`,
|
||||
[businessId, tel, c.id]
|
||||
);
|
||||
return "updated";
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import {
|
||||
buscarConversaciones,
|
||||
mensajesDeConversacion,
|
||||
obtenerConversacion,
|
||||
normalizarTipo,
|
||||
type CrmConversation,
|
||||
type CrmMessage,
|
||||
} from "./conversations.ts";
|
||||
|
||||
/**
|
||||
* Fecha del CRM → `Date`.
|
||||
*
|
||||
* La API mezcla formatos: `lastMessageDate` llega como epoch en milisegundos y
|
||||
* `dateAdded` como ISO. Aceptar los dos aquí evita repartir esa comprobación por
|
||||
* todos los sitios que guardan una fecha.
|
||||
*/
|
||||
function fecha(v: string | number | undefined | null): Date | null {
|
||||
if (v == null) return null;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function upsertConversacion(
|
||||
businessId: number,
|
||||
c: CrmConversation
|
||||
): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO conversations
|
||||
(business_id, crm_conversation_id, crm_contact_id, contact_name,
|
||||
last_message_type, last_message_body, last_message_at, unread_count,
|
||||
client_id, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||||
(SELECT id FROM clients
|
||||
WHERE business_id = $1 AND crm_contact_id = $3 AND deleted_at IS NULL
|
||||
LIMIT 1),
|
||||
now())
|
||||
ON CONFLICT (business_id, crm_conversation_id) DO UPDATE SET
|
||||
crm_contact_id = COALESCE(EXCLUDED.crm_contact_id, conversations.crm_contact_id),
|
||||
-- Ni el nombre ni el canal se degradan.
|
||||
--
|
||||
-- MEDIDO: GET /conversations/{id} NO devuelve el nombre del contacto ni
|
||||
-- un canal reconocible; eso solo viene del buscador. Sincronizar un hilo
|
||||
-- por su id sobrescribia un nombre bueno con "Sin nombre" y el canal con
|
||||
-- "Desconocido". Un dato pobre no puede pisar a uno que ya se tenia.
|
||||
contact_name = CASE
|
||||
WHEN EXCLUDED.contact_name = 'Sin nombre'
|
||||
THEN COALESCE(conversations.contact_name, EXCLUDED.contact_name)
|
||||
ELSE EXCLUDED.contact_name
|
||||
END,
|
||||
last_message_type = CASE
|
||||
WHEN EXCLUDED.last_message_type = 'Desconocido'
|
||||
THEN COALESCE(conversations.last_message_type, EXCLUDED.last_message_type)
|
||||
ELSE EXCLUDED.last_message_type
|
||||
END,
|
||||
last_message_body = COALESCE(EXCLUDED.last_message_body, conversations.last_message_body),
|
||||
last_message_at = COALESCE(EXCLUDED.last_message_at, conversations.last_message_at),
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
-- El enlace con la clienta solo se RELLENA, nunca se borra: si la
|
||||
-- sincronizacion de contactos todavia no ha corrido, client_id es NULL,
|
||||
-- y pisarlo con NULL mas tarde perderia un enlace ya resuelto.
|
||||
client_id = COALESCE(conversations.client_id, EXCLUDED.client_id),
|
||||
synced_at = now()
|
||||
RETURNING id`,
|
||||
[
|
||||
businessId,
|
||||
c.id,
|
||||
c.contactId ?? null,
|
||||
c.fullName || c.contactName || "Sin nombre",
|
||||
normalizarTipo(c.lastMessageType),
|
||||
c.lastMessageBody ?? null,
|
||||
fecha(c.lastMessageDate),
|
||||
c.unreadCount ?? 0,
|
||||
]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
export async function upsertMensaje(
|
||||
businessId: number,
|
||||
conversationId: number,
|
||||
m: CrmMessage
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO messages
|
||||
(business_id, conversation_id, crm_message_id, crm_contact_id,
|
||||
direction, channel, channel_raw, body, status, sent_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (business_id, crm_message_id) DO UPDATE SET
|
||||
-- El CRM es el dueno del historico: aqui se reescribe desde el, nunca se
|
||||
-- edita. Solo cambian cuerpo y estado; el resto es inmutable.
|
||||
body = EXCLUDED.body,
|
||||
status = EXCLUDED.status`,
|
||||
[
|
||||
businessId,
|
||||
conversationId,
|
||||
m.id,
|
||||
m.contactId ?? null,
|
||||
m.direction === "outbound" ? "outbound" : "inbound",
|
||||
normalizarTipo(m.messageType),
|
||||
m.messageType ?? null,
|
||||
m.body ?? null,
|
||||
m.status ?? null,
|
||||
fecha(m.dateAdded),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResumenSyncConv {
|
||||
conversaciones: number;
|
||||
mensajes: number;
|
||||
runId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Espeja UNA conversación con todos sus mensajes.
|
||||
*
|
||||
* Pagina hasta 20 vueltas de 100: son 2 000 mensajes por hilo, muy por encima de
|
||||
* cualquier conversación real, y el tope existe para que un `nextPage` que nunca
|
||||
* deje de ser `true` no cuelgue la petición para siempre.
|
||||
*/
|
||||
export async function sincronizarConversacion(
|
||||
businessId: number,
|
||||
crmConversationId: string
|
||||
): Promise<{ conversacion: number; mensajes: number }> {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const c = await obtenerConversacion(ctx, crmConversationId);
|
||||
if (!c) throw { status: 404, error: "Esa conversación no existe en Bucéfalo CRM" };
|
||||
|
||||
// El endpoint de una conversación suelta no trae el nombre del contacto. Si
|
||||
// la clienta ya está en la plataforma, se usa el suyo: es mejor dato que el
|
||||
// relleno, y evita que la bandeja muestre "Sin nombre" para alguien conocido.
|
||||
let nombre = c.fullName || c.contactName;
|
||||
if (!nombre && c.contactId) {
|
||||
const { rows } = await pool.query<{ name: string }>(
|
||||
`SELECT name FROM clients
|
||||
WHERE business_id = $1 AND crm_contact_id = $2 AND deleted_at IS NULL LIMIT 1`,
|
||||
[businessId, c.contactId]
|
||||
);
|
||||
nombre = rows[0]?.name;
|
||||
}
|
||||
|
||||
const convId = await upsertConversacion(businessId, {
|
||||
...c,
|
||||
id: crmConversationId,
|
||||
fullName: nombre,
|
||||
});
|
||||
|
||||
let cursor: string | undefined;
|
||||
let total = 0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { mensajes, lastMessageId, hayMas } = await mensajesDeConversacion(
|
||||
ctx,
|
||||
crmConversationId,
|
||||
{ limit: 100, lastMessageId: cursor }
|
||||
);
|
||||
for (const m of mensajes) {
|
||||
await upsertMensaje(businessId, convId, m);
|
||||
total++;
|
||||
}
|
||||
if (!hayMas || !lastMessageId || !mensajes.length) break;
|
||||
cursor = lastMessageId;
|
||||
}
|
||||
|
||||
// El canal del hilo se deduce de su ultimo mensaje real.
|
||||
//
|
||||
// GET /conversations/{id} no devuelve un canal reconocible, pero los mensajes
|
||||
// que acabamos de traer si lo traen. Deducirlo de ahi es mejor que dejar
|
||||
// "Desconocido" en la bandeja, y no cuesta ni una peticion mas.
|
||||
// Se excluyen las actividades: son notas que el propio CRM escribe en el hilo,
|
||||
// no un canal por el que hablar con la clienta.
|
||||
await pool.query(
|
||||
`UPDATE conversations c
|
||||
SET last_message_type = COALESCE(
|
||||
(SELECT m.channel FROM messages m
|
||||
WHERE m.conversation_id = c.id AND m.channel <> 'Actividad'
|
||||
ORDER BY m.sent_at DESC NULLS LAST, m.id DESC LIMIT 1),
|
||||
c.last_message_type)
|
||||
WHERE c.id = $1 AND c.last_message_type = 'Desconocido'`,
|
||||
[convId]
|
||||
);
|
||||
|
||||
return { conversacion: convId, mensajes: total };
|
||||
}
|
||||
|
||||
/** Espeja las conversaciones más recientes con sus últimos mensajes. */
|
||||
export async function sincronizarConversaciones(
|
||||
businessId: number,
|
||||
opts: { limit?: number; userId?: number | null } = {}
|
||||
): Promise<ResumenSyncConv> {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const { rows: run } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO crm_sync_runs (business_id, kind, direction, started_by_user_id)
|
||||
VALUES ($1,'conversations','pull',$2) RETURNING id`,
|
||||
[businessId, opts.userId ?? null]
|
||||
);
|
||||
const runId = run[0].id;
|
||||
|
||||
try {
|
||||
const { conversations } = await buscarConversaciones(ctx, { limit: opts.limit ?? 50 });
|
||||
let mensajes = 0;
|
||||
for (const c of conversations) {
|
||||
const convId = await upsertConversacion(businessId, c);
|
||||
const { mensajes: ms } = await mensajesDeConversacion(ctx, c.id, { limit: 50 });
|
||||
for (const m of ms) {
|
||||
await upsertMensaje(businessId, convId, m);
|
||||
mensajes++;
|
||||
}
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs
|
||||
SET status='ok', finished_at=now(), fetched=$2, created=$3
|
||||
WHERE id = $1`,
|
||||
[runId, conversations.length, mensajes]
|
||||
);
|
||||
return { conversaciones: conversations.length, mensajes, runId };
|
||||
} catch (e: any) {
|
||||
await pool.query(
|
||||
`UPDATE crm_sync_runs SET status='error', finished_at=now(), error=$2 WHERE id=$1`,
|
||||
[runId, String(e?.error ?? e?.message ?? e).slice(0, 500)]
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ctxDe } from "./ctx.ts";
|
||||
import { obtenerContacto } from "./contacts.ts";
|
||||
import { upsertClienteDesdeCrm } from "./syncContacts.ts";
|
||||
import { sincronizarConversacion } from "./syncConversations.ts";
|
||||
import { obtenerMensaje } from "./conversations.ts";
|
||||
import { proyectarCita } from "./syncAppointments.ts";
|
||||
import { publicarServicio } from "./services.ts";
|
||||
|
||||
export const ENTIDADES = ["contacto", "conversacion", "mensaje", "cita", "servicio"] as const;
|
||||
export type Entidad = (typeof ENTIDADES)[number];
|
||||
|
||||
export function esEntidad(v: string): v is Entidad {
|
||||
return (ENTIDADES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export interface ResultadoUno {
|
||||
entidad: Entidad;
|
||||
id: string;
|
||||
accion: string;
|
||||
detalle: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza UNA entidad por su identificador.
|
||||
*
|
||||
* La dirección no es la misma para las cinco, y no es un capricho:
|
||||
*
|
||||
* - **contacto, conversación y mensaje se TRAEN**: el CRM es su dueño. Es donde
|
||||
* viven la deduplicación y las automatizaciones.
|
||||
* - **cita y servicio se EMPUJAN.** MEDIDO (hallazgos 29 y 32): el calendario
|
||||
* del CRM tiene UNA cita en dos años y su catálogo de servicios está vacío.
|
||||
* No hay nada que arrastrar; la agenda y el catálogo nacen en la plataforma.
|
||||
*
|
||||
* Si algún día el spa empieza a agendar dentro del CRM, esa premisa se cae y
|
||||
* habrá que decidir cuál de los dos manda cuando difieran. Conviene decidirlo
|
||||
* antes de que ocurra.
|
||||
*/
|
||||
export async function sincronizarPorId(
|
||||
businessId: number,
|
||||
entidad: Entidad,
|
||||
id: string
|
||||
): Promise<ResultadoUno> {
|
||||
switch (entidad) {
|
||||
case "contacto": {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const c = await obtenerContacto(ctx, id);
|
||||
if (!c) throw { status: 404, error: "Ese contacto no existe en Bucéfalo CRM" };
|
||||
const r = await upsertClienteDesdeCrm(businessId, c);
|
||||
return {
|
||||
entidad,
|
||||
id,
|
||||
accion: r === "created" ? "creado" : r === "updated" ? "actualizado" : "sin cambios",
|
||||
detalle: { resultado: r },
|
||||
};
|
||||
}
|
||||
|
||||
case "conversacion": {
|
||||
const r = await sincronizarConversacion(businessId, id);
|
||||
return { entidad, id, accion: "espejada", detalle: r };
|
||||
}
|
||||
|
||||
case "mensaje": {
|
||||
const ctx = await ctxDe(businessId);
|
||||
const m = await obtenerMensaje(ctx, id);
|
||||
if (!m) throw { status: 404, error: "Ese mensaje no existe en Bucéfalo CRM" };
|
||||
if (!m.conversationId) {
|
||||
throw {
|
||||
status: 409,
|
||||
error: "El mensaje no dice a qué conversación pertenece, y sin ella no se puede guardar",
|
||||
};
|
||||
}
|
||||
// Se sincroniza el hilo entero: `messages.conversation_id` es obligatorio,
|
||||
// así que un mensaje suelto sin su conversación no tiene dónde ir.
|
||||
const r = await sincronizarConversacion(businessId, m.conversationId);
|
||||
return {
|
||||
entidad,
|
||||
id,
|
||||
accion: "espejado con su hilo",
|
||||
detalle: { ...r, conversacion_crm: m.conversationId },
|
||||
};
|
||||
}
|
||||
|
||||
case "cita": {
|
||||
const n = Number(id);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw { status: 400, error: "El identificador de la cita es el numérico de la plataforma" };
|
||||
}
|
||||
const r = await proyectarCita(businessId, n);
|
||||
return { entidad, id, accion: "empujada", detalle: r as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
case "servicio": {
|
||||
const n = Number(id);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw {
|
||||
status: 400,
|
||||
error: "El identificador del servicio es el numérico de la plataforma",
|
||||
};
|
||||
}
|
||||
const r = await publicarServicio(businessId, n);
|
||||
return {
|
||||
entidad,
|
||||
id,
|
||||
accion: r.yaEstaba ? "ya estaba publicado" : "publicado",
|
||||
detalle: r as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { despachar } from "./outbox.ts";
|
||||
|
||||
let corriendo = false;
|
||||
|
||||
/**
|
||||
* Vacía la bandeja de salida cada cierto tiempo.
|
||||
*
|
||||
* Es un intervalo y no una cola de verdad a propósito: hay un solo negocio, el
|
||||
* CRM estrangula a ~1 petición cada 0.65 s, y el volumen real son unas pocas
|
||||
* citas al día. Redis y un worker aparte serían infraestructura sin problema
|
||||
* que resolver. Cuando haya varios negocios habrá que revisarlo, porque el
|
||||
* estrangulamiento es **por token** y estos despachos serían secuenciales.
|
||||
*
|
||||
* La guarda `corriendo` evita que dos vueltas se solapen: dos escrituras
|
||||
* concurrentes sobre la misma cita corren contra una base que ya cambió.
|
||||
*/
|
||||
export function arrancarWorker(intervaloMs = 60_000): NodeJS.Timeout {
|
||||
const tick = async () => {
|
||||
if (corriendo) return;
|
||||
corriendo = true;
|
||||
try {
|
||||
const { rows } = await pool.query<{ business_id: number }>(
|
||||
`SELECT DISTINCT business_id FROM crm_outbox WHERE status = 'pendiente'`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const res = await despachar(r.business_id, 25);
|
||||
if (res.tomadas) {
|
||||
console.log(
|
||||
`[crm-worker] negocio ${r.business_id}: ${res.confirmadas} confirmadas, ` +
|
||||
`${res.fallidas} fallidas, ${res.indeterminadas} indeterminadas`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Un fallo aquí no debe tumbar el servidor: la bandeja seguirá llena y el
|
||||
// panel de clientes lo enseña, que es justo para lo que existe.
|
||||
console.error("[crm-worker]", (e as Error).message);
|
||||
} finally {
|
||||
corriendo = false;
|
||||
}
|
||||
};
|
||||
|
||||
const t = setInterval(tick, intervaloMs);
|
||||
// No mantiene vivo el proceso: si el servidor se cierra, no hay que esperarlo.
|
||||
t.unref?.();
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pool } from "./pool.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
|
||||
|
||||
/**
|
||||
* Aplica en orden alfabético los .sql que aún no estén en schema_migrations.
|
||||
* Cada archivo corre dentro de su propia transacción: si falla a la mitad, no
|
||||
* queda registrado y la siguiente corrida lo reintenta entero.
|
||||
*/
|
||||
export async function runMigrations(): Promise<string[]> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
const files = fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
const { rows } = await pool.query<{ filename: string }>(
|
||||
`SELECT filename FROM schema_migrations`
|
||||
);
|
||||
const applied = new Set(rows.map((r) => r.filename));
|
||||
|
||||
const ran: string[] = [];
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(sql);
|
||||
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||
await client.query("COMMIT");
|
||||
ran.push(file);
|
||||
console.log(`[migrate] aplicada ${file}`);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw new Error(`Migración ${file} falló: ${(e as Error).message}`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
return ran;
|
||||
}
|
||||
|
||||
// Permite `node scripts/run-tsx.mjs platform/db/migrate.ts` desde la línea de comandos.
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
||||
runMigrations()
|
||||
.then((ran) => {
|
||||
console.log(ran.length ? `[migrate] ${ran.length} aplicadas` : "[migrate] al día");
|
||||
return pool.end();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- btree_gist permite mezclar un igualador (employee_id) con un operador de
|
||||
-- solapamiento (&&) dentro de la misma restricción de exclusión. Sin esta
|
||||
-- extensión, EXCLUDE USING gist (employee_id WITH =, during WITH &&) no compila.
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
@@ -0,0 +1,199 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Núcleo de la plataforma. Nombres de tabla y columna en inglés a propósito:
|
||||
-- son los que shared/types.ts y el frontend ya consumen.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE businesses (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
industry text NOT NULL DEFAULT 'Estética y Spa',
|
||||
currency text NOT NULL DEFAULT 'MXN',
|
||||
currency_symbol text NOT NULL DEFAULT '$',
|
||||
phone text,
|
||||
address text,
|
||||
slug text UNIQUE,
|
||||
timezone text NOT NULL DEFAULT 'America/Mexico_City',
|
||||
working_hours jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE employees (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
phone text,
|
||||
color text NOT NULL DEFAULT '#3b66ff',
|
||||
role text NOT NULL DEFAULT 'specialist',
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
working_hours jsonb, -- NULL = hereda del negocio
|
||||
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE services (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
category text NOT NULL DEFAULT 'General',
|
||||
duration_min integer NOT NULL DEFAULT 60,
|
||||
price numeric(10,2) NOT NULL DEFAULT 0,
|
||||
color text NOT NULL DEFAULT '#3b66ff',
|
||||
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE employee_services (
|
||||
employee_id bigint NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
service_id bigint NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (employee_id, service_id)
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
email text NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
name text NOT NULL,
|
||||
role text NOT NULL CHECK (role IN ('admin','owner','employee')),
|
||||
employee_id bigint REFERENCES employees(id),
|
||||
avatar_color text NOT NULL DEFAULT '#3b66ff',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- La clienta. `phone_e164` es la clave de identidad: es lo único que puede
|
||||
-- reconciliar el mismo número que llega por canales distintos, y el índice
|
||||
-- parcial de abajo es lo que impide el duplicado.
|
||||
CREATE TABLE clients (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
phone text, -- lo que tecleó la persona, tal cual
|
||||
phone_e164 text, -- lo normalizado; NULL si no se pudo
|
||||
contactable boolean GENERATED ALWAYS AS (phone_e164 IS NOT NULL) STORED,
|
||||
birth_date date,
|
||||
notes text,
|
||||
tags text,
|
||||
source_channel text, -- whatsapp|facebook|instagram|mostrador|referido
|
||||
-- Se declara desde el día uno aunque la Fase 2 aún no exista: es el ancla de
|
||||
-- correlación con Bucéfalo CRM, y añadirla después obliga a un backfill que
|
||||
-- no se puede hacer sin releer el CRM entero.
|
||||
crm_contact_id text,
|
||||
crm_synced_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz -- baja lógica: la clienta nunca se borra
|
||||
);
|
||||
|
||||
-- Un mismo teléfono no puede repetirse dentro de un negocio. Es parcial porque
|
||||
-- el 40.8 % del histórico medido no tiene teléfono y esas filas deben convivir.
|
||||
CREATE UNIQUE INDEX clients_phone_unique
|
||||
ON clients (business_id, phone_e164)
|
||||
WHERE phone_e164 IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX clients_business_name ON clients (business_id, name);
|
||||
|
||||
CREATE TABLE appointments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
client_id bigint NOT NULL REFERENCES clients(id),
|
||||
employee_id bigint NOT NULL REFERENCES employees(id),
|
||||
service_id bigint NOT NULL REFERENCES services(id),
|
||||
start_at timestamptz NOT NULL,
|
||||
-- `end_at` se materializa, no se deriva: si mañana cambia la duración del
|
||||
-- servicio, las citas ya agendadas no deben moverse.
|
||||
end_at timestamptz NOT NULL,
|
||||
during tstzrange GENERATED ALWAYS AS (tstzrange(start_at, end_at, '[)')) STORED,
|
||||
status text NOT NULL DEFAULT 'scheduled'
|
||||
CHECK (status IN ('scheduled','completed','cancelled','no_show')),
|
||||
cancelled_by text CHECK (cancelled_by IN ('client','business')),
|
||||
cancel_reason text,
|
||||
price numeric(10,2) NOT NULL DEFAULT 0,
|
||||
notes text,
|
||||
source_channel text,
|
||||
created_by_user_id bigint REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT appointments_end_after_start CHECK (end_at > start_at),
|
||||
CONSTRAINT appointments_cancelled_by_only_when_cancelled
|
||||
CHECK (cancelled_by IS NULL OR status = 'cancelled'),
|
||||
-- Aquí está la diferencia con el backend de SQLite: la doble reserva deja de
|
||||
-- ser una validación que alguien puede saltarse y pasa a ser el motor
|
||||
-- rechazando la fila. Las canceladas no reservan hueco.
|
||||
CONSTRAINT appointments_no_overlap EXCLUDE USING gist (
|
||||
employee_id WITH =,
|
||||
during WITH &&
|
||||
) WHERE (status <> 'cancelled')
|
||||
);
|
||||
|
||||
CREATE INDEX appointments_business_start ON appointments (business_id, start_at);
|
||||
CREATE INDEX appointments_employee_start ON appointments (employee_id, start_at);
|
||||
CREATE INDEX appointments_client ON appointments (client_id);
|
||||
|
||||
-- La visita es el hecho consumado, y está separada de la cita a propósito:
|
||||
-- una cita es una intención. Fusionarlas es el error que dejó 3 002
|
||||
-- oportunidades congeladas en el CRM — un registro que sirve para planear y
|
||||
-- para cerrar termina sin cerrarse nunca.
|
||||
CREATE TABLE visits (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
appointment_id bigint UNIQUE REFERENCES appointments(id),
|
||||
client_id bigint NOT NULL REFERENCES clients(id),
|
||||
employee_id bigint NOT NULL REFERENCES employees(id),
|
||||
occurred_at timestamptz NOT NULL,
|
||||
total_charged numeric(10,2),
|
||||
payment_method text CHECK (payment_method IN ('cash','card','transfer','other')),
|
||||
recorded_by_user_id bigint REFERENCES users(id),
|
||||
recorded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX visits_business_occurred ON visits (business_id, occurred_at);
|
||||
CREATE INDEX visits_client ON visits (client_id);
|
||||
|
||||
-- Historial de la cita. Append-only: nunca se actualiza ni se borra.
|
||||
CREATE TABLE appointment_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
appointment_id bigint NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
|
||||
actor_user_id bigint REFERENCES users(id),
|
||||
action text NOT NULL, -- created|rescheduled|cancelled|attended|no_show
|
||||
from_status text,
|
||||
to_status text,
|
||||
detail jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX appointment_events_appointment ON appointment_events (appointment_id, created_at);
|
||||
|
||||
-- Quién cambió qué, cuándo y desde dónde.
|
||||
CREATE TABLE audit_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint,
|
||||
actor_user_id bigint REFERENCES users(id),
|
||||
entity text NOT NULL,
|
||||
entity_id bigint,
|
||||
action text NOT NULL,
|
||||
before jsonb,
|
||||
after jsonb,
|
||||
ip text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_business_created ON audit_log (business_id, created_at DESC);
|
||||
CREATE INDEX audit_log_entity ON audit_log (entity, entity_id);
|
||||
|
||||
-- El cierre de día. Una fila por día cerrado; la restricción única es lo que
|
||||
-- hace que cerrar dos veces no sea posible.
|
||||
CREATE TABLE day_closures (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
business_date date NOT NULL,
|
||||
closed_by_user_id bigint NOT NULL REFERENCES users(id),
|
||||
closed_at timestamptz NOT NULL DEFAULT now(),
|
||||
attended_count integer NOT NULL,
|
||||
no_show_count integer NOT NULL,
|
||||
cancelled_count integer NOT NULL,
|
||||
UNIQUE (business_id, business_date)
|
||||
);
|
||||
@@ -0,0 +1,157 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Integración con Bucéfalo CRM.
|
||||
--
|
||||
-- Todo lo de aquí está diseñado contra hallazgos MEDIDOS contra la subcuenta
|
||||
-- real de Yola Franco Spa (Pk89Wa23QaxvkOfKgwjZ) el 2026-08-29, no contra la
|
||||
-- especificación. Ver platform/crm/HALLAZGOS.md.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- La conexión con la subcuenta. Una fila por negocio.
|
||||
-- El token NO vive aquí: vive en el entorno del servidor. Esta tabla guarda
|
||||
-- qué subcuenta, qué pipeline y qué etapas usa cada negocio.
|
||||
CREATE TABLE crm_connections (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL UNIQUE REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
location_id text NOT NULL,
|
||||
pipeline_id text,
|
||||
stage_open_id text,
|
||||
stage_won_id text,
|
||||
stage_lost_id text,
|
||||
-- MEDIDO: la subcuenta trae `allowDuplicateOpportunity: false`, así que el
|
||||
-- CRM rechaza una segunda oportunidad por contacto AUNQUE la anterior esté
|
||||
-- cerrada. Mientras esté en false, la plataforma recicla la oportunidad
|
||||
-- existente en vez de crear una por cita. Si el cliente activa el ajuste,
|
||||
-- esta bandera pasa a true y cada cita estrena la suya.
|
||||
allow_duplicate_opp boolean NOT NULL DEFAULT false,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_status text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Atribución de la clienta. Se separa de `clients` porque son 10+ columnas que
|
||||
-- solo existen si el contacto vino del CRM, y porque el CRM las declara
|
||||
-- inmutables: se escriben en el alta y un PUT posterior devuelve 200 sin
|
||||
-- guardar nada. Aquí son espejo de lectura.
|
||||
ALTER TABLE clients
|
||||
ADD COLUMN crm_source text,
|
||||
ADD COLUMN attr_session_source text,
|
||||
ADD COLUMN attr_medium text,
|
||||
ADD COLUMN attr_campaign text,
|
||||
ADD COLUMN attr_campaign_id text,
|
||||
ADD COLUMN attr_utm_source text,
|
||||
ADD COLUMN attr_utm_medium text,
|
||||
ADD COLUMN attr_utm_content text,
|
||||
ADD COLUMN attr_ad_id text,
|
||||
ADD COLUMN attr_referrer text,
|
||||
ADD COLUMN crm_tags text,
|
||||
ADD COLUMN crm_date_added timestamptz;
|
||||
|
||||
CREATE INDEX clients_crm_contact ON clients (crm_contact_id)
|
||||
WHERE crm_contact_id IS NOT NULL;
|
||||
|
||||
-- La cita se proyecta al CRM como oportunidad.
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN crm_opportunity_id text,
|
||||
ADD COLUMN crm_synced_at timestamptz,
|
||||
ADD COLUMN crm_status text; -- lo que el CRM cree: open|won|lost
|
||||
|
||||
CREATE INDEX appointments_crm_opp ON appointments (crm_opportunity_id)
|
||||
WHERE crm_opportunity_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Bandeja de salida. Existe desde el día uno a propósito: la API del CRM falla,
|
||||
-- y sin cola un fallo se traga la cita de una clienta sin que nadie lo sepa.
|
||||
-- El cambio local y su fila de bandeja se escriben en la MISMA transacción; sin
|
||||
-- eso aparece la escritura perdida (el usuario ve "guardado", el proceso muere
|
||||
-- antes de encolar, y nadie lo reclama nunca).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE crm_outbox (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
entity text NOT NULL, -- client | appointment | message
|
||||
entity_id bigint NOT NULL,
|
||||
operation text NOT NULL, -- create | update | status | send
|
||||
payload jsonb NOT NULL,
|
||||
-- pendiente → enviando → confirmado | fallido | indeterminado
|
||||
--
|
||||
-- `indeterminado` no es un adorno: es donde cae un fallo de TRANSPORTE
|
||||
-- (timeout, conexión caída). Un 5xx es una respuesta —el servidor habló—;
|
||||
-- un timeout no dice nada sobre si la escritura entró. Reenviarlo es
|
||||
-- fabricar la doble creación, así que se resuelve leyendo, nunca reenviando.
|
||||
status text NOT NULL DEFAULT 'pendiente'
|
||||
CHECK (status IN ('pendiente','enviando','confirmado','fallido','indeterminado')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
last_error text,
|
||||
-- Clave de deduplicación propia y estable. NUNCA se deriva del contenido:
|
||||
-- dos ediciones que dejan el mismo valor son dos intenciones distintas.
|
||||
dedup_key text NOT NULL,
|
||||
crm_id text, -- se llena tras RELEER, no tras el 200
|
||||
evidence text, -- relectura | 400_meta | busqueda
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
sent_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX crm_outbox_pendientes ON crm_outbox (business_id, status, id)
|
||||
WHERE status IN ('pendiente','indeterminado');
|
||||
CREATE INDEX crm_outbox_entidad ON crm_outbox (entity, entity_id);
|
||||
CREATE UNIQUE INDEX crm_outbox_dedup ON crm_outbox (dedup_key);
|
||||
|
||||
-- Historial de cada corrida del botón de sincronización.
|
||||
CREATE TABLE crm_sync_runs (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, -- contacts | appointments
|
||||
direction text NOT NULL, -- pull | push
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
status text NOT NULL DEFAULT 'corriendo'
|
||||
CHECK (status IN ('corriendo','ok','error')),
|
||||
fetched integer NOT NULL DEFAULT 0,
|
||||
created integer NOT NULL DEFAULT 0,
|
||||
updated integer NOT NULL DEFAULT 0,
|
||||
skipped integer NOT NULL DEFAULT 0,
|
||||
error text,
|
||||
started_by_user_id bigint REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX crm_sync_runs_business ON crm_sync_runs (business_id, started_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Espejo de conversaciones y mensajes. El CRM es el dueño: aquí solo se
|
||||
-- guardan metadatos y referencias, y nunca se editan — se reescriben desde el
|
||||
-- CRM. La plataforma solo CREA mensajes salientes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE conversations (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
crm_conversation_id text NOT NULL,
|
||||
client_id bigint REFERENCES clients(id),
|
||||
crm_contact_id text,
|
||||
contact_name text,
|
||||
last_message_type text,
|
||||
last_message_body text,
|
||||
last_message_at timestamptz,
|
||||
unread_count integer NOT NULL DEFAULT 0,
|
||||
synced_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (business_id, crm_conversation_id)
|
||||
);
|
||||
|
||||
CREATE INDEX conversations_reciente ON conversations (business_id, last_message_at DESC);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
conversation_id bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
crm_message_id text,
|
||||
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||||
channel text NOT NULL, -- Email | SMS | WhatsApp | FB | IG…
|
||||
body text,
|
||||
subject text,
|
||||
status text, -- del CRM: queued|sent|delivered|failed
|
||||
sent_by_user_id bigint REFERENCES users(id),
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (business_id, crm_message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX messages_conversacion ON messages (conversation_id, sent_at);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- De un negocio con un token global, a N negocios con credencial propia.
|
||||
--
|
||||
-- Hasta aquí `crm_connections.location_id` ya era por negocio, pero el token
|
||||
-- vivía en la variable de entorno CRM_TOKEN, una sola para todo el proceso
|
||||
-- (platform/crm/client.ts). Con dos negocios eso usa el token del primero
|
||||
-- contra la subcuenta del segundo: 401 en el mejor caso, escritura en la
|
||||
-- subcuenta equivocada en el peor.
|
||||
--
|
||||
-- El token se guarda CIFRADO con AES-256-GCM (platform/lib/crypto.ts). La clave
|
||||
-- maestra vive en CRM_MASTER_KEY, fuera de la base: quien consiga un volcado de
|
||||
-- Postgres no consigue los tokens de los clientes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE crm_connections
|
||||
ADD COLUMN token_cipher bytea,
|
||||
ADD COLUMN token_nonce bytea,
|
||||
ADD COLUMN token_tag bytea,
|
||||
-- Los 6 últimos caracteres. Permite que la interfaz diga «termina en …f4a2c1»
|
||||
-- y detectar una rotación, sin exponer nunca la credencial.
|
||||
ADD COLUMN token_fingerprint text,
|
||||
ADD COLUMN token_updated_at timestamptz,
|
||||
-- MEDIDO (hallazgo 29): la subcuenta tiene 7 calendarios y la única cita real
|
||||
-- está en «Servicio Spa». Sin fijar cuál, empujar una cita al calendario del
|
||||
-- CRM sería adivinar a cuál.
|
||||
ADD COLUMN calendar_id text,
|
||||
-- La red de seguridad de mensajes pasa a ser POR NEGOCIO. Como variable de
|
||||
-- entorno global decidía por todas las cuentas a la vez: o se abrían los
|
||||
-- envíos reales para todas, o ninguna podía salir de pruebas.
|
||||
ADD COLUMN test_email text,
|
||||
ADD COLUMN allow_real_sends boolean NOT NULL DEFAULT false,
|
||||
-- Nombre legible de la subcuenta, para que la administración no tenga que
|
||||
-- reconocer cuentas por un identificador opaco.
|
||||
ADD COLUMN label text;
|
||||
|
||||
-- La credencial va completa o no va. Media credencial produce un descifrado que
|
||||
-- falla en tiempo de petición, y eso es un fallo lejos de su causa.
|
||||
ALTER TABLE crm_connections
|
||||
ADD CONSTRAINT crm_connections_credencial_completa CHECK (
|
||||
(token_cipher IS NULL AND token_nonce IS NULL AND token_tag IS NULL)
|
||||
OR
|
||||
(token_cipher IS NOT NULL AND token_nonce IS NOT NULL AND token_tag IS NOT NULL)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN crm_connections.token_cipher IS
|
||||
'Token privado de la subcuenta, cifrado con AES-256-GCM. Nunca se devuelve por la API.';
|
||||
COMMENT ON COLUMN crm_connections.token_fingerprint IS
|
||||
'Los 6 ultimos caracteres del token. Lo unico de la credencial que puede salir del servidor.';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sincronización por id de las cinco entidades.
|
||||
--
|
||||
-- Las tablas `conversations` y `messages` se declararon en 002_crm.sql y hasta
|
||||
-- ahora NADIE escribía en ellas: la bandeja consultaba el CRM en vivo en cada
|
||||
-- carga. Eso significa que sin red no hay bandeja, que cada visita gasta cuota,
|
||||
-- y que no se puede cruzar un hilo con una clienta sin volver a salir a internet.
|
||||
-- Aquí se añade lo que faltaba para llenarlas.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE messages
|
||||
-- De qué contacto del CRM es el mensaje, para cruzarlo con la clienta sin
|
||||
-- pasar por la conversación.
|
||||
ADD COLUMN crm_contact_id text,
|
||||
-- El canal tal cual lo devolvió el CRM, además del normalizado. La API da el
|
||||
-- tipo como número o como cadena según el endpoint, y guardar solo la versión
|
||||
-- traducida perdería el dato original si mañana cambia la traducción.
|
||||
ADD COLUMN channel_raw text;
|
||||
|
||||
CREATE INDEX messages_crm_contact ON messages (business_id, crm_contact_id)
|
||||
WHERE crm_contact_id IS NOT NULL;
|
||||
|
||||
-- Cursor de la última sincronización de conversaciones, para continuar donde se
|
||||
-- quedó en vez de releer las 3 213 cada vez.
|
||||
ALTER TABLE crm_connections
|
||||
ADD COLUMN conv_cursor_date bigint;
|
||||
|
||||
-- El servicio de la plataforma, una vez publicado en el catálogo del CRM.
|
||||
-- MEDIDO (hallazgos 6 y 32): el catálogo del CRM está VACÍO, así que
|
||||
-- «sincronizar servicios» solo puede significar empujar, nunca traer.
|
||||
ALTER TABLE services
|
||||
ADD COLUMN crm_service_id text,
|
||||
ADD COLUMN crm_synced_at timestamptz;
|
||||
|
||||
CREATE INDEX services_crm ON services (crm_service_id) WHERE crm_service_id IS NOT NULL;
|
||||
|
||||
-- La cita de la plataforma, una vez escrita como evento en el calendario del
|
||||
-- CRM. Es distinto de `crm_opportunity_id`: la oportunidad es el embudo de
|
||||
-- ventas y el evento es la agenda. Una cita puede tener las dos cosas.
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN crm_event_id text;
|
||||
|
||||
CREATE INDEX appointments_crm_event ON appointments (crm_event_id)
|
||||
WHERE crm_event_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN crm_sync_runs.kind IS
|
||||
'contacts | appointments | conversations | one — "one" es la sincronizacion de una sola entidad por id';
|
||||
@@ -0,0 +1,32 @@
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
/**
|
||||
* Postgres devuelve NUMERIC como string para no perder precisión, y bigint igual.
|
||||
* El frontend declara `number` en shared/types.ts, así que se convierten aquí, en
|
||||
* el único sitio que abre conexiones, y no en cada handler.
|
||||
*/
|
||||
pg.types.setTypeParser(1700, (v: string) => Number(v)); // numeric
|
||||
pg.types.setTypeParser(20, (v: string) => Number(v)); // int8 / bigint
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL || "postgres://yola:[email protected]:5434/yola";
|
||||
|
||||
export const pool = new Pool({ connectionString, max: 10 });
|
||||
|
||||
/** Ejecuta `fn` dentro de una transacción; hace ROLLBACK ante cualquier excepción. */
|
||||
export async function withTx<T>(fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const out = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return out;
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: yola-postgres
|
||||
environment:
|
||||
POSTGRES_USER: yola
|
||||
POSTGRES_PASSWORD: yola_dev
|
||||
POSTGRES_DB: yola
|
||||
# 5434 y no 5432/5433: los dos están ocupados por contenedores de otros
|
||||
# proyectos en esta máquina.
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- yola_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U yola -d yola"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
yola_pgdata:
|
||||
@@ -0,0 +1,56 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { authRequired } from "./lib/auth.ts";
|
||||
import { authRouter } from "./routes/auth.ts";
|
||||
import { businessRouter } from "./routes/business.ts";
|
||||
import { clientsRouter } from "./routes/clients.ts";
|
||||
import { appointmentsRouter } from "./routes/appointments.ts";
|
||||
import { attendanceRouter } from "./routes/attendance.ts";
|
||||
import { dayCloseRouter } from "./routes/dayClose.ts";
|
||||
import { adminRouter } from "./routes/admin.ts";
|
||||
import { crmRouter } from "./routes/crm.ts";
|
||||
import { messagesRouter } from "./routes/messages.ts";
|
||||
import { arrancarWorker } from "./crm/worker.ts";
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/business", authRequired, businessRouter);
|
||||
app.use("/api/clients", authRequired, clientsRouter);
|
||||
// Va ANTES que el router de citas: si se monta después, el `/:id` de
|
||||
// appointments se traga la ruta y `/attendance` nunca llega aquí.
|
||||
app.use("/api/appointments/:id/attendance", authRequired, attendanceRouter);
|
||||
app.use("/api/appointments", authRequired, appointmentsRouter);
|
||||
app.use("/api/day-close", authRequired, dayCloseRouter);
|
||||
app.use("/api/admin", authRequired, adminRouter);
|
||||
app.use("/api/crm", authRequired, crmRouter);
|
||||
app.use("/api/messages", authRequired, messagesRouter);
|
||||
|
||||
// Traductor final de errores: sin esto, un rechazo dentro de un handler async
|
||||
// devuelve el HTML de stack de Express y el cliente no puede leer el mensaje.
|
||||
app.use(
|
||||
(
|
||||
e: any,
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
console.error("[platform]", e);
|
||||
res.status(e?.status ?? 500).json({ error: e?.error ?? "Error interno del servidor" });
|
||||
}
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
const invoked = process.argv[1]?.replace(/\\/g, "/") ?? "";
|
||||
if (invoked.endsWith("platform/index.ts")) {
|
||||
const port = Number(process.env.PLATFORM_PORT) || 3100;
|
||||
createApp().listen(port, () => console.log(`[platform] escuchando en :${port}`));
|
||||
// Despacha la bandeja hacia el CRM. No se arranca en `createApp()` para que
|
||||
// las pruebas no salgan a la red por su cuenta.
|
||||
arrancarWorker(60_000);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PoolClient } from "pg";
|
||||
|
||||
export interface AuditEntry {
|
||||
businessId: number | null;
|
||||
actorUserId: number | null;
|
||||
entity: string;
|
||||
entityId: number | null;
|
||||
action: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escribe una fila de auditoría **con el cliente de la transacción en curso**.
|
||||
* Recibe el `PoolClient` a propósito y no usa el pool por su cuenta: si el
|
||||
* cambio se revierte, su rastro tiene que revertirse con él. Una auditoría que
|
||||
* registra cambios que no ocurrieron es peor que no tener auditoría.
|
||||
*/
|
||||
export async function writeAudit(c: PoolClient, e: AuditEntry): Promise<void> {
|
||||
await c.query(
|
||||
`INSERT INTO audit_log
|
||||
(business_id, actor_user_id, entity, entity_id, action, before, after, ip)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8)`,
|
||||
[
|
||||
e.businessId,
|
||||
e.actorUserId,
|
||||
e.entity,
|
||||
e.entityId,
|
||||
e.action,
|
||||
e.before === undefined ? null : JSON.stringify(e.before),
|
||||
e.after === undefined ? null : JSON.stringify(e.after),
|
||||
e.ip ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
|
||||
export interface PlatformUser {
|
||||
id: number;
|
||||
business_id: number | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "owner" | "employee";
|
||||
employee_id: number | null;
|
||||
avatar_color: string;
|
||||
}
|
||||
|
||||
export interface AuthedRequest extends Request {
|
||||
user?: PlatformUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* DEUDA CONOCIDA: el token es el id del usuario en texto plano y la contraseña
|
||||
* se compara sin hashear. Se porta tal cual desde el backend de demo para no
|
||||
* romper `src/lib/api.ts`, el AuthProvider y los .mjs de prueba en el mismo
|
||||
* cambio. Endurecerlo es un entregable propio: bcrypt/Argon2id + sesión real +
|
||||
* los cinco sitios a la vez.
|
||||
*/
|
||||
export async function authRequired(
|
||||
req: AuthedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const header = req.header("authorization") || "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id");
|
||||
if (!token) {
|
||||
err(res, 401, "No autorizado");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = Number(token);
|
||||
if (!Number.isFinite(userId)) {
|
||||
err(res, 401, "Token inválido");
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<PlatformUser>(
|
||||
`SELECT id, business_id, email, name, role, employee_id, avatar_color
|
||||
FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 401, "Usuario no encontrado");
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = rows[0];
|
||||
next();
|
||||
}
|
||||
|
||||
export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||
if (req.user?.role !== "owner") {
|
||||
err(res, 403, "Solo la administradora puede realizar esta acción");
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Administración de la plataforma: opera todas las cuentas y su `business_id`
|
||||
* es NULL.
|
||||
*
|
||||
* No se confunde con `ownerOnly`, que manda dentro de UN negocio. Son dos
|
||||
* autoridades distintas: la dueña de un spa no debe poder dar de alta cuentas
|
||||
* ajenas ni ver las credenciales de nadie.
|
||||
*/
|
||||
export function adminOnly(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||
if (req.user?.role !== "admin") {
|
||||
err(res, 403, "Solo la administración de la plataforma puede realizar esta acción");
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function err(res: Response, status: number, message: string) {
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
/** Envuelve un handler async para que un rechazo no cuelgue la petición. */
|
||||
export function h(fn: (req: AuthedRequest, res: Response) => Promise<unknown>) {
|
||||
return (req: AuthedRequest, res: Response, next: NextFunction) => {
|
||||
fn(req, res).catch(next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { PoolClient } from "pg";
|
||||
|
||||
/**
|
||||
* Valores con los que nace un negocio.
|
||||
*
|
||||
* Existe por la misma razón que su gemelo del backend de demo: un negocio sin
|
||||
* `working_hours` no tiene ninguna franja agendable en ninguna fecha, y uno sin
|
||||
* `slug` no tiene página pública. Poner el default en el `INSERT` —y no en una
|
||||
* migración de relleno— es lo único que cubre a las filas creadas después de que
|
||||
* la migración ya corrió.
|
||||
*
|
||||
* NO se importa desde `server/`: los dos backends conviven sin compartir código,
|
||||
* y cruzarlos ataría la evolución de uno a la del otro.
|
||||
*/
|
||||
export const DEFAULT_WORKING_HOURS = JSON.stringify({
|
||||
1: { start: "09:00", end: "20:00" },
|
||||
2: { start: "09:00", end: "20:00" },
|
||||
3: { start: "09:00", end: "20:00" },
|
||||
4: { start: "09:00", end: "20:00" },
|
||||
5: { start: "09:00", end: "20:00" },
|
||||
6: null,
|
||||
7: null,
|
||||
});
|
||||
|
||||
/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */
|
||||
export function slugify(s: string): string {
|
||||
return (
|
||||
(s || "negocio")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "negocio"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug único dentro de la plataforma, con sufijo numérico si ya está tomado.
|
||||
*
|
||||
* Recibe el cliente de la transacción, no el pool: comprobar la unicidad en una
|
||||
* conexión y escribir en otra deja una ventana en la que dos altas simultáneas
|
||||
* eligen el mismo slug. La restricción `UNIQUE` de la columna es la red final,
|
||||
* pero conviene no depender de que salte.
|
||||
*/
|
||||
export async function uniqueSlugPg(tx: PoolClient, nombre: string): Promise<string> {
|
||||
const base = slugify(nombre);
|
||||
let slug = base;
|
||||
for (let n = 2; ; n++) {
|
||||
const { rows } = await tx.query(`SELECT 1 FROM businesses WHERE slug = $1`, [slug]);
|
||||
if (!rows.length) return slug;
|
||||
slug = `${base}-${n}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { cifrar, descifrar, huella } from "./crypto.ts";
|
||||
|
||||
const CLAVE = Buffer.alloc(32, 7).toString("base64");
|
||||
|
||||
test("cifrar/descifrar: ida y vuelta devuelve el original", () => {
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
const token = "pit-abc123def456";
|
||||
assert.equal(descifrar(cifrar(token)), token);
|
||||
});
|
||||
|
||||
test("cifrar: dos cifrados del mismo texto son distintos (nonce aleatorio)", () => {
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
const a = cifrar("mismo-token");
|
||||
const b = cifrar("mismo-token");
|
||||
assert.notEqual(a.cipher.toString("hex"), b.cipher.toString("hex"));
|
||||
assert.equal(descifrar(a), descifrar(b));
|
||||
});
|
||||
|
||||
test("descifrar: un cipher manipulado lanza, no devuelve basura", () => {
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
const c = cifrar("token-real");
|
||||
c.cipher[0] ^= 0xff;
|
||||
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
|
||||
});
|
||||
|
||||
test("descifrar: con otra clave maestra lanza, no devuelve basura", () => {
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
const c = cifrar("token-real");
|
||||
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 9).toString("base64");
|
||||
assert.throws(() => descifrar(c), /no se pudo descifrar/i);
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
});
|
||||
|
||||
test("huella: son los 6 últimos caracteres, para distinguir tokens sin exponerlos", () => {
|
||||
assert.equal(huella("pit-abcdef123456"), "123456");
|
||||
assert.equal(huella("corto"), "corto");
|
||||
});
|
||||
|
||||
test("una clave que no mide 32 bytes se rechaza con un mensaje que lo dice", () => {
|
||||
process.env.CRM_MASTER_KEY = Buffer.alloc(16, 1).toString("base64");
|
||||
assert.throws(() => cifrar("x"), /32 bytes/);
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
});
|
||||
|
||||
test("sin CRM_MASTER_KEY se lanza un error que dice qué falta y dónde ponerlo", () => {
|
||||
delete process.env.CRM_MASTER_KEY;
|
||||
assert.throws(() => cifrar("x"), /CRM_MASTER_KEY/);
|
||||
process.env.CRM_MASTER_KEY = CLAVE;
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import crypto from "node:crypto";
|
||||
import { loadEnv } from "./env.ts";
|
||||
|
||||
export interface Cifrado {
|
||||
cipher: Buffer;
|
||||
nonce: Buffer;
|
||||
tag: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cifrado de los tokens de subcuenta que se guardan en Postgres.
|
||||
*
|
||||
* AES-256-GCM, es decir cifrado **autenticado**, y eso es la decisión que
|
||||
* importa: si alguien manipula la fila en la base, `descifrar` lanza en vez de
|
||||
* devolver basura. Con un cifrado sin autenticar, una fila corrupta se
|
||||
* convertiría en una petición al CRM con una credencial mal formada, y el fallo
|
||||
* aparecería lejos de su causa.
|
||||
*
|
||||
* La clave maestra vive en el entorno, nunca en la base: quien consiga un
|
||||
* volcado de Postgres no consigue los tokens de los clientes.
|
||||
*/
|
||||
function clave(): Buffer {
|
||||
loadEnv();
|
||||
const b64 = process.env.CRM_MASTER_KEY;
|
||||
if (!b64) {
|
||||
throw new Error(
|
||||
'Falta CRM_MASTER_KEY. Genera una con: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))" y ponla en platform/.env'
|
||||
);
|
||||
}
|
||||
const k = Buffer.from(b64, "base64");
|
||||
if (k.length !== 32) {
|
||||
throw new Error(
|
||||
`CRM_MASTER_KEY debe ser de 32 bytes en base64; llegaron ${k.length}. Genera una nueva con randomBytes(32).`
|
||||
);
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
export function cifrar(claro: string): Cifrado {
|
||||
const nonce = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv("aes-256-gcm", clave(), nonce);
|
||||
const cipher = Buffer.concat([c.update(claro, "utf8"), c.final()]);
|
||||
return { cipher, nonce, tag: c.getAuthTag() };
|
||||
}
|
||||
|
||||
export function descifrar(c: Cifrado): string {
|
||||
// La clave se pide FUERA del try: si falta o mide mal, ese error debe salir
|
||||
// tal cual, no disfrazado de «fila corrupta». Son dos causas distintas y
|
||||
// llevan a dos arreglos distintos.
|
||||
const k = clave();
|
||||
try {
|
||||
const d = crypto.createDecipheriv("aes-256-gcm", k, c.nonce);
|
||||
d.setAuthTag(c.tag);
|
||||
return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"El token guardado no se pudo descifrar: la clave maestra cambió o la fila está corrupta. Hay que volver a vincular la subcuenta."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Los 6 últimos caracteres del token. Sirve para que la interfaz pueda decir
|
||||
* «termina en …f4a2c1» y para detectar una rotación, sin exponer nunca la
|
||||
* credencial completa ni en la API, ni en los registros, ni en la auditoría.
|
||||
*/
|
||||
export function huella(token: string): string {
|
||||
return token.length <= 6 ? token : token.slice(-6);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENV_PATH = path.resolve(__dirname, "..", ".env");
|
||||
|
||||
let cargado = false;
|
||||
|
||||
/**
|
||||
* Lee `platform/.env` y lo vuelca en `process.env` sin pisar lo que ya viniera
|
||||
* del entorno — un valor exportado en la terminal gana al archivo, que es lo
|
||||
* que se espera al apuntar a otra subcuenta sin editar nada.
|
||||
*
|
||||
* Sin dependencia externa a propósito: son quince líneas y el archivo lleva
|
||||
* el token del CRM, así que conviene que se vea exactamente qué lo lee.
|
||||
*/
|
||||
export function loadEnv(): void {
|
||||
if (cargado) return;
|
||||
cargado = true;
|
||||
if (!fs.existsSync(ENV_PATH)) return;
|
||||
|
||||
for (const raw of fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lee una variable obligatoria, con un mensaje que dice qué falta y dónde ponerlo. */
|
||||
export function requireEnv(key: string): string {
|
||||
loadEnv();
|
||||
const v = process.env[key];
|
||||
if (!v) {
|
||||
throw new Error(
|
||||
`Falta ${key}. Defínelo en platform/.env (ver platform/.env.example) o expórtalo en el entorno.`
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizePhone } from "./phone.ts";
|
||||
|
||||
test("normaliza las formas mexicanas de diez dígitos", () => {
|
||||
assert.equal(normalizePhone("5588887777"), "+525588887777");
|
||||
assert.equal(normalizePhone("55 8888 7777"), "+525588887777");
|
||||
assert.equal(normalizePhone("(55) 8888-7777"), "+525588887777");
|
||||
assert.equal(normalizePhone("55.8888.7777"), "+525588887777");
|
||||
});
|
||||
|
||||
test("acepta el prefijo de larga distancia 01", () => {
|
||||
assert.equal(normalizePhone("01 55 8888 7777"), "+525588887777");
|
||||
});
|
||||
|
||||
test("acepta el 52 con y sin más", () => {
|
||||
assert.equal(normalizePhone("+52 55 8888 7777"), "+525588887777");
|
||||
assert.equal(normalizePhone("525588887777"), "+525588887777");
|
||||
assert.equal(normalizePhone("0052 55 8888 7777"), "+525588887777");
|
||||
});
|
||||
|
||||
test("colapsa el 521 heredado de WhatsApp al formato actual", () => {
|
||||
// El 1 después del 52 era el marcador de móvil; desde 2019 ya no se disca,
|
||||
// pero sigue apareciendo en los identificadores de mensajería.
|
||||
assert.equal(normalizePhone("5215588887777"), "+525588887777");
|
||||
assert.equal(normalizePhone("+52 1 55 8888 7777"), "+525588887777");
|
||||
});
|
||||
|
||||
test("respeta un internacional que no es México", () => {
|
||||
assert.equal(normalizePhone("+1 305 555 0134"), "+13055550134");
|
||||
assert.equal(normalizePhone("+34 600 123 456"), "+34600123456");
|
||||
});
|
||||
|
||||
test("devuelve null cuando no se puede normalizar", () => {
|
||||
assert.equal(normalizePhone(null), null);
|
||||
assert.equal(normalizePhone(""), null);
|
||||
assert.equal(normalizePhone(" "), null);
|
||||
assert.equal(normalizePhone("no tengo"), null);
|
||||
assert.equal(normalizePhone("123"), null, "demasiado corto");
|
||||
assert.equal(normalizePhone("12345678901234567"), null, "demasiado largo");
|
||||
});
|
||||
|
||||
test("es idempotente sobre su propia salida", () => {
|
||||
const once = normalizePhone("55 8888 7777")!;
|
||||
assert.equal(normalizePhone(once), once);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Normaliza un teléfono a E.164 (`+` seguido de 8 a 15 dígitos).
|
||||
*
|
||||
* Es la clave de identidad de la clienta: sin ella, el mismo número tecleado de
|
||||
* dos formas produce dos fichas, y la auditoría del spa midió que el teléfono es
|
||||
* el único campo con cobertura suficiente para reconciliar canales.
|
||||
*
|
||||
* Devuelve `null` cuando no se puede normalizar con certeza. `null` no es un
|
||||
* error: significa "clienta no contactable", que es un estado legítimo y medido
|
||||
* (40.8 % del histórico). Nunca se inventa un país para rellenarlo.
|
||||
*/
|
||||
export function normalizePhone(
|
||||
raw: string | null | undefined,
|
||||
defaultCountry = "52"
|
||||
): string | null {
|
||||
if (raw == null) return null;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Una letra en el campo significa texto libre ("no tengo", "el de su mamá"),
|
||||
// no un teléfono mal escrito. No se intenta rescatar.
|
||||
if (/[a-zA-Z]/.test(trimmed)) return null;
|
||||
|
||||
const explicitIntl = trimmed.startsWith("+") || /^00\d/.test(trimmed);
|
||||
let digits = trimmed.replace(/\D/g, "");
|
||||
if (trimmed.startsWith("00")) digits = digits.slice(2);
|
||||
|
||||
if (!digits) return null;
|
||||
|
||||
if (!explicitIntl) {
|
||||
// "01" es el prefijo mexicano de larga distancia y se quita como unidad, no
|
||||
// como "ceros a la izquierda": si solo se quitara el 0, el 1 restante se
|
||||
// confundiría con el código de país de Estados Unidos.
|
||||
if (digits.length === 12 && digits.startsWith("01")) {
|
||||
digits = digits.slice(2);
|
||||
} else {
|
||||
digits = digits.replace(/^0+/, "");
|
||||
}
|
||||
}
|
||||
|
||||
// "52 1 XXXXXXXXXX": el 1 de móvil que WhatsApp sigue arrastrando.
|
||||
if (digits.length === 13 && digits.startsWith(`${defaultCountry}1`)) {
|
||||
digits = defaultCountry + digits.slice(3);
|
||||
}
|
||||
|
||||
// Diez dígitos sueltos = número nacional.
|
||||
if (!explicitIntl && digits.length === 10) {
|
||||
digits = defaultCountry + digits;
|
||||
}
|
||||
|
||||
if (digits.length < 8 || digits.length > 15) return null;
|
||||
return `+${digits}`;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { adminOnly, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
|
||||
import { crmRequest, CrmError } from "../crm/client.ts";
|
||||
import { DEFAULT_WORKING_HOURS, uniqueSlugPg } from "../lib/businessDefaults.ts";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
// Todo el router exige rol de plataforma. Se aplica una vez aquí y no ruta por
|
||||
// ruta: olvidarlo en una sola ruta abriría el alta de cuentas a cualquier dueña.
|
||||
adminRouter.use(adminOnly);
|
||||
|
||||
/**
|
||||
* Las cuentas de la plataforma, con el estado de su vínculo con Bucéfalo CRM.
|
||||
*
|
||||
* `token_cipher` NO se selecciona siquiera: lo único de la credencial que sale
|
||||
* del servidor es la huella de 6 caracteres.
|
||||
*/
|
||||
adminRouter.get(
|
||||
"/businesses",
|
||||
h(async (_req: AuthedRequest, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT b.id, b.name, b.slug, b.timezone, b.status, b.created_at,
|
||||
c.location_id, c.label AS crm_label, c.token_fingerprint,
|
||||
c.token_updated_at, c.pipeline_id, c.calendar_id,
|
||||
c.allow_real_sends, c.test_email,
|
||||
c.last_sync_at, c.last_sync_status,
|
||||
(SELECT count(*) FROM clients cl
|
||||
WHERE cl.business_id = b.id AND cl.deleted_at IS NULL)::int AS clientes,
|
||||
(SELECT count(*) FROM users u WHERE u.business_id = b.id)::int AS usuarios
|
||||
FROM businesses b
|
||||
LEFT JOIN crm_connections c ON c.business_id = b.id
|
||||
ORDER BY b.created_at DESC`
|
||||
);
|
||||
res.json({ businesses: rows });
|
||||
})
|
||||
);
|
||||
|
||||
/** Alta de cuenta: el negocio y su dueña, en la misma transacción. */
|
||||
adminRouter.post(
|
||||
"/businesses",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { name, timezone, owner_email, owner_name, owner_password, industry } = req.body ?? {};
|
||||
if (!name || !owner_email || !owner_name || !owner_password) {
|
||||
err(res, 400, "Faltan el nombre del negocio y los datos de la dueña");
|
||||
return;
|
||||
}
|
||||
|
||||
const email = String(owner_email).trim().toLowerCase();
|
||||
const { rows: ya } = await pool.query(`SELECT 1 FROM users WHERE email = $1`, [email]);
|
||||
if (ya.length) {
|
||||
err(res, 409, `Ya existe una persona con el correo ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const creado = await withTx(async (tx) => {
|
||||
const slug = await uniqueSlugPg(tx, String(name));
|
||||
const { rows: bs } = await tx.query(
|
||||
`INSERT INTO businesses (name, industry, timezone, slug, working_hours)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
RETURNING id, name, slug, timezone, status, created_at`,
|
||||
[
|
||||
String(name).trim(),
|
||||
industry || "Estética y Spa",
|
||||
timezone || "America/Mexico_City",
|
||||
slug,
|
||||
DEFAULT_WORKING_HOURS,
|
||||
]
|
||||
);
|
||||
const business = bs[0];
|
||||
|
||||
const { rows: us } = await tx.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES ($1, $2, $3, $4, 'owner')
|
||||
RETURNING id, email, name, role`,
|
||||
[business.id, email, owner_password, String(owner_name).trim()]
|
||||
);
|
||||
|
||||
await writeAudit(tx, {
|
||||
businessId: business.id,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "businesses",
|
||||
entityId: business.id,
|
||||
action: "create",
|
||||
after: { name: business.name, slug: business.slug, owner_email: email },
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
|
||||
return { business, owner: us[0] };
|
||||
});
|
||||
|
||||
res.status(201).json(creado);
|
||||
} catch (e: any) {
|
||||
if (e?.code === "23505") {
|
||||
err(res, 409, "Ya existe una cuenta con ese nombre o ese correo");
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Vincula la cuenta con su subcuenta de Bucéfalo CRM.
|
||||
*
|
||||
* Las credenciales se COMPRUEBAN antes de guardarlas. Un token que no se valida
|
||||
* traslada el fallo al primer intento de sincronizar, lejos de donde se cometió,
|
||||
* y con un mensaje que no dice cuál de las dos cosas está mal. La prueba correcta
|
||||
* es leer la propia subcuenta con ese token y comparar identidad contra identidad,
|
||||
* no dar por bueno un 200 genérico.
|
||||
*/
|
||||
adminRouter.put(
|
||||
"/businesses/:id/crm",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const businessId = Number(req.params.id);
|
||||
const { location_id, token, label } = req.body ?? {};
|
||||
if (!Number.isFinite(businessId)) {
|
||||
err(res, 400, "Identificador de cuenta inválido");
|
||||
return;
|
||||
}
|
||||
if (!location_id || !token) {
|
||||
err(res, 400, "Hacen falta el identificador de la subcuenta y el token privado");
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [
|
||||
businessId,
|
||||
]);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "La cuenta no existe");
|
||||
return;
|
||||
}
|
||||
|
||||
let nombreSubcuenta: string | null = null;
|
||||
try {
|
||||
const loc = await crmRequest<any>("GET", `/locations/${location_id}`, {
|
||||
token: String(token),
|
||||
});
|
||||
const devuelto = loc?.location?.id;
|
||||
if (devuelto && devuelto !== location_id) {
|
||||
err(
|
||||
res,
|
||||
400,
|
||||
"El token pertenece a otra subcuenta distinta de la que indicaste"
|
||||
);
|
||||
return;
|
||||
}
|
||||
nombreSubcuenta = loc?.location?.name ?? null;
|
||||
} catch (e: any) {
|
||||
if (e instanceof CrmError && e.status === 401) {
|
||||
// MEDIDO: en esta API el 401 es ambiguo — token caducado, sin permiso, o
|
||||
// de otra subcuenta. El mensaje lo dice en vez de afirmar una sola causa.
|
||||
err(
|
||||
res,
|
||||
400,
|
||||
"Bucéfalo CRM rechazó el token: puede estar caducado, no tener permiso de lectura de la subcuenta, o pertenecer a otra"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e instanceof CrmError && e.status === 404) {
|
||||
err(res, 400, "Ese identificador de subcuenta no existe, o el token no da acceso a ella");
|
||||
return;
|
||||
}
|
||||
err(res, 502, `Bucéfalo CRM no respondió: ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await guardarCredencial(
|
||||
businessId,
|
||||
String(location_id),
|
||||
String(token),
|
||||
label || nombreSubcuenta || undefined
|
||||
);
|
||||
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "crm_connections",
|
||||
entityId: businessId,
|
||||
action: "link",
|
||||
// El token NO se audita, ni cifrado: el registro de auditoría se lee, se
|
||||
// exporta y se copia, y una credencial ahí dentro acaba donde no debe.
|
||||
after: { location_id, label: label || nombreSubcuenta },
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ ok: true, location_id, label: label || nombreSubcuenta });
|
||||
})
|
||||
);
|
||||
|
||||
/** Desvincula. Borra la credencial y conserva todo lo ya sincronizado. */
|
||||
adminRouter.delete(
|
||||
"/businesses/:id/crm",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const businessId = Number(req.params.id);
|
||||
if (!Number.isFinite(businessId)) {
|
||||
err(res, 400, "Identificador de cuenta inválido");
|
||||
return;
|
||||
}
|
||||
await olvidarCredencial(businessId);
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "crm_connections",
|
||||
entityId: businessId,
|
||||
action: "unlink",
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
/** Ajustes de la cuenta que pertenecen a la plataforma, no al negocio. */
|
||||
adminRouter.patch(
|
||||
"/businesses/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const businessId = Number(req.params.id);
|
||||
const { status, name, timezone } = req.body ?? {};
|
||||
if (status && !["active", "suspended"].includes(status)) {
|
||||
err(res, 400, "El estado solo puede ser «active» o «suspended»");
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE businesses
|
||||
SET status = COALESCE($2, status),
|
||||
name = COALESCE($3, name),
|
||||
timezone = COALESCE($4, timezone)
|
||||
WHERE id = $1
|
||||
RETURNING id, name, slug, timezone, status`,
|
||||
[businessId, status ?? null, name ?? null, timezone ?? null]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "La cuenta no existe");
|
||||
return;
|
||||
}
|
||||
res.json({ business: rows[0] });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { encolar } from "../crm/outbox.ts";
|
||||
|
||||
export const appointmentsRouter = Router();
|
||||
|
||||
// `start_at` y `end_at` se serializan a ISO-Z sin milisegundos, que es el
|
||||
// formato que el frontend ya parsea. `to_char` sobre el valor convertido a UTC
|
||||
// evita depender de la zona del proceso de Node.
|
||||
const COLS = `id, business_id, client_id, employee_id, service_id,
|
||||
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||
status, cancelled_by, cancel_reason, price, notes, source_channel, created_at`;
|
||||
|
||||
/** Traduce la violación de exclusión de Postgres a un 409 en español. */
|
||||
function isOverlap(e: any) {
|
||||
return e?.code === "23P01" && String(e?.constraint) === "appointments_no_overlap";
|
||||
}
|
||||
|
||||
const OCUPADO = "Ese horario ya está ocupado para esta especialista";
|
||||
|
||||
appointmentsRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { from, to, employee_id, client_id, status, limit } = req.query as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
const params: unknown[] = [req.user!.business_id];
|
||||
let sql = `SELECT ${COLS} FROM appointments WHERE business_id = $1`;
|
||||
if (from) {
|
||||
params.push(from);
|
||||
sql += ` AND start_at >= $${params.length}::timestamptz`;
|
||||
}
|
||||
if (to) {
|
||||
params.push(to);
|
||||
sql += ` AND start_at < $${params.length}::timestamptz`;
|
||||
}
|
||||
if (employee_id) {
|
||||
params.push(Number(employee_id));
|
||||
sql += ` AND employee_id = $${params.length}`;
|
||||
}
|
||||
// Sin este filtro, la ficha de una clienta enseñaba las citas de todas: el
|
||||
// cliente lo mandaba y el servidor lo ignoraba en silencio.
|
||||
if (client_id) {
|
||||
params.push(Number(client_id));
|
||||
sql += ` AND client_id = $${params.length}`;
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
sql += ` AND status = $${params.length}`;
|
||||
}
|
||||
params.push(Math.min(Number(limit) || 500, 1000));
|
||||
sql += ` ORDER BY start_at DESC LIMIT $${params.length}`;
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json({ appointments: rows });
|
||||
})
|
||||
);
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { client_id, employee_id, service_id, start_at, notes, source_channel } =
|
||||
req.body ?? {};
|
||||
if (!client_id || !employee_id || !service_id || !start_at) {
|
||||
err(res, 400, "Faltan datos de la cita");
|
||||
return;
|
||||
}
|
||||
const bid = req.user!.business_id;
|
||||
|
||||
const svc = await pool.query(
|
||||
`SELECT duration_min, price FROM services
|
||||
WHERE id = $1 AND business_id = $2 AND active`,
|
||||
[service_id, bid]
|
||||
);
|
||||
if (!svc.rows[0]) {
|
||||
err(res, 404, "Servicio no encontrado");
|
||||
return;
|
||||
}
|
||||
|
||||
const emp = await pool.query(
|
||||
`SELECT 1 FROM employees WHERE id = $1 AND business_id = $2 AND active`,
|
||||
[employee_id, bid]
|
||||
);
|
||||
if (!emp.rows[0]) {
|
||||
err(res, 404, "Especialista no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
const cli = await pool.query(
|
||||
`SELECT 1 FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||
[client_id, bid]
|
||||
);
|
||||
if (!cli.rows[0]) {
|
||||
err(res, 404, "Clienta no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appointment = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`INSERT INTO appointments
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at,
|
||||
price, notes, source_channel, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5::timestamptz,
|
||||
$5::timestamptz + make_interval(mins => $6::int),
|
||||
$7,$8,$9,$10)
|
||||
RETURNING ${COLS}`,
|
||||
[
|
||||
bid,
|
||||
client_id,
|
||||
employee_id,
|
||||
service_id,
|
||||
start_at,
|
||||
svc.rows[0].duration_min,
|
||||
svc.rows[0].price,
|
||||
notes || null,
|
||||
source_channel || null,
|
||||
req.user!.id,
|
||||
]
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, to_status)
|
||||
VALUES ($1,$2,'created','scheduled')`,
|
||||
[rows[0].id, req.user!.id]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: rows[0].id,
|
||||
action: "create",
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
// Se encola en la MISMA transacción: si el proceso muere aquí, la cita
|
||||
// y su intención de sincronizar caen juntas o sobreviven juntas.
|
||||
await encolar(c, {
|
||||
businessId: bid!, entidad: "appointment", entidadId: rows[0].id,
|
||||
operacion: "create", payload: { status: "scheduled" }, secuencia: "create",
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.status(201).json({ appointment });
|
||||
} catch (e) {
|
||||
if (isOverlap(e)) {
|
||||
err(res, 409, OCUPADO);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
appointmentsRouter.patch(
|
||||
"/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { start_at, employee_id, notes } = req.body ?? {};
|
||||
|
||||
const cur = await pool.query(
|
||||
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||
[id, bid]
|
||||
);
|
||||
if (!cur.rows[0]) {
|
||||
err(res, 404, "Cita no encontrada");
|
||||
return;
|
||||
}
|
||||
if (cur.rows[0].status === "cancelled") {
|
||||
err(res, 409, "Una cita cancelada no se puede modificar");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appointment = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`UPDATE appointments SET
|
||||
start_at = COALESCE($3::timestamptz, start_at),
|
||||
end_at = CASE WHEN $3::timestamptz IS NULL THEN end_at
|
||||
ELSE $3::timestamptz + (end_at - start_at) END,
|
||||
employee_id = COALESCE($4::bigint, employee_id),
|
||||
notes = COALESCE($5::text, notes),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND business_id = $2
|
||||
RETURNING ${COLS}`,
|
||||
[id, bid, start_at ?? null, employee_id ?? null, notes ?? null]
|
||||
);
|
||||
if (start_at || employee_id) {
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, detail)
|
||||
VALUES ($1,$2,'rescheduled',$3::jsonb)`,
|
||||
[
|
||||
id,
|
||||
req.user!.id,
|
||||
JSON.stringify({
|
||||
from: {
|
||||
start_at: cur.rows[0].start_at,
|
||||
employee_id: cur.rows[0].employee_id,
|
||||
},
|
||||
to: { start_at: rows[0].start_at, employee_id: rows[0].employee_id },
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: id,
|
||||
action: "update",
|
||||
before: cur.rows[0],
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.json({ appointment });
|
||||
} catch (e) {
|
||||
if (isOverlap(e)) {
|
||||
err(res, 409, OCUPADO);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/:id/cancel",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { cancelled_by, reason } = req.body ?? {};
|
||||
if (cancelled_by !== "client" && cancelled_by !== "business") {
|
||||
err(res, 400, "Indica quién canceló: la clienta o el spa");
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = await pool.query(
|
||||
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||
[id, bid]
|
||||
);
|
||||
if (!cur.rows[0]) {
|
||||
err(res, 404, "Cita no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
const appointment = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`UPDATE appointments
|
||||
SET status = 'cancelled', cancelled_by = $3, cancel_reason = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`,
|
||||
[id, bid, cancelled_by, reason || null]
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events
|
||||
(appointment_id, actor_user_id, action, from_status, to_status, detail)
|
||||
VALUES ($1,$2,'cancelled',$3,'cancelled',$4::jsonb)`,
|
||||
[
|
||||
id,
|
||||
req.user!.id,
|
||||
cur.rows[0].status,
|
||||
JSON.stringify({ cancelled_by, reason: reason || null }),
|
||||
]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: id,
|
||||
action: "cancel",
|
||||
before: cur.rows[0],
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
await encolar(c, {
|
||||
businessId: bid!, entidad: "appointment", entidadId: id,
|
||||
operacion: "status", payload: { status: "cancelled", cancelled_by },
|
||||
secuencia: "cancelled",
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.json({ appointment });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Router } from "express";
|
||||
import { withTx, pool } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { encolar } from "../crm/outbox.ts";
|
||||
|
||||
export const attendanceRouter = Router({ mergeParams: true });
|
||||
|
||||
const APPT_COLS = `id, business_id, client_id, employee_id, service_id,
|
||||
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||
status, cancelled_by, price, notes, created_at`;
|
||||
|
||||
const VALID_PAYMENT = new Set(["cash", "card", "transfer", "other"]);
|
||||
|
||||
/**
|
||||
* El toque de asistencia: un solo POST resuelve la cita.
|
||||
*
|
||||
* La visita se crea **solo** si la clienta vino. Una visita es un hecho con
|
||||
* dinero; el "no vino" es un estado de la cita. Fusionar los dos conceptos es
|
||||
* lo que produce registros que sirven para planear y para cerrar, y que
|
||||
* terminan sin cerrarse nunca.
|
||||
*/
|
||||
attendanceRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { attended, total_charged, payment_method } = req.body ?? {};
|
||||
|
||||
if (typeof attended !== "boolean") {
|
||||
err(res, 400, "Indica si la clienta vino o no vino");
|
||||
return;
|
||||
}
|
||||
if (payment_method != null && !VALID_PAYMENT.has(payment_method)) {
|
||||
err(res, 400, "Método de pago no válido");
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = await pool.query(
|
||||
`SELECT ${APPT_COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
|
||||
[id, bid]
|
||||
);
|
||||
const appt = cur.rows[0];
|
||||
if (!appt) {
|
||||
err(res, 404, "Cita no encontrada");
|
||||
return;
|
||||
}
|
||||
if (appt.status === "cancelled") {
|
||||
err(res, 409, "Esta cita está cancelada: no se le puede marcar asistencia");
|
||||
return;
|
||||
}
|
||||
if (appt.status === "completed" || appt.status === "no_show") {
|
||||
err(res, 409, "Esta cita ya se resolvió");
|
||||
return;
|
||||
}
|
||||
|
||||
// Una empleada solo resuelve sus propias citas; la administradora, cualquiera.
|
||||
if (req.user!.role === "employee" && req.user!.employee_id !== appt.employee_id) {
|
||||
err(res, 403, "Solo puedes marcar asistencia en tus propias citas");
|
||||
return;
|
||||
}
|
||||
|
||||
const out = await withTx(async (c) => {
|
||||
const nextStatus = attended ? "completed" : "no_show";
|
||||
const { rows } = await c.query(
|
||||
`UPDATE appointments SET status = $3, updated_at = now()
|
||||
WHERE id = $1 AND business_id = $2 RETURNING ${APPT_COLS}`,
|
||||
[id, bid, nextStatus]
|
||||
);
|
||||
|
||||
let visit = null;
|
||||
if (attended) {
|
||||
const v = await c.query(
|
||||
`INSERT INTO visits
|
||||
(business_id, appointment_id, client_id, employee_id, occurred_at,
|
||||
total_charged, payment_method, recorded_by_user_id)
|
||||
SELECT business_id, id, client_id, employee_id, start_at, $2, $3, $4
|
||||
FROM appointments WHERE id = $1
|
||||
RETURNING id, business_id, appointment_id, client_id, employee_id,
|
||||
to_char(occurred_at AT TIME ZONE 'UTC',
|
||||
'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS occurred_at,
|
||||
total_charged, payment_method, recorded_by_user_id, recorded_at`,
|
||||
[id, total_charged ?? null, payment_method ?? null, req.user!.id]
|
||||
);
|
||||
visit = v.rows[0];
|
||||
}
|
||||
|
||||
await c.query(
|
||||
`INSERT INTO appointment_events
|
||||
(appointment_id, actor_user_id, action, from_status, to_status)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
[id, req.user!.id, attended ? "attended" : "no_show", appt.status, nextStatus]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "appointments",
|
||||
entityId: id,
|
||||
action: "attendance",
|
||||
before: { status: appt.status },
|
||||
after: { status: nextStatus, visit_id: visit?.id ?? null },
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
|
||||
// La proyección al CRM se ENCOLA en esta misma transacción. Si se hiciera
|
||||
// aquí una llamada de red, un CRM caído impediría marcar la asistencia —
|
||||
// justo el registro que el negocio no puede permitirse perder.
|
||||
await encolar(c, {
|
||||
businessId: bid!,
|
||||
entidad: "appointment",
|
||||
entidadId: id,
|
||||
operacion: "status",
|
||||
payload: { status: nextStatus },
|
||||
secuencia: nextStatus,
|
||||
});
|
||||
|
||||
return { appointment: rows[0], visit };
|
||||
});
|
||||
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { authRequired, err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
/**
|
||||
* Portado tal cual del backend de demo: el token es el id del usuario y la
|
||||
* contraseña se compara en claro. Ver la nota de deuda en lib/auth.ts — se
|
||||
* endurece entero (hash + sesión real + cliente + pruebas) o no se toca.
|
||||
*/
|
||||
authRouter.post(
|
||||
"/login",
|
||||
h(async (req, res) => {
|
||||
const { email, password } = req.body ?? {};
|
||||
if (!email || !password) {
|
||||
err(res, 400, "Faltan credenciales");
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, business_id, email, name, role, employee_id, avatar_color, password
|
||||
FROM users WHERE email = $1`,
|
||||
[String(email).toLowerCase().trim()]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || user.password !== password) {
|
||||
err(res, 401, "Correo o contraseña incorrectos");
|
||||
return;
|
||||
}
|
||||
const { password: _pw, ...safe } = user;
|
||||
res.json({ token: String(user.id), user: safe });
|
||||
})
|
||||
);
|
||||
|
||||
authRouter.get("/me", authRequired, (req: AuthedRequest, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
authRouter.get(
|
||||
"/demo-users",
|
||||
h(async (_req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT email, name, role, avatar_color FROM users
|
||||
ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name`
|
||||
);
|
||||
res.json({ users: rows });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
|
||||
export const businessRouter = Router();
|
||||
|
||||
businessRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, industry, currency, currency_symbol, phone, address,
|
||||
slug, timezone, working_hours, status
|
||||
FROM businesses WHERE id = $1`,
|
||||
[req.user!.business_id]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "Negocio no encontrado");
|
||||
return;
|
||||
}
|
||||
res.json({ business: rows[0] });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
|
||||
export const clientsRouter = Router();
|
||||
|
||||
const COLS = `id, business_id, name, email, phone, phone_e164, contactable,
|
||||
birth_date, notes, tags, source_channel, created_at,
|
||||
crm_contact_id, crm_synced_at, crm_source, crm_tags,
|
||||
attr_session_source, attr_medium, attr_campaign, attr_campaign_id,
|
||||
attr_utm_source, attr_utm_medium, attr_utm_content, attr_ad_id,
|
||||
attr_referrer`;
|
||||
|
||||
clientsRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const q = (req.query.q as string | undefined)?.trim();
|
||||
const bid = req.user!.business_id;
|
||||
|
||||
if (!q) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE business_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY name LIMIT 50`,
|
||||
[bid]
|
||||
);
|
||||
res.json({ clients: rows });
|
||||
return;
|
||||
}
|
||||
|
||||
// Se busca por tres vías a la vez: el nombre, el teléfono tal cual se guardó,
|
||||
// y el normalizado. La tercera es la que hace que teclear "5588887777"
|
||||
// encuentre a quien está guardada como "+52 55 8888 7777".
|
||||
const like = `%${q}%`;
|
||||
const e164 = normalizePhone(q);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE business_id = $1 AND deleted_at IS NULL
|
||||
AND (name ILIKE $2 OR phone ILIKE $2 OR email ILIKE $2
|
||||
OR ($3::text IS NOT NULL AND phone_e164 = $3))
|
||||
ORDER BY name LIMIT 50`,
|
||||
[bid, like, e164]
|
||||
);
|
||||
res.json({ clients: rows });
|
||||
})
|
||||
);
|
||||
|
||||
clientsRouter.get(
|
||||
"/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const bid = req.user!.business_id;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||
[id, bid]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
err(res, 404, "Clienta no encontrada");
|
||||
return;
|
||||
}
|
||||
|
||||
// Las visitas salen de `visits`, no de las citas: una cita es una
|
||||
// intención y una visita es el hecho consumado. Contar citas como visitas
|
||||
// infla el historial con gente que no vino.
|
||||
const v = await pool.query(
|
||||
`SELECT count(*)::int AS visits,
|
||||
COALESCE(sum(total_charged),0)::float AS total_spent,
|
||||
max(occurred_at) AS last_visit
|
||||
FROM visits WHERE business_id = $1 AND client_id = $2`,
|
||||
[bid, id]
|
||||
);
|
||||
const ns = await pool.query(
|
||||
`SELECT count(*)::int AS c FROM appointments
|
||||
WHERE business_id = $1 AND client_id = $2 AND status = 'no_show'`,
|
||||
[bid, id]
|
||||
);
|
||||
const visits = v.rows[0].visits as number;
|
||||
const total = v.rows[0].total_spent as number;
|
||||
|
||||
res.json({
|
||||
client: {
|
||||
...rows[0],
|
||||
stats: {
|
||||
visits,
|
||||
total_spent: Math.round(total * 100) / 100,
|
||||
last_visit: v.rows[0].last_visit,
|
||||
avg_ticket: visits ? Math.round((total / visits) * 100) / 100 : 0,
|
||||
no_show_count: ns.rows[0].c,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
clientsRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { name, phone, email, notes, source_channel } = req.body ?? {};
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
err(res, 400, "El nombre es obligatorio");
|
||||
return;
|
||||
}
|
||||
const bid = req.user!.business_id;
|
||||
const e164 = normalizePhone(phone);
|
||||
|
||||
// Se pregunta antes de insertar para poder devolver la ficha existente. El
|
||||
// índice único sigue siendo la garantía real: entre esta consulta y el
|
||||
// INSERT cabe otra alta, y por eso abajo también se atrapa el 23505.
|
||||
if (e164) {
|
||||
const dup = await pool.query(
|
||||
`SELECT ${COLS} FROM clients
|
||||
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
|
||||
[bid, e164]
|
||||
);
|
||||
if (dup.rows[0]) {
|
||||
res.status(409).json({
|
||||
error: "Ya existe una clienta con ese teléfono",
|
||||
existing: dup.rows[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await withTx(async (c) => {
|
||||
const { rows } = await c.query(
|
||||
`INSERT INTO clients
|
||||
(business_id, name, email, phone, phone_e164, notes, source_channel)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING ${COLS}`,
|
||||
[
|
||||
bid,
|
||||
name.trim(),
|
||||
email || null,
|
||||
phone || null,
|
||||
e164,
|
||||
notes || null,
|
||||
source_channel || null,
|
||||
]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "clients",
|
||||
entityId: rows[0].id,
|
||||
action: "create",
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
res.status(201).json({ client });
|
||||
} catch (e: any) {
|
||||
if (e.code === "23505") {
|
||||
const dup = await pool.query(
|
||||
`SELECT ${COLS} FROM clients WHERE business_id = $1 AND phone_e164 = $2`,
|
||||
[bid, e164]
|
||||
);
|
||||
res.status(409).json({
|
||||
error: "Ya existe una clienta con ese teléfono",
|
||||
existing: dup.rows[0] ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { err, h, ownerOnly, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { withTx } from "../db/pool.ts";
|
||||
import { obtenerConexion, autoconfigurar } from "../crm/connection.ts";
|
||||
import { ctxDe } from "../crm/ctx.ts";
|
||||
import { sincronizarContactos } from "../crm/syncContacts.ts";
|
||||
import { proyectarCita } from "../crm/syncAppointments.ts";
|
||||
import { despachar, estadoOutbox } from "../crm/outbox.ts";
|
||||
import { sincronizarPorId, esEntidad, ENTIDADES } from "../crm/syncOne.ts";
|
||||
import { sincronizarConversaciones } from "../crm/syncConversations.ts";
|
||||
|
||||
export const crmRouter = Router();
|
||||
|
||||
/** Estado de la conexión: lo que la pantalla de clientes necesita para el botón. */
|
||||
crmRouter.get(
|
||||
"/status",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const conexion = await obtenerConexion(bid);
|
||||
if (!conexion) {
|
||||
res.json({ connected: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await pool.query(
|
||||
`SELECT count(*)::int AS clientes,
|
||||
count(*) FILTER (WHERE crm_contact_id IS NOT NULL)::int AS sincronizados,
|
||||
count(*) FILTER (WHERE contactable)::int AS contactables,
|
||||
count(*) FILTER (WHERE attr_campaign IS NOT NULL)::int AS con_campana
|
||||
FROM clients WHERE business_id = $1 AND deleted_at IS NULL`,
|
||||
[bid]
|
||||
);
|
||||
const ultima = await pool.query(
|
||||
`SELECT id, kind, status, fetched, created, updated, started_at, finished_at, error
|
||||
FROM crm_sync_runs WHERE business_id = $1 ORDER BY id DESC LIMIT 1`,
|
||||
[bid]
|
||||
);
|
||||
|
||||
res.json({
|
||||
connected: true,
|
||||
location_id: conexion.location_id,
|
||||
pipeline_id: conexion.pipeline_id,
|
||||
allow_duplicate_opp: conexion.allow_duplicate_opp,
|
||||
last_sync_at: conexion.last_sync_at,
|
||||
last_sync_status: conexion.last_sync_status,
|
||||
stats: stats.rows[0],
|
||||
last_run: ultima.rows[0] ?? null,
|
||||
outbox: await estadoOutbox(bid),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/** Conecta o reconfigura la subcuenta. El token vive en el entorno, no en el body. */
|
||||
crmRouter.post(
|
||||
"/connect",
|
||||
ownerOnly,
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
// Las credenciales las pone la administración de la plataforma, no el
|
||||
// negocio: son de la subcuenta del cliente y no deben viajar por aquí.
|
||||
// Esta ruta solo redetecta pipeline y etapas de la subcuenta ya vinculada.
|
||||
const ctx = await ctxDe(bid); // lanza 409 si no está vinculado
|
||||
const c = await autoconfigurar(ctx);
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "crm_connections",
|
||||
entityId: c.id,
|
||||
action: "connect",
|
||||
after: { location_id: c.location_id, pipeline_id: c.pipeline_id },
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
res.json({ connection: c });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* El botón de sincronizar contactos.
|
||||
*
|
||||
* Es una corrida en primer plano y no una tarea de fondo a propósito: 3 200
|
||||
* contactos tardan ~22 s y quien pulsa el botón quiere ver el resultado. Si el
|
||||
* volumen crece hasta molestar, se mueve a la bandeja; hoy sería complejidad
|
||||
* sin problema que resolver.
|
||||
*/
|
||||
crmRouter.post(
|
||||
"/sync/contacts",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
try {
|
||||
const r = await sincronizarContactos(bid, {
|
||||
userId: req.user!.id,
|
||||
maxPaginas: Number(req.body?.max_paginas) || 60,
|
||||
});
|
||||
res.json(r);
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/** Empuja una cita concreta al CRM como oportunidad. */
|
||||
crmRouter.post(
|
||||
"/sync/appointment/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
try {
|
||||
const r = await proyectarCita(bid, Number(req.params.id));
|
||||
res.json(r);
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `El CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/** Vacía la bandeja de salida. */
|
||||
crmRouter.post(
|
||||
"/outbox/flush",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const r = await despachar(bid, Number(req.body?.limite) || 25);
|
||||
res.json(r);
|
||||
})
|
||||
);
|
||||
|
||||
/** Lo que no se pudo sincronizar, para que alguien pueda mirarlo. */
|
||||
crmRouter.get(
|
||||
"/outbox",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, entity, entity_id, operation, status, attempts, last_error,
|
||||
crm_id, created_at, sent_at
|
||||
FROM crm_outbox
|
||||
WHERE business_id = $1
|
||||
ORDER BY CASE status WHEN 'indeterminado' THEN 0 WHEN 'fallido' THEN 1
|
||||
WHEN 'pendiente' THEN 2 ELSE 3 END, id DESC
|
||||
LIMIT 100`,
|
||||
[bid]
|
||||
);
|
||||
res.json({ items: rows, resumen: await estadoOutbox(bid) });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Espejo de las conversaciones recientes con sus mensajes.
|
||||
*
|
||||
* Va declarada ANTES que `/sync/:entidad/:id` no por ambigüedad —tienen distinto
|
||||
* número de segmentos— sino para que el orden del archivo diga cuál es la ruta
|
||||
* concreta y cuál la genérica.
|
||||
*/
|
||||
crmRouter.post(
|
||||
"/sync/conversations",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
try {
|
||||
res.json(
|
||||
await sincronizarConversaciones(bid, {
|
||||
limit: Math.min(Number(req.body?.limite) || 50, 200),
|
||||
userId: req.user!.id,
|
||||
})
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.error ?? e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Sincroniza UNA entidad por su identificador.
|
||||
*
|
||||
* Es el punto de entrada único que pedía el encargo. La dirección la decide la
|
||||
* entidad, no quien llama: contactos, conversaciones y mensajes se traen del
|
||||
* CRM; citas y servicios se empujan hacia él. Ver `crm/syncOne.ts`.
|
||||
*/
|
||||
crmRouter.post(
|
||||
"/sync/:entidad/:id",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const { entidad, id } = req.params;
|
||||
if (!esEntidad(entidad)) {
|
||||
err(res, 400, `Entidad no reconocida. Las válidas son: ${ENTIDADES.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (!id || id.length > 64) {
|
||||
err(res, 400, "Identificador ausente o demasiado largo");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.json(await sincronizarPorId(req.user!.business_id!, entidad, id));
|
||||
} catch (e: any) {
|
||||
if (e?.status) {
|
||||
err(res, e.status, e.error ?? e.message);
|
||||
return;
|
||||
}
|
||||
err(res, 502, `Bucéfalo CRM no respondió como se esperaba: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../../server/lib/time.ts";
|
||||
|
||||
export const dayCloseRouter = Router();
|
||||
|
||||
const APPT_COLS = `a.id, a.client_id, a.employee_id, a.service_id,
|
||||
to_char(a.start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
|
||||
to_char(a.end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
|
||||
a.status, a.price, c.name AS client_name, e.name AS employee_name, s.name AS service_name`;
|
||||
|
||||
// `bizDayBoundsIsoFor` devuelve un fin INCLUSIVO (23:59:59 hora local), así que
|
||||
// la comparación es `<=`. Con `<` se perdería la última cita del día.
|
||||
const IN_DAY = `a.start_at >= $2::timestamptz AND a.start_at <= $3::timestamptz`;
|
||||
|
||||
const UNRESOLVED_SQL = `
|
||||
SELECT ${APPT_COLS} FROM appointments a
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
JOIN employees e ON e.id = a.employee_id
|
||||
JOIN services s ON s.id = a.service_id
|
||||
WHERE a.business_id = $1 AND ${IN_DAY} AND a.status = 'scheduled'
|
||||
ORDER BY a.start_at`;
|
||||
|
||||
const COUNTS_SQL = `
|
||||
SELECT
|
||||
count(*) FILTER (WHERE status = 'completed')::int AS attended,
|
||||
count(*) FILTER (WHERE status = 'no_show')::int AS no_show,
|
||||
count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled
|
||||
FROM appointments
|
||||
WHERE business_id = $1
|
||||
AND start_at >= $2::timestamptz AND start_at <= $3::timestamptz`;
|
||||
|
||||
async function bizTz(businessId: number): Promise<string> {
|
||||
const { rows } = await pool.query(`SELECT timezone FROM businesses WHERE id = $1`, [
|
||||
businessId,
|
||||
]);
|
||||
return rows[0]?.timezone || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
dayCloseRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const tz = await bizTz(bid);
|
||||
const date = (req.query.date as string | undefined) || bizTodayISO(tz);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
err(res, 400, "Fecha no válida");
|
||||
return;
|
||||
}
|
||||
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||
|
||||
const unresolved = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||
const counts = await pool.query(COUNTS_SQL, [bid, start, end]);
|
||||
const closure = await pool.query(
|
||||
`SELECT closed_at FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||
[bid, date]
|
||||
);
|
||||
|
||||
res.json({
|
||||
date,
|
||||
closed_at: closure.rows[0]?.closed_at ?? null,
|
||||
unresolved: unresolved.rows,
|
||||
attended: counts.rows[0].attended,
|
||||
no_show: counts.rows[0].no_show,
|
||||
cancelled: counts.rows[0].cancelled,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
dayCloseRouter.post(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const tz = await bizTz(bid);
|
||||
const date = (req.body?.date as string | undefined) || bizTodayISO(tz);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
err(res, 400, "Fecha no válida");
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, end } = bizDayBoundsIsoFor(tz, date);
|
||||
|
||||
const ya = await pool.query(
|
||||
`SELECT 1 FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
|
||||
[bid, date]
|
||||
);
|
||||
if (ya.rows[0]) {
|
||||
err(res, 409, "Ese día ya está cerrado");
|
||||
return;
|
||||
}
|
||||
|
||||
// La regla que sostiene todo el proyecto: no se puede cerrar el día dejando
|
||||
// citas sin desenlace. Es lo que convierte el registro en el camino más
|
||||
// corto para trabajar, en vez de una tarea añadida al final.
|
||||
const pend = await pool.query(UNRESOLVED_SQL, [bid, start, end]);
|
||||
if (pend.rows.length) {
|
||||
res.status(409).json({
|
||||
error: `Quedan ${pend.rows.length} cita(s) sin resolver: marca si vinieron o no antes de cerrar`,
|
||||
unresolved: pend.rows,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const closure = await withTx(async (c) => {
|
||||
const counts = await c.query(COUNTS_SQL, [bid, start, end]);
|
||||
const { rows } = await c.query(
|
||||
`INSERT INTO day_closures
|
||||
(business_id, business_date, closed_by_user_id,
|
||||
attended_count, no_show_count, cancelled_count)
|
||||
VALUES ($1,$2::date,$3,$4,$5,$6)
|
||||
RETURNING id, business_id,
|
||||
to_char(business_date, 'YYYY-MM-DD') AS business_date,
|
||||
closed_by_user_id, closed_at,
|
||||
attended_count, no_show_count, cancelled_count`,
|
||||
[
|
||||
bid,
|
||||
date,
|
||||
req.user!.id,
|
||||
counts.rows[0].attended,
|
||||
counts.rows[0].no_show,
|
||||
counts.rows[0].cancelled,
|
||||
]
|
||||
);
|
||||
await writeAudit(c, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "day_closures",
|
||||
entityId: rows[0].id,
|
||||
action: "close",
|
||||
after: rows[0],
|
||||
ip: req.ip ?? null,
|
||||
});
|
||||
return rows[0];
|
||||
});
|
||||
|
||||
res.json({ closure });
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Router } from "express";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { err, h, type AuthedRequest } from "../lib/auth.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { obtenerConexion } from "../crm/connection.ts";
|
||||
import { ctxDe } from "../crm/ctx.ts";
|
||||
import { buscarConversaciones, mensajesDeConversacion } from "../crm/conversations.ts";
|
||||
import { enviarCorreo } from "../crm/messages.ts";
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
|
||||
export const messagesRouter = Router();
|
||||
|
||||
/**
|
||||
* MODO PRUEBA — restricción deliberada del MVP.
|
||||
*
|
||||
* Mientras `CRM_TEST_EMAIL` esté definido, **el servidor solo envía a esa
|
||||
* dirección**, sin importar a quién apunte la interfaz. La subcuenta es la de un
|
||||
* cliente real con 3 200 contactos: un bucle mal escrito o un clic de más
|
||||
* escribiría a personas de verdad, y eso no se arregla pidiendo perdón.
|
||||
*
|
||||
* Se quita definiendo `CRM_ALLOW_REAL_SENDS=1`, y esa es una decisión del dueño
|
||||
* del proyecto, no un descuido de configuración.
|
||||
*/
|
||||
function destinoPermitido(deseado: string): { to: string; forzado: boolean } {
|
||||
loadEnv();
|
||||
const prueba = process.env.CRM_TEST_EMAIL;
|
||||
const libre = process.env.CRM_ALLOW_REAL_SENDS === "1";
|
||||
if (prueba && !libre) {
|
||||
return { to: prueba, forzado: prueba.toLowerCase() !== deseado.toLowerCase() };
|
||||
}
|
||||
return { to: deseado, forzado: false };
|
||||
}
|
||||
|
||||
/** Bandeja: conversaciones del CRM, con la clienta local enlazada si se conoce. */
|
||||
messagesRouter.get(
|
||||
"/",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const conexion = await obtenerConexion(bid);
|
||||
if (!conexion) {
|
||||
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = await ctxDe(bid);
|
||||
const { conversations, total } = await buscarConversaciones(ctx, {
|
||||
limit: Number(req.query.limit) || 20,
|
||||
});
|
||||
|
||||
// Se enlazan con las clientas locales por el id del CRM para poder abrir su
|
||||
// ficha desde la bandeja: es el "al lado" que hace útil esta pantalla.
|
||||
const ids = conversations.map((c) => c.contactId).filter(Boolean) as string[];
|
||||
const locales = ids.length
|
||||
? await pool.query(
|
||||
`SELECT id, name, crm_contact_id, phone_e164, contactable
|
||||
FROM clients WHERE business_id = $1 AND crm_contact_id = ANY($2::text[])`,
|
||||
[bid, ids]
|
||||
)
|
||||
: { rows: [] as any[] };
|
||||
const porCrmId = new Map(locales.rows.map((r: any) => [r.crm_contact_id, r]));
|
||||
|
||||
res.json({
|
||||
total,
|
||||
conversations: conversations.map((c) => ({
|
||||
crm_conversation_id: c.id,
|
||||
crm_contact_id: c.contactId ?? null,
|
||||
contact_name: c.fullName || c.contactName || "Sin nombre",
|
||||
last_message_body: c.lastMessageBody ?? null,
|
||||
last_message_type: c.lastMessageType ?? null,
|
||||
last_message_at: c.lastMessageDate ?? null,
|
||||
unread_count: c.unreadCount ?? 0,
|
||||
client: porCrmId.get(c.contactId ?? "") ?? null,
|
||||
})),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/** Los mensajes de un hilo. Solo lectura: el CRM es el dueño del histórico. */
|
||||
messagesRouter.get(
|
||||
"/:conversationId",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const conexion = await obtenerConexion(req.user!.business_id!);
|
||||
if (!conexion) {
|
||||
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||
return;
|
||||
}
|
||||
const ctx = await ctxDe(req.user!.business_id!);
|
||||
const { mensajes, hayMas } = await mensajesDeConversacion(ctx, req.params.conversationId, {
|
||||
limit: 50,
|
||||
});
|
||||
res.json({
|
||||
hay_mas: hayMas,
|
||||
messages: mensajes.map((m) => ({
|
||||
id: m.id,
|
||||
body: m.body ?? null,
|
||||
direction: m.direction ?? null,
|
||||
channel: m.messageType ?? null,
|
||||
status: m.status ?? null,
|
||||
sent_at: m.dateAdded ?? null,
|
||||
})),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Responder por correo.
|
||||
*
|
||||
* WhatsApp y SMS no están conectados en esta subcuenta: el correo es el único
|
||||
* canal ejercible hoy, y la respuesta lo dice explícitamente para que la
|
||||
* interfaz no prometa lo que no puede cumplir.
|
||||
*/
|
||||
messagesRouter.post(
|
||||
"/send",
|
||||
h(async (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id!;
|
||||
const { client_id, crm_contact_id, subject, body } = req.body ?? {};
|
||||
if (!subject || !body) {
|
||||
err(res, 400, "El asunto y el mensaje son obligatorios");
|
||||
return;
|
||||
}
|
||||
|
||||
const conexion = await obtenerConexion(bid);
|
||||
if (!conexion) {
|
||||
err(res, 409, "Este negocio no tiene conexión con Bucéfalo CRM");
|
||||
return;
|
||||
}
|
||||
|
||||
let contactId: string | null = crm_contact_id ?? null;
|
||||
let correoDestino: string | null = null;
|
||||
let clienteLocal: any = null;
|
||||
|
||||
if (client_id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, email, crm_contact_id FROM clients
|
||||
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
|
||||
[Number(client_id), bid]
|
||||
);
|
||||
clienteLocal = rows[0] ?? null;
|
||||
if (!clienteLocal) {
|
||||
err(res, 404, "Clienta no encontrada");
|
||||
return;
|
||||
}
|
||||
contactId = contactId ?? clienteLocal.crm_contact_id;
|
||||
correoDestino = clienteLocal.email;
|
||||
}
|
||||
|
||||
if (!contactId) {
|
||||
err(res, 409, "Esta clienta todavía no está sincronizada con el CRM");
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, forzado } = destinoPermitido(correoDestino || "");
|
||||
if (!to) {
|
||||
err(res, 409, "No hay dirección de correo a la que escribir");
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = await ctxDe(bid);
|
||||
const r = await enviarCorreo(ctx, {
|
||||
contactId,
|
||||
emailTo: to,
|
||||
subject: String(subject).slice(0, 200),
|
||||
html: `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.6">${String(
|
||||
body
|
||||
)
|
||||
.split("\n")
|
||||
.map((l) => `<p>${escaparHtml(l)}</p>`)
|
||||
.join("")}</div>`,
|
||||
});
|
||||
|
||||
await withTx((tx) =>
|
||||
writeAudit(tx, {
|
||||
businessId: bid,
|
||||
actorUserId: req.user!.id,
|
||||
entity: "messages",
|
||||
entityId: clienteLocal?.id ?? null,
|
||||
action: "send_email",
|
||||
after: { to, forzado, crm: r },
|
||||
ip: req.ip ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
// El CRM responde "Email queued successfully": es acuse de ENCOLADO, no
|
||||
// de entrega. La interfaz debe decir "en camino", nunca "entregado".
|
||||
queued: true,
|
||||
crm: r,
|
||||
sent_to: to,
|
||||
redirigido: forzado,
|
||||
aviso: forzado
|
||||
? `Modo prueba: el mensaje se envió a ${to}, no a la clienta.`
|
||||
: null,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
function escaparHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Comprueba que la consola de cuentas se pinta de verdad y que el token NO
|
||||
* aparece en ningún sitio del DOM.
|
||||
*
|
||||
* Un typecheck limpio y un build correcto no dicen nada sobre si la pantalla
|
||||
* renderiza: eso hay que mirarlo.
|
||||
*
|
||||
* node platform/scripts/admin-ui-check.mjs
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.UI_BASE_URL || "http://localhost:5176";
|
||||
const EMAIL = "[email protected]";
|
||||
const PASS = "demo1234";
|
||||
|
||||
let fallos = 0;
|
||||
const check = (nombre, ok, detalle = "") => {
|
||||
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
|
||||
if (!ok) fallos++;
|
||||
};
|
||||
|
||||
const navegador = await chromium.launch();
|
||||
const pagina = await navegador.newPage();
|
||||
const erroresConsola = [];
|
||||
pagina.on("console", (m) => m.type() === "error" && erroresConsola.push(m.text()));
|
||||
pagina.on("pageerror", (e) => erroresConsola.push(String(e)));
|
||||
|
||||
try {
|
||||
await pagina.goto(`${BASE}/login`, { waitUntil: "networkidle" });
|
||||
await pagina.fill('input[type="email"]', EMAIL);
|
||||
await pagina.fill('input[type="password"]', PASS);
|
||||
await pagina.click('button[type="submit"]');
|
||||
await pagina.waitForURL(/\/admin/, { timeout: 15000 });
|
||||
check("entra como administración de plataforma", true, pagina.url());
|
||||
|
||||
await pagina.goto(`${BASE}/admin/cuentas`, { waitUntil: "networkidle" });
|
||||
await pagina.waitForSelector("table", { timeout: 15000 });
|
||||
|
||||
const texto = await pagina.innerText("body");
|
||||
check("se pinta la tabla de cuentas", /Cuentas de la plataforma/.test(texto));
|
||||
check("aparece el negocio real", /Yola Franco Spa/.test(texto));
|
||||
check("muestra la huella del token", /token …f89621|token \.\.\.f89621/.test(texto), "huella visible");
|
||||
|
||||
// Lo que NO puede pasar bajo ningún concepto.
|
||||
const html = await pagina.content();
|
||||
check("el token completo NO está en el DOM", !/pit-/i.test(html) && !html.includes("f89621f"), "");
|
||||
|
||||
// El modal de vínculo: el campo del token debe ser de contraseña y venir vacío.
|
||||
await pagina.click("text=Cambiar token");
|
||||
await pagina.waitForSelector("#cr-tok", { timeout: 8000 });
|
||||
const tipo = await pagina.getAttribute("#cr-tok", "type");
|
||||
const valor = await pagina.inputValue("#cr-tok");
|
||||
check("el campo del token es de contraseña", tipo === "password", `type=${tipo}`);
|
||||
check("y viene vacío: no hay token que traer", valor === "", `valor=«${valor}»`);
|
||||
const locId = await pagina.inputValue("#cr-loc");
|
||||
check("la subcuenta sí se prerrellena", locId === "Pk89Wa23QaxvkOfKgwjZ", locId);
|
||||
|
||||
// Etiquetas asociadas a su control.
|
||||
const sinLabel = await pagina.$$eval("#cr-tok, #cr-loc", (els) =>
|
||||
els.filter((el) => !document.querySelector(`label[for="${el.id}"]`)).map((el) => el.id)
|
||||
);
|
||||
check("cada campo tiene su etiqueta asociada", sinLabel.length === 0, sinLabel.join(", "));
|
||||
|
||||
check("sin errores de consola", erroresConsola.length === 0, erroresConsola.slice(0, 2).join(" | "));
|
||||
|
||||
await pagina.screenshot({ path: "screenshots/admin-cuentas.png", fullPage: true });
|
||||
console.log("\n captura en screenshots/admin-cuentas.png");
|
||||
} finally {
|
||||
await navegador.close();
|
||||
}
|
||||
|
||||
console.log(`\n${fallos === 0 ? "Todo en verde" : `${fallos} comprobaciones fallaron`}`);
|
||||
process.exit(fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Conecta el negocio de la plataforma con su subcuenta de Bucéfalo CRM y
|
||||
* autodetecta pipeline y etapas.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts [slug]
|
||||
*/
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { loadEnv, requireEnv } from "../lib/env.ts";
|
||||
import { autoconfigurar } from "../crm/connection.ts";
|
||||
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
|
||||
|
||||
loadEnv();
|
||||
|
||||
async function main() {
|
||||
const slug = process.argv[2] || "yola-franco";
|
||||
const locationId = requireEnv("CRM_LOCATION_ID");
|
||||
|
||||
const b = await pool.query<{ id: number; name: string }>(
|
||||
`SELECT id, name FROM businesses WHERE slug = $1`,
|
||||
[slug]
|
||||
);
|
||||
if (!b.rows[0]) {
|
||||
console.error(`No existe el negocio con slug «${slug}»`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// El script vincula la subcuenta configurada en el entorno: guarda la
|
||||
// credencial cifrada y después autodetecta pipeline y etapas. Antes solo
|
||||
// hacía lo segundo, porque el token era global.
|
||||
await guardarCredencial(b.rows[0].id, locationId, ctxDesdeEnv().token);
|
||||
const c = await autoconfigurar(await ctxDe(b.rows[0].id));
|
||||
console.log(`Conectado «${b.rows[0].name}» ↔ subcuenta ${c.location_id}`);
|
||||
console.log(` pipeline : ${c.pipeline_id}`);
|
||||
console.log(` etapa «en espera» : ${c.stage_open_id}`);
|
||||
console.log(` etapa «ganado» : ${c.stage_won_id}`);
|
||||
console.log(` etapa «perdido» : ${c.stage_lost_id}`);
|
||||
console.log(` permite duplicados : ${c.allow_duplicate_opp}`);
|
||||
console.log(` nota : ${c.last_sync_status}`);
|
||||
if (!c.allow_duplicate_opp) {
|
||||
console.log(
|
||||
`\n >> Con «allow duplicate opportunity» desactivado, cada clienta tiene UNA\n` +
|
||||
` oportunidad que se recicla en cada cita. Para que cada cita estrene la\n` +
|
||||
` suya, hay que activar ese ajuste en la UI del CRM.`
|
||||
);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Borra del CRM lo que crearon los spikes.
|
||||
*
|
||||
* Los spikes escriben en la subcuenta REAL del cliente. Todo lo que crean lleva
|
||||
* el tag `agendamax:prueba` y el correo autorizado; esto lo busca por ese tag y
|
||||
* lo elimina, para no dejar basura en la base de un negocio en producción.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts (lista)
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar (borra)
|
||||
*/
|
||||
import { loadEnv, requireEnv } from "../lib/env.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { crmRequest } from "../crm/client.ts";
|
||||
import { oportunidadesDeContacto } from "../crm/opportunities.ts";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
const CORREO = process.env.CRM_TEST_EMAIL || "[email protected]";
|
||||
const BORRAR = process.argv.includes("--borrar");
|
||||
|
||||
async function main() {
|
||||
const r = await crmRequest<any>("GET", "/contacts/", { token: CTX.token,
|
||||
query: { locationId: LOC, query: CORREO, limit: 20 },
|
||||
});
|
||||
const contactos: any[] = (r?.contacts ?? []).filter(
|
||||
(c: any) =>
|
||||
(c.email ?? "").toLowerCase() === CORREO.toLowerCase() ||
|
||||
(c.tags ?? []).includes("agendamax:prueba")
|
||||
);
|
||||
|
||||
if (!contactos.length) {
|
||||
console.log("No hay contactos de prueba en la subcuenta.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const c of contactos) {
|
||||
console.log(`\nContacto ${c.id} — ${c.contactName ?? c.firstName} <${c.email}>`);
|
||||
console.log(` tags: ${(c.tags ?? []).join(", ") || "(ninguno)"}`);
|
||||
|
||||
const opps = await oportunidadesDeContacto(CTX, c.id);
|
||||
for (const o of opps) {
|
||||
console.log(` oportunidad ${o.id} — «${o.name}» (${o.status})`);
|
||||
if (BORRAR) {
|
||||
await crmRequest("DELETE", `/opportunities/${o.id}`, { token: CTX.token });
|
||||
console.log(" borrada");
|
||||
}
|
||||
}
|
||||
|
||||
if (BORRAR) {
|
||||
await crmRequest("DELETE", `/contacts/${c.id}`, { token: CTX.token });
|
||||
console.log(" contacto borrado");
|
||||
}
|
||||
}
|
||||
|
||||
if (!BORRAR) {
|
||||
console.log("\n(Solo listado. Añade --borrar para eliminarlos de verdad.)");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Mueve la credencial de `platform/.env` a la base, cifrada, para un negocio
|
||||
* que ya estaba vinculado.
|
||||
*
|
||||
* Es de un solo uso por negocio: después, las credenciales se ponen desde la
|
||||
* consola de administración (`PUT /api/admin/businesses/:id/crm`).
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
|
||||
*/
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { crmRequest } from "../crm/client.ts";
|
||||
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
|
||||
|
||||
const businessId = Number(process.argv[2]);
|
||||
if (!Number.isFinite(businessId)) {
|
||||
console.error("Uso: node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const desdeEnv = ctxDesdeEnv(businessId);
|
||||
|
||||
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [businessId]);
|
||||
if (!rows[0]) {
|
||||
console.error(`No existe el negocio ${businessId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Negocio ${businessId}: ${rows[0].name}`);
|
||||
|
||||
// Se comprueba la credencial ANTES de guardarla, igual que hace la consola.
|
||||
const loc = await crmRequest<any>("GET", `/locations/${desdeEnv.locationId}`, {
|
||||
token: desdeEnv.token,
|
||||
});
|
||||
const nombre = loc?.location?.name ?? null;
|
||||
if (loc?.location?.id && loc.location.id !== desdeEnv.locationId) {
|
||||
console.error("El token pertenece a otra subcuenta distinta de CRM_LOCATION_ID");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Subcuenta verificada: ${nombre} (${desdeEnv.locationId})`);
|
||||
|
||||
await guardarCredencial(businessId, desdeEnv.locationId, desdeEnv.token, nombre ?? undefined);
|
||||
|
||||
// No se acepta el guardado como prueba: se relee de la base, se descifra y se
|
||||
// USA contra el CRM. Es la única forma de saber que el ciclo entero funciona.
|
||||
const ctx = await ctxDe(businessId);
|
||||
const rel = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, { token: ctx.token });
|
||||
|
||||
if (rel?.location?.id === desdeEnv.locationId) {
|
||||
const { rows: f } = await pool.query(
|
||||
`SELECT token_fingerprint, label FROM crm_connections WHERE business_id = $1`,
|
||||
[businessId]
|
||||
);
|
||||
console.log(
|
||||
`\n✔ Credencial cifrada, releída de la base y verificada contra el CRM.` +
|
||||
`\n etiqueta: ${f[0].label}` +
|
||||
`\n huella : …${f[0].token_fingerprint}` +
|
||||
`\n\nYa puedes quitar CRM_TOKEN de platform/.env para este negocio.`
|
||||
);
|
||||
} else {
|
||||
console.error("✖ La relectura no coincide: la credencial guardada no sirve");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* ¿Se pueden BORRAR citas y servicios? Sin crear nada.
|
||||
*
|
||||
* Se pregunta antes de escribir, no después: si el borrado no existe o el token
|
||||
* no lo tiene, cualquier prueba de escritura dejaría basura permanente en el CRM
|
||||
* de un cliente real. Se manda un DELETE contra un id inventado y se mira el
|
||||
* error:
|
||||
*
|
||||
* 401 «not authorized for this scope» → no hay permiso de borrado
|
||||
* 404 / 422 → el permiso está; solo falta el id real
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts
|
||||
*/
|
||||
import { requireEnv, loadEnv } from "../lib/env.ts";
|
||||
|
||||
loadEnv();
|
||||
const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com";
|
||||
const TOKEN = requireEnv("CRM_TOKEN");
|
||||
|
||||
async function sonda(method: string, path: string, version: string) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${TOKEN}`,
|
||||
version,
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
const texto = (await res.text()).slice(0, 260);
|
||||
const sinPermiso = res.status === 401;
|
||||
console.log(`\n── ${method} ${path}`);
|
||||
console.log(
|
||||
` ${sinPermiso ? "✖ SIN PERMISO DE BORRADO" : "✔ EL BORRADO EXISTE (falla por el id, no por el token)"}`
|
||||
);
|
||||
console.log(` ${res.status} · ${texto}`);
|
||||
return !sinPermiso;
|
||||
}
|
||||
|
||||
const ID_FALSO = "idQueNoExisteJamas123";
|
||||
|
||||
const cita = await sonda("DELETE", `/calendars/events/${ID_FALSO}`, "v3");
|
||||
const servicio = await sonda("DELETE", `/calendars/services/catalog/${ID_FALSO}`, "v3");
|
||||
|
||||
console.log(
|
||||
`\n────────────\ncitas: ${cita ? "se pueden borrar" : "NO se pueden borrar"} · ` +
|
||||
`servicios: ${servicio ? "se pueden borrar" : "NO se pueden borrar"}`
|
||||
);
|
||||
if (!cita || !servicio) {
|
||||
console.log(
|
||||
"\nNo se debe ejercer la escritura de lo que no se pueda deshacer en la\n" +
|
||||
"subcuenta de un cliente real."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* ¿Hay citas en los calendarios de la subcuenta? Solo lectura.
|
||||
*
|
||||
* El sondeo anterior miró únicamente el PRIMER calendario, que resultó ser uno
|
||||
* personal, y concluir «no hay citas» a partir de eso habría sido un error: el
|
||||
* que importa es «Servicio Spa». Aquí se recorren los SIETE, y se prueban las
|
||||
* dos formas de acotar que admite la API (por calendario y por usuario).
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts
|
||||
*/
|
||||
import { crmRequest, CrmError, VERSION_CALENDARS } from "../crm/client.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { requireEnv } from "../lib/env.ts";
|
||||
|
||||
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
const DIA = 86400000;
|
||||
|
||||
async function eventos(query: Record<string, string | number>): Promise<any[] | string> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
|
||||
query,
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return r?.events ?? [];
|
||||
} catch (e: any) {
|
||||
if (e instanceof CrmError) {
|
||||
const cuerpo = typeof e.body === "string" ? e.body : JSON.stringify(e.body);
|
||||
return `${e.status}: ${String(e.message).slice(0, 120)}${cuerpo ? ` | ${cuerpo.slice(0, 160)}` : ""}`;
|
||||
}
|
||||
return String(e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const r = await crmRequest<any>("GET", "/calendars/", { token: CTX.token,
|
||||
query: { locationId: LOC },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
const cals: any[] = r?.calendars ?? [];
|
||||
console.log(`${cals.length} calendarios en la subcuenta ${LOC}\n`);
|
||||
|
||||
const ahora = Date.now();
|
||||
// Ventana amplia: dos años hacia atrás y uno hacia adelante.
|
||||
const desde = String(ahora - 730 * DIA);
|
||||
const hasta = String(ahora + 365 * DIA);
|
||||
|
||||
let totalEventos = 0;
|
||||
for (const c of cals) {
|
||||
const res = await eventos({
|
||||
locationId: LOC,
|
||||
calendarId: c.id,
|
||||
startTime: desde,
|
||||
endTime: hasta,
|
||||
});
|
||||
const activo = c.isActive === false ? " (inactivo)" : "";
|
||||
if (typeof res === "string") {
|
||||
console.log(` ✖ ${String(c.name).padEnd(42)}${activo} → ${res}`);
|
||||
} else {
|
||||
totalEventos += res.length;
|
||||
const marca = res.length ? "✔" : "·";
|
||||
console.log(` ${marca} ${String(c.name).padEnd(42)}${activo} → ${res.length} citas`);
|
||||
if (res.length) {
|
||||
const e = res[0];
|
||||
console.log(` ejemplo: ${JSON.stringify(e).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal de citas en los 7 calendarios (2 años atrás → 1 adelante): ${totalEventos}`);
|
||||
|
||||
// La otra forma de acotar que documenta la API: por usuario en vez de por
|
||||
// calendario. Si por calendario no sale nada, conviene descartar que las
|
||||
// citas cuelguen de un usuario y no de un calendario.
|
||||
console.log("\n── Prueba alterna: acotar por usuario en vez de por calendario");
|
||||
const porUsuario = await eventos({
|
||||
locationId: LOC,
|
||||
userId: "x",
|
||||
startTime: desde,
|
||||
endTime: hasta,
|
||||
});
|
||||
console.log(
|
||||
typeof porUsuario === "string"
|
||||
? ` respuesta: ${porUsuario}`
|
||||
: ` ${porUsuario.length} citas`
|
||||
);
|
||||
|
||||
console.log("\n── ¿Y sin acotar por calendario ni usuario?");
|
||||
const sinFiltro = await eventos({ locationId: LOC, startTime: desde, endTime: hasta });
|
||||
console.log(
|
||||
typeof sinFiltro === "string"
|
||||
? ` respuesta: ${sinFiltro}`
|
||||
: ` ${sinFiltro.length} citas`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Se detuvo:", e?.message ?? e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* MEDIDO: el CRM rechaza una segunda oportunidad para el mismo contacto aunque
|
||||
* la primera esté en `won` (400 OPPORTUNITY_NO_DUPLICATE).
|
||||
*
|
||||
* Antes de rediseñar el mapeo hay que saber si eso es un límite duro o un
|
||||
* ajuste de la subcuenta que el cliente puede cambiar. Tres sondeos:
|
||||
* a) ¿el ajuste aparece en la ficha de la subcuenta?
|
||||
* b) ¿el bloqueo es por (contacto, pipeline) o global por contacto?
|
||||
* c) ¿el buscador de oportunidades permite recuperar la existente para
|
||||
* reciclarla? — es el plan B, y tiene que funcionar sí o sí.
|
||||
*/
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { crmRequest } from "../crm/client.ts";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const LOC = process.env.CRM_LOCATION_ID!;
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
const CONTACTO = "WzBTBaHkNnpmjMb1Avx3";
|
||||
|
||||
const ok = (s: string) => console.log(` ✔ ${s}`);
|
||||
const fail = (s: string) => console.log(` ✗ ${s}`);
|
||||
|
||||
async function main() {
|
||||
console.log("── a) ¿La ficha de la subcuenta expone algún ajuste de duplicados?");
|
||||
try {
|
||||
const r: any = await crmRequest("GET", `/locations/${LOC}`, { token: CTX.token });
|
||||
const loc = r?.location ?? {};
|
||||
const claves = Object.keys(loc).sort();
|
||||
console.log(" claves:", claves.join(", "));
|
||||
const settings = loc.settings ?? null;
|
||||
console.log(" settings:", JSON.stringify(settings, null, 2));
|
||||
} catch (e: any) {
|
||||
fail(e.message);
|
||||
}
|
||||
|
||||
console.log("\n── b) ¿Se pueden listar los pipelines y crear uno propio de spa?");
|
||||
try {
|
||||
const r: any = await crmRequest("POST", "/opportunities/pipelines", { token: CTX.token,
|
||||
body: { locationId: LOC, name: "AgendaMax — Agenda Spa (prueba)" },
|
||||
});
|
||||
ok(`pipeline creado id=${r?.pipeline?.id ?? JSON.stringify(r).slice(0, 200)}`);
|
||||
console.log(" >> Se puede rediseñar el pipeline a etapas de spa desde la plataforma.");
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
|
||||
console.log(" >> El token no tiene `pipelines.create`; el pipeline se rediseña a mano.");
|
||||
}
|
||||
|
||||
console.log("\n── c) PLAN B: recuperar la oportunidad existente para reciclarla");
|
||||
try {
|
||||
const r: any = await crmRequest("GET", "/opportunities/search", { token: CTX.token,
|
||||
query: { location_id: LOC, contact_id: CONTACTO, limit: 20 },
|
||||
});
|
||||
const opps = r?.opportunities ?? [];
|
||||
ok(`el buscador devuelve ${opps.length} oportunidad(es) del contacto`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
opps.map((o: any) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
status: o.status,
|
||||
monetaryValue: o.monetaryValue,
|
||||
pipelineId: o.pipelineId,
|
||||
pipelineStageId: o.pipelineStageId,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(" >> Con esto se puede localizar y actualizar la existente en vez de crear.");
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
console.log("\n── d) ¿El PUT general permite renombrar y cambiar el importe (reciclar)?");
|
||||
const OPP = "IMkYdAkBowggN9aKVbfc";
|
||||
try {
|
||||
const nuevoNombre = `Pedicura — Prueba AgendaMax (reciclada ${Date.now()})`;
|
||||
await crmRequest("PUT", `/opportunities/${OPP}`, { token: CTX.token,
|
||||
body: { pipelineId: "Mrclt4VzRZV1DI4Vbt5c", name: nuevoNombre, monetaryValue: 400 },
|
||||
});
|
||||
const r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||
const o = r?.opportunity;
|
||||
if (o?.name === nuevoNombre && o?.monetaryValue === 400) {
|
||||
ok(`releído: nombre e importe reciclados (status sigue «${o.status}»)`);
|
||||
console.log(" >> PLAN B VIABLE: la oportunidad se reutiliza por clienta.");
|
||||
} else {
|
||||
fail(`al releer nombre=${o?.name} importe=${o?.monetaryValue}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
|
||||
}
|
||||
|
||||
console.log("\n── e) ¿Y volver a abrirla (open) tras haberla cerrado?");
|
||||
try {
|
||||
await crmRequest("PUT", `/opportunities/${OPP}/status`, { token: CTX.token, body: { status: "open" } });
|
||||
const r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||
if (r?.opportunity?.status === "open") ok("sí: una oportunidad cerrada se puede reabrir");
|
||||
else fail(`al releer status=${r?.opportunity?.status}`);
|
||||
} catch (e: any) {
|
||||
fail(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Ejerce por primera vez la ESCRITURA de citas y servicios contra el CRM real.
|
||||
*
|
||||
* Ciclo completo y autolimpiable: crear → RELEER → borrar → confirmar que ya no
|
||||
* está. Nada queda en la subcuenta del cliente aunque el script falle a mitad:
|
||||
* lo creado se registra y se borra en el `finally`.
|
||||
*
|
||||
* Usa las funciones de producción (`crearCita`, `obtenerCita`, `publicarServicio`)
|
||||
* y no una implementación paralela: lo que se valida aquí es el código que va a
|
||||
* correr, no un primo suyo.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts
|
||||
*/
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { crmRequest, VERSION_CALENDARS } from "../crm/client.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import {
|
||||
crearCita,
|
||||
obtenerCita,
|
||||
listarCalendarios,
|
||||
listarPersonal,
|
||||
isoConDesplazamiento,
|
||||
} from "../crm/calendars.ts";
|
||||
import { catalogoDelCrm } from "../crm/services.ts";
|
||||
|
||||
const CTX = ctxDesdeEnv(1);
|
||||
const CONTACTO_PRUEBA = "WzBTBaHkNnpmjMb1Avx3"; // el que ya lleva el tag agendamax:prueba
|
||||
const TZ = "America/Mexico_City";
|
||||
|
||||
const creados: { que: string; id: string; ruta: string }[] = [];
|
||||
let spaId = "";
|
||||
let fallos = 0;
|
||||
|
||||
const check = (nombre: string, ok: boolean, detalle = "") => {
|
||||
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
|
||||
if (!ok) fallos++;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
// ── CITA ──────────────────────────────────────────────────────────────────
|
||||
console.log("\n══ ESCRITURA DE CITA AL CALENDARIO ══");
|
||||
|
||||
const cals = await listarCalendarios(CTX);
|
||||
const spa = cals.find((c) => /servicio spa/i.test(c.name)) ?? cals[0];
|
||||
spaId = spa.id;
|
||||
console.log(` calendario: ${spa.name} (${spa.id})`);
|
||||
|
||||
const personal = await listarPersonal(CTX);
|
||||
const quien = personal.find((p) => /recepci/i.test(p.name)) ?? personal[0];
|
||||
console.log(` asignada a: ${quien.name} (${quien.id})`);
|
||||
|
||||
// Una fecha lejana y a una hora inequívoca, para que se distinga de lo real.
|
||||
const inicio = new Date(Date.now() + 120 * 86400000);
|
||||
inicio.setUTCHours(20, 0, 0, 0); // 14:00 en México
|
||||
const fin = new Date(inicio.getTime() + 45 * 60000);
|
||||
|
||||
const startTime = isoConDesplazamiento(inicio, TZ);
|
||||
const endTime = isoConDesplazamiento(fin, TZ);
|
||||
console.log(` ventana : ${startTime} → ${endTime}`);
|
||||
|
||||
const { id: eventoId } = await crearCita(CTX, {
|
||||
calendarId: spa.id,
|
||||
contactId: CONTACTO_PRUEBA,
|
||||
startTime,
|
||||
endTime,
|
||||
title: "[agendamax:prueba] Cita de verificación — BORRAR",
|
||||
assignedUserId: quien.id,
|
||||
appointmentStatus: "confirmed",
|
||||
});
|
||||
creados.push({ que: "cita", id: eventoId, ruta: `/calendars/events/${eventoId}` });
|
||||
check("el CRM aceptó la cita y devolvió id", Boolean(eventoId), eventoId);
|
||||
|
||||
// No se acepta el 200 como prueba: se relee.
|
||||
const releida = await obtenerCita(CTX, eventoId);
|
||||
check("la cita existe al releerla", Boolean(releida));
|
||||
check(
|
||||
"el contacto es el correcto",
|
||||
releida?.contactId === CONTACTO_PRUEBA,
|
||||
releida?.contactId ?? "(sin contacto)"
|
||||
);
|
||||
check(
|
||||
"la hora de pared coincide con la que escribimos",
|
||||
String(releida?.startTime ?? "").startsWith(startTime.slice(0, 16)),
|
||||
`escrita ${startTime} · releída ${releida?.startTime}`
|
||||
);
|
||||
check(
|
||||
"el estado quedó confirmado",
|
||||
releida?.appointmentStatus === "confirmed",
|
||||
String(releida?.appointmentStatus)
|
||||
);
|
||||
|
||||
// ── SERVICIO ──────────────────────────────────────────────────────────────
|
||||
console.log("\n══ PUBLICACIÓN DE SERVICIO AL CATÁLOGO ══");
|
||||
|
||||
const antes = await catalogoDelCrm(CTX);
|
||||
console.log(` catálogo antes: ${antes.length} servicios`);
|
||||
|
||||
const r = await crmRequest<any>("POST", "/calendars/services/catalog", {
|
||||
token: CTX.token,
|
||||
version: VERSION_CALENDARS,
|
||||
body: {
|
||||
locationId: CTX.locationId,
|
||||
name: "[agendamax:prueba] Servicio de verificación",
|
||||
slug: `agendamax-prueba-${Date.now()}`,
|
||||
serviceDuration: 45,
|
||||
serviceDurationUnit: "mins",
|
||||
staff: [{ id: quien.id }],
|
||||
variations: [],
|
||||
},
|
||||
});
|
||||
const servicioId = r?.service?.id ?? r?.id;
|
||||
if (servicioId) {
|
||||
creados.push({
|
||||
que: "servicio",
|
||||
id: servicioId,
|
||||
ruta: `/calendars/services/catalog/${servicioId}`,
|
||||
});
|
||||
}
|
||||
check("el CRM aceptó el servicio y devolvió id", Boolean(servicioId), String(servicioId));
|
||||
|
||||
const despues = await catalogoDelCrm(CTX);
|
||||
check(
|
||||
"el servicio aparece al releer el catálogo",
|
||||
despues.some((s) => s.id === servicioId),
|
||||
`${antes.length} → ${despues.length} servicios`
|
||||
);
|
||||
const nuevo = despues.find((s) => s.id === servicioId);
|
||||
check("con la duración que le pusimos", nuevo?.serviceDuration === 45, String(nuevo?.serviceDuration));
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e: any) {
|
||||
fallos++;
|
||||
console.error("\n✖ El sondeo falló:", e?.error ?? e?.message ?? e);
|
||||
if (e?.body) console.error(" cuerpo:", JSON.stringify(e.body).slice(0, 400));
|
||||
} finally {
|
||||
// ── LIMPIEZA: pase lo que pase, no se deja nada en la subcuenta ───────────
|
||||
console.log("\n══ LIMPIEZA ══");
|
||||
for (const c of creados) {
|
||||
try {
|
||||
await crmRequest("DELETE", c.ruta, { token: CTX.token, version: VERSION_CALENDARS });
|
||||
console.log(` ✔ ${c.que} ${c.id} borrada`);
|
||||
} catch (e: any) {
|
||||
fallos++;
|
||||
console.error(` ✖ NO se pudo borrar ${c.que} ${c.id}: ${e?.message ?? e}`);
|
||||
console.error(` BÓRRALA A MANO en el CRM.`);
|
||||
}
|
||||
}
|
||||
// Se confirma el borrado releyendo, no fiándose del 200.
|
||||
for (const c of creados) {
|
||||
if (c.que === "cita") {
|
||||
// MEDIDO: `GET /calendars/events/appointments/{id}` SIGUE devolviendo la
|
||||
// cita después de borrarla — es un borrado lógico. La comprobación fiable
|
||||
// es listar el rango del calendario, donde ya no aparece.
|
||||
const ahora = Date.now();
|
||||
const ev = await crmRequest<any>("GET", "/calendars/events", {
|
||||
token: CTX.token,
|
||||
version: VERSION_CALENDARS,
|
||||
query: {
|
||||
locationId: CTX.locationId,
|
||||
calendarId: spaId,
|
||||
startTime: String(ahora),
|
||||
endTime: String(ahora + 365 * 86400000),
|
||||
},
|
||||
});
|
||||
const sigue = (ev?.events ?? []).some((e: any) => e.id === c.id);
|
||||
check("la cita ya no aparece en el calendario", !sigue);
|
||||
} else {
|
||||
const cat = await catalogoDelCrm(CTX);
|
||||
check("el servicio ya no está en el catálogo", !cat.some((s) => s.id === c.id));
|
||||
}
|
||||
}
|
||||
await pool.end();
|
||||
console.log(`\n${fallos === 0 ? "Todo en verde y la subcuenta queda limpia" : `${fallos} fallos`}`);
|
||||
process.exit(fallos === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Sondeo de SOLO LECTURA de las rutas que hacen falta para sincronizar por id.
|
||||
*
|
||||
* El objetivo es «traer por id» contactos, conversaciones, mensajes, citas y
|
||||
* servicios. De esas cinco, solo contactos está ejercida hoy (HALLAZGOS 1-23);
|
||||
* el resto está sin medir, y en este proyecto lo medido gana a lo documentado.
|
||||
*
|
||||
* NO escribe nada en la subcuenta. Todas las peticiones son GET.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts
|
||||
*/
|
||||
import { crmRequest, CrmError, VERSION_CALENDARS } from "../crm/client.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { requireEnv } from "../lib/env.ts";
|
||||
|
||||
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
async function probe(titulo: string, fn: () => Promise<string>) {
|
||||
process.stdout.write(`\n── ${titulo}\n`);
|
||||
try {
|
||||
const detalle = await fn();
|
||||
ok++;
|
||||
console.log(` ✔ ${detalle}`);
|
||||
} catch (e: any) {
|
||||
fail++;
|
||||
if (e instanceof CrmError) {
|
||||
const cuerpo = typeof e.body === "string" ? e.body.slice(0, 200) : JSON.stringify(e.body)?.slice(0, 300);
|
||||
console.log(` ✖ ${e.status} — ${e.message}`);
|
||||
if (cuerpo && cuerpo !== "null") console.log(` cuerpo: ${cuerpo}`);
|
||||
} else {
|
||||
console.log(` ✖ ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const claves = (o: unknown, n = 14) =>
|
||||
o && typeof o === "object" ? Object.keys(o as object).slice(0, n).join(", ") : String(o);
|
||||
|
||||
async function main() {
|
||||
console.log(`Subcuenta ${LOC} — sondeo de lectura por id (sin escrituras)`);
|
||||
|
||||
// ── CONVERSACIONES ────────────────────────────────────────────────────────
|
||||
let convId = "";
|
||||
let contactoDeConv = "";
|
||||
await probe("GET /conversations/search — listar para obtener un id real", async () => {
|
||||
const r = await crmRequest<any>("GET", "/conversations/search", { token: CTX.token,
|
||||
query: { locationId: LOC, limit: 3 },
|
||||
});
|
||||
const c = r?.conversations?.[0];
|
||||
if (!c) throw new Error("no devolvió ninguna conversación");
|
||||
convId = c.id;
|
||||
contactoDeConv = c.contactId ?? "";
|
||||
return `total=${r.total} · primera id=${convId} · campos: ${claves(c)}`;
|
||||
});
|
||||
|
||||
await probe("GET /conversations/{id} — traer UNA conversación por su id", async () => {
|
||||
if (!convId) throw new Error("sin id de conversación");
|
||||
const r = await crmRequest<any>("GET", `/conversations/${convId}`, { token: CTX.token });
|
||||
const c = r?.conversation ?? r;
|
||||
return `campos: ${claves(c)}`;
|
||||
});
|
||||
|
||||
await probe("GET /conversations/search?contactId= — conversaciones de un contacto", async () => {
|
||||
if (!contactoDeConv) throw new Error("la conversación no traía contactId");
|
||||
const r = await crmRequest<any>("GET", "/conversations/search", { token: CTX.token,
|
||||
query: { locationId: LOC, contactId: contactoDeConv, limit: 5 },
|
||||
});
|
||||
return `contacto ${contactoDeConv} → ${r?.conversations?.length ?? 0} conversaciones (total=${r?.total})`;
|
||||
});
|
||||
|
||||
// ── MENSAJES ──────────────────────────────────────────────────────────────
|
||||
let msgId = "";
|
||||
await probe("GET /conversations/{id}/messages — mensajes del hilo", async () => {
|
||||
if (!convId) throw new Error("sin id de conversación");
|
||||
const r = await crmRequest<any>("GET", `/conversations/${convId}/messages`, { token: CTX.token,
|
||||
query: { limit: 5 },
|
||||
});
|
||||
const lista = r?.messages?.messages ?? r?.messages ?? [];
|
||||
msgId = lista[0]?.id ?? "";
|
||||
return `${lista.length} mensajes · paginación: ${claves(r?.messages)} · campos del mensaje: ${claves(lista[0])}`;
|
||||
});
|
||||
|
||||
await probe("GET /conversations/messages/{id} — traer UN mensaje por su id", async () => {
|
||||
if (!msgId) throw new Error("sin id de mensaje");
|
||||
const r = await crmRequest<any>("GET", `/conversations/messages/${msgId}`, { token: CTX.token });
|
||||
return `campos: ${claves(r?.message ?? r)}`;
|
||||
});
|
||||
|
||||
// ── CALENDARIOS Y CITAS ───────────────────────────────────────────────────
|
||||
let calId = "";
|
||||
await probe("GET /calendars/ — calendarios de la subcuenta", async () => {
|
||||
const r = await crmRequest<any>("GET", "/calendars/", { token: CTX.token,
|
||||
query: { locationId: LOC },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
const cals: any[] = r?.calendars ?? [];
|
||||
calId = cals[0]?.id ?? "";
|
||||
return `${cals.length} calendarios: ${cals.map((c) => `${c.name}(${c.id})`).join(", ").slice(0, 260)}`;
|
||||
});
|
||||
|
||||
await probe("GET /calendars/events — citas en un rango de fechas", async () => {
|
||||
if (!calId) throw new Error("sin calendario");
|
||||
const ahora = Date.now();
|
||||
const r = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
|
||||
query: {
|
||||
locationId: LOC,
|
||||
calendarId: calId,
|
||||
startTime: String(ahora - 90 * 86400000),
|
||||
endTime: String(ahora + 90 * 86400000),
|
||||
},
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
const ev: any[] = r?.events ?? [];
|
||||
return `${ev.length} eventos en ±90 días · campos: ${claves(ev[0])}`;
|
||||
});
|
||||
|
||||
await probe("GET /calendars/events/appointments/{id} — traer UNA cita por id", async () => {
|
||||
const ahora = Date.now();
|
||||
if (!calId) throw new Error("sin calendario");
|
||||
const lista = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
|
||||
query: {
|
||||
locationId: LOC,
|
||||
calendarId: calId,
|
||||
startTime: String(ahora - 365 * 86400000),
|
||||
endTime: String(ahora + 365 * 86400000),
|
||||
},
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
const id = lista?.events?.[0]?.id;
|
||||
if (!id) throw new Error("no hay ninguna cita en ±365 días con la que probar");
|
||||
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${id}`, { token: CTX.token,
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return `cita ${id} · campos: ${claves(r?.appointment ?? r)}`;
|
||||
});
|
||||
|
||||
// ── SERVICIOS ─────────────────────────────────────────────────────────────
|
||||
await probe("GET /calendars/services/catalog — catálogo de servicios", async () => {
|
||||
const r = await crmRequest<any>("GET", "/calendars/services/catalog", { token: CTX.token,
|
||||
query: { locationId: LOC },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
const s: any[] = r?.services ?? [];
|
||||
return `${s.length} servicios${s.length ? ` · campos: ${claves(s[0])}` : " (vacío, confirma el hallazgo 6)"}`;
|
||||
});
|
||||
|
||||
await probe("GET /calendars/groups — agrupaciones de calendarios", async () => {
|
||||
const r = await crmRequest<any>("GET", "/calendars/groups", { token: CTX.token,
|
||||
query: { locationId: LOC },
|
||||
version: VERSION_CALENDARS,
|
||||
});
|
||||
return `${r?.groups?.length ?? 0} grupos · ${claves(r?.groups?.[0])}`;
|
||||
});
|
||||
|
||||
// ── CONTACTO POR ID (ya medido; se reconfirma para tener la forma) ────────
|
||||
await probe("GET /contacts/{id} — traer UN contacto por id", async () => {
|
||||
const b = await crmRequest<any>("POST", "/contacts/search", { token: CTX.token,
|
||||
body: { locationId: LOC, pageLimit: 1 },
|
||||
});
|
||||
const id = b?.contacts?.[0]?.id;
|
||||
if (!id) throw new Error("el buscador no devolvió contactos");
|
||||
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: CTX.token });
|
||||
return `contacto ${id} · campos: ${claves(r?.contact, 20)}`;
|
||||
});
|
||||
|
||||
console.log(`\n────────────\n${ok} rutas responden · ${fail} fallan`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("\nEl sondeo se detuvo:", e?.message ?? e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* La pregunta que decide el mapeo cita → oportunidad:
|
||||
*
|
||||
* MEDIDO: `POST /opportunities/` devuelve 400 OPPORTUNITY_NO_DUPLICATE con
|
||||
* `meta.existingId` cuando el contacto ya tiene una.
|
||||
*
|
||||
* ¿El bloqueo es sobre CUALQUIER oportunidad, o solo sobre las abiertas? De eso
|
||||
* depende todo: una clienta de spa vuelve muchas veces, y si el CRM solo admite
|
||||
* una oportunidad por contacto en toda su vida, entonces "una cita = una
|
||||
* oportunidad" es un modelo imposible y hay que reciclar la misma fila.
|
||||
*/
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { crmRequest } from "../crm/client.ts";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const LOC = process.env.CRM_LOCATION_ID!;
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
const PIPELINE = "Mrclt4VzRZV1DI4Vbt5c";
|
||||
const ETAPA_PRIMERA = "8063839c-fa73-419a-8b16-9606fe8e64c1";
|
||||
const ETAPA_GANADO = "b91c1653-e785-43a8-8b54-f493205c9b5a";
|
||||
const CONTACTO = "WzBTBaHkNnpmjMb1Avx3"; // el de prueba, ya creado
|
||||
const OPP = "IMkYdAkBowggN9aKVbfc";
|
||||
|
||||
const ok = (s: string) => console.log(` ✔ ${s}`);
|
||||
const fail = (s: string) => console.log(` ✗ ${s}`);
|
||||
|
||||
async function crearOtra(nombre: string) {
|
||||
try {
|
||||
const r: any = await crmRequest("POST", "/opportunities/", { token: CTX.token,
|
||||
body: {
|
||||
pipelineId: PIPELINE,
|
||||
locationId: LOC,
|
||||
name: nombre,
|
||||
pipelineStageId: ETAPA_PRIMERA,
|
||||
status: "open",
|
||||
contactId: CONTACTO,
|
||||
monetaryValue: 400,
|
||||
},
|
||||
});
|
||||
return { creada: r?.opportunity?.id as string, error: null as any };
|
||||
} catch (e: any) {
|
||||
return { creada: null, error: e };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("── 1. Cerrar la oportunidad existente como «won» (cita completada)");
|
||||
await crmRequest("PUT", `/opportunities/${OPP}/status`, { token: CTX.token, body: { status: "won" } });
|
||||
let r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||
if (r?.opportunity?.status === "won") ok(`releído status=won, etapa=${r.opportunity.pipelineStageId}`);
|
||||
else fail(`al releer status=${r?.opportunity?.status}`);
|
||||
|
||||
console.log("\n── 2. ¿El PUT general mueve la etapa a «Ganado»?");
|
||||
try {
|
||||
await crmRequest("PUT", `/opportunities/${OPP}`, { token: CTX.token,
|
||||
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_GANADO },
|
||||
});
|
||||
r = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
|
||||
if (r?.opportunity?.pipelineStageId === ETAPA_GANADO)
|
||||
ok(`etapa movida a Ganado, status sigue en «${r.opportunity.status}»`);
|
||||
else fail(`al releer etapa=${r?.opportunity?.pipelineStageId}`);
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
|
||||
}
|
||||
|
||||
console.log("\n── 3. LA PREGUNTA: ¿deja crear otra con la anterior ya cerrada?");
|
||||
const segunda = await crearOtra("Manicura — Prueba AgendaMax (segunda visita)");
|
||||
if (segunda.creada) {
|
||||
ok(`SÍ — creada id=${segunda.creada}`);
|
||||
console.log(" >> El bloqueo es solo sobre oportunidades ABIERTAS.");
|
||||
console.log(" >> Modelo viable: una cita = una oportunidad, cerrando la previa.");
|
||||
|
||||
console.log("\n── 4. Y con esta abierta, ¿rechaza una tercera?");
|
||||
const tercera = await crearOtra("Pedicura — Prueba AgendaMax (tercera)");
|
||||
if (tercera.creada) {
|
||||
console.log(` ✔ también la creó (id=${tercera.creada})`);
|
||||
console.log(" >> Entonces el rechazo anterior fue por nombre/importe idénticos.");
|
||||
console.log(` Limpieza extra: DELETE /opportunities/${tercera.creada}`);
|
||||
} else {
|
||||
ok(`rechazada como se esperaba: ${tercera.error?.body?.code ?? tercera.error?.message}`);
|
||||
console.log(" >> CONFIRMADO: máximo UNA oportunidad abierta por contacto.");
|
||||
}
|
||||
console.log(`\n Limpieza: DELETE /opportunities/${segunda.creada}`);
|
||||
} else {
|
||||
fail(`NO — ${segunda.error?.body?.code ?? segunda.error?.message}`);
|
||||
console.log(" >> Grave: un contacto solo puede tener UNA oportunidad en toda su vida.");
|
||||
console.log(" >> Entonces la oportunidad no puede representar una cita, sino la");
|
||||
console.log(" relación con la clienta, y se recicla en cada visita.");
|
||||
}
|
||||
|
||||
console.log("\n── 5. Oportunidades del contacto, tal como las ve el CRM");
|
||||
try {
|
||||
const l: any = await crmRequest("GET", `/contacts/${CONTACTO}/opportunities`, { token: CTX.token });
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
(l?.opportunities ?? []).map((o: any) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
status: o.status,
|
||||
monetaryValue: o.monetaryValue,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} catch (e: any) {
|
||||
fail(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ¿Tiene el token permiso de ESCRITURA sobre calendarios y servicios?
|
||||
*
|
||||
* Sin crear nada. El truco: se manda un POST deliberadamente incompleto y se
|
||||
* mira QUÉ error vuelve.
|
||||
*
|
||||
* 401 «not authorized for this scope» → falta el permiso
|
||||
* 400 / 422 sobre campos → el permiso está, lo que falla es el cuerpo
|
||||
*
|
||||
* Distinguir esas dos cosas es justo lo que hace falta para saber si se puede
|
||||
* planificar la escritura de citas al calendario del CRM, y no cuesta un solo
|
||||
* registro basura en la subcuenta del cliente.
|
||||
*
|
||||
* De paso lee las cabeceras X-RateLimit-*, que hoy no lee nadie: el intervalo de
|
||||
* 650 ms del cliente es una estimación observada, no una cuota conocida.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts
|
||||
*/
|
||||
import { requireEnv, loadEnv } from "../lib/env.ts";
|
||||
|
||||
loadEnv();
|
||||
const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com";
|
||||
const LOC = requireEnv("CRM_LOCATION_ID");
|
||||
const TOKEN = requireEnv("CRM_TOKEN");
|
||||
|
||||
async function crudo(
|
||||
method: string,
|
||||
path: string,
|
||||
version: string,
|
||||
body?: unknown
|
||||
): Promise<{ status: number; texto: string; headers: Record<string, string> }> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${TOKEN}`,
|
||||
version,
|
||||
accept: "application/json",
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => {
|
||||
if (k.toLowerCase().startsWith("x-ratelimit")) headers[k] = v;
|
||||
});
|
||||
return { status: res.status, texto: await res.text(), headers };
|
||||
}
|
||||
|
||||
function veredicto(status: number, texto: string): string {
|
||||
if (status === 401 && /not authorized for this scope/i.test(texto)) {
|
||||
return "✖ FALTA EL PERMISO";
|
||||
}
|
||||
if (status === 401) return "✖ 401 (token rechazado o sin permiso — ambiguo)";
|
||||
if (status === 400 || status === 422) return "✔ EL PERMISO ESTÁ (rechaza por el cuerpo, no por el token)";
|
||||
if (status >= 200 && status < 300) return "⚠ ACEPTÓ LA PETICIÓN — revisa si creó algo";
|
||||
return `? ${status}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Subcuenta ${LOC}\n`);
|
||||
|
||||
console.log("── ¿calendars/events.write? (POST incompleto a propósito)");
|
||||
{
|
||||
// Falta `startTime`, que es obligatorio. Si el permiso está, la API se queja
|
||||
// del campo; si no está, se queja del token antes de mirar el cuerpo.
|
||||
const r = await crudo("POST", "/calendars/events/appointments", "v3", {
|
||||
locationId: LOC,
|
||||
});
|
||||
console.log(` ${veredicto(r.status, r.texto)}`);
|
||||
console.log(` ${r.status} · ${r.texto.slice(0, 320)}`);
|
||||
}
|
||||
|
||||
console.log("\n── ¿calendars.write? (POST incompleto al catálogo de servicios)");
|
||||
{
|
||||
// Faltan `name`, `slug` y `staff[]`, todos obligatorios.
|
||||
const r = await crudo("POST", "/calendars/services/catalog", "v3", {
|
||||
locationId: LOC,
|
||||
});
|
||||
console.log(` ${veredicto(r.status, r.texto)}`);
|
||||
console.log(` ${r.status} · ${r.texto.slice(0, 320)}`);
|
||||
}
|
||||
|
||||
console.log("\n── ¿users.readonly? (confirma el hallazgo 14)");
|
||||
{
|
||||
const r = await crudo("GET", `/users/?locationId=${LOC}`, "2021-07-28");
|
||||
let n = 0;
|
||||
try { n = JSON.parse(r.texto || "{}")?.users?.length ?? 0; } catch { n = 0; }
|
||||
console.log(
|
||||
r.status === 200
|
||||
? ` ✔ RESPONDE 200 con ${n} usuarios — el hallazgo 14 (401) ha quedado obsoleto`
|
||||
: ` ✖ ${r.status} · ${r.texto.slice(0, 160)}`
|
||||
);
|
||||
if (r.status === 200 && n) {
|
||||
const us = JSON.parse(r.texto).users.slice(0, 8);
|
||||
for (const u of us) console.log(` ${u.id} ${u.name ?? ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n── Citas de un contacto: GET /contacts/{id}/appointments");
|
||||
{
|
||||
const b = await crudo("POST", "/contacts/search", "2021-07-28", {
|
||||
locationId: LOC,
|
||||
pageLimit: 1,
|
||||
});
|
||||
let id: string | undefined;
|
||||
try { id = JSON.parse(b.texto || "{}")?.contacts?.[0]?.id; } catch { id = undefined; }
|
||||
if (!id) {
|
||||
console.log(" (no se pudo obtener un contacto de prueba)");
|
||||
} else {
|
||||
const r = await crudo("GET", `/contacts/${id}/appointments`, "2021-07-28");
|
||||
console.log(` contacto ${id} → ${r.status} · ${r.texto.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n── Cabeceras de límite de tasa (nadie las lee hoy)");
|
||||
{
|
||||
const r = await crudo("GET", `/locations/${LOC}`, "2021-07-28");
|
||||
const hs = Object.entries(r.headers);
|
||||
if (!hs.length) {
|
||||
console.log(" la respuesta no trae ninguna cabecera X-RateLimit-*");
|
||||
} else {
|
||||
for (const [k, v] of hs) console.log(` ${k}: ${v}`);
|
||||
const max = Number(r.headers["x-ratelimit-max"]);
|
||||
const ventana = Number(r.headers["x-ratelimit-interval-milliseconds"]);
|
||||
if (max && ventana) {
|
||||
console.log(
|
||||
` → cuota real: ${max} peticiones / ${ventana} ms = 1 cada ${Math.ceil(ventana / max)} ms`
|
||||
);
|
||||
console.log(` → el cliente usa 650 ms; margen sin aprovechar: ${(650 / (ventana / max)).toFixed(1)}×`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Se detuvo:", e?.message ?? e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Spike de ESCRITURA contra Bucéfalo CRM. Escribe de verdad en la subcuenta del
|
||||
* cliente, así que todo lo que crea lleva el tag `agendamax:prueba` y el correo
|
||||
* autorizado, y al final imprime cómo borrarlo.
|
||||
*
|
||||
* Regla que gobierna este archivo: **un 200 no es prueba de nada**. Cada
|
||||
* escritura se vuelve a leer desde la API antes de darla por buena. El proyecto
|
||||
* hermano pasó meses creyendo que escribía porque los tests estaban escritos
|
||||
* desde la implementación y no contra el contrato real.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts
|
||||
*/
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { crmRequest } from "../crm/client.ts";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const LOC = process.env.CRM_LOCATION_ID!;
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
const CORREO = process.env.CRM_TEST_EMAIL || "[email protected]";
|
||||
const PIPELINE = "Mrclt4VzRZV1DI4Vbt5c"; // "Standar", medido en el spike de lectura
|
||||
const ETAPA_PRIMERA = "8063839c-fa73-419a-8b16-9606fe8e64c1"; // 1er Contacto
|
||||
const ETAPA_GANADO = "b91c1653-e785-43a8-8b54-f493205c9b5a";
|
||||
const ETAPA_PERDIDO = "04b28d7f-167b-4e97-af24-5a229f56b27f";
|
||||
|
||||
const marca = `agendamax-spike-${Date.now()}`;
|
||||
|
||||
function ok(s: string) {
|
||||
console.log(` ✔ ${s}`);
|
||||
}
|
||||
function fail(s: string) {
|
||||
console.log(` ✗ ${s}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Subcuenta ${LOC} · correo de prueba ${CORREO}\n`);
|
||||
let contactId: string | null = null;
|
||||
let oppId: string | null = null;
|
||||
|
||||
// ── 1. Crear contacto CON atribución UTM ────────────────────────────────
|
||||
console.log("── POST /contacts/ (con attributionSource)");
|
||||
try {
|
||||
const r: any = await crmRequest("POST", "/contacts/", { token: CTX.token,
|
||||
body: {
|
||||
locationId: LOC,
|
||||
firstName: "Prueba",
|
||||
lastName: "AgendaMax",
|
||||
email: CORREO,
|
||||
phone: "+524451052792",
|
||||
country: "MX",
|
||||
source: "AgendaMax",
|
||||
tags: ["agendamax:prueba"],
|
||||
attributionSource: {
|
||||
sessionSource: "Referral",
|
||||
utmSource: "agendamax",
|
||||
utmMedium: "plataforma",
|
||||
utmCampaign: marca,
|
||||
campaign: marca, // hay que mandar los dos: /contacts/search descarta utmCampaign
|
||||
medium: "form",
|
||||
referrer: "https://agendamax.consultoriae3.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
contactId = r?.contact?.id ?? null;
|
||||
ok(`creado id=${contactId}`);
|
||||
} catch (e: any) {
|
||||
if (e.status === 400 && e.body?.meta?.contactId) {
|
||||
contactId = e.body.meta.contactId;
|
||||
ok(`ya existía (400 con meta) id=${contactId} · campo=${e.body?.meta?.matchingField}`);
|
||||
console.log(" >> El rechazo de duplicado del CRM funciona: es idempotencia real.");
|
||||
} else {
|
||||
fail(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. RELEER el contacto: ¿persistió la atribución? ────────────────────
|
||||
console.log("\n── GET /contacts/{id} — relectura (¿persistió el UTM?)");
|
||||
if (contactId) {
|
||||
try {
|
||||
const r: any = await crmRequest("GET", `/contacts/${contactId}`, { token: CTX.token });
|
||||
const c = r?.contact;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
id: c?.id,
|
||||
email: c?.email,
|
||||
phone: c?.phone,
|
||||
tags: c?.tags,
|
||||
source: c?.source,
|
||||
attributionSource: c?.attributionSource,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const utm = c?.attributionSource?.utmCampaign || c?.attributionSource?.campaign;
|
||||
if (utm === marca) ok("la atribución persistió y se puede releer");
|
||||
else fail(`la atribución NO coincide (esperaba ${marca}, leí ${utm})`);
|
||||
} catch (e: any) {
|
||||
fail(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Crear oportunidad ────────────────────────────────────────────────
|
||||
console.log("\n── POST /opportunities/ (cita en espera → status open)");
|
||||
if (contactId) {
|
||||
try {
|
||||
const r: any = await crmRequest("POST", "/opportunities/", { token: CTX.token,
|
||||
body: {
|
||||
pipelineId: PIPELINE,
|
||||
locationId: LOC,
|
||||
name: "Extensiones de pestañas — Prueba AgendaMax",
|
||||
pipelineStageId: ETAPA_PRIMERA,
|
||||
status: "open",
|
||||
contactId,
|
||||
monetaryValue: 850,
|
||||
},
|
||||
});
|
||||
oppId = r?.opportunity?.id ?? null;
|
||||
ok(`creada id=${oppId}`);
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. RELEER la oportunidad ────────────────────────────────────────────
|
||||
console.log("\n── GET /opportunities/{id} — relectura");
|
||||
if (oppId) {
|
||||
try {
|
||||
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||
const o = r?.opportunity;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
id: o?.id,
|
||||
name: o?.name,
|
||||
status: o?.status,
|
||||
monetaryValue: o?.monetaryValue,
|
||||
pipelineStageId: o?.pipelineStageId,
|
||||
contactId: o?.contact?.id ?? o?.contactId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
if (o?.monetaryValue === 850) ok("el importe persistió");
|
||||
else fail(`el importe NO persistió: leí ${o?.monetaryValue}`);
|
||||
} catch (e: any) {
|
||||
fail(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Cambiar el estado a won (cita completada) ────────────────────────
|
||||
// MEDIDO: /status NO acepta pipelineStageId (422 "should not exist"). Solo status.
|
||||
console.log("\n── PUT /opportunities/{id}/status — cita completada → won (solo status)");
|
||||
if (oppId) {
|
||||
try {
|
||||
await crmRequest("PUT", `/opportunities/${oppId}/status`, { token: CTX.token, body: { status: "won" } });
|
||||
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||
const o = r?.opportunity;
|
||||
if (o?.status === "won") ok(`releído: status=${o.status}, etapa=${o.pipelineStageId}`);
|
||||
else fail(`devolvió 200 pero al releer status=${o?.status}`);
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5b. ¿Se puede mover la etapa por el PUT general? ─────────────────────
|
||||
console.log("\n── PUT /opportunities/{id} — mover a la etapa «Ganado»");
|
||||
if (oppId) {
|
||||
try {
|
||||
await crmRequest("PUT", `/opportunities/${oppId}`, { token: CTX.token,
|
||||
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_GANADO },
|
||||
});
|
||||
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||
const o = r?.opportunity;
|
||||
if (o?.pipelineStageId === ETAPA_GANADO) ok(`releído: etapa movida, status=${o.status}`);
|
||||
else fail(`al releer etapa=${o?.pipelineStageId}`);
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Volver a lost (cita cancelada) ───────────────────────────────────
|
||||
console.log("\n── PUT /opportunities/{id}/status — cita cancelada → lost");
|
||||
if (oppId) {
|
||||
try {
|
||||
await crmRequest("PUT", `/opportunities/${oppId}/status`, { token: CTX.token, body: { status: "lost" } });
|
||||
await crmRequest("PUT", `/opportunities/${oppId}`, { token: CTX.token,
|
||||
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_PERDIDO },
|
||||
});
|
||||
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
|
||||
const o = r?.opportunity;
|
||||
if (o?.status === "lost") ok(`releído: status=lost, etapa=${o.pipelineStageId}`);
|
||||
else fail(`al releer status=${o?.status}`);
|
||||
} catch (e: any) {
|
||||
fail(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Enviar un correo ─────────────────────────────────────────────────
|
||||
console.log("\n── POST /conversations/messages — correo de prueba");
|
||||
if (contactId) {
|
||||
try {
|
||||
const r: any = await crmRequest("POST", "/conversations/messages", { token: CTX.token,
|
||||
body: {
|
||||
type: "Email",
|
||||
contactId,
|
||||
subject: "Prueba de integración AgendaMax ↔ Bucéfalo CRM",
|
||||
html: `<p>Mensaje de prueba enviado desde AgendaMax.</p><p>Marca: <code>${marca}</code></p>`,
|
||||
emailTo: CORREO,
|
||||
},
|
||||
});
|
||||
ok(`aceptado: ${JSON.stringify(r).slice(0, 300)}`);
|
||||
console.log(" >> Un 200 aquí NO prueba entrega. Hay que mirar la bandeja real.");
|
||||
} catch (e: any) {
|
||||
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 400)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n\nLimpieza (marca ${marca}):`);
|
||||
if (oppId) console.log(` DELETE /opportunities/${oppId}`);
|
||||
if (contactId) console.log(` DELETE /contacts/${contactId}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Spike de integración contra Bucéfalo CRM. Solo lecturas.
|
||||
*
|
||||
* Existe porque el proyecto hermano ya pagó el precio de descubrir tarde que
|
||||
* ninguna escritura funcionaba: los tests estaban escritos desde la
|
||||
* implementación y no contra el contrato real de la API. Aquí no se da por
|
||||
* buena ninguna capacidad sin haberla ejercido.
|
||||
*
|
||||
* node scripts/run-tsx.mjs platform/scripts/crm-spike.ts
|
||||
*/
|
||||
import { loadEnv } from "../lib/env.ts";
|
||||
import { ctxDesdeEnv } from "../crm/ctx.ts";
|
||||
import { crmRequest } from "../crm/client.ts";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const LOC = process.env.CRM_LOCATION_ID!;
|
||||
|
||||
const CTX = ctxDesdeEnv();
|
||||
|
||||
async function probe(label: string, fn: () => Promise<unknown>) {
|
||||
process.stdout.write(`\n── ${label}\n`);
|
||||
try {
|
||||
const out = await fn();
|
||||
console.log(JSON.stringify(out, null, 2).slice(0, 1800));
|
||||
return out as any;
|
||||
} catch (e: any) {
|
||||
console.log(` ✗ ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Subcuenta: ${LOC}`);
|
||||
|
||||
await probe("GET /locations/{id} — ¿el token ve la subcuenta?", async () => {
|
||||
const r: any = await crmRequest("GET", `/locations/${LOC}`, { token: CTX.token });
|
||||
return {
|
||||
name: r?.location?.name,
|
||||
timezone: r?.location?.timezone,
|
||||
country: r?.location?.country,
|
||||
};
|
||||
});
|
||||
|
||||
await probe("POST /contacts/search — forma real de un contacto", async () => {
|
||||
const r: any = await crmRequest("POST", `/contacts/search`, { token: CTX.token,
|
||||
body: { locationId: LOC, page: 1, pageLimit: 2 },
|
||||
});
|
||||
const c = r?.contacts?.[0];
|
||||
return {
|
||||
total: r?.total,
|
||||
devueltos: r?.contacts?.length,
|
||||
claves_de_un_contacto: c ? Object.keys(c).sort() : null,
|
||||
attributionSource: c?.attributionSource ?? null,
|
||||
customFields_ejemplo: c?.customFields?.slice(0, 3) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const pipes = await probe("GET /opportunities/pipelines — pipeline y etapas", async () => {
|
||||
const r: any = await crmRequest("GET", `/opportunities/pipelines?locationId=${LOC}`, { token: CTX.token });
|
||||
return (r?.pipelines ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
stages: (p.stages ?? []).map((s: any) => ({ id: s.id, name: s.name, position: s.position })),
|
||||
}));
|
||||
});
|
||||
|
||||
await probe("GET /locations/{id}/customFields — campos personalizados", async () => {
|
||||
const r: any = await crmRequest("GET", `/locations/${LOC}/customFields`, { token: CTX.token });
|
||||
return (r?.customFields ?? []).map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
fieldKey: f.fieldKey,
|
||||
dataType: f.dataType,
|
||||
}));
|
||||
});
|
||||
|
||||
await probe("GET /users/?locationId — personal del CRM", async () => {
|
||||
const r: any = await crmRequest("GET", `/users/?locationId=${LOC}`, { token: CTX.token });
|
||||
return (r?.users ?? []).map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
roles: u.roles?.role,
|
||||
}));
|
||||
});
|
||||
|
||||
await probe("GET /calendars/?locationId — ¿EXISTEN calendarios?", async () => {
|
||||
const r: any = await crmRequest("GET", `/calendars/?locationId=${LOC}`, { token: CTX.token, version: "v3" });
|
||||
return {
|
||||
cuantos: r?.calendars?.length ?? 0,
|
||||
calendarios: (r?.calendars ?? []).map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isActive: c.isActive,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
await probe("GET /calendars/services/catalog — ¿hay catálogo de servicios?", async () => {
|
||||
const r: any = await crmRequest("GET", `/calendars/services/catalog?locationId=${LOC}`, { token: CTX.token,
|
||||
version: "v3",
|
||||
});
|
||||
return r;
|
||||
});
|
||||
|
||||
await probe("POST /conversations/search — conversaciones", async () => {
|
||||
const r: any = await crmRequest("GET", `/conversations/search?locationId=${LOC}&limit=2`, { token: CTX.token });
|
||||
const c = r?.conversations?.[0];
|
||||
return {
|
||||
total: r?.total,
|
||||
claves_de_una_conversacion: c ? Object.keys(c).sort() : null,
|
||||
muestra: c
|
||||
? { id: c.id, contactId: c.contactId, lastMessageType: c.lastMessageType, type: c.type }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const pipeline = (pipes ?? [])[0];
|
||||
if (pipeline) {
|
||||
console.log(
|
||||
`\n>> Pipeline por defecto: ${pipeline.name} (${pipeline.id}) con ${pipeline.stages.length} etapas`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Siembra la base de desarrollo con el spa, su personal, un catálogo de partida
|
||||
* y unas citas de hoy sin resolver, para poder recorrer el cierre de día.
|
||||
*
|
||||
* ATENCIÓN SOBRE EL CATÁLOGO: los servicios de abajo salen del vocabulario
|
||||
* medido en la muestra anotada de hilos del CRM (`extensiones`, `facial`,
|
||||
* `pedicura`, `pestañas`, `uñas`, `depilación`, `masaje`) — no de la lista de
|
||||
* precios de la dueña. **Las duraciones y los precios son marcadores de
|
||||
* posición**, puestos para que la rejilla tenga algo que dibujar. Hay que
|
||||
* sustituirlos por los reales en una sesión con ella antes de enseñar esto como
|
||||
* catálogo del negocio.
|
||||
*/
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { runMigrations } from "../db/migrate.ts";
|
||||
import { normalizePhone } from "../lib/phone.ts";
|
||||
|
||||
const WORKING_HOURS = JSON.stringify({
|
||||
1: { start: "09:00", end: "20:00" },
|
||||
2: { start: "09:00", end: "20:00" },
|
||||
3: { start: "09:00", end: "20:00" },
|
||||
4: { start: "09:00", end: "20:00" },
|
||||
5: { start: "09:00", end: "20:00" },
|
||||
6: { start: "10:00", end: "18:00" },
|
||||
7: null,
|
||||
});
|
||||
|
||||
// nombre, categoría, duración (min), precio — duración y precio SIN VERIFICAR.
|
||||
const SERVICIOS: [string, string, number, number][] = [
|
||||
["Extensiones de pestañas", "pestañas", 120, 850],
|
||||
["Retoque de pestañas", "pestañas", 75, 550],
|
||||
["Limpieza facial profunda", "facial", 60, 700],
|
||||
["Manicura", "uñas", 45, 300],
|
||||
["Pedicura", "uñas", 60, 400],
|
||||
["Uñas acrílicas", "uñas", 90, 600],
|
||||
["Depilación con cera", "depilación", 30, 250],
|
||||
["Masaje relajante", "masaje", 60, 750],
|
||||
];
|
||||
|
||||
const PERSONAL: [string, string][] = [
|
||||
["Karla Ruiz", "[email protected]"],
|
||||
["Brenda Salas", "[email protected]"],
|
||||
["Paola Núñez", "[email protected]"],
|
||||
];
|
||||
|
||||
const CLIENTAS: [string, string | null][] = [
|
||||
["Mariana López", "55 8888 7777"],
|
||||
["Alejandra Torres", "5544443333"],
|
||||
["Gabriela Méndez", "+52 55 2222 1111"],
|
||||
["Rocío Herrera", null], // sin teléfono: no contactable, y es un caso real y frecuente
|
||||
["Diana Castillo", "01 55 6666 5555"],
|
||||
];
|
||||
|
||||
async function main() {
|
||||
await runMigrations();
|
||||
|
||||
const ya = await pool.query(`SELECT id FROM businesses WHERE slug = 'yola-franco'`);
|
||||
if (ya.rows[0]) {
|
||||
console.log("[seed] el negocio ya existe — no se toca nada");
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const biz = await pool.query(
|
||||
`INSERT INTO businesses (name, industry, slug, timezone, working_hours)
|
||||
VALUES ('Yola Franco Spa','Estética y Spa','yola-franco','America/Mexico_City',$1::jsonb)
|
||||
RETURNING id`,
|
||||
[WORKING_HOURS]
|
||||
);
|
||||
const bid = biz.rows[0].id as number;
|
||||
|
||||
const serviceIds: number[] = [];
|
||||
for (const [name, category, duration, price] of SERVICIOS) {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO services (business_id, name, category, duration_min, price)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||
[bid, name, category, duration, price]
|
||||
);
|
||||
serviceIds.push(r.rows[0].id);
|
||||
}
|
||||
|
||||
const employeeIds: number[] = [];
|
||||
for (const [name, email] of PERSONAL) {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO employees (business_id, name, email) VALUES ($1,$2,$3) RETURNING id`,
|
||||
[bid, name, email]
|
||||
);
|
||||
employeeIds.push(r.rows[0].id);
|
||||
// Todo el personal puede dar todos los servicios hasta que la dueña acote
|
||||
// quién hace qué. Es una suposición, y conviene que se note.
|
||||
for (const sid of serviceIds) {
|
||||
await pool.query(
|
||||
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
|
||||
[r.rows[0].id, sid]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner')`,
|
||||
[bid]
|
||||
);
|
||||
for (let i = 0; i < PERSONAL.length; i++) {
|
||||
await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role, employee_id)
|
||||
VALUES ($1,$2,'demo1234',$3,'employee',$4)`,
|
||||
[bid, PERSONAL[i][1], PERSONAL[i][0], employeeIds[i]]
|
||||
);
|
||||
}
|
||||
|
||||
const clientIds: number[] = [];
|
||||
for (const [name, phone] of CLIENTAS) {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO clients (business_id, name, phone, phone_e164) VALUES ($1,$2,$3,$4)
|
||||
RETURNING id`,
|
||||
[bid, name, phone, normalizePhone(phone)]
|
||||
);
|
||||
clientIds.push(r.rows[0].id);
|
||||
}
|
||||
|
||||
// Citas de HOY, sin resolver, para que el cierre de día tenga trabajo. Las
|
||||
// horas se construyen sobre el día local del proceso, que en desarrollo es el
|
||||
// del spa; el servidor las acota con la zona del negocio de todas formas.
|
||||
const hoy = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
const dia = `${hoy.getFullYear()}-${p(hoy.getMonth() + 1)}-${p(hoy.getDate())}`;
|
||||
// Hora local de México → UTC: +6 h. Se escribe explícito para no depender del
|
||||
// reloj del proceso.
|
||||
const citas: [number, number, number, string][] = [
|
||||
[clientIds[0], employeeIds[0], serviceIds[0], `${dia}T16:00:00Z`], // 10:00 local
|
||||
[clientIds[1], employeeIds[1], serviceIds[3], `${dia}T17:00:00Z`], // 11:00
|
||||
[clientIds[2], employeeIds[2], serviceIds[2], `${dia}T18:30:00Z`], // 12:30
|
||||
[clientIds[3], employeeIds[0], serviceIds[6], `${dia}T20:00:00Z`], // 14:00
|
||||
[clientIds[4], employeeIds[1], serviceIds[7], `${dia}T22:00:00Z`], // 16:00
|
||||
];
|
||||
for (const [cid, eid, sid, start] of citas) {
|
||||
await pool.query(
|
||||
`INSERT INTO appointments (business_id, client_id, employee_id, service_id,
|
||||
start_at, end_at, price)
|
||||
SELECT $1,$2,$3,$4,$5::timestamptz,
|
||||
$5::timestamptz + make_interval(mins => duration_min), price
|
||||
FROM services WHERE id = $4`,
|
||||
[bid, cid, eid, sid, start]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[seed] Yola Franco Spa creado: ${SERVICIOS.length} servicios, ` +
|
||||
`${PERSONAL.length} especialistas, ${CLIENTAS.length} clientas, ${citas.length} citas de hoy.`
|
||||
);
|
||||
console.log("[seed] Entra con [email protected] / demo1234");
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { createApp } from "../index.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
import { guardarCredencial } from "../crm/ctx.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 5).toString("base64");
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
let adminId: number;
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES (NULL,'[email protected]','demo1234','Plataforma','admin') RETURNING id`
|
||||
);
|
||||
adminId = rows[0].id;
|
||||
server = createApp().listen(0);
|
||||
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
after(async () => {
|
||||
server.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
function req(path: string, init: RequestInit = {}, userId: number = adminId) {
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${userId}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("una dueña de negocio no puede entrar a la consola de plataforma", async () => {
|
||||
const r = await req("/api/admin/businesses", {}, ids.ownerUserId);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test("una empleada tampoco", async () => {
|
||||
const r = await req("/api/admin/businesses", {}, ids.employeeUserId);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test("el superadministrador da de alta una cuenta con su dueña", async () => {
|
||||
const r = await req("/api/admin/businesses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Spa Nuevo",
|
||||
owner_email: "[email protected]",
|
||||
owner_name: "Dueña",
|
||||
owner_password: "demo1234",
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
const b = await r.json();
|
||||
assert.equal(b.business.name, "Spa Nuevo");
|
||||
assert.equal(b.business.slug, "spa-nuevo", "el negocio nace con slug");
|
||||
assert.equal(b.owner.email, "[email protected]", "el correo se normaliza a minúsculas");
|
||||
|
||||
// Sin horario, un negocio nuevo no tiene ninguna franja agendable.
|
||||
const { rows } = await pool.query(
|
||||
`SELECT working_hours FROM businesses WHERE id = $1`, [b.business.id]
|
||||
);
|
||||
assert.ok(rows[0].working_hours?.["1"], "el negocio nace con horario laboral");
|
||||
});
|
||||
|
||||
test("dos cuentas con el mismo nombre no chocan de slug", async () => {
|
||||
const r = await req("/api/admin/businesses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Spa Nuevo", owner_email: "[email protected]",
|
||||
owner_name: "Otra", owner_password: "x",
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal((await r.json()).business.slug, "spa-nuevo-2");
|
||||
});
|
||||
|
||||
test("un correo repetido se rechaza con 409, no con un 500 de la base", async () => {
|
||||
const r = await req("/api/admin/businesses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Tercero", owner_email: "[email protected]",
|
||||
owner_name: "X", owner_password: "x",
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
});
|
||||
|
||||
test("faltar datos de la dueña da 400 y lo dice", async () => {
|
||||
const r = await req("/api/admin/businesses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Sin dueña" }),
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
assert.match((await r.json()).error, /dueña/i);
|
||||
});
|
||||
|
||||
test("el listado nunca devuelve el token, solo su huella", async () => {
|
||||
await guardarCredencial(ids.businessId, "loc-1", "token-secretisimo", "Yola Franco Spa");
|
||||
const r = await req("/api/admin/businesses");
|
||||
assert.equal(r.status, 200);
|
||||
const texto = JSON.stringify(await r.json());
|
||||
assert.ok(!texto.includes("token-secretisimo"), "el token no puede salir por la API");
|
||||
assert.ok(texto.includes("etisimo".slice(-6)), "sí debe salir la huella");
|
||||
assert.ok(texto.includes("Yola Franco Spa"), "y la etiqueta de la subcuenta");
|
||||
});
|
||||
|
||||
test("suspender una cuenta la deja suspendida", async () => {
|
||||
const r = await req(`/api/admin/businesses/${ids.businessId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "suspended" }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal((await r.json()).business.status, "suspended");
|
||||
});
|
||||
|
||||
test("un estado inventado se rechaza", async () => {
|
||||
const r = await req(`/api/admin/businesses/${ids.businessId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "lo-que-sea" }),
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
test("desvincular borra la credencial y conserva la subcuenta", async () => {
|
||||
await guardarCredencial(ids.businessId, "loc-9", "token-x");
|
||||
const r = await req(`/api/admin/businesses/${ids.businessId}/crm`, { method: "DELETE" });
|
||||
assert.equal(r.status, 200);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT location_id, token_cipher FROM crm_connections WHERE business_id = $1`,
|
||||
[ids.businessId]
|
||||
);
|
||||
assert.equal(rows[0].location_id, "loc-9");
|
||||
assert.equal(rows[0].token_cipher, null);
|
||||
});
|
||||
|
||||
test("vincular sin token o sin subcuenta da 400", async () => {
|
||||
const r = await req(`/api/admin/businesses/${ids.businessId}/crm`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ location_id: "loc-1" }),
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { createApp } from "../index.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
server = createApp().listen(0);
|
||||
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
after(async () => {
|
||||
server.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${userId}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const nueva = (start: string) => ({
|
||||
client_id: ids.clientId,
|
||||
employee_id: ids.employeeId,
|
||||
service_id: ids.serviceId,
|
||||
start_at: start,
|
||||
});
|
||||
|
||||
test("crea una cita y calcula el fin con la duración del servicio", async () => {
|
||||
const r = await req("/api/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(nueva("2026-09-02T16:00:00Z")),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
const { appointment } = await r.json();
|
||||
// El servicio sembrado dura 90 min.
|
||||
assert.equal(appointment.end_at, "2026-09-02T17:30:00Z");
|
||||
assert.equal(appointment.price, 850);
|
||||
assert.equal(appointment.status, "scheduled");
|
||||
});
|
||||
|
||||
test("un solape devuelve 409 en español, no un 500", async () => {
|
||||
const r = await req("/api/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(nueva("2026-09-02T17:00:00Z")),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
const body = await r.json();
|
||||
assert.match(body.error, /ocupad/i);
|
||||
});
|
||||
|
||||
test("reprogramar a un hueco libre funciona", async () => {
|
||||
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
|
||||
const r = await req(`/api/appointments/${rows[0].id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ start_at: "2026-09-02T19:00:00Z" }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const { appointment } = await r.json();
|
||||
assert.equal(appointment.start_at, "2026-09-02T19:00:00Z");
|
||||
assert.equal(appointment.end_at, "2026-09-02T20:30:00Z");
|
||||
});
|
||||
|
||||
test("reprogramar encima de otra cita devuelve 409", async () => {
|
||||
await req("/api/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(nueva("2026-09-02T12:00:00Z")),
|
||||
});
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM appointments WHERE start_at = '2026-09-02T12:00:00Z'`
|
||||
);
|
||||
const r = await req(`/api/appointments/${rows[0].id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ start_at: "2026-09-02T19:30:00Z" }),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
});
|
||||
|
||||
test("cancelar libera el hueco y registra quién canceló", async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM appointments WHERE start_at = '2026-09-02T19:00:00Z'`
|
||||
);
|
||||
const r = await req(`/api/appointments/${rows[0].id}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ cancelled_by: "client", reason: "Se enfermó" }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const { appointment } = await r.json();
|
||||
assert.equal(appointment.status, "cancelled");
|
||||
assert.equal(appointment.cancelled_by, "client");
|
||||
|
||||
const libre = await req("/api/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(nueva("2026-09-02T19:00:00Z")),
|
||||
});
|
||||
assert.equal(libre.status, 201, "el hueco de una cancelada vuelve a estar libre");
|
||||
});
|
||||
|
||||
test("cada cambio deja un evento en appointment_events", async () => {
|
||||
const { rows } = await pool.query(`SELECT action FROM appointment_events ORDER BY id`);
|
||||
const acciones = rows.map((r) => r.action);
|
||||
assert.ok(acciones.includes("created"));
|
||||
assert.ok(acciones.includes("rescheduled"));
|
||||
assert.ok(acciones.includes("cancelled"));
|
||||
});
|
||||
|
||||
test("no se puede tocar una cita de otro negocio", async () => {
|
||||
const other = await pool.query(
|
||||
`INSERT INTO businesses (name, slug, working_hours)
|
||||
VALUES ('Otro Spa 2','otro-2','{}'::jsonb) RETURNING id`
|
||||
);
|
||||
const otherUser = await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES ($1,'[email protected]','x','Otra','owner') RETURNING id`,
|
||||
[other.rows[0].id]
|
||||
);
|
||||
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
|
||||
const r = await req(
|
||||
`/api/appointments/${rows[0].id}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ notes: "intruso" }) },
|
||||
otherUser.rows[0].id
|
||||
);
|
||||
assert.equal(r.status, 404);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { createApp } from "../index.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
server = createApp().listen(0);
|
||||
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
after(async () => {
|
||||
server.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
function req(path: string, init: RequestInit = {}, userId = ids.employeeUserId) {
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${userId}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function crearCita(start: string): Promise<number> {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO appointments
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||
VALUES ($1,$2,$3,$4,$5::timestamptz,$5::timestamptz + interval '90 minutes',850)
|
||||
RETURNING id`,
|
||||
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, start]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
test("marcar Vino crea la visita y completa la cita", async () => {
|
||||
const id = await crearCita("2026-09-03T16:00:00Z");
|
||||
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: true, total_charged: 900, payment_method: "cash" }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const { appointment, visit } = await r.json();
|
||||
assert.equal(appointment.status, "completed");
|
||||
assert.equal(visit.total_charged, 900);
|
||||
assert.equal(visit.payment_method, "cash");
|
||||
assert.equal(visit.recorded_by_user_id, ids.employeeUserId);
|
||||
});
|
||||
|
||||
test("marcar No vino no crea visita", async () => {
|
||||
const id = await crearCita("2026-09-03T18:00:00Z");
|
||||
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: false }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const { appointment, visit } = await r.json();
|
||||
assert.equal(appointment.status, "no_show");
|
||||
assert.equal(visit, null);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*)::int c FROM visits WHERE appointment_id = $1`,
|
||||
[id]
|
||||
);
|
||||
assert.equal(rows[0].c, 0);
|
||||
});
|
||||
|
||||
test("marcar dos veces la misma cita devuelve 409", async () => {
|
||||
const id = await crearCita("2026-09-03T20:00:00Z");
|
||||
await req(`/api/appointments/${id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: true }),
|
||||
});
|
||||
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: false }),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
const body = await r.json();
|
||||
assert.match(body.error, /ya se resolvió/i);
|
||||
});
|
||||
|
||||
test("no se puede marcar asistencia en una cita cancelada", async () => {
|
||||
const id = await crearCita("2026-09-04T16:00:00Z");
|
||||
await pool.query(
|
||||
`UPDATE appointments SET status='cancelled', cancelled_by='client' WHERE id=$1`,
|
||||
[id]
|
||||
);
|
||||
const r = await req(`/api/appointments/${id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: true }),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
});
|
||||
|
||||
test("una empleada no puede resolver la cita de otra", async () => {
|
||||
const otra = await pool.query(
|
||||
`INSERT INTO employees (business_id, name) VALUES ($1,'Otra especialista') RETURNING id`,
|
||||
[ids.businessId]
|
||||
);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO appointments
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||
VALUES ($1,$2,$3,$4,'2026-09-05T16:00:00Z','2026-09-05T17:00:00Z',0)
|
||||
RETURNING id`,
|
||||
[ids.businessId, ids.clientId, otra.rows[0].id, ids.serviceId]
|
||||
);
|
||||
const r = await req(`/api/appointments/${rows[0].id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: true }),
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
|
||||
// La administradora sí puede.
|
||||
const rOwner = await req(
|
||||
`/api/appointments/${rows[0].id}/attendance`,
|
||||
{ method: "POST", body: JSON.stringify({ attended: true }) },
|
||||
ids.ownerUserId
|
||||
);
|
||||
assert.equal(rOwner.status, 200);
|
||||
});
|
||||
|
||||
test("el toque deja evento y auditoría", async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT action FROM appointment_events WHERE action IN ('attended','no_show')`
|
||||
);
|
||||
assert.ok(rows.some((r) => r.action === "attended"));
|
||||
assert.ok(rows.some((r) => r.action === "no_show"));
|
||||
|
||||
const audit = await pool.query(
|
||||
`SELECT count(*)::int c FROM audit_log WHERE action = 'attendance'`
|
||||
);
|
||||
assert.ok(audit.rows[0].c >= 2);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool, withTx } from "../db/pool.ts";
|
||||
import { writeAudit } from "../lib/audit.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
});
|
||||
after(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("writeAudit guarda el antes y el después como jsonb", async () => {
|
||||
await withTx(async (c) => {
|
||||
await writeAudit(c, {
|
||||
businessId: ids.businessId,
|
||||
actorUserId: ids.ownerUserId,
|
||||
entity: "clients",
|
||||
entityId: ids.clientId,
|
||||
action: "update",
|
||||
before: { name: "Mariana" },
|
||||
after: { name: "Mariana López" },
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT entity, action, before, after, ip FROM audit_log
|
||||
WHERE entity_id = $1 ORDER BY id DESC LIMIT 1`,
|
||||
[ids.clientId]
|
||||
);
|
||||
assert.equal(rows[0].entity, "clients");
|
||||
assert.equal(rows[0].action, "update");
|
||||
assert.deepEqual(rows[0].before, { name: "Mariana" });
|
||||
assert.deepEqual(rows[0].after, { name: "Mariana López" });
|
||||
assert.equal(rows[0].ip, "127.0.0.1");
|
||||
});
|
||||
|
||||
test("writeAudit se apunta a la transacción que lo llama", async () => {
|
||||
await assert.rejects(
|
||||
withTx(async (c) => {
|
||||
await writeAudit(c, {
|
||||
businessId: ids.businessId,
|
||||
actorUserId: ids.ownerUserId,
|
||||
entity: "clients",
|
||||
entityId: ids.clientId,
|
||||
action: "delete",
|
||||
});
|
||||
throw new Error("boom");
|
||||
})
|
||||
);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*)::int c FROM audit_log WHERE action = 'delete'`
|
||||
);
|
||||
assert.equal(rows[0].c, 0, "el rollback debe llevarse también la auditoría");
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { createApp } from "../index.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
server = createApp().listen(0);
|
||||
const addr = server.address() as { port: number };
|
||||
base = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
after(async () => {
|
||||
server.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${userId}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("crea una clienta y guarda el teléfono normalizado", async () => {
|
||||
const r = await req("/api/clients", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Sofía Ramírez", phone: "(55) 4444-3333" }),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
const { client } = await r.json();
|
||||
assert.equal(client.phone, "(55) 4444-3333", "conserva lo que tecleó la persona");
|
||||
assert.equal(client.phone_e164, "+525544443333");
|
||||
assert.equal(client.contactable, true);
|
||||
});
|
||||
|
||||
test("el segundo alta con el mismo número devuelve 409 con la ficha existente", async () => {
|
||||
const r = await req("/api/clients", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Sofia R.", phone: "+52 55 4444 3333" }),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
const body = await r.json();
|
||||
assert.match(body.error, /ya existe/i);
|
||||
assert.equal(body.existing.name, "Sofía Ramírez");
|
||||
assert.equal(body.existing.phone_e164, "+525544443333");
|
||||
});
|
||||
|
||||
test("permite dar de alta sin teléfono, marcada como no contactable", async () => {
|
||||
const r = await req("/api/clients", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Clienta de mostrador" }),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
const { client } = await r.json();
|
||||
assert.equal(client.phone_e164, null);
|
||||
assert.equal(client.contactable, false);
|
||||
});
|
||||
|
||||
test("la búsqueda encuentra por teléfono sin formato", async () => {
|
||||
const r = await req("/api/clients?q=5544443333");
|
||||
const { clients } = await r.json();
|
||||
assert.equal(clients.length, 1);
|
||||
assert.equal(clients[0].name, "Sofía Ramírez");
|
||||
});
|
||||
|
||||
test("el alta deja rastro en audit_log", async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT action, actor_user_id FROM audit_log
|
||||
WHERE entity = 'clients' AND action = 'create' ORDER BY id DESC LIMIT 1`
|
||||
);
|
||||
assert.equal(rows[0].action, "create");
|
||||
assert.equal(rows[0].actor_user_id, ids.ownerUserId);
|
||||
});
|
||||
|
||||
test("no se ven clientas de otro negocio", async () => {
|
||||
const other = await pool.query(
|
||||
`INSERT INTO businesses (name, slug, working_hours)
|
||||
VALUES ('Otro Spa','otro','{}'::jsonb) RETURNING id`
|
||||
);
|
||||
const otherUser = await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES ($1,'[email protected]','x','Otro','owner') RETURNING id`,
|
||||
[other.rows[0].id]
|
||||
);
|
||||
const r = await req("/api/clients", {}, otherUser.rows[0].id);
|
||||
const { clients } = await r.json();
|
||||
assert.equal(clients.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { resetDb, crearNegocio } from "./helpers.ts";
|
||||
import { ctxDe, guardarCredencial, olvidarCredencial } from "../crm/ctx.ts";
|
||||
|
||||
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 3).toString("base64");
|
||||
|
||||
test("dos negocios tienen credenciales distintas y no se cruzan", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio({ name: "Spa A", slug: "spa-a" });
|
||||
const b = await crearNegocio({ name: "Spa B", slug: "spa-b" });
|
||||
|
||||
await guardarCredencial(a.id, "loc-AAA", "token-de-A", "Spa A");
|
||||
await guardarCredencial(b.id, "loc-BBB", "token-de-B", "Spa B");
|
||||
|
||||
const ctxA = await ctxDe(a.id);
|
||||
const ctxB = await ctxDe(b.id);
|
||||
|
||||
assert.equal(ctxA.locationId, "loc-AAA");
|
||||
assert.equal(ctxA.token, "token-de-A");
|
||||
assert.equal(ctxB.locationId, "loc-BBB");
|
||||
assert.equal(ctxB.token, "token-de-B");
|
||||
assert.equal(ctxA.businessId, a.id);
|
||||
});
|
||||
|
||||
test("el token no queda en claro en la base", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio();
|
||||
await guardarCredencial(a.id, "loc-1", "token-secretisimo");
|
||||
const { rows } = await pool.query<{ token_cipher: Buffer; token_fingerprint: string }>(
|
||||
`SELECT token_cipher, token_fingerprint FROM crm_connections WHERE business_id = $1`,
|
||||
[a.id]
|
||||
);
|
||||
assert.ok(
|
||||
!rows[0].token_cipher.toString("utf8").includes("token-secretisimo"),
|
||||
"el cifrado no puede contener el token legible"
|
||||
);
|
||||
assert.equal(rows[0].token_fingerprint, "etisimo".slice(-6));
|
||||
});
|
||||
|
||||
test("volver a guardar rota la credencial sin duplicar la fila", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio();
|
||||
await guardarCredencial(a.id, "loc-1", "token-viejo");
|
||||
await guardarCredencial(a.id, "loc-1", "token-nuevo");
|
||||
assert.equal((await ctxDe(a.id)).token, "token-nuevo");
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM crm_connections WHERE business_id = $1`,
|
||||
[a.id]
|
||||
);
|
||||
assert.equal(rows[0].n, 1);
|
||||
});
|
||||
|
||||
test("rotar la credencial conserva la etiqueta anterior si no se manda otra", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio();
|
||||
await guardarCredencial(a.id, "loc-1", "t1", "Yola Franco Spa");
|
||||
await guardarCredencial(a.id, "loc-1", "t2");
|
||||
const { rows } = await pool.query<{ label: string }>(
|
||||
`SELECT label FROM crm_connections WHERE business_id = $1`,
|
||||
[a.id]
|
||||
);
|
||||
assert.equal(rows[0].label, "Yola Franco Spa");
|
||||
});
|
||||
|
||||
test("un negocio sin conexión da un 409 que dice qué hacer", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio();
|
||||
await assert.rejects(
|
||||
() => ctxDe(a.id),
|
||||
(e: any) => {
|
||||
assert.equal(e.status, 409);
|
||||
assert.match(e.error, /no está vinculado/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("una conexión sin token da un 409 distinto del de sin conexión", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio();
|
||||
await pool.query(
|
||||
`INSERT INTO crm_connections (business_id, location_id) VALUES ($1, 'loc-1')`,
|
||||
[a.id]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => ctxDe(a.id),
|
||||
(e: any) => {
|
||||
assert.equal(e.status, 409);
|
||||
assert.match(e.error, /token/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("olvidarCredencial borra el token pero conserva la conexión y lo sincronizado", async () => {
|
||||
await resetDb();
|
||||
const a = await crearNegocio();
|
||||
await guardarCredencial(a.id, "loc-1", "token-x", "Etiqueta");
|
||||
await olvidarCredencial(a.id);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT location_id, label, token_cipher, token_fingerprint
|
||||
FROM crm_connections WHERE business_id = $1`,
|
||||
[a.id]
|
||||
);
|
||||
assert.equal(rows[0].location_id, "loc-1", "la subcuenta se recuerda");
|
||||
assert.equal(rows[0].label, "Etiqueta");
|
||||
assert.equal(rows[0].token_cipher, null);
|
||||
assert.equal(rows[0].token_fingerprint, null);
|
||||
// `ctxDe` lanza `{ status, error }`, no un Error: la forma con expresión
|
||||
// regular compara contra `message`, que un objeto plano no tiene.
|
||||
await assert.rejects(
|
||||
() => ctxDe(a.id),
|
||||
(e: any) => {
|
||||
assert.match(e.error, /token/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// La zona del proceso es UTC y la del negocio America/Mexico_City: nunca
|
||||
// coinciden, así que una recaída de zona horaria falla aquí y no en producción.
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { createApp } from "../index.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
server = createApp().listen(0);
|
||||
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
after(async () => {
|
||||
server.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
|
||||
return fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${userId}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function crearCita(startUtc: string): Promise<number> {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO appointments
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||
VALUES ($1,$2,$3,$4,$5::timestamptz,$5::timestamptz + interval '60 minutes',850)
|
||||
RETURNING id`,
|
||||
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, startUtc]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
test("una cita de las 19:00 de México cuenta en su día local, no en el UTC", async () => {
|
||||
// 2026-09-07 19:00 en México (UTC-6) = 2026-09-08 01:00 UTC.
|
||||
await crearCita("2026-09-08T01:00:00Z");
|
||||
const r = await req("/api/day-close?date=2026-09-07");
|
||||
const body = await r.json();
|
||||
assert.equal(body.unresolved.length, 1, "debe contarse en el 7, no en el 8");
|
||||
});
|
||||
|
||||
test("no deja cerrar el día con citas sin resolver", async () => {
|
||||
const r = await req("/api/day-close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ date: "2026-09-07" }),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
const body = await r.json();
|
||||
assert.equal(body.unresolved.length, 1);
|
||||
assert.match(body.error, /sin resolver/i);
|
||||
});
|
||||
|
||||
test("cierra el día cuando todas están resueltas y guarda el conteo", async () => {
|
||||
const { rows } = await pool.query(`SELECT id FROM appointments WHERE status = 'scheduled'`);
|
||||
await req(`/api/appointments/${rows[0].id}/attendance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attended: true, total_charged: 850 }),
|
||||
});
|
||||
|
||||
const r = await req("/api/day-close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ date: "2026-09-07" }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const { closure } = await r.json();
|
||||
assert.equal(closure.attended_count, 1);
|
||||
assert.equal(closure.no_show_count, 0);
|
||||
assert.equal(closure.closed_by_user_id, ids.ownerUserId);
|
||||
});
|
||||
|
||||
test("cerrar dos veces el mismo día devuelve 409", async () => {
|
||||
const r = await req("/api/day-close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ date: "2026-09-07" }),
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
const body = await r.json();
|
||||
assert.match(body.error, /ya está cerrado/i);
|
||||
});
|
||||
|
||||
test("el resumen del día muestra la fecha de cierre", async () => {
|
||||
const r = await req("/api/day-close?date=2026-09-07");
|
||||
const body = await r.json();
|
||||
assert.ok(body.closed_at, "un día cerrado reporta cuándo se cerró");
|
||||
assert.equal(body.attended, 1);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { runMigrations } from "../db/migrate.ts";
|
||||
|
||||
export interface SeedIds {
|
||||
businessId: number;
|
||||
ownerUserId: number;
|
||||
employeeUserId: number;
|
||||
employeeId: number;
|
||||
serviceId: number;
|
||||
clientId: number;
|
||||
}
|
||||
|
||||
const WORKING_HOURS = JSON.stringify({
|
||||
1: { start: "09:00", end: "20:00" },
|
||||
2: { start: "09:00", end: "20:00" },
|
||||
3: { start: "09:00", end: "20:00" },
|
||||
4: { start: "09:00", end: "20:00" },
|
||||
5: { start: "09:00", end: "20:00" },
|
||||
6: { start: "10:00", end: "18:00" },
|
||||
7: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Vacía el esquema. La guarda del nombre no es decorativa: este DROP SCHEMA
|
||||
* contra la base de desarrollo se llevaría los datos del spa por delante.
|
||||
*/
|
||||
export async function dropSchema(): Promise<void> {
|
||||
if (!/yola_test/.test(process.env.DATABASE_URL || "")) {
|
||||
throw new Error("dropSchema solo corre contra yola_test — revisa DATABASE_URL");
|
||||
}
|
||||
await pool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
|
||||
}
|
||||
|
||||
/** Deja la base vacía y con el esquema al día. */
|
||||
export async function resetDb(): Promise<void> {
|
||||
await dropSchema();
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
export async function seedMinimal(): Promise<SeedIds> {
|
||||
const biz = await pool.query(
|
||||
`INSERT INTO businesses (name, slug, working_hours)
|
||||
VALUES ('Yola Franco Spa', 'yola-franco', $1::jsonb) RETURNING id`,
|
||||
[WORKING_HOURS]
|
||||
);
|
||||
const businessId = biz.rows[0].id as number;
|
||||
|
||||
const emp = await pool.query(
|
||||
`INSERT INTO employees (business_id, name, email)
|
||||
VALUES ($1,'Karla Ruiz','[email protected]') RETURNING id`,
|
||||
[businessId]
|
||||
);
|
||||
const employeeId = emp.rows[0].id as number;
|
||||
|
||||
const svc = await pool.query(
|
||||
`INSERT INTO services (business_id, name, duration_min, price)
|
||||
VALUES ($1,'Extensiones de pestañas',90,850) RETURNING id`,
|
||||
[businessId]
|
||||
);
|
||||
const serviceId = svc.rows[0].id as number;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
|
||||
[employeeId, serviceId]
|
||||
);
|
||||
|
||||
const owner = await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role)
|
||||
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner') RETURNING id`,
|
||||
[businessId]
|
||||
);
|
||||
const empUser = await pool.query(
|
||||
`INSERT INTO users (business_id, email, password, name, role, employee_id)
|
||||
VALUES ($1,'[email protected]','demo1234','Karla Ruiz','employee',$2) RETURNING id`,
|
||||
[businessId, employeeId]
|
||||
);
|
||||
|
||||
const cli = await pool.query(
|
||||
`INSERT INTO clients (business_id, name, phone, phone_e164)
|
||||
VALUES ($1,'Mariana López','55 8888 7777','+525588887777') RETURNING id`,
|
||||
[businessId]
|
||||
);
|
||||
|
||||
return {
|
||||
businessId,
|
||||
ownerUserId: owner.rows[0].id,
|
||||
employeeUserId: empUser.rows[0].id,
|
||||
employeeId,
|
||||
serviceId,
|
||||
clientId: cli.rows[0].id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un negocio suelto, sin catálogo ni personal.
|
||||
*
|
||||
* `seedMinimal` siembra UN negocio completo y sirve para casi todo; esto existe
|
||||
* para las pruebas multi-negocio, donde lo que se comprueba es justamente que
|
||||
* dos cuentas no se pisan y no hace falta el resto del inventario.
|
||||
*/
|
||||
export async function crearNegocio(
|
||||
opts: { name?: string; slug?: string } = {}
|
||||
): Promise<{ id: number; name: string }> {
|
||||
const name = opts.name ?? `Negocio ${Math.random().toString(36).slice(2, 8)}`;
|
||||
const slug = opts.slug ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO businesses (name, slug, working_hours)
|
||||
VALUES ($1, $2, $3::jsonb) RETURNING id, name`,
|
||||
[name, slug, WORKING_HOURS]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { runMigrations } from "../db/migrate.ts";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { dropSchema } from "./helpers.ts";
|
||||
|
||||
// Parte de un esquema vacío a propósito: la aserción es que el bootstrap se
|
||||
// aplica, y eso solo es cierto sobre una base sin migrar.
|
||||
before(async () => {
|
||||
await dropSchema();
|
||||
});
|
||||
after(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("runMigrations aplica los archivos pendientes y es idempotente", async () => {
|
||||
const first = await runMigrations();
|
||||
assert.ok(first.includes("000_bootstrap.sql"), "debe aplicar el bootstrap");
|
||||
assert.ok(first.includes("001_core.sql"), "debe aplicar el núcleo");
|
||||
|
||||
const second = await runMigrations();
|
||||
assert.deepEqual(second, [], "una segunda corrida no aplica nada");
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*)::int AS c FROM schema_migrations WHERE filename = '000_bootstrap.sql'`
|
||||
);
|
||||
assert.equal(rows[0].c, 1, "no debe registrarse dos veces");
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test, before, after } from "node:test";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { pool } from "../db/pool.ts";
|
||||
|
||||
import { resetDb, seedMinimal, crearNegocio } from "./helpers.ts";
|
||||
|
||||
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
|
||||
|
||||
|
||||
before(async () => {
|
||||
|
||||
await resetDb();
|
||||
|
||||
ids = await seedMinimal();
|
||||
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
||||
await pool.end();
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("la base rechaza dos citas solapadas de la misma empleada", async () => {
|
||||
|
||||
const ins = `INSERT INTO appointments
|
||||
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||
|
||||
VALUES ($1,$2,$3,$4,$5,$6,0) RETURNING id`;
|
||||
|
||||
|
||||
|
||||
await pool.query(ins, [
|
||||
|
||||
ids.businessId,
|
||||
|
||||
ids.clientId,
|
||||
|
||||
ids.employeeId,
|
||||
|
||||
ids.serviceId,
|
||||
|
||||
"2026-09-01T16:00:00Z",
|
||||
|
||||
"2026-09-01T17:00:00Z",
|
||||
|
||||
]);
|
||||
|
||||
|
||||
|
||||
await assert.rejects(
|
||||
|
||||
() =>
|
||||
|
||||
pool.query(ins, [
|
||||
|
||||
ids.businessId,
|
||||
|
||||
ids.clientId,
|
||||
|
||||
ids.employeeId,
|
||||
|
||||
ids.serviceId,
|
||||
|
||||
"2026-09-01T16:30:00Z",
|
||||
|
||||
"2026-09-01T17:30:00Z",
|
||||
|
||||
]),
|
||||
|
||||
(e: any) => e.code === "23P01",
|
||||
|
||||
"debe ser una violación de exclusión (23P01), no un error cualquiera"
|
||||
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("una cita cancelada libera el hueco", async () => {
|
||||
|
||||
await pool.query(
|
||||
|
||||
`UPDATE appointments SET status = 'cancelled', cancelled_by = 'client'
|
||||
|
||||
WHERE business_id = $1`,
|
||||
|
||||
[ids.businessId]
|
||||
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
|
||||
`INSERT INTO appointments
|
||||
|
||||
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
|
||||
|
||||
VALUES ($1,$2,$3,$4,'2026-09-01T16:15:00Z','2026-09-01T17:15:00Z',0)
|
||||
|
||||
RETURNING id`,
|
||||
|
||||
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId]
|
||||
|
||||
);
|
||||
|
||||
assert.ok(rows[0].id > 0);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("dos clientas del mismo negocio no pueden compartir teléfono normalizado", async () => {
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { resetDb, crearNegocio } from "./helpers.ts";
|
||||
import { upsertConversacion, upsertMensaje } from "../crm/syncConversations.ts";
|
||||
|
||||
test("upsertConversacion es idempotente: dos veces no duplica", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
const conv = {
|
||||
id: "conv-1",
|
||||
contactId: "c-1",
|
||||
fullName: "Ana",
|
||||
lastMessageBody: "hola",
|
||||
lastMessageType: "TYPE_SMS",
|
||||
lastMessageDate: 1756000000000,
|
||||
unreadCount: 2,
|
||||
};
|
||||
const id1 = await upsertConversacion(b.id, conv as any);
|
||||
const id2 = await upsertConversacion(b.id, conv as any);
|
||||
assert.equal(id1, id2);
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM conversations WHERE business_id = $1`,
|
||||
[b.id]
|
||||
);
|
||||
assert.equal(rows[0].n, 1);
|
||||
});
|
||||
|
||||
test("upsertConversacion enlaza con la clienta local por crm_contact_id", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
const { rows: cl } = await pool.query(
|
||||
`INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-9') RETURNING id`,
|
||||
[b.id]
|
||||
);
|
||||
await upsertConversacion(b.id, { id: "conv-9", contactId: "c-9", fullName: "Ana" } as any);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT client_id FROM conversations WHERE business_id = $1 AND crm_conversation_id = 'conv-9'`,
|
||||
[b.id]
|
||||
);
|
||||
assert.equal(rows[0].client_id, cl[0].id);
|
||||
});
|
||||
|
||||
test("el enlace con la clienta se rellena después, y no se pierde al resincronizar", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
// Primero llega la conversación, cuando la clienta todavía no existe.
|
||||
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
|
||||
const { rows: sin } = await pool.query(
|
||||
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
|
||||
);
|
||||
assert.equal(sin[0].client_id, null);
|
||||
|
||||
// Luego la sincronización de contactos crea la clienta…
|
||||
await pool.query(
|
||||
`INSERT INTO clients (business_id, name, crm_contact_id) VALUES ($1,'Ana','c-x')`,
|
||||
[b.id]
|
||||
);
|
||||
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
|
||||
const { rows: con } = await pool.query(
|
||||
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
|
||||
);
|
||||
assert.ok(con[0].client_id, "al resincronizar debe quedar enlazada");
|
||||
|
||||
// …y una tercera pasada NO puede desenlazarla.
|
||||
await pool.query(`UPDATE clients SET crm_contact_id = NULL WHERE business_id = $1`, [b.id]);
|
||||
await upsertConversacion(b.id, { id: "conv-x", contactId: "c-x", fullName: "Ana" } as any);
|
||||
const { rows: sigue } = await pool.query(
|
||||
`SELECT client_id FROM conversations WHERE crm_conversation_id = 'conv-x'`
|
||||
);
|
||||
assert.equal(sigue[0].client_id, con[0].client_id, "un enlace resuelto no se borra");
|
||||
});
|
||||
|
||||
test("upsertMensaje no duplica el mismo crm_message_id", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
const convId = await upsertConversacion(b.id, { id: "conv-2", contactId: "c-2" } as any);
|
||||
const m = { id: "msg-1", body: "hola", direction: "inbound", messageType: "TYPE_SMS" };
|
||||
await upsertMensaje(b.id, convId, m as any);
|
||||
await upsertMensaje(b.id, convId, m as any);
|
||||
const { rows } = await pool.query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM messages WHERE business_id = $1`,
|
||||
[b.id]
|
||||
);
|
||||
assert.equal(rows[0].n, 1);
|
||||
});
|
||||
|
||||
test("upsertMensaje guarda el canal normalizado y el crudo", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
const convId = await upsertConversacion(b.id, { id: "conv-3" } as any);
|
||||
await upsertMensaje(b.id, convId, {
|
||||
id: "msg-2", body: "x", direction: "outbound", messageType: "TYPE_EMAIL",
|
||||
} as any);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT channel, channel_raw, direction FROM messages WHERE crm_message_id = 'msg-2'`
|
||||
);
|
||||
assert.equal(rows[0].channel, "Email");
|
||||
assert.equal(rows[0].channel_raw, "TYPE_EMAIL", "el valor original se conserva");
|
||||
assert.equal(rows[0].direction, "outbound");
|
||||
});
|
||||
|
||||
test("un mensaje con dirección desconocida se guarda como entrante, no revienta", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
const convId = await upsertConversacion(b.id, { id: "conv-4" } as any);
|
||||
await upsertMensaje(b.id, convId, { id: "msg-3", body: "x" } as any);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT direction FROM messages WHERE crm_message_id = 'msg-3'`
|
||||
);
|
||||
assert.equal(rows[0].direction, "inbound");
|
||||
});
|
||||
|
||||
test("sincronizar un hilo por id NO degrada el nombre ni el canal ya conocidos", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
// Primero llega desde el buscador, con nombre y canal buenos.
|
||||
await upsertConversacion(b.id, {
|
||||
id: "conv-deg", contactId: "c-d", fullName: "Carmen García",
|
||||
lastMessageType: "TYPE_INSTAGRAM", lastMessageBody: "hola",
|
||||
} as any);
|
||||
// Luego llega por id, que no trae ni nombre ni canal reconocible.
|
||||
await upsertConversacion(b.id, { id: "conv-deg", contactId: "c-d" } as any);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT contact_name, last_message_type, last_message_body
|
||||
FROM conversations WHERE crm_conversation_id = 'conv-deg'`
|
||||
);
|
||||
assert.equal(rows[0].contact_name, "Carmen García", "el nombre bueno se conserva");
|
||||
assert.equal(rows[0].last_message_type, "Instagram", "el canal bueno se conserva");
|
||||
assert.equal(rows[0].last_message_body, "hola", "y el último mensaje también");
|
||||
});
|
||||
|
||||
test("un nombre nuevo y bueno SÍ reemplaza al anterior", async () => {
|
||||
await resetDb();
|
||||
const b = await crearNegocio();
|
||||
await upsertConversacion(b.id, { id: "conv-n", fullName: "Nombre Viejo" } as any);
|
||||
await upsertConversacion(b.id, { id: "conv-n", fullName: "Nombre Nuevo" } as any);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT contact_name FROM conversations WHERE crm_conversation_id = 'conv-n'`
|
||||
);
|
||||
assert.equal(rows[0].contact_name, "Nombre Nuevo");
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pool } from "../db/pool.ts";
|
||||
import { createApp } from "../index.ts";
|
||||
import { resetDb, seedMinimal } from "./helpers.ts";
|
||||
import { esEntidad, ENTIDADES } from "../crm/syncOne.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
process.env.CRM_MASTER_KEY = Buffer.alloc(32, 11).toString("base64");
|
||||
|
||||
let ids: Awaited<ReturnType<typeof seedMinimal>>;
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
||||
before(async () => {
|
||||
await resetDb();
|
||||
ids = await seedMinimal();
|
||||
server = createApp().listen(0);
|
||||
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
after(async () => {
|
||||
server.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
const req = (path: string, init: RequestInit = {}) =>
|
||||
fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${ids.ownerUserId}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
test("esEntidad acepta solo las cinco entidades del encargo", () => {
|
||||
for (const e of ENTIDADES) assert.ok(esEntidad(e));
|
||||
assert.equal(esEntidad("cliente"), false);
|
||||
assert.equal(esEntidad(""), false);
|
||||
assert.equal(esEntidad("../../etc/passwd"), false);
|
||||
assert.equal(esEntidad("CONTACTO"), false);
|
||||
});
|
||||
|
||||
test("ENTIDADES son exactamente las cinco, ni una más", () => {
|
||||
assert.deepEqual(
|
||||
[...ENTIDADES].sort(),
|
||||
["cita", "contacto", "conversacion", "mensaje", "servicio"]
|
||||
);
|
||||
});
|
||||
|
||||
test("una entidad inventada da 400 y dice cuáles valen", async () => {
|
||||
const r = await req("/api/crm/sync/pedido/abc", { method: "POST" });
|
||||
assert.equal(r.status, 400);
|
||||
const b = await r.json();
|
||||
assert.match(b.error, /contacto/);
|
||||
assert.match(b.error, /servicio/);
|
||||
});
|
||||
|
||||
test("un identificador desmesurado se rechaza antes de salir a la red", async () => {
|
||||
const r = await req(`/api/crm/sync/contacto/${"x".repeat(200)}`, { method: "POST" });
|
||||
assert.equal(r.status, 400);
|
||||
assert.match((await r.json()).error, /demasiado largo/i);
|
||||
});
|
||||
|
||||
test("sin vínculo con el CRM, sincronizar por id da 409, no 500", async () => {
|
||||
const r = await req("/api/crm/sync/contacto/abc123", { method: "POST" });
|
||||
assert.equal(r.status, 409);
|
||||
assert.match((await r.json()).error, /no está vinculado/i);
|
||||
});
|
||||
|
||||
test("el espejo de conversaciones sin vínculo también da 409", async () => {
|
||||
const r = await req("/api/crm/sync/conversations", { method: "POST" });
|
||||
assert.equal(r.status, 409);
|
||||
});
|
||||
|
||||
test("la cita exige un identificador numérico de la plataforma", async () => {
|
||||
// Se vincula con credencial falsa: basta para pasar de `ctxDe` y llegar a la
|
||||
// validación del identificador, que es lo que se prueba aquí.
|
||||
const { guardarCredencial } = await import("../crm/ctx.ts");
|
||||
await guardarCredencial(ids.businessId, "loc-x", "tok-x");
|
||||
const r = await req("/api/crm/sync/cita/no-es-un-numero", { method: "POST" });
|
||||
assert.equal(r.status, 400);
|
||||
assert.match((await r.json()).error, /numérico/i);
|
||||
});
|
||||
Reference in New Issue
Block a user