Compare commits

..
Author SHA1 Message Date
AgendaPro DevandClaude Opus 5 2437012c46 feat(deploy): empaquetar platform/ y desplegarlo en el server E3
Prepara el backend Postgres para producción y lo despliega como stack de Swarm
en el servidor de Consultoría E3, detrás de Traefik.

## Lo que le faltaba al proyecto para poder empaquetarse

- **`platform/` importaba de `server/`.** `routes/dayClose.ts` traía los helpers
  de zona horaria de `../../server/lib/time.ts`, contradiciendo lo que el propio
  README declara. El typecheck pasaba limpio porque en el equipo de desarrollo
  el archivo existe; solo se vio al contenerizar, cuando el proceso murió al
  arrancar con ERR_MODULE_NOT_FOUND. Ahora hay `platform/lib/time.ts`, copia
  deliberada —misma decisión que `businessDefaults.ts`— y una prueba que impide
  que vuelva a colarse una importación cruzada.
- **No había endpoint de salud.** `/api/health` consulta la base: uno que solo
  responde `{ok:true}` sigue en verde con Postgres caído, justo cuando el
  orquestador debería reiniciar.
- **No servía el frontend.** Ahora sirve `dist/` con fallback de SPA que excluye
  `/api/`, para que una ruta de API inexistente devuelva 404 y no el index.
- **No había apagado ordenado.** Sin él, cada redespliegue corta las peticiones
  en vuelo. Verificado: la tarea vieja registra el SIGTERM y cierra.

## Imagen

`platform/Dockerfile`, multi-etapa. Corre como `USER node`, fija `TZ=UTC` —la
agenda saca la zona de `businesses.timezone`, y dejar la del proceso en México
haría que una recaída pasara desapercibida— y **no** ejecuta las migraciones al
arrancar: se lanzan como paso explícito del despliegue, porque hacerlo al inicio
deja el esquema a medias si el arranque falla.

`tsx` pasa de devDependencies a dependencies: este backend corre TypeScript
directo y arrancar con `npx tsx` lo bajaría de npm en cada arranque, sin versión
fijada y sobre todo el código del servidor.

## Estado en el servidor

- Base `agendapro` con rol `agendapro_app` de mínimo privilegio, `PUBLIC`
  revocado. Verificado: el rol no ve ninguna tabla de las otras bases.
- Clave maestra de cifrado **generada en el servidor**, nunca copiada de
  desarrollo.
- Stack `agendapro` desplegado, 1/1 réplicas, 5 migraciones aplicadas, 17 tablas.
- Salud, SPA y API de administración verificadas desde dentro de la red overlay.

## Pendiente y bloqueante para el acceso externo

El A-record `agendapro.consultoriae3.com` -> 157.173.205.217 hay que crearlo a
mano en SiteGround. Sin él no hay certificado, porque Let's Encrypt valida por
HTTP-01.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 15:33:28 -06:00
AgendaPro DevandClaude Opus 5 6d67b23e55 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]>
2026-08-30 15:07:20 -06:00
AgendaPro DevandClaude Opus 5 dcbf750c09 docs: registrar cómo se sostiene la persistencia en producción
El VOLUME anónimo del Dockerfile y el volumen con nombre de Coolify son dos piezas
del mismo mecanismo: quitar una y no poner la otra deja la base efímera. Queda
documentado junto con el respaldo diario y por qué usa VACUUM INTO en vez de cp.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 01:09:04 -06:00
AgendaPro DevandClaude Opus 5 4f566ddde7 fix: quitar el VOLUME anónimo que borraba la base en cada despliegue
`VOLUME /app/data` hacía que Docker fabricara un volumen anónimo en cada arranque,
y Coolify los purga al recrear el contenedor. Cada despliegue estrenaba una base
vacía que `ensureSeed()` volvía a sembrar: se perdían las citas, los clientes y
todo lo configurado desde Ajustes, en silencio y sin error.

Verificado en el host: el volumen montado era 991d42df… y el día anterior era
3cff8c0d…, y no queda ningún volumen huérfano con agendapro.db — los anteriores
fueron destruidos. La base se creaba un minuto después del contenedor.

La persistencia la aporta ahora un volumen con nombre declarado como Persistent
Storage en Coolify y montado en /app/data, precargado con un snapshot consistente
de la base actual (155 citas, 15 clientes, 8 usuarios, integrity_check ok).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 00:33:11 -06:00
AgendaPro DevandClaude Opus 5 521c206ba7 fix: el paso de especialista solo ofrece a quien da ese servicio
Elegir "Corte + arreglo de barba" y luego cualquier especialista que no fuera el
barbero dejaba el asistente atascado: /slots responde 400 "El especialista no
ofrece este servicio" y la UI lo pintaba como "No pudimos cargar los horarios.
Intenta otra fecha" — un mensaje que manda a probar días al azar cuando ninguna
fecha lo resuelve.

La raíz es que el payload público listaba todos los empleados activos sin decir
qué servicios ofrece cada uno, así que el cliente no tenía con qué filtrar.

- GET /api/public/:slug devuelve ahora `service_ids` por empleado. El JOIN contra
  employees filtra por negocio: employee_services no tiene business_id propio.
- El paso 2 lista solo a los elegibles, y si nadie ofrece el servicio en concreto
  lo dice en vez de mostrar una lista vacía.
- Cambiar de servicio suelta un especialista que ya no aplique, en lugar de
  arrastrar una combinación que el servidor va a rechazar.
- El estado de error muestra el motivo real del servidor.

booking-e2e.mjs cubre el contrato: cada empleado publica service_ids, quien
ofrece el servicio responde 200 y quien no responde 400. 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 15:24:10 -06:00
AgendaPro DevandClaude Opus 5 f48a9ac3bf fix: anclar la agenda a la zona horaria del negocio, no a la del proceso
La reserva pública no ofrecía horarios en producción. La ventana laboral se
construía con `new Date(y, m, d, hh, mm)`, que resuelve el reloj de pared en la
tz del proceso. El Dockerfile no fijaba TZ y node:22-slim arranca en UTC,
mientras que la máquina de desarrollo está en America/Mexico_City: por eso solo
fallaba desplegado. Un negocio de 09:00-20:00 se publicaba como 09:00-20:00 UTC
(03:00-14:00 de México), y como el generador descarta lo anterior a ahora+30min,
a partir de la 1 PM la lista quedaba vacía.

Toda la API de scheduling.ts lleva ahora `tz` explícita y resuelve el reloj de
pared con wallToUtcDate/bizDateISO de time.ts, que ya existían para esto.

Arrastraba cinco defectos más en la misma ruta:

- getExistingBusy acotaba el día concatenando `${fecha}T00:00:00`. Como start_at
  se guarda en UTC, una cita de las 19:00 de México vive en el día UTC siguiente
  y quedaba fuera del rango: el guard anti doble-reserva no veía la tarde entera.
  Ahora usa bizDayBoundsIsoFor.
- Un negocio recién sembrado nacía con working_hours y slug en NULL, o sea con
  cero franjas agendables y /b/:slug en 404: el backfill vivía solo dentro de las
  migraciones, que corren antes de que exista la fila. Los defaults se fijan en el
  INSERT (server/lib/businessDefaults.ts) en los tres sitios que crean negocios, y
  migrateV4ToV5 repara los ya rotos. El demo usa slug fijo `mi-negocio-demo`
  porque es la URL ya publicada y el volumen se recrea en cada despliegue.
- El chip mostraba la hora formateada por el servidor y el resumen la del
  navegador: dos horas distintas para el mismo slot. Ambas salen ahora del
  instante resuelto en la tz del negocio.
- La separación mañana/tarde usaba /PM/i sobre un texto ya localizado, y es-MX
  rinde "05:00 p.m." con puntos: nunca casaba, así que el grupo "Tarde"
  desaparecía y toda la tarde se agrupaba bajo "Mañana".
- MonthCalendar comparaba canPrev contra el día 1 del mes visible en vez de
  contra minDate, de modo que la flecha de mes anterior nunca se podía pulsar.

Las guardas de migración comparaban la versión como texto ("10" >= "2" es false),
lo que habría reejecutado migrateV1ToV2 y su DROP TABLE users al llegar a dos
dígitos; ahora comparan números.

Verificación: scheduling.test.ts fija TZ=UTC y usa negocios en America/Mexico_City
para que la tz del proceso y la del negocio nunca coincidan; el Dockerfile fija
ENV TZ=UTC por lo mismo. 43 unitarias + 33 e2e + 12 booking + 17 admin en verde
con el servidor en UTC y base recién sembrada; typecheck limpio. booking-e2e.mjs
busca el próximo día abierto en vez de asumir "mañana", que lo hacía fallar cada
viernes y sábado por calendario.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 10:47:59 -06:00
AgendaPro DevandClaude Opus 5 0069d23744 docs: record the production verification of both fixes
Adds the production evidence for commit 4281567 (`test:pwa` passing against the HTTPS
domain, the install button present with the chunk delayed, the chart running as a
CSSAnimation, smoke 16/16) and what this deploy taught about the setup:

- Auto-deploy webhooks are active on BOTH remotes, so pushing to gitea and github in
  sequence queues two concurrent deploys of the same commit.
- `force=true` is never needed after a push; it stops the container before building and
  adds avoidable downtime.
- Measured 241s of unavailability for this deploy — explicitly not attributed to cold
  start, since a second deploy was building concurrently.
- This Coolify instance only answers on collection endpoints; everything per-resource
  404s, so container env vars and logs are not readable over the API.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:41:17 -06:00
AgendaPro DevandClaude Opus 5 4281567207 fix: two defects that only reproduce over a real network
Neither is visible against localhost, which is why both shipped.

**Install button never appeared on `/login`** — a regression from making the login a
lazy route. `beforeinstallprompt` fires once per page load and is never replayed;
`useInstallPrompt` attached its listener from a `useEffect`, i.e. at mount, and
`InstallAppPrompt` now mounts only after an extra round-trip for its chunk. Locally
that round-trip is a millisecond so the listener still won a race it should never
have been in. Over a real connection the event was long gone, so a user on a slow
link lost the install button entirely.

The listener now lives in `src/lib/installPrompt.ts` and registers when the module
evaluates — `main.tsx` imports it for its side effect before mounting React. The hook
only reads from that store. Reproduced deterministically by delaying
`/assets/LoginPage-*.js` by 1.5s via `route()`: absent before, present after (also at
a 4s delay). `test:pwa` against the HTTPS domain now passes.

**Revenue chart did not animate on a real iPhone.** Measured rather than guessed:
the path animation does run in WebKit, and it triggers with the chart 100% visible at
y=601..715 of an 844px viewport — so neither "broken" nor "fires too early". What
fits is that iOS Safari suspends `requestAnimationFrame` during momentum scrolling
while framer-motion interpolates against wall-clock time: the animation spends its
1.4s without painting a frame and snaps to the end on resume, which looks exactly
like it never ran.

The chart's three animations move to CSS keyframes, which keep their own timeline in
the engine. The component only decides *when* (a `useInView` setting
`data-ld-rev-visible`). `prefers-reduced-motion` resolves in CSS too, and still
resolves to the *drawn* state — a line left at `dashoffset: 1px` with no animation
would be invisible forever. Verified: `getAnimations()` returns a `CSSAnimation`, and
under reduced motion the line renders complete.

Not verified: no physical iPhone here, and Playwright WebKit on Windows does not
reproduce iOS's rAF suspension. This is the standard mitigation and changes nothing
on desktop, but on-device confirmation is still outstanding.

Verified: typecheck clean; landing 13/13, PWA passed, responsive 0 findings, visual
62 screens / 0 errors, unit 39/39, e2e 33/33, admin 17/17, booking 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:09:29 -06:00
AgendaPro DevandClaude Opus 5 842a9ea07e docs: record the landing deploy and document the demo build flag
README: route table for `/`, `/login` and `/b/:slug`; what `VITE_DEMO_UI` does to a
production build and how to turn the demo accounts off; the two test commands that
were missing (`test:landing`, `audit:responsive`).

progress.md: the deploy entry, including the defect it caught — the deployed bundle
had every magic-login string eliminated, so the new landing would have promised "no
registration" and led to an empty form — and the two measurement traps that cost a
false pass (a Playwright context without `hasTouch` reports `pointer: fine`, and
`GET /deployments/{uuid}` nests the application object before the deployment status).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:36:46 -06:00
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00
AgendaPro Dev 0b466f33f3 fix: address final PWA review findings 2026-07-27 17:19:45 -06:00
AgendaPro Dev 43f5d1374c test: verify service worker API bypass 2026-07-27 17:01:25 -06:00
AgendaPro Dev a8becf07d6 test: harden AgendaMax PWA checks 2026-07-27 16:57:40 -06:00
AgendaPro Dev b6dcad68fb test: verify AgendaMax PWA installation 2026-07-27 16:45:14 -06:00
AgendaPro Dev 9ded129478 fix: harden PWA install prompt 2026-07-27 16:32:58 -06:00
AgendaPro Dev 0238dff5b1 feat: add cross-platform PWA install prompt 2026-07-27 16:28:54 -06:00
AgendaPro Dev 65233f2e4d feat: add production PWA service worker 2026-07-27 16:24:26 -06:00
AgendaPro Dev de947f9d35 feat: add AgendaMax PWA metadata and icons 2026-07-27 16:20:36 -06:00
AgendaPro Dev 9d2ab39d84 docs: plan AgendaMax PWA implementation 2026-07-27 16:16:58 -06:00
AgendaPro Dev fe8b701e27 docs: specify AgendaMax PWA installability 2026-07-27 15:12:58 -06:00
AgendaPro Dev 3e056a0dbf docs: fix demo domain acceptance check 2026-07-27 13:57:44 -06:00
AgendaPro Dev 3f84371842 docs: harden demo email migration checks 2026-07-27 13:48:12 -06:00
AgendaPro Dev 811b664405 docs: plan demo email migration 2026-07-27 12:13:35 -06:00
AgendaPro Dev 50dea33728 docs: record demo access task report 2026-07-27 11:38:49 -06:00
AgendaPro Dev 6355d18a07 docs: standardize AgendaMax demo access 2026-07-27 11:36:42 -06:00
182 changed files with 26534 additions and 661 deletions
+12
View File
@@ -8,3 +8,15 @@ data
graphify-out graphify-out
screenshots screenshots
docs docs
# Credenciales: nunca dentro de la imagen. Llegan como variables de entorno al
# desplegar. `.env` ya está gitignorado, pero el contexto de Docker no mira el
# .gitignore — hay que decirlo aquí también.
.env
*.env
!*.env.example
platform/.env
# Respaldos y estado local
.cache
platform/data
+33
View File
@@ -0,0 +1,33 @@
name: build-and-push
on:
push:
branches: [ main ]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Tag desplegable
run: echo "Despliega ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
+3
View File
@@ -37,3 +37,6 @@ Thumbs.db
# OpenCode runtime state (machine-local: sessions, goals) # OpenCode runtime state (machine-local: sessions, goals)
.opencode/ .opencode/
# Grafo de conocimiento (regenerable con `graphify update`)
graphify-out/
+241
View File
@@ -43,3 +43,244 @@
3. Replace local overlapsRange with imported overlaps. 3. Replace local overlapsRange with imported overlaps.
4. Hoist per-slot employee-name SELECT out of slots loop. 4. Hoist per-slot employee-name SELECT out of slots loop.
5. MonthCalendar nav buttons → h-11 w-11 (44px touch target). 5. MonthCalendar nav buttons → h-11 w-11 (44px touch target).
== DEMO EMAIL DOMAIN TASK ==
- Task 1: complete — source constants, README credentials, active fixtures, and executable plan login example updated. Commits 6355d18..50dea33; review clean.
- Task 2: complete — local data/agendapro.db migrated 2 users; second update changed 0; appointments 161 and clients 15 preserved; review clean.
- Task 3: complete with limitation — typecheck/build/unit/admin passed; auth paths passed; e2e/booking slot assertions remain blocked by pre-existing null working-hours data, not the email change; review approved.
- Task 4: complete — committed source/docs through 3e056a0, pushed to GitHub and Gitea, Coolify deployment wbnbq9b0kneba67sznu7p1qf finished, runtime container healthy.
- Task 5: complete — production health/page passed, new admin/owner logins returned 200, old-domain logins returned 401; active DB was fresh and seeded with new emails, so targeted SQL was a no-op.
- Final review: complete — final-fix review approved; regex acceptance fix review approved.
== AGENDAMAX PWA INSTALLABILITY TASK ==
- Task 1: complete — manifest, iOS metadata, reproducible Playwright-generated 192/512 PNG icons, and package scripts. Commits 9d2ab39..de947f9; review clean.
- Task 2: complete — versioned shell/static service worker and production-only registration. Commits de947f9..65233f2; review clean.
- Task 3: complete — install prompt hook/component, iOS Safari guide, safe-area styling, and Login/AppShell/AdminShell integration; review fixes added prompt guard, appinstalled precedence, close-button label, and compact mobile layout. Commits 65233f2..9ded129; review clean.
- Task 4: complete — production-server PWA endpoint/browser tests, online/offline API bypass checks, three-shell coverage, and install documentation; review clean. Commits 9ded129..43f5d13.
- Final fix wave: complete — credential-aware cache policy, AgendaMax-scoped cleanup, session dismissal persistence, configurable PWA test credentials, dialog semantics, and dismissal regression coverage. Commit 0b466f3; final whole-feature review approved.
== LANDING PÚBLICA + ACCESO DE UN CLIC (2026-07-28) ==
Spec: docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md
Plan: docs/superpowers/plans/2026-07-28-landing-funnel-bi.md
Ruteo: `/` sirve la landing (pública, React.lazy); el login pasa a `/login`. `homePathFor(user)`
centraliza el destino por rol. `/b/:slug` intacto, fuera del AuthProvider.
Dos iteraciones de diseño a pedido del usuario:
1. Primera versión oscura/sobria (criterio Apple). Entregada y verificada.
2. Rediseño a claro, vívido y animado, con copy humano para dueñas de salón y público de 50+
(guía de dolores: Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md, Q15/Q72/Q87:
hablar de lo que gana el cliente, no de lo que hace el sistema). Se eliminó todo vocabulario de
sistema del copy visible: ni «business intelligence», ni «datos», ni «métricas», ni «panel».
3. Paleta rebasada sobre los colores REALES del panel a pedido del usuario: las 8 muestras del
abanico del hero son PIE_COLORS de DashboardPage.tsx; la marca sale de public/favicon.svg vía
src/components/BrandMark.tsx (única fuente); CTA = brand-500→brand-700 como .btn-primary.
Defectos encontrados y corregidos durante la verificación (no estaban en el plan):
- El chunk de entrada arrastraba framer-motion (39 KB gz) a TODA carga del panel, porque LoginPage
era eager. Se hizo lazy junto con la landing.
- La landing descargaba recharts (111 KB gz) y FullCalendar (76 KB gz) sin usarlos, porque
DashboardPage y CalendarPage eran eager. Ambas a lazy, con Suspense en el <Outlet> de AppShell.
- `clsx` lo comparten lib/format.ts y recharts; Rollup lo asignaba al chunk `charts`, así que el
entry importaba 111 KB para una utilidad de 200 bytes. Fijado al chunk `react`.
- LOGIN CON EL TEXTO PEGADO AL BORDE en móvil: `.safe-x` está fuera de @layer y gana a `px-5`,
dejando el padding en 0 con inset 0 (todo lo que no sea iPhone landscape). Nueva clase `.ld-gutter`.
Ningún check existente lo detectaba (no hay desborde: hay cero margen) → añadido check `gutter`.
- `shell-height` del audit daba falso positivo en toda página que scrollea: comparaba #root contra
el viewport vía el fallback a body.firstElementChild. Acotado a [data-app-shell]; a cambio se
añadió el check estático `raw-viewport-unit` sobre las fuentes públicas (verificado inyectando
`min-h-screen` en ProofSection: dispara).
- Rótulo «Como una de tus muchachas» en /login con Diego Castillo en la lista debajo. Reescrito sin
asumir género («Como alguien de tu equipo»); igual en ObjectionsSection.
- Anclas bajo el nav fijo (scroll-mt-24); degradado del cierre invisible tras `-z-10`; etiquetas del
abanico ilegibles (eliminadas); avisos flotantes solapando el abanico (reubicados a las 3 zonas
libres) y detalle truncado (acortado).
Audits ajustados por el cambio de ruteo (`/` → `/login`): visual-audit.mjs, responsive-audit.mjs y
los SIETE `goto(baseUrl)` de pwa-e2e.mjs que esperan InstallAppPrompt. La landing se añadió a las
listas de páginas de los dos audits.
VERIFICACIÓN FINAL (todo en verde):
- typecheck: 0 errores
- test:landing (NUEVO, WebKit): 13/13 — ruteo, acceso de un clic, sin desborde a 390px y
prefers-reduced-motion sin contenido invisible en las 7 secciones
- audit:responsive: 0 hallazgos (9 dispositivos × 10 páginas)
- audit:visual: 62 pantallas, 0 fallos, 0 desborde, 0 errores de consola
- test:pwa sobre el build: passed
- test:unit 39/39 · test:e2e 33/33 · test:admin 17/17 · test:booking 12/12
- build: el chunk de entrada NO importa charts, framer ni calendar (verificado en dist/)
- WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin
objetivos <40px, sin errores
`npm run lint` sigue roto por falta de eslint.config.js (preexistente).
---
## 2026-07-28 — Commit y despliegue a producción (pedido explícito)
Commit `4c19244` en `main` (53 archivos), empujado a **gitea** y a **github**. Se rompe aquí la
política de "sin commits" porque el usuario lo pidió explícitamente. `graphify-out/` pasó a
.gitignore: es regenerable y ensuciaba todo `git status`.
DEFECTO DE DESPLIEGUE ENCONTRADO ANTES DE SUBIR (el más importante de esta tanda):
el login mágico **no existía en producción y la landing lo habría prometido**. `DEMO` es
`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, una constante de build: en un build de
producción `DEV` es false y Vite eliminaba por dead-code elimination el acceso de un clic y el
«Ver como…». Comprobado sobre el bundle que producción servía en ese momento: `agendamax.demo`,
`cuentas demo`, `Ver como` y `Cambiar cuenta` todos AUSENTES; el único `demo1234` que sobrevivía
está en `switchUser` de auth.tsx, que no está detrás de la bandera. Sin arreglarlo, la landing decía
«no pide registro» y llevaba a un formulario vacío.
Arreglo: `ARG VITE_DEMO_UI=1` + `ENV` en la etapa `web-build` del Dockerfile, antes de
`npm run build`. Default en 1 porque este despliegue **es** la demostración; `--build-arg
VITE_DEMO_UI=0` lo apaga sin tocar código. Verificado construyendo de las dos formas: con la bandera
aparecen `data-magic-login`, `[email protected]`, `Ver como` y `Cambiar cuenta`; sin ella
desaparecen las cuatro. En ambos casos el entry sigue importando solo icons/query/react.
También: `CACHE_NAME` del service worker a `agendamax-shell-v2`. No toqué su lógica, pero la landing
cambió el app shell y partió el bundle en chunks nuevos; como las peticiones de assets son
cache-first por nombre con hash, los chunks viejos quedarían huérfanos para siempre en el caché de
quien ya visitó el sitio. El `activate` los purga al cambiar el sufijo. `pwa-e2e.mjs` solo verifica
el prefijo, así que no hubo que tocar el test.
DESPLIEGUE (Coolify 4.1.2, app `s30f7egdlkx4wyjp59o1iunc`, build pack dockerfile, rama main,
fuente `urieljarethbusiness-cpu/agendamax`):
- El **auto-deploy por webhook sí está configurado**: el push a github disparó el deploy 145 solo.
Mi `POST /deploy?force=true` (146) quedó encolado detrás y fue redundante.
- Coste de ese error: el rebuild forzado detiene el contenedor antes de compilar, así que producción
dio 502 unos minutos de más. Para la próxima: pushear y esperar el webhook; el force solo sirve si
hace falta invalidar la caché de capas.
- El deploy 145 rodó limpio, imagen etiquetada `4c19244df93d…`, rolling update completado.
VERIFICACIÓN EN PRODUCCIÓN (https://agendamax.urieljareth.org, WebKit, 16/16):
- El hash del bundle que sirve producción (`index-D_TEdX1K.js`) es idéntico al del build local CON
la bandera. Un build sin ella da otro hash, así que es prueba de que el flag se aplicó.
- Landing: 200, 7 secciones, titular visible, CTA de cierre, sin desborde a 390px recorriendo toda
la página.
- Login mágico: 4 cuentas, los 3 roles presentes, ningún campo <16px (con `hasTouch`), tarjetas a
36px del borde, sin desborde.
- Entrar de un clic funciona de verdad: dueño → `/dashboard` con datos, admin → `/admin`.
- Sin errores de JS. Service worker `agendamax-shell-v2` con el bypass de `/api` intacto.
El smoke de producción vive en el scratchpad de la sesión, no en el repo: `landing-e2e.mjs` ya cubre
lo mismo en local y no se pidió otro test versionado. Si se va a desplegar seguido, vale la pena
promoverlo a `npm run test:prod`.
Dos trampas de medición que costaron una pasada en falso, por si reaparecen:
- Un contexto de Playwright **sin `hasTouch`** reporta `pointer: fine`, así que el `@media (pointer:
coarse)` que sube los campos a 16px no aplica y el check falla midiendo un CSS que el dispositivo
real nunca ve. `responsive-audit.mjs` ya lo hacía bien.
- `LoginPage` navega a `/` y es la ruta `/` la que redirige según el rol. Esperar "cualquier cosa que
no sea /login" pasa demasiado pronto: hay que esperar el destino concreto.
- `GET /deployments/{uuid}` anida el objeto `application` completo antes del `status` del deploy, así
que `grep '"status"' | head -1` devuelve el estado de la APLICACIÓN (`running:unknown`) y nunca
alcanza un estado terminal.
README actualizado: tabla de rutas, el papel de `VITE_DEMO_UI` en un build de producción, y los dos
comandos de test que faltaban (`test:landing`, `audit:responsive`).
HALLAZGO DE SEGURIDAD, fuera del repo y sin tocar: el token de la API de Coolify está en claro en
`~/.openclaw/workspace/check_coolify.ps1`, y `~/coolify-agent-skill.md` tiene correo, contraseña y
una llave SSH privada en texto plano. Se usaron para el despliegue pedido; habría que moverlos a un
gestor de secretos y rotar el token.
---
## 2026-07-28 — Dos defectos que solo aparecen en producción
Encontrados después del despliegue anterior: ninguno de los dos se ve contra `localhost`.
### 1. El botón «Instalar AgendaMax» no aparecía en `/login` (regresión propia)
`npm run test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` falló en
`assertInstallAction(..., "login")`. Contra `localhost` pasaba.
Causa: `beforeinstallprompt` se dispara **una sola vez** por carga y no se repite.
`useInstallPrompt` registraba su listener dentro de un `useEffect`, o sea al montar el componente. Al
volver `LoginPage` una ruta `lazy` —cambio de esta misma tanda— `InstallAppPrompt` monta después de
una ida y vuelta de red extra para traer su chunk. En local eso es un milisegundo y el listener llega
a tiempo; sobre la red real el evento ya pasó. No era solo el test: un usuario en conexión lenta
perdía el botón de instalar.
Arreglo: `src/lib/installPrompt.ts`, un almacén que registra el listener al evaluarse el módulo, que
`main.tsx` importa por su efecto antes de montar React. `useInstallPrompt` pasa a leer de ahí
(`eventoDisponible`/`tomarEvento`/`devolverEvento`) y ya no añade listeners propios.
Reproducción determinista, porque el test local no lo veía: interceptar `/assets/LoginPage-*.js` con
`route()` y retrasarlo 1.5s, manteniendo el disparo sintético en `load`+100ms.
- Contra producción con el código viejo: evento a los 490ms, botón AUSENTE.
- Contra el build con el arreglo, retraso de 1.5s y de 4s: botón PRESENTE en los dos.
### 2. La gráfica de ingresos no animaba en un iPhone real (reportado por el usuario)
`aria-label="Ingresos mensuales en tendencia ascendente"`, en la landing.
Lo primero fue descartar hipótesis midiendo, no suponiendo:
- ¿Está roto el `pathLength`? No: en WebKit a 390px con scroll lento la animación **sí** corría
(dasharray 0.14 → 1 en ~1.5s).
- ¿Se dispara demasiado pronto, con el elemento asomando por el borde? **No.** Se dispara con la
gráfica en y=601..715 de un viewport de 844px, **100% visible**. Hipótesis descartada.
- ¿`prefers-reduced-motion`? Reproduce el síntoma **exactamente**: sale ya dibujada y estática. Es
comportamiento por diseño, no un defecto. Queda como causa posible del reporte.
Causa que sí se puede blindar: iOS Safari suspende `requestAnimationFrame` durante el scroll por
inercia. framer-motion interpola contra el reloj de pared, así que la animación consume su 1.4s sin
pintar un fotograma y al reanudarse salta al estado final — se ve idéntico a "nunca animó".
Arreglo: sacar las tres animaciones de la gráfica de framer-motion y pasarlas a keyframes CSS
(`ld-rev-trazo`, `ld-rev-aparecer`, `ld-rev-aterrizar` en index.css). El componente solo decide
cuándo empezar, con un `useInView` que pone `data-ld-rev-visible`. Las animaciones CSS llevan su
propia línea de tiempo en el motor y siguen dibujando cuando rAF no corre. `prefers-reduced-motion`
también se resuelve en CSS, así que el componente ya no necesita el hook.
Verificado: dashoffset 0.93 → 0 en ~1.5s con el área entrando en su retardo; con movimiento reducido
la línea queda dibujada (dashoffset 0), **no invisible**; y `getAnimations()` devuelve una
`CSSAnimation` llamada `ld-rev-trazo` de 1400ms en estado `running`, o sea que la lleva el motor.
**Lo que NO pude verificar:** no tengo un iPhone físico aquí, y Playwright WebKit en Windows no
reproduce la suspensión de rAF durante el scroll de iOS. El arreglo es la mitigación estándar para
ese comportamiento y no cambia nada visualmente en escritorio, pero la confirmación en el dispositivo
queda pendiente del usuario. Si con Reduce Motion **desactivado** sigue sin animar, la causa es otra
y hay que volver a medir en el dispositivo.
Gates tras los dos arreglos: typecheck 0 · test:landing 13/13 · test:pwa passed · audit:responsive 0
hallazgos · audit:visual 62 pantallas 0 fallos 0 errores · unit 39/39 · e2e 33/33 · admin 17/17 ·
booking 12/12.
VERIFICADO EN PRODUCCIÓN (commit `4281567`, bundle `index-CGg6by-0.js`):
- `test:pwa` con `PWA_BASE_URL=https://agendamax.urieljareth.org` **passed** — es exactamente el test
que fallaba antes del arreglo.
- La reproducción con el chunk retrasado 1.5s contra producción: evento sintético a los 471ms, botón
«Instalar AgendaMax» PRESENTE (antes AUSENTE).
- La gráfica: `getAnimations()` devuelve `CSSAnimation` en producción, y el muestreo confirma que la
línea se dibuja.
- Smoke completo 16/16.
### Sobre desplegar aquí, para la próxima
- **El auto-deploy por webhook está activo en los DOS remotos.** Pushear a gitea y a github seguido
encoló dos deploys simultáneos del mismo commit (150 y 151). Es inofensivo —misma imagen— pero
duplica el trabajo del servidor y añade un swap de contenedor extra. Si solo se quiere un deploy,
pushear a uno y dejar que el otro se sincronice después.
- **Nunca hace falta `force=true` después de un push**; el webhook ya construye. El force detiene el
contenedor antes de compilar y añade una caída evitable.
- Ventana de indisponibilidad medida en este despliegue: **241s** desde el primer 502 hasta el primer
200 en `/api/health`. Ojo con atribuirla: había un segundo deploy construyendo a la vez, así que es
la ventana del despliegue completo, **no** una medida limpia del arranque en frío.
- Hecho de código relacionado, sin medir y sin tocar: `tsx` está en `devDependencies` y la etapa de
runtime del Dockerfile hace `npm ci --omit=dev`, así que la imagen no lo contiene y
`CMD ["npx","tsx",...]` lo resuelve del registro **en cada arranque de contenedor**. Eso alarga el
arranque y hace que levantar dependa de que npm sea alcanzable. Moverlo a `dependencies` es una
línea; no se hizo porque nadie lo pidió y cambia la imagen de runtime.
- La API de esta instancia (Coolify 4.1.2) solo responde en endpoints de colección: `/resources`,
`/projects`, `/servers`, `/deployments` y `/deployments/{uuid}`. Todo lo per-resource
(`/applications/{uuid}`, `/…/envs`, `/…/logs`) devuelve 404, así que no se pueden leer las
variables de entorno ni los logs del contenedor por API.
FOLLOW-UPS DIFERIDOS:
1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta).
No es un defecto medible; revisar si molesta en uso real.
2. La landing no tiene contenido de precios ni captura de leads: en fase demo la conversión es entrar
a la demo. Habrá que decidirlo antes de un lanzamiento real.
3. Seguridad sin cambios: `/login` expone contraseñas de cuentas existentes bajo la bandera DEMO.
Autorizado solo para esta fase; es lo único que separa esto de una fuga si se publica.
+29 -42
View File
@@ -1,50 +1,37 @@
# Task 1 Report — Migración V3→V4 + tipos compartidos # Task 1 Implementation Report
## Files changed ## Scope
- `H:\MegaSync\Proyectos\AgendaPro\server\db.ts`
- Added `migrateV3ToV4()` immediately before `export function runMigrations()`. Implemented only the static AgendaMax PWA contract from Task 1. Existing worktree changes and graphify outputs were not modified.
- Registered `migrateV3ToV4();` as the last call inside `runMigrations()` (after `migrateV2ToV3();`).
- `H:\MegaSync\Proyectos\AgendaPro\shared\types.ts` ## Changed Files
- Replaced `Business` interface to add booking fields (`booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`) and v4 fields (`auto_assign_specialist`, `working_hours`).
- Added `WorkingDay` and `WorkingHoursMap` types after the `Business` interface. - `scripts/generate-pwa-icons.mjs`: reproducible Playwright generator using the existing `public/favicon.svg` as a base64 data URL.
- Added `specialties`, `working_hours`, `efficiency_score` to the `Employee` interface (after `service_ids?`). - `public/icon-192.png`: generated 192x192 PNG install icon.
- `public/icon-512.png`: generated 512x512 PNG install icon.
- `public/manifest.webmanifest`: exact AgendaMax manifest metadata and icon declarations.
- `index.html`: manifest link, Apple install metadata, and `viewport-fit=cover`; existing favicon, theme, and description preserved.
- `package.json`: added `generate:pwa-icons` and `test:pwa` scripts.
## Verification ## Verification
### `npm run typecheck` output (last line) - `node scripts/generate-pwa-icons.mjs`: exit 0, no output.
``` - PNG signature/dimensions Node check: passed; `public/icon-192.png` is PNG 192x192 and `public/icon-512.png` is PNG 512x512.
> [email protected] typecheck - Reproducibility check: passed. SHA256 values remained stable across regeneration:
> tsc -b --noEmit - 192 icon: `A5073D1EA7662F7E90D4E8E1DF12FF563A6DCC161FFCF6C4591D7C5F2695F6FE`
``` - 512 icon: `19914C9D4C3F626782C85920DAD9E16B069809B5D876A903BF88695E1F9F045F`
Exit code 0 — **0 errors**. (Output was just the script banner + the tsc command echo; tsc prints nothing on success.) - `npm.cmd run typecheck`: exit 0.
- `npm.cmd run build`: exit 0; Vite transformed 2463 modules and emitted the production bundle.
- Dist asset/manifest check: passed; `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist and the manifest contract is valid.
- `git diff --check`: no whitespace errors. Git emitted only existing line-ending normalization warnings.
### tsx column check output (verbatim) ## Self-Review
```
businesses has auto_assign_specialist: true
businesses has working_hours: true
employees has specialties: true
employees has working_hours: true
employees has efficiency_score: true
schema_version: [Object: null prototype] { value: '4' }
business working_hours backfilled: true
```
The check was run twice (second run for idempotency): identical output both times, no errors, schema_version stable at `4`.
## Deviations from the plan - The browser-tab icon remains `/favicon.svg`.
- None in the actual code edits. The `migrateV3ToV4` function, the `runMigrations` body, and all type additions match the plan verbatim. - The manifest uses `/` for both `start_url` and `scope`, standalone display, portrait-primary orientation, the requested colors, and PNG icon declarations.
- Verification detail: the plan's one-liner `npx tsx -e "..."` command failed under Windows PowerShell 5.1 because PowerShell parsed the embedded double-quoted SQL string (`"SELECT value FROM meta WHERE key='schema_version'"`) as terminating the outer `-e` argument, producing an "Unterminated string literal" esbuild error. To get a clean result without altering any project code, the check was executed via a throwaway `.mjs` file placed in the pre-approved temp dir (`C:\Users\URIELJ~1\AppData\Local\Temp\opencode`) that imports `server/db.ts` via an absolute `file:///H:/MegaSync/Proyectos/AgendaPro/server/db.ts` URL. The file was deleted after running. No project files were created or modified beyond `server/db.ts` and `shared/types.ts`. - The generator resolves paths from its own module location, closes pages and the browser, and produces deterministic 1x screenshots.
- No unrelated tracked or untracked worktree files were staged.
## Self-review
- **All `Business` fields from the plan added?** Yes — interface body matches the plan exactly, including `booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`, `auto_assign_specialist`, `working_hours`. The DB already has the v3 booking columns from `migrateV2ToV3`, so the type is now honest vs. the DB.
- **Does `migrateV3ToV4` guard with `schema_version >= "4"`?** Yes — first line of the function is `if (getMeta("schema_version") >= "4") return;`, mirroring the v2/v3 pattern. String comparison is safe here because the values are single-digit ASCII numerics ("3" < "4").
- **Is the backfill idempotent?** Yes, in two layers:
1. The schema-version guard short-circuits the entire function on subsequent runs.
2. Even if that guard were bypassed, the backfill UPDATE is gated by `WHERE working_hours IS NULL`, so businesses whose `working_hours` was already filled (e.g. by the user via PATCH `/settings`) are never overwritten. Confirmed by the second run producing identical output.
- **Column-add idempotency:** Each ALTER is guarded by `columnExists(...)` — re-running against a DB that already has the column is a no-op.
- **Backfill content matches the plan's default** (`1..5 = 09:00-20:00`, `6,7 = null`), which replicates the previously hard-coded hours used by `booking.ts` pre-redesign.
- **Pre-existing WIP untouched:** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, and the FullCalendar block of `src/index.css` were not modified. Only `server/db.ts` and `shared/types.ts` were edited.
- **No commit was made** (per repo policy).
## Concerns ## Concerns
None. Typecheck is clean and the migration ran end-to-end against the real `data/agendapro.db`, producing exactly the expected column set and backfilled data.
- `npm.cmd run test:pwa` was not run because `pwa-e2e.mjs` is intentionally a Task 4 deliverable and does not exist at this task boundary. The script is present as required for the later task.
+27 -50
View File
@@ -1,61 +1,38 @@
# Task 2 Report — scheduling.ts pure functions + unit tests # Task 2 Implementation Report
## Status ## Scope
✅ COMPLETE — all acceptance criteria met.
## Files changed Implemented only the production AgendaMax service worker and its production-only registration. Existing `.superpowers/sdd/*` changes and `graphify-out/*` outputs were preserved and were not staged.
- **Created** `server/lib/scheduling.ts` — 15 pure exports (types + functions), zero imports. ~95 lines.
- **Created** `server/lib/scheduling.test.ts` — 15 `test()` blocks using `node:test` + `node:assert/strict`. ## Changed Files
- **Modified** `package.json` — added `"test:unit": "node --import tsx --test server/lib/scheduling.test.ts"` to `scripts`.
- `public/sw.js`: added versioned shell/static caching with `agendamax-shell-v1`, install/activate lifecycle handling, network-first navigation fallback, and cache-first handling limited to script, style, image, font, manifest, and worker requests.
- `src/main.tsx`: registered `/sw.js` after the React root render only when `import.meta.env.PROD` and service workers are supported.
## Verification ## Verification
### `npm run typecheck` → 0 errors - `npm.cmd run typecheck`: exit 0.
```
> [email protected] typecheck
> tsc -b --noEmit
(clean — no diagnostics)
```
`allowImportingTsExtensions: true` is set in `tsconfig.json`, so `import { ... } from "./scheduling.ts"` resolves correctly under tsc.
### `npm run test:unit` → all PASS ```text
``` > [email protected] typecheck
✔ parseWorkingHours: json válido → mapa 1..7 > tsc -b --noEmit
✔ parseWorkingHours: null/invalid → null ```
✔ isoDayOfWeek: lunes=1, domingo=7
✔ getWorkingHoursForDate: empleado tiene prioridad sobre negocio
✔ getWorkingHoursForDate: día cerrado → null
✔ normalizeText/tokens: quita acentos y lowercase
✔ specialtyMatch: coincidencia exacta de etiqueta → 1
✔ specialtyMatch: etiqueta con acento/case → 1
✔ specialtyMatch: sin etiquetas → 0.5
✔ specialtyMatch: etiqueta no relacionada → 0.5
✔ overlaps: bordes inclusivos de no-traslape
✔ hasConflict: lista vacía → false
✔ scoreCandidate: pesos 50/30/20
✔ scoreCandidate: mayor efficiency → mayor score
✔ scoreCandidate: menor load (más disponible) → mayor score
ℹ tests 15
ℹ pass 15
ℹ fail 0
ℹ duration_ms 201.8453
```
**Summary line:** `# tests 15 | # pass 15 | # fail 0 | duration 201.8 ms` - `npm.cmd run build`: exit 0. Vite transformed 2463 modules and emitted the production bundle.
- `Test-Path -LiteralPath "dist/sw.js"`: `True`; Vite copied the public worker to the production output.
- `git diff --check`: no whitespace errors. Git emitted only LF-to-CRLF normalization warnings for existing/changed working-tree files.
## Deviations from plan ## Self-Review
- **None.** The plan said "≈14 tests" but the spec actually contains 15 `test()` blocks — all 15 are present and pass. Code is verbatim from the plan (only the pure functions; no DB imports added — those belong to Task 3).
## Self-review - Cache name and shell URLs match the Task 2 contract exactly.
- Activation removes older cache names and claims clients after activation.
- **All 15 tests present?** ✅ Counted 15 `test(` calls in the file; runner reports `tests 15 / pass 15`. - Fetch handling rejects non-GET requests, cross-origin requests, and paths beginning with `/api/` before any response interception.
- **`scoreCandidate` verifies 50/30/20 weights?** ✅ The "pesos 50/30/20" test asserts three exact points on the response surface: - Navigation requests use network-first behavior and fall back to the cached root document.
- `(1, 1, 0)` → 100 (50·1 + 30·1 + 20·1) - Static caching is allowlisted by request destination; there is no catch-all cache path.
- `(0, 0, 1)` → 0 (all terms zero) - Registration is deferred until `load`, uses `{ updateViaCache: "none" }`, and registration failure is non-fatal.
- `(0.5, 0, 1)` → 25 (only specialty term contributes: 50·0.5 = 25) - Only `public/sw.js` and `src/main.tsx` are intended for the Task 2 commit.
These three points uniquely pin the 50/30/20 coefficients — any other weights would fail at least one assertion. The two follow-up tests ("mayor efficiency", "menor load") additionally confirm the monotonic direction of each knob.
- **`overlaps` boundary case verified?** ✅ The "bordes inclusivos de no-traslape" test explicitly asserts `overlaps(100, 200, 200, 300) === false` — i.e. one window ending exactly when another starts is **not** an overlap. Two true-overlap cases (forward and reverse order) are also covered.
- **No DB leakage?** ✅ `scheduling.ts` has zero `import` statements and does not reference `DatabaseSync`, `db`, or any module. Task 3 can append the DB section cleanly.
## Concerns ## Concerns
None. Ready for Task 3.
- Validation is static/build-level only; browser service-worker runtime behavior is intentionally left to the later PWA end-to-end task.
- `git diff --check` reports normal line-ending normalization warnings from the existing PowerShell/Git configuration, not whitespace errors.
+134 -30
View File
@@ -1,41 +1,145 @@
# Task 3 Report — scheduling.ts DB functions # Task 3 Report: Add Install Detection and UI
## Files changed ## Status
- `server/lib/scheduling.ts` — appended DB-backed functions from Task 3 plan (`CandidateInfo`, `safeArr`, `getCandidates`, `getExistingBusy`, `isAvailable`, `rankOne`, `pickBestSlotEmployee`, `AutoAssignResult`, `AutoAssignCtx`, `autoAssign`, `runInTransaction`) + `import type { DatabaseSync } from "node:sqlite"`.
## Typecheck Implemented Task 3 only. The install hook detects standalone mode, Chromium's deferred install event, and iOS Safari instructions. The reusable prompt is rendered once in each required shell surface and uses the existing modal/button/icon patterns.
`npm run typecheck` → **0 errors** (after the deviation below).
## test:unit ## Changed files
`npm run test:unit` → **15/15 pass** (pure tests unaffected).
## tx smoke-test output - `src/lib/useInstallPrompt.ts`: added the browser-only install state hook, local deferred-event type, event listener lifecycle, install action, and dismissal behavior.
Temp file `_tx-smoke.mjs` (deleted after run). It imports `runInTransaction` and `db`, exercises happy path + throw path, and verifies a real `UPDATE` was rolled back. - `src/components/InstallAppPrompt.tsx`: added the reusable Chromium/iOS install action and iOS instructions modal.
- `src/index.css`: added `.safe-area-bottom` using `env(safe-area-inset-bottom)`.
- `src/pages/LoginPage.tsx`: added one compact install action below the login form.
- `src/components/AppShell.tsx`: added one install action to the mobile header.
- `src/components/AdminShell.tsx`: added one install action to the mobile header.
``` ## Verification
PASS runInTransaction returns fn value (42)
PASS no active tx after commit ### Typecheck
PASS runInTransaction rethrows on error
PASS no active tx after rollback Command:
PASS rollback undid the UPDATE
5/5 passed ```text
EXIT=0 npm.cmd run typecheck
``` ```
`PRAGMA active_transaction` was used (newer node:sqlite exposes it; falls back to a no-row / 0 object) to confirm no dangling transaction after both commit and rollback. The real-state rollback check (UPDATE `_tx_probe SET v=999` inside a throwing fn) proves the ROLLBACK is functional, not just emitted. Output:
## Deviations ```text
1. **Plan typo fix in `autoAssign` (line 256).** The plan literally wrote: > [email protected] typecheck
```ts > tsc -b --noEmit
if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) { ```
```
but inside `autoAssign` the loop variable `c` is a `CandidateInfo` (no `.info` field), so `c.info.id` is a type error (TS2339). Fixed to `c.id < best.info.id`. `best` is still a `RankedCandidate` so `best.info.id` is correct. The semantic intent (tie-break by lowest employee id) is preserved exactly. Note: the sibling function `pickBestSlotEmployee` uses `c.id` correctly in the plan — confirming the autoAssign line was a typo, not an intentional shape.
No other deviations. The `import type { DatabaseSync }` is placed mid-file (after `scoreCandidate`), which is valid because ES module `import` declarations are hoisted; `tsc -b --noEmit` and `tsx` both accept it. Exit code: `0`.
### Production build
Command:
```text
npm.cmd run build
```
Output summary:
```text
Preflight OK: Node v26.4.0.
vite v5.4.21 building for production...
✓ 2465 modules transformed.
✓ built in 4.08s
```
Exit code: `0`.
### Diff validation
Command:
```text
git diff --check
```
Result: no whitespace errors. Git emitted only existing LF/CRLF conversion notices for worktree files.
## Self-review ## Self-review
- **Does `autoAssign` return `null` when no candidate is free?** YES. Two null paths:
1. `candidates.length === 0` → immediate `return null` (line 245). - The hook uses a local `BeforeInstallPromptEvent` interface and does not add an unsafe global declaration.
2. After the loop, `if (!best) return null` (line 260) covers the case where every candidate was filtered out (closed that day, slot outside working window, or has a conflict). - `beforeinstallprompt` and `appinstalled` listeners are removed on unmount.
- **Does it respect per-employee working hours?** YES. For each candidate it calls `getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs))` — employee override wins, business is the fallback, and a closed day returns `null` → candidate is `continue`d. Then the slot must satisfy `ctx.startMs >= wh.startMs && ctx.endMs <= wh.endMs`, otherwise the candidate is skipped. - Standalone detection suppresses the CTA, and unsupported browsers remain unchanged.
- **Does `runInTransaction` roll back on throw?** YES. Verified by smoke test: a thrown error inside `fn` causes `db.exec("ROLLBACK")` to run, the error is re-thrown, and a real `UPDATE` made inside the throwing fn is undone (the probe row retained its pre-tx value). - iOS instructions are limited to Safari and the existing `Modal` owns backdrop/Escape close behavior.
- The safe-area utility is applied only inside the iOS modal content.
- Each shell renders exactly one prompt instance outside `SidebarContent`, avoiding duplicate listeners from the desktop/mobile sidebar duplication.
- The accessible action text remains `Instalar AgendaMax` in every supported prompt.
- No Task 4 tests or documentation changes were added.
## Concerns
- No browser-level visual/manual check was run; verification was limited to the required typecheck, production build, and diff inspection.
- `git diff --check` reports line-ending notices from the existing Windows worktree configuration, not whitespace failures.
## Preserved worktree changes
Existing `.superpowers/sdd/*` changes and untracked `graphify-out/*` artifacts were not reverted or staged.
## Review Fixes
- `src/lib/useInstallPrompt.ts`: added a ref-based in-flight guard so rapid clicks can invoke the native prompt only once. The deferred event is restored when `prompt()` or `userChoice` fails, the error is contained, and the state returns to `available` for retry. `appinstalled` now clears dismissal before setting `installed`.
- `src/components/Modal.tsx`: added `aria-label="Cerrar"` to the shared icon-only close button.
- `src/components/InstallAppPrompt.tsx`: added an explicit `compact` presentation. Header instances use a fixed icon button with a screen-reader label and tooltip, while Login keeps the full visible action label.
- `src/components/AppShell.tsx` and `src/components/AdminShell.tsx`: enabled the compact prompt presentation in the narrow mobile headers.
## Review Fix Verification
### Typecheck
Command:
```text
npm.cmd run typecheck
```
Output:
```text
> [email protected] typecheck
> tsc -b --noEmit
```
Exit code: `0`.
### Production build
Command:
```text
npm.cmd run build
```
Output summary:
```text
Preflight OK: Node v26.4.0.
vite v5.4.21 building for production...
✓ 2465 modules transformed.
✓ built in 3.95s
```
Exit code: `0`.
### Diff inspection
`git diff --check` reported no whitespace errors. The only messages were existing Windows LF/CRLF conversion notices. The implementation diff contains only the hook, shared prompt, shared modal accessibility, and two mobile header call-site fixes described above.
## Review Fix Self-Review
- A second click while `installInFlight.current` is true returns immediately, including clicks occurring after React has scheduled the deferred-event state update.
- Failed native prompt or choice promises no longer produce an unhandled rejection and restore the captured event for a retryable `available` state.
- A later `appinstalled` event overrides any prior dismissal and returns the hook state as `installed`.
- Both modal close mechanisms remain intact, and the icon-only close control now has an accessible name.
- Header install controls are compact without hiding the action from assistive technology; the full accessible name remains `Instalar AgendaMax`.
- No unrelated worktree files were modified or staged, and no Task 4 tests or documentation were added.
## Remaining Concerns
- No browser-level visual/manual check was run; verification is limited to typecheck, production build, and source diff inspection.
- The existing Windows worktree continues to emit LF/CRLF conversion notices during Git operations.
+68 -78
View File
@@ -1,89 +1,79 @@
# Task 4 Report — backend `booking.ts`: slots con working_hours + `pickBestSlotEmployee` # Task 4 Report
**Branch:** `feat/auto-assign-specialist` ## Files
**Date:** 2026-07-26
**Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` (Task 4)
## Files changed - Added `pwa-e2e.mjs` with production endpoint, manifest, SPA fallback, Chromium install prompt, iOS instructions, standalone/unsupported states, service-worker readiness/source checks, and real browser `/api/health` request verification.
- `server/routes/booking.ts` — only file modified by this task. Diff: **+56 / −31** lines (`git diff --stat`). - Updated `README.md` with local production validation, Android/Chrome installation, iOS Safari instructions, standalone behavior, connectivity expectations, and Coolify HTTPS validation guidance.
No other files touched. `POST /:slug/book` left exactly as-is (Task 5 territory).
## What changed
1. **Imports (top of file).** Added scheduling imports from `../lib/scheduling.ts`:
`parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy, pickBestSlotEmployee, type WorkingHoursMap`.
2. **`publicBusiness(slug)`** now SELECTs two extra columns: `auto_assign_specialist, working_hours` (so the public business payload exposes them for the frontend).
3. **`GET /:slug/slots`** computation block fully rewritten:
- `bizWh = parseWorkingHours(biz.working_hours)` — real business working-hours map.
- When `employeeId` is **absent** (`useRanking = !employeeId`): pre-fetches ranked `CandidateInfo[]` via `getCandidates(...)` and the per-employee busy map via `getExistingBusy(...)`, then for every grid step calls `pickBestSlotEmployee(...)` to attach the best-ranked free specialist.
- When `employeeId` is **provided**: resolves the per-employee working-hours window (falling back to business hours) and uses the existing busy list with the local `overlapsRange` helper.
- Day window comes from `getWorkingHoursForDate(...)` — **no more hardcoded `09:00`/`20:00`**.
- If the resolved window is `null` (business closed that day) → `res.json({ slots: [], service: {...} })`, no throw.
- 30-min grid, `t + dur*60000 <= dayEnd` end-clamp, `t < now+30min` skip, 40-slot cap, and the response shape `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` are all preserved.
4. Added a tiny module-local `overlapsRange(a,b,c,d)` helper (equivalent to `overlaps` in scheduling.ts; the plan suggested this to keep imports minimal).
## Verification ## Verification
### `npm run typecheck` - `node --check pwa-e2e.mjs`: passed.
``` - `npm.cmd run typecheck`: passed.
> [email protected] typecheck - `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
> tsc -b --noEmit - `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
``` - `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit reported 8 console 404 messages while still exiting 0.
**0 errors.** ✓ - `git diff --check`: passed with no whitespace errors. Git emitted existing LF-to-CRLF working-copy warnings for tracked files.
### Smoke test (live server on :3000, seeded business `lumiere-estetica-spa`) The first PWA run exposed a test-harness race where the synthetic event could fire before React registered its listener. The harness was corrected to dispatch 100 ms after `load`; the subsequent production run passed.
Wrote a throwaway `.mjs` script (deleted after) that logs in as owner, reads `/api/settings` for the slug, then hits `/api/public/<slug>` and `/api/public/<slug>/slots`.
**Public business now exposes the new fields:**
```
[info] public.business.auto_assign_specialist = 0
[info] public.business.working_hours = string
[ok] publicBusiness() exposes auto_assign_specialist + working_hours
```
**Slots — auto-assign path (no `employee_id`, uses `pickBestSlotEmployee`):**
Service `id=6` "Corte + arreglo de barba" (Barbería, 45 min), date `2026-07-27` (Monday, dow=1).
```
[info] slots status=200 count=18
[info] service in resp = { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 }
[sample] first slot = { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
[sample] last slot = { time: '07:00 p.m.', iso: '2026-07-28T01:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
[ok] all 18 slots have employee_id+employee_name and iso within 09:00-20:00 (inWindow=true)
```
- 18 slots, each carries `employee_id` + `employee_name` (resolved by `pickBestSlotEmployee`).
- Window strictly within the seed's Lun-Vie 09:00–20:00.
- Only Mateo Herrera offers service #6 in the seed, so the ranker correctly resolves every slot to him.
**Closed-day path (Saturday, default seed = closed):**
```
[info] Saturday 2026-08-01 slots count=0 (expected 0 if Sat closed)
[ok] closed day → { slots: [] } with service object (no throw)
```
The endpoint returns `{ slots: [], service: {...} }` — no exception, matching constraint #5.
**Specific-employee path (`employee_id=2`, the only employee offering svc #6):**
```
status: 200 count: 18
all match eid: true
first: { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
service in resp: { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 }
```
Every slot's `employee_id === 2`; service object included.
## Deviations from the plan
1. **Dropped `isoDateStr` from the Task-4 import list.** The plan's Task-4 import block includes `isoDateStr`, but the Task-4 code never references it, and `tsconfig.json` has `"noUnusedLocals": true` — keeping it would fail the `npm run typecheck` verification step. Task 5 ("Ampliar imports de scheduling") explicitly adds more imports and can re-introduce `isoDateStr` at that point when it's actually used by the `POST /book` rewrite. Functionality identical.
2. **Renamed `const any = …` → `const anyEmp = …`** in the fallback query inside the candidates block. `any` is a reserved-ish TypeScript type name and using it as a value identifier is confusing and risky under strict mode. Purely cosmetic; behavior identical.
## Self-review ## Self-review
- **Does the day window come from `working_hours` (not the old hardcoded 09/20)?** Yes. The old `new Date(${date}T09:00:00)` / `T20:00:00` literals are gone. The window now comes from `getWorkingHoursForDate(singleEmpWh | null, bizWh, dateObj)`, which reads `parseWorkingHours(biz.working_hours)`. Confirmed empirically: a Saturday (closed in seed) returns 0 slots, while a Monday returns 18 slots within 09:00–20:00. - Endpoint checks use the configured `PWA_BASE_URL`, defaulting to `http://localhost:3000`.
- **When `employeeId` is null, does it use `pickBestSlotEmployee`?** Yes. `useRanking = !employeeId` gates the branch; when true, the per-slot resolver is `chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, {name, category}, slotStart, slotEnd)`. The previous "first free employee" inner loop is removed. - Manifest assertions cover standalone display, root start URL/scope, and both required icon sizes.
- **Response shape preserved?** Yes — `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` verified in all three paths (ranking, specific-employee, closed-day). - Browser contexts are isolated for Chromium prompt, iOS Safari instructions, standalone mode, and unsupported/no-event mode.
- **30-min grid, 40-slot cap, skip-past/<30min-from-now?** All preserved (loop body unchanged in spirit; observed 18 slots, well under 40). - Service-worker readiness has a five-second bound, and the source guard checks cover `/api/` and non-GET requests.
- **Closed day → `{slots: []}` + service object, no throw?** Confirmed. - The browser API check observes a real request and validates its JSON response rather than using a fixture.
- **POST /book untouched?** Yes — diff shows the entire POST handler is byte-identical to the pre-task version. - No app authentication, tenant data, API route, database schema, or runtime behavior was changed.
## Concerns ## Concerns
- The seed only has one employee (Mateo Herrera) offering service #6, so the ranker resolves every slot to the same person. To genuinely exercise the multi-candidate ranking in integration, Task 7's `booking-e2e.mjs` (or a manual second employee with overlapping service + different `efficiency_score` / `specialties`) would be needed. Out of scope for Task 4.
- `biz.working_hours` is returned to the frontend as a raw JSON **string** (not parsed). Frontend will need to `JSON.parse` it — that's expected per `shared/types.ts` (`working_hours?: WorkingHoursMap | string | null`) and consistent with how Settings already returns it. No action needed here. - The visual audit continues to record eight pre-existing 404 console messages on some authenticated/admin visits. They were not introduced by Task 4 and were not changed because the task explicitly limits scope to PWA verification and production documentation.
- A leftover orphan `tsx watch` process from an initial failed `Start-Process` attempt was killed during cleanup; the user's main `npm run dev` (concurrently) was left untouched and is still serving on :3000. - The report itself and pre-existing `.superpowers/sdd/*` and `graphify-out/*` changes remain outside the Task 4 commit staging set.
## Review Fixes
- Added `fetchWithTimeout()` using `AbortController` for every direct endpoint/source fetch. The in-page `/api/health` request now uses its own bounded `AbortController` as well.
- Added exact-one accessible-action coverage for the login page, authenticated business shell, and authenticated admin shell. Business/admin flows use the existing demo credentials and a mobile viewport so their compact install controls are visible and accessible.
- Replaced fixed sleeps in standalone/unsupported checks with bounded DOM-state polling for zero matching install actions.
- Replaced iOS body-text checks with visible exact-text locators for `Compartir` and `Añadir a pantalla de inicio`.
- Relaxed service-worker source checks to tolerate whitespace and quote style while still requiring the pathname `/api/` bypass and non-GET guard.
- Tracked all browser contexts and closes them explicitly before closing the browser in `finally`; default UI/navigation timeouts are bounded.
## Review-Fix Verification
- `node --check pwa-e2e.mjs`: passed.
- `npm.cmd run typecheck`: passed.
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit still reports 8 pre-existing console 404 messages.
- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings.
## Review-Fix Self-review
- Direct network operations now have bounded completion, including response-body reads for endpoint/source assertions and the browser-side API response.
- Login, business, and admin each assert one and only one accessible `Instalar AgendaMax` action; the two shell checks exercise the authenticated UI path rather than assuming route markup.
- Negative install-state checks wait on the DOM condition with a timeout and fail non-zero if the condition never becomes true.
- All changes remain in the PWA test harness and this report; application behavior and unrelated tests are unchanged.
## Remaining Concerns
- The visual audit's 8 pre-existing 404 console messages remain outside Task 4 scope. They do not cause failed loads, overflow failures, or a non-zero audit exit.
## Service-worker Behavioral Fix
- Added a reload and `navigator.serviceWorker.controller` gate before behavioral API checks, ensuring requests run through an active, controlling worker.
- While online, the browser now verifies `GET /api/health` returns `200` and a safe invalid `POST /api/auth/login` returns `401`.
- With the Playwright context offline, both API requests must produce bounded network errors rather than cached responses. Connectivity is restored in a `finally` block.
## Service-worker Behavioral Verification
- `npm.cmd run typecheck`: passed.
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings.
## Service-worker Behavioral Self-review
- The online assertions validate real statuses (`200` and `401`) before offline mode is enabled, so a cached fixture cannot satisfy the positive path.
- Offline assertions require both requests to reject with a network-error result and do not accept a response status or body.
- The offline state is always reverted in `finally`, and the existing bounded request, navigation, worker-readiness, UI, install-surface, and cleanup checks remain intact.
+187 -36
View File
@@ -1,42 +1,193 @@
# AgendaPro — Auditoría de funcionalidades (basada en investigación real) # AgendaPro — Auditoría de producto e investigación de mercado
Fuentes: agendapro.com/mx (site + blog), comparativa Wellbe vs AgendaPro, artículo de política de cancelaciones (CEO Julio Guzmán). G2/Capterra/Trustpilot bloquearon scraping (403), pero el blog oficial expone casos de uso y pain points reales. > Revisión: **2026-07-28**. Sustituye la versión anterior de este archivo, que daba por faltantes
> cinco módulos que ya están construidos (caja, comisiones, recordatorios, booking público y política
> de cancelación). Cada fila de las tablas de abajo se verificó contra el esquema de
> [server/db.ts](server/db.ts), los endpoints montados en [server/index.ts](server/index.ts) y las
> páginas de [src/pages/](src/pages/) — no contra memoria ni contra el documento anterior.
## Lo que YA tenemos (vs AgendaPro real) ---
- ✅ Calendario drag & drop (mes/semana/día/lista) con auto-asignación a empleados
- ✅ Servicios, empleados, clientes + historial/ficha
- ✅ Tickets, pagos, propinas
- ✅ Dashboard dueño (mejor empleado/servicio, top tickets, top/frecuentes clientes, ingresos)
- ✅ Multi-tenant SaaS + consola admin + plantillas
- ✅ Reseñas/ratings
## Brechas CRÍTICAS vs AgendaPro real (oportunidades de mejora) ## 1. El producto que emulamos
1. **Política de cancelación + no-shows** — AgendaPro destaca esto; en MX la inasistencia es 15–35%. Nosotros solo marcamos estado, sin penalización ni depósito. **#1 pain real.**
2. **Comisiones** — cálculo automático por venta/servicio. Nosotros no lo tenemos. Pain operativo grande.
3. **Cierre de caja / flujo de caja** — apertura/cierre diario, ingresos/egresos. Nosotros solo listamos tickets.
4. **Página de reservas online pública (24/7)** — el cliente se auto-agenda. Nuestro mayor gap de adquisición. Reduce ruido de WhatsApp.
5. **Recordatorios automáticos** (WhatsApp/SMS/email) — reducen inasistencias. Nosotros no tenemos centro de notificaciones.
6. **Inventario** — control de productos con alertas de stock bajo.
7. **Lista de espera / waitlist** — para rellenar cancelaciones.
8. **Fidelización / giftcards / membresías** — paquetes y crédito.
9. **Encuestas de satisfacción (NPS)** — aparte de reseñas puntuales.
10. **Multi-sucursal** dentro de un negocio (ya tenemos multi-tenant, no multi-branch).
11. **Sincronización Google Calendar.**
## Pain points reales de los usuarios (del blog/casos) AgendaPro es el software de agendamiento líder en Latinoamérica: **+20.000 negocios**, presencia en
- No-shows = pérdida directa de ingresos (un salón perdía 15 citas/mes). **+100 países**, foco en México, Colombia, Argentina y Chile. Su promesa comercial es *«el único
- Cierre de caja con descuadre ("menos dinero del que debería haber"). software que ordena tu negocio y acelera su crecimiento un 82%»*.
- Cálculo manual de comisiones = error y tiempo.
- Inventario desordenado en salones.
- Cobranza incómoda ("recordatorios de pago").
- Pérdida de clientes por falta de fidelización.
- Saturación de recepción y WhatsApps repetitivos preguntando horario/precio.
## Priorización (impacto × factibilidad) — lo que implementaremos **Verticales:** salones de belleza, spas, barberías, peluquerías, centros de estética, clínicas,
- **P0 Política de cancelación + no-show** (company) — resuelve el dolor #1. psicólogos, nutricionistas, fisioterapeutas, podólogos y bienestar en general.
- **P0 Comisiones** (company/empleado) — dolor operativo top.
- **P0 Página pública de reservas /b/:slug** (cliente) — mayor diferenciador, reduce WhatsApp.
- **P1 Cierre de caja** (company) — control financiero del día.
- **P1 Centro de notificaciones/recordatorios** (company) — reduce no-shows (simulado, sin WhatsApp real).
Perspectivas cubiertas: **cliente** (booking público), **empresa** (caja, comisiones, política, dashboard), **empleado** (sus comisiones, su agenda). ### 1.1 La escalera de planes (esto *es* el producto)
El modelo de negocio de AgendaPro es la escalera de planes, y cada módulo está deliberadamente
asignado a un escalón. Precios de lista en USD (LatAm) y EUR (España):
| Plan | USD/mes | EUR/mes | Profesionales | Correos mkt | Qué añade sobre el plan anterior |
|---|---|---|---|---|---|
| **Individual** | $9 | €10 | 1 | 500 | Agenda ilimitada + presencia en Marketplace, CRM, recordatorios automáticos, sitio de reservas, reportes de gestión, sistema de caja, niveles de acceso, control de ocupación, gestión de presupuesto |
| **Básico** | $29 | €19 | hasta 20 | 1.000 | **Inventario** (control + alertas de stock bajo), **Comisiones** (cálculo automático) |
| **Premium** ★ | $59 | €59 | hasta 20 | 2.000 | **Encuestas de satisfacción**, **Fichas personalizables**, **Ficha clínica + consentimiento informado**, **Giftcards**, **Presupuestos**, email automático de cumpleaños, sitio con URL y colores propios |
| **Pro** | $199 | €199 | hasta 20 | 5.000 | **Acceso a API**, soporte personalizado, integración Google Analytics / Meta Pixel |
★ = el que ellos marcan como «más popular».
**Complementos que se cobran aparte** (dato relevante: lo que su marketing presenta como bandera
central no viene incluido en ningún plan):
| Add-on | Precio | Detalle |
|---|---|---|
| WhatsApp | desde $7 USD / €5 al mes | **50 mensajes mensuales** |
| Videoconferencia | desde $11 USD al mes | pack de 2.500 min |
| Charly (asistente de marketing con IA) | desde $55 USD al mes | pago por resultados |
| Facturación electrónica | «próximamente» | — |
Prueba gratuita: **7 días**.
### 1.2 Módulos que anuncian, agrupados como ellos los agrupan
- **Citas:** agenda online, sitio de reservas 24/7, recordatorios automáticos por WhatsApp y email,
«IA de recordatorios por WhatsApp», administración de horarios.
- **CRM:** base de datos de clientes, historial de visitas, control de sesiones y tratamientos, ficha
del cliente, promociones personalizadas.
- **Inventario:** control de inventario, alertas de inventario bajo, comisiones por venta de productos.
- **Marketing y fidelización:** integración con Google Reserve en Google My Business, LinkPro (tarjeta
de presentación para redes), campañas de email marketing, programas de lealtad y giftcards, acceso
al **marketplace** de servicios.
- **Pagos:** registro y reportes de pagos, pagos con terminal, pagos online y link de pago,
facturación / CFDI, pago de sesiones.
- **Control del negocio:** control de caja, reportes de ingresos y egresos, reportes de ventas con IA,
control de múltiples sucursales, cálculo automático de comisiones.
- **Gimnasios / fitness:** agenda de clases grupales, control de membresías, gestión de entrenadores.
- **Multi-sucursal:** varias sedes desde una sola cuenta, con reportes consolidados.
### 1.3 Dónde AgendaPro es débil (según reseñas de usuarios)
Esto importa: son los huecos donde un competidor puede ganar en lugar de empatar.
1. **Fichas clínicas genéricas.** No tienen CIE-10 electrónico ni plantillas por especialidad. Es la
queja más concreta y sistemática.
2. **Permisos poco granulares por sede.** Problema real para clínicas medianas y grandes: no se puede
acotar bien qué ve cada persona en cada sucursal.
3. **Precio y alzas periódicas.** Usuarios reportan subidas recurrentes y retiro de funcionalidades de
planes que ya pagaban.
4. **Prueba de 7 días**, contra 14+ de la competencia.
5. **WhatsApp de pago y racionado** (50 mensajes/mes desde $7), siendo el canal que su propio marketing
pone al frente.
6. **Curva de costo para negocios chicos:** los planes intermedios superan los $40–80 USD/mes, lo que
los saca de rango para un negocio de 1–3 personas.
### 1.4 El marco competitivo, y la parte que no se resuelve con features
| | Modelo | Implicación |
|---|---|---|
| **AgendaPro** | Suscripción por escalones + add-ons | Ingreso predecible; el cliente paga antes de ver valor |
| **Fresha** | **$0 de mensualidad**; comisión sobre clientes nuevos del marketplace + ~2,19% + $0,20 USD por transacción | Sin barrera de entrada; monetiza adquisición y pagos |
La conclusión honesta de la investigación: **el foso de los dos líderes no es el software, es el
marketplace.** Fresha y AgendaPro traen clientes nuevos al negocio; eso no se replica implementando
módulos. Cualquier plan de equivalencia funcional debe asumir que empata en producto y no en
distribución.
### 1.5 Datos de industria (con su fuente, y una corrección)
- **No-shows: 10%–30%** según sector; **15%–25%** en belleza y estética. En clínicas de bienestar
(España) 12%–19%, hasta **23%** en odontología y masajes.
- **Recordatorios por WhatsApp a 3 días y 24 h antes reducen las ausencias entre 30% y 50%.**
- **Depósitos recomendados: 20%–30%** del valor del servicio, y el depósito debe **abonarse al costo
final**, no ser un cargo extra.
- **Ventana de cancelación estándar: 24–48 h**; 72–96 h en sectores de alta demanda.
- **Escala de penalización de ejemplo:** gratis con más de 48 h; 25% entre 48 y 24 h; 50% entre 24 y
12 h; 100% el mismo día.
- Coste ilustrativo: 5 citas perdidas por semana a 60 € ≈ **18.000 €/año** de ingreso perdido.
> **Corrección al documento anterior:** la versión previa de este archivo afirmaba «en MX la
> inasistencia es 15–35%» sin fuente. No encontré respaldo para el techo del 35%. Los rangos de arriba
> sí están sostenidos. Además, el artículo de política de cancelaciones de AgendaPro —citado antes como
> fuente de cifras— **no contiene estadísticas de no-show**: solo recomendaciones. Las cifras de arriba
> vienen de fuentes de industria independientes.
---
## 2. Auditoría: qué tiene hoy AgendaMax
Verificado contra esquema, endpoints y páginas.
### 2.1 Construido y funcionando
| Capacidad de AgendaPro | Estado | Evidencia en el repo |
|---|---|---|
| Agenda / calendario | ✅ | [CalendarPage.tsx](src/pages/CalendarPage.tsx) con FullCalendar, 3 breakpoints, drag & drop; `/api/appointments` |
| Sitio de reservas público 24/7 | ✅ | `/b/:slug` → [BookingPage.tsx](src/pages/public/BookingPage.tsx); `/api/public/:slug`, `/slots`, `/book` |
| Guard anti doble-reserva | ✅ **superior** | `runInTransaction` + `BEGIN IMMEDIATE` + `isAvailable` + INSERT en la misma tx ([scheduling.ts](server/lib/scheduling.ts)) |
| Auto-asignación de especialista | ✅ **no lo tiene AgendaPro** | `autoAssign`, `pickBestSlotEmployee`, `scoreCandidate` con especialidades y `efficiency_score` |
| Horarios por negocio y por empleado | ✅ | `businesses.working_hours`, `employees.working_hours` (JSON 1..7, `null` = hereda) |
| CRM / ficha e historial de cliente | ✅ | `clients` + [ClientDetailPage.tsx](src/pages/ClientDetailPage.tsx) con `no_show_count` |
| Comisiones | ✅ | `services.commission_pct`, `employees.commission_pct`, snapshot en `tickets.commission`; `/api/dashboard/commissions`, `/api/me/commissions`, [MyPerformancePage.tsx](src/pages/MyPerformancePage.tsx) |
| Control de caja / cierre diario | ✅ | `cash_sessions` (apertura, cierre, `expected_amount` → descuadre) + `cash_entries` (income/expense); `/api/cash/*`; [CashPage.tsx](src/pages/CashPage.tsx) |
| Reportes de gestión | ✅ | 8 endpoints en `/api/dashboard/*` + [DashboardPage.tsx](src/pages/DashboardPage.tsx) con recharts |
| Reseñas / ratings | ✅ | tabla `reviews` (AgendaPro no lo vende como módulo) |
| Multi-tenant + consola de plataforma | ✅ **superior** | `/api/admin/*`, plantillas por vertical, alta de negocio, `reset-demo`, cascade |
| Sitio de reservas con marca propia | ✅ | landing + booking con paleta heredada del panel (AgendaPro lo cobra en Premium) |
| App instalable | ✅ | PWA con service worker y prompt de instalación (equivalente funcional a su app nativa) |
| Aislamiento por tenant verificado | ✅ | `test:e2e` y `test:admin` comprueban 403 y no-visibilidad cruzada |
### 2.2 Construido a medias — el hueco entre «configurable» y «operante»
Estas tres son las más engañosas de la auditoría: existen en el esquema y en la UI, pero **no cierran
el ciclo**. Un demo puede mostrarlas y no hacen nada.
| Capacidad | Qué hay | Qué falta |
|---|---|---|
| **Política de cancelación y depósito** | `businesses.cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`; `GET /api/appointments/:id/cancellation-policy`; editor en [SettingsPage.tsx](src/pages/SettingsPage.tsx) | **Nunca se cobra nada.** No hay captura de depósito en el booking, ni aplicación de la penalización a un ticket, ni registro del cargo. La política se configura y se consulta; no tiene efecto económico. |
| **Recordatorios automáticos** | tabla `notifications` (canales `whatsapp`/`sms`/`email`, tipos `confirmation`/`reminder`/`cancellation`/`follow_up`/`review`), `/api/notifications` con `regenerate`/`send`/`cancel`/`stats`, [NotificationsPage.tsx](src/pages/NotificationsPage.tsx) | Es una **bandeja simulada**: `send` marca `sent` sin salir a ningún proveedor. Aceptable para demo, pero no equivale al módulo real. |
| **Niveles de acceso y permisos** | 3 roles fijos (`admin`/`owner`/`employee`) con autorización real en middlewares y aislamiento probado | Sin permisos granulares. Nota: **AgendaPro también es débil aquí** (§1.3), así que es una oportunidad, no solo una deuda. |
### 2.3 Brechas reales — lo que no existe
Ordenado por el escalón de plan al que pertenece en AgendaPro, porque eso define qué se puede
reclamar como «equivalente al plan X».
| # | Brecha | Plan AgendaPro | Notas de implementación |
|---|---|---|---|
| 1 | **Inventario de productos + alertas de stock bajo** | Básico | No hay tabla de productos ni movimientos de stock |
| 2 | **Venta multi-ítem (POS real)** | Básico/Individual | **Bloqueo estructural:** hoy `tickets` es 1 ticket = 1 cita = 1 servicio (`service_id` único). No se puede vender «corte + shampoo + giftcard» en una venta |
| 3 | **Comisión por venta de productos** | Básico | Depende de 1 y 2 |
| 4 | **Encuestas de satisfacción / NPS** | Premium | `reviews` cubre la reseña puntual, no la campaña de medición |
| 5 | **Fichas personalizables + ficha clínica + consentimiento informado** | Premium | Hoy solo `clients.notes`, texto libre. **Aquí AgendaPro es débil** (§1.3) |
| 6 | **Giftcards** | Premium | Depende de 2 |
| 7 | **Presupuestos** | Premium | Depende de 2 |
| 8 | **Paquetes, sesiones y membresías** | transversal | «Control de sesiones y tratamientos», «pago de sesiones», membresías de gimnasio. Nada de saldo prepago |
| 9 | **Clases grupales (capacidad > 1)** | fitness | `appointments` es estrictamente 1:1. Toca la invariante de agendado |
| 10 | **Multi-sucursal con reportes consolidados** | transversal | Tenemos multi-*tenant*, no multi-*branch*. Cambio de esquema profundo |
| 11 | **Email marketing / campañas / cumpleaños** | Individual+ (con cuotas por plan) | `notifications` es transaccional, no de campañas |
| 12 | **Pagos en línea / link de pago / cobro anticipado** | transversal | Cero captura de pago. Es lo que bloquea §2.2 |
| 13 | **Facturación electrónica / CFDI** | add-on | En AgendaPro está «próximamente» |
| 14 | **Google Reserve / Google Calendar** | Individual+ | Integración externa |
| 15 | **Control de ocupación (% de agenda ocupada)** | Individual | [metrics.ts](server/lib/metrics.ts) solo tiene `cancelRate`, `noShowRate`, `completionRate`. **Barato y visible** |
| 16 | **Gating por plan** | — | `businesses.plan` existe pero **no restringe nada**. Sin esto, la escalera de planes —que es el producto de AgendaPro— no se está emulando |
| 17 | **Videoconferencia** | add-on | |
| 18 | **API pública documentada** | Pro | |
| 19 | **Marketplace** | Individual+ | **El foso real (§1.4).** No es un módulo, es un canal de distribución |
| 20 | **Reportes con IA** | add-on (Charly) | |
---
## 3. Lectura de la auditoría
Tres conclusiones que condicionan cualquier plan:
1. **La brecha no es de agenda, es de dinero.** Todo lo que falta cuelga de dos cosas que no existen:
la **venta multi-ítem** (brecha 2) y la **captura de pago** (brecha 12). Inventario, giftcards,
presupuestos, paquetes, comisión de producto y el cierre de la política de cancelación dependen de
una o de las dos. Atacar módulos sueltos antes de eso produce features que no se pueden conectar.
2. **Ya somos mejores en el núcleo de agendado.** Auto-asignación por especialidad y eficiencia, guard
transaccional anti doble-reserva y consola de plataforma multi-tenant no están en la oferta de
AgendaPro. La equivalencia que falta es comercial y administrativa, no de calendario.
3. **La escalera de planes no se está emulando.** Es lo más desalineado del proyecto respecto al
producto real: `businesses.plan` es decorativo. Es además de las cosas más baratas de arreglar y la
que más hace que el demo se lea como un SaaS y no como una app.
El backlog priorizado y la descomposición en sub-proyectos viven en el spec de diseño de esta tanda,
no en este archivo. Este documento es la línea base de hechos.
+441
View File
@@ -0,0 +1,441 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> El código, la UI y los mensajes de error de este repo están en **español**. Mantén ese idioma en
> strings visibles al usuario y en mensajes de error de la API.
## Requisitos del entorno
- **Node.js >= 22.5** (recomendado 24+). El servidor usa `node:sqlite` nativo: no hay dependencia
de compilación nativa, pero tampoco funciona en Node < 22.5. `scripts/preflight.mjs` valida esto
antes de `dev`, `build` y `start`.
- Windows/PowerShell: si `npm.ps1` está bloqueado por execution policy, usa **`npm.cmd`**.
- `.npmrc` apunta la caché de npm a `.cache/npm` dentro del proyecto (el caché global no siempre es
escribible en las máquinas de este proyecto). Instala con `npm run setup` (`npm ci`), no con
`npm install`, para no mutar el lockfile.
- `scripts/run-tsx.mjs` envuelve a `tsx` con un fallback de `os.userInfo()`; todo script de servidor
se lanza a través de él, nunca con `npx tsx` directo.
## Comandos
```bash
npm run setup # npm ci con caché local (instalación recomendada)
npm run dev # preflight + server (:3000) y vite (:5173) en paralelo
npm run dev:background # lo mismo, desacoplado; logs en .cache/runtime/dev.*.log
npm run build # tsc -b && vite build -> dist/
npm start # producción: sirve API + dist/ en :3000
npm run typecheck # tsc -b --noEmit <-- este es el gate real de calidad
npm run seed # regenera la DB demo
```
Overrides de puerto sin tocar código (preflight aborta si el puerto está ocupado):
```bash
PORT=3001 VITE_PORT=5174 API_URL=http://127.0.0.1:3001 npm run dev
$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev
```
`RESET_DB=1` borra `data/agendapro.db` al arrancar y vuelve a sembrar desde cero.
### Tests
```bash
npm run test:unit # node:test sobre server/lib/{scheduling,time,metrics}.test.ts (puro, sin servidor)
npm run test:e2e # API: login, CRUD, dashboard, roles, aislamiento -> requiere `npm run dev`
npm run test:admin # consola admin: alta con plantilla, reset-demo, cascade -> requiere `npm run dev`
npm run test:booking # booking público: auto-asignación y 409 -> requiere `npm run dev`
npm run audit:visual # Playwright: 10 páginas x 4 viewports -> screenshots/
npm run test:pwa # requiere `npm run build` + `npm start` (golpea :3000)
```
Detalle importante: los `.mjs` de e2e/admin/booking/visual apuntan a **`http://localhost:5173`**
(pasan por el proxy de Vite), mientras que `pwa-e2e.mjs` apunta a **`:3000`** porque necesita el
build servido y el service worker (que solo se registra en `import.meta.env.PROD`).
`PWA_BASE_URL` permite apuntarlo a otro host; la validación real de instalabilidad exige un dominio
**HTTPS**, no una IP.
Un solo test unitario:
```bash
node --import tsx --test server/lib/scheduling.test.ts
node --import tsx --test --test-name-pattern="autoAssign" server/lib/scheduling.test.ts
```
**`npm run lint` está roto** (ESLint 9 requiere `eslint.config.js` y el repo no tiene ninguno; es un
hueco preexistente). No lo uses como señal de verificación: usa `typecheck` + los tests.
## Arquitectura
### Forma general
Un solo proceso Node sirve la API y, en producción, el frontend compilado:
- `server/` — Express + SQLite (`node:sqlite`). Los routers se montan en [server/index.ts](server/index.ts).
- `src/` — SPA React 18 + Vite + Tailwind. En dev, Vite proxya `/api` al `:3000`.
- `shared/types.ts` — los tipos TS que cruzan cliente/servidor. **Actualízalo junto con el esquema.**
- Aliases: `@/*` → `src/*`, `@server/*` → `server/*` (definidos en `tsconfig.json`; Vite solo resuelve `@`).
- En producción `server/index.ts` sirve `dist/` estático con fallback SPA (`app.get("*")`) que excluye
`/api/`. Los TS del servidor se ejecutan directo con `tsx` — no hay paso de compilación de backend
(el `Dockerfile` hace `npx tsx server/index.ts`).
### Multi-tenancy: la invariante central
Cada negocio es un tenant. Salvo `businesses` y `users`, **toda** tabla lleva `business_id`, y toda
query debe filtrar por `req.user!.business_id`. Nunca aceptes un `business_id` que venga del body.
El patrón canónico está en todas las rutas:
```ts
db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id)
```
Tres roles (`users.role`): `admin` (administrador de plataforma, `business_id` NULL, opera todos los
negocios vía `/api/admin`), `owner` y `employee`. Middlewares en [server/lib/auth.ts](server/lib/auth.ts):
`authRequired`, `ownerOnly`, `adminOnly`, más el helper `err(res, status, msg)`.
`src/App.tsx` esconde rutas por rol, pero eso es solo UX: **la autorización real vive en los
middlewares del servidor** y los tests de e2e/admin verifican el aislamiento (403 / datos de otro
tenant no visibles).
### Autenticación (demo, deliberadamente trivial)
El token **es el id del usuario en texto plano** y las contraseñas se comparan sin hashear
([server/routes/auth.ts](server/routes/auth.ts)). `authRequired` rehidrata `req.user` desde la DB en
cada request. El cliente guarda el token en `localStorage` bajo `ap_token`
([src/lib/api.ts](src/lib/api.ts)). Esto habilita el "Ver como…" del `DemoSwitcher`.
No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `api.ts`, el
`AuthProvider` y los `.mjs` de test a la vez.
### Esquema y migraciones
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` → `migrateV4ToV5()` **en ese orden**,
cada una idempotente y guardada por `schemaVersion()`. Ojo: en el archivo `migrateV3ToV4`
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
archivo. Versión actual: **5**. Las guardas comparan con `schemaVersion()` (número): la comparación
de texto que había hacía que `"10" >= "2"` fuera false y reejecutara `migrateV1ToV2`, que contiene
un `DROP TABLE users`.
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV5ToV6()` nueva que
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "6")`.
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
### Siembra y plantillas
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
**Todo negocio nuevo debe nacer con `slug`, `working_hours` y `timezone` en el propio `INSERT`.**
Los valores viven en [server/lib/businessDefaults.ts](server/lib/businessDefaults.ts)
(`DEFAULT_WORKING_HOURS`, `uniqueSlug`) y hay **tres** sitios que crean negocios —
`server/index.ts` (`ensureSeed`), `server/scripts/seed.ts` (`npm run seed`) y
`server/routes/admin.ts` (alta desde la consola): si añades otro, usa el mismo módulo. El backfill
de esos campos vivía solo dentro de `migrateV2ToV3`/`migrateV3ToV4`, y las migraciones corren al
importar `db.ts`, o sea **antes** de que exista la fila: un negocio sembrado después quedaba con
`working_hours = NULL` (cero franjas agendables en cualquier fecha) y `slug = NULL`
(`/b/:slug` → 404). `migrateV4ToV5()` repara los que ya quedaron rotos.
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
`blank`) están en [server/lib/templates.ts](server/lib/templates.ts).
Cuentas demo (todas con contraseña `demo1234`): `[email protected]`, `[email protected]`, y
seis empleados `*@lumiere.mx`. Los `.mjs` de test dependen de estos emails y del dominio
`agendamax.demo`.
### Zonas horarias: no uses `date('now')`
Es el error más fácil de cometer aquí. Los negocios son de México (UTC-6), así que el `date('now')`
de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
- Las citas se guardan en UTC con formato ISO-Z **`YYYY-MM-DDTHH:MM:SSZ`** (sin milisegundos).
- `datetime(columna)` de SQLite devuelve el formato canónico **`YYYY-MM-DD HH:MM:SS`**.
- Por eso [server/lib/time.ts](server/lib/time.ts) tiene **dos familias** de helpers:
`bizDayBoundsIso()` para comparar contra `start_at` crudo, y `bizDayBoundsSqlite()` para comparar
contra `datetime(columna)`. Elegir mal produce rangos que no matchean nada.
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
explícito, por eso son testeables (`server/lib/time.test.ts`).
- **`new Date(y, m, d, hh, mm)` está prohibido en lógica de negocio**, igual que `date('now')`.
Ese constructor resuelve el reloj de pared en la tz del **proceso**, no en la del negocio. En dev
(Windows en `America/Mexico_City`) coincide y todo pasa; en el contenedor de producción
(`node:22-slim`, sin `TZ`) el proceso corre en UTC y la jornada entera se desplaza 6 h — un
negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México, de modo que a partir de la
1 PM el endpoint de slots devolvía la lista vacía y no se podía reservar. Usa
`wallToUtcDate(tz, …)` y `bizDateISO(instant, tz)` de [server/lib/time.ts](server/lib/time.ts).
`isoDateStr`/`isoDayOfWeek` de `scheduling.ts` quedan marcados `@deprecated` por lo mismo.
- Por eso **toda la API de `scheduling.ts` lleva `tz` explícita**: `getWorkingHoursForDate(empWh,
bizWh, dateIso, tz)`, `getExistingBusy(db, ids, dateIso, tz)`, `isAvailable(db, emp, s, e, tz)`,
`pickBestSlotEmployee(…, tz)` y `AutoAssignCtx.tz`. No añadas sobrecargas sin `tz`.
- `getExistingBusy` acota el día con `bizDayBoundsIsoFor(tz, dateIso)`, no concatenando
`` `${dateIso}T00:00:00` ``: `start_at` se guarda en UTC, así que una cita de las 19:00 de México
vive en el día UTC siguiente y la versión anterior no la veía — el guard anti doble-reserva era
ciego a toda la tarde.
- `server/lib/scheduling.test.ts` fija `process.env.TZ = "UTC"` en su primera línea y usa negocios
en `America/Mexico_City`: la tz del proceso y la del negocio **nunca** coinciden, así que una
recaída falla en el test y no en producción. `Dockerfile` fija `ENV TZ=UTC` por lo mismo. No las
alinees "para que sea más simple".
### Agendado, auto-asignación y guard anti doble-reserva
[server/lib/scheduling.ts](server/lib/scheduling.ts) está partido en dos mitades a propósito:
primero helpers **puros** (`parseWorkingHours`, `specialtyMatch`, `hasConflict`, `scoreCandidate`,
`getWorkingHoursForDate`…) cubiertos por `scheduling.test.ts`, y después los que tocan la DB
(`getCandidates`, `getExistingBusy`, `isAvailable`, `pickBestSlotEmployee`, `autoAssign`,
`runInTransaction`). Si añades lógica pura, ponla en la primera mitad y testéala.
Las **dos** rutas que crean citas — `POST /api/appointments`
([server/routes/appointments.ts](server/routes/appointments.ts)) y `POST /api/public/:slug/book`
([server/routes/booking.ts](server/routes/booking.ts)) — deben mantener esta secuencia dentro de
`runInTransaction` (`BEGIN IMMEDIATE` … `COMMIT` / `ROLLBACK`):
1. resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es `employee` → `autoAssign`),
2. `isAvailable(db, empId, startMs, endMs)`; si falla, lanzar `{ status: 409, error: … }`,
3. `INSERT` de la cita en la **misma** transacción.
Sacar el chequeo de la transacción reabre la carrera de doble reserva. Los errores se lanzan como
objetos `{ status, error }` y el handler los traduce con `err()`.
Limitación conocida: `getExistingBusy` acota por día vía `start_at`, así que **no detecta citas que
cruzan medianoche**; hoy queda neutralizado por el guard de horario laboral. Revisítalo si alguna vez
se permite agendar 24h.
`businesses.working_hours` y `employees.working_hours` guardan un JSON `Record<1..7, {start,end}|null>`
(1=Lunes … 7=Domingo); `null` en el empleado significa "hereda del negocio". El lado cliente lo
maneja en [src/lib/workingHours.ts](src/lib/workingHours.ts).
### Frontend
Rutas públicas y protegidas ([src/App.tsx](src/App.tsx)):
```
/ → LandingPage público; con sesión redirige según rol
/login → LoginPage formulario + acceso de un clic a las cuentas demo
/b/:slug → BookingPage fuera del AuthProvider; no asumas usuario
/dashboard… → panel sin sesión → /login
```
`homePathFor(user)` es la **única** definición de a dónde va cada rol (`admin` → `/admin`,
`owner` → `/dashboard`, `employee` → `/calendar`). No la repliques.
**Cuatro páginas se cargan con `React.lazy`** y el motivo es medible, no estético:
| Lazy | Por qué |
|---|---|
| `LandingPage`, `LoginPage` | Son las únicas que usan `framer-motion` (~40 KB gz). Con el login eager, Rollup mete `lib/motion` en el chunk de entrada y el panel paga la librería en cada carga. |
| `DashboardPage`, `CalendarPage` | Son las únicas que importan `recharts` (~111 KB gz) y FullCalendar (~76 KB gz). Eager, la landing las descargaba sin graficar ni agendar nada. |
Su frontera de `Suspense` está en el `<Outlet>` de `AppShell` (fallback `RouteSpinner`).
`clsx` está fijado al chunk `react` en `vite.config.ts` a propósito: lo comparten `lib/format.ts` y
recharts, y sin fijarlo Rollup lo asigna al chunk `charts`, de modo que el chunk de entrada acaba
importando 111 KB de recharts para obtener una utilidad de 200 bytes. Verificable: `npm run build` y
comprobar que `index-*.js` no importe `charts-*` ni `framer-*`.
- [src/lib/api.ts](src/lib/api.ts) es el **único** cliente HTTP de la app autenticada: un `request<T>()`
que inyecta el bearer y normaliza errores a `Error & { status }`. Añade endpoints ahí, no `fetch`
suelto en componentes. El flujo público usa [src/lib/publicApi.ts](src/lib/publicApi.ts) aparte.
- React Query con `staleTime: 15_000`, sin refetch al enfocar, `retry: 1` ([src/main.tsx](src/main.tsx)).
- `AppShell` para negocio, `AdminShell` para plataforma; `/b/:slug` (reservas públicas) se monta
**fuera** del `AuthProvider` en [src/App.tsx](src/App.tsx) — no asumas usuario en ese árbol.
- Calendario con FullCalendar. En móvil abre en vista Día + vista Lista; Semana/Mes se reservan a
tablet/desktop a propósito (columnas aplastadas). `vite.config.ts` separa FullCalendar, recharts,
react y react-query en chunks manuales.
- Estilos de formulario (`.input`, `.select`, `.textarea`) viven en un `@layer components` de
`src/index.css`; si los sacas de la capa, la especificidad de Tailwind los pisa.
### Responsividad (iPhone / iPad) — invariantes que no hay que romper
Objetivo: iPhone 12 (390px), iPhone 16 Pro Max (440px) e iPad (744-1180px). Verificable con
`npm run audit:responsive` (WebKit real; requiere `npm run dev`). Reglas que sostienen el
comportamiento actual:
- **Ningún campo de formulario por debajo de 16px en táctil.** Mobile Safari hace auto-zoom al
enfocar un `input`/`select`/`textarea` con `font-size < 16px` y **no revierte** al desenfocar: la
vista queda escalada y corrida. Era la causa del "arranca con zoom y desplazada" al tocar el campo
de correo. La regla vive en `@media (pointer: coarse)` en [src/index.css](src/index.css) y va por
tipo de puntero, no por ancho: un iPad también se toca con el dedo. No se resuelve con
`maximum-scale=1` porque eso rompe el pinch-zoom de accesibilidad.
- **Alturas de viewport con `dvh`, nunca `100vh` a secas.** `100vh` en iOS no descuenta la barra de
URL dinámica. Usa `.h-screen-safe` / `.min-h-screen-safe` / `.max-h-screen-safe`, que declaran
`vh` como fallback y `dvh` encima.
- **`viewport-fit=cover` obliga a descontar los insets del sistema.** El chrome (headers, sidebars,
bottom-sheets) usa `.safe-top`, `.safe-x` y `.safe-area-bottom`, apoyadas en las variables
`--safe-*` de `:root`. Sin ellas el contenido queda bajo la Dynamic Island o el home indicator.
- **Los objetivos táctiles se deciden por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja
fuera a las tablets, que son táctiles. Hay dos utilidades: `.tap-target` (40px de alto, para chips
densos) y `.icon-btn` (40×40, para botones de solo icono).
- **`.icon-btn` es una clase explícita a propósito; no intentes detectar los botones de icono por
selector.** Se probó `button:has(> svg:only-child)` y es una trampa: `:only-child` solo mira hijos
**elemento**, así que un botón o enlace con etiqueta (`<Plus/> Nueva cita`) también encaja, porque el
texto es un nodo de texto. Esa regla, al fijar `display: inline-flex`, convirtió los `NavLink` del
menú lateral en inline y los repartió en dos columnas en iPad Pro. Dos corolarios: `.icon-btn`
**no** toca `display` (está fuera de `@layer` y ganaría a `hidden`/`lg:block`, sacando el botón de
colapsar en el móvil), y centra el icono con `margin-inline: auto` sobre el `svg`, porque el
preflight de Tailwind lo deja en `display: block` y así ignora `text-align`.
- **Scrollbars personalizadas solo en `pointer: fine`.** Una barra de 10px en táctil roba ancho al
layout (el ancho útil bajaba de 390 a 380px) y descuadra los cálculos de 100%.
- **No combines `.safe-x` con `px-*` en el mismo elemento.** `.safe-x` está declarada **fuera** de
`@layer` en [src/index.css](src/index.css), así que gana a las utilidades de Tailwind. Como resuelve
`env(safe-area-inset-left, 0px)`, en cualquier dispositivo que no sea un iPhone en landscape deja el
padding lateral en **0** y el texto pega con el borde. Ni el check de overflow ni el de zoom lo
detectan, porque no hay desborde: hay cero margen. Usa `.ld-gutter` (o el patrón
`padding-inline: max(1.25rem, var(--safe-left))` de `.ld-section`), que resuelve las dos cosas en una
declaración. `npm run audit:responsive` lo vigila con el check `gutter` en las páginas públicas.
- **El check `shell-height` solo aplica a páginas con shell de altura fija** (`[data-app-shell]`, o sea
`AppShell` y `AdminShell`). Una landing o un login scrollean a propósito y son legítimamente más
altos que el viewport; compararlos contra `innerHeight` no mide un defecto, mide que existe scroll.
Para esas páginas el equivalente es el check estático `raw-viewport-unit`, que busca `h-screen`/`100vh`
crudos en las fuentes.
- **Nada debe poder desplazar la página en horizontal.** `body` lleva `overflow-x: hidden` como red
de seguridad, pero las causas se arreglan en origen: por ejemplo, un `grid` sin `grid-cols-1`
explícito dimensiona su columna implícita a `max-content` y la estiraba 23px más que la pantalla.
### Landing pública y marca
La landing vive en [src/components/landing/](src/components/landing/), una sección por archivo, todas
sin props ni estado compartido; [src/pages/LandingPage.tsx](src/pages/LandingPage.tsx) solo las
compone. El test de aceptación es `npm run test:landing` (`landing-e2e.mjs`, WebKit), y depende de
estos atributos de datos — si los quitas, se rompe: `data-ld-section` (deben ser **7**),
`data-ld-hero-title`, `data-ld-closing-cta`, `data-magic-login` + `data-role`.
**La paleta no se inventa: se hereda del panel.** Los tokens `--ld-*` de `src/index.css` apuntan a los
colores que la app ya usa. En particular, las ocho muestras del abanico del hero son `PIE_COLORS` de
[src/pages/DashboardPage.tsx](src/pages/DashboardPage.tsx) — los mismos hex de los servicios en la dona
y de los avatares que reparte el seed. Por eso el hilo de la página («sus colores se vuelven sus
números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico.
- La marca vive en **un** sitio: [src/components/BrandMark.tsx](src/components/BrandMark.tsx), que
replica `public/favicon.svg` (cuadrado `#3b66ff`, renglones blancos, punto `#f17616`). Si tocas esos
colores, toca también el favicon y `npm run generate:pwa-icons`, o la pestaña deja de coincidir.
- El azul hace de superficie de acción (`.ld-cta` usa brand-500 → brand-700, los dos tonos que
`.btn-primary` ya usa en normal y hover) y el naranja marca, nunca es fondo de botón. Es la misma
lógica del favicon.
- **`NotebookVisual` es la única excepción** y es deliberada: papel crema, renglones y grafito. Es lo
que el producto reemplaza; si se pareciera al producto, la sección dejaría de contar un cambio.
- Tipografía: Inter de cuerpo (la misma del panel) y **Fraunces** solo en titulares. El cuerpo va a
17px mínimo con interlínea 1.6 porque el público objetivo incluye personas de 50+.
- Las animaciones salen de [src/lib/motion.ts](src/lib/motion.ts) con un único easing. Con
`prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un
`initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El
test lo verifica recorriendo las siete secciones.
- **La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion.** iOS Safari
suspende `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion interpola
contra el reloj de pared: la animación gasta su duración sin pintar un fotograma y al reanudarse
salta al estado final. En un iPhone se ve como si nunca hubiera animado — fue un defecto reportado
desde producción, y ningún audit lo detecta porque en WebKit de escritorio sí anima.
[RevenueLineVisual](src/components/landing/visuals/RevenueLineVisual.tsx) solo decide *cuándo*
empieza (un `useInView` que pone `data-ld-rev-visible`); el *cómo* son los keyframes `ld-rev-*` de
[src/index.css](src/index.css), que llevan su propia línea de tiempo en el motor. El trazo usa
`pathLength={1}` para que dasharray/dashoffset sean fracciones. Si añades otra animación de trazo
de línea en la landing, hazla igual.
### Barra lateral
Un único menú vertical, colapsable a solo iconos en `lg+` mediante
[src/lib/useSidebarCollapsed.ts](src/lib/useSidebarCollapsed.ts) (persistido en `localStorage` bajo
`ap_sidebar_collapsed`, sincronizado entre pestañas). Colapsada mide 68px y devuelve **188px** al
contenido, que es lo que más se nota en un iPad Pro portrait. `AppShell` y `AdminShell` comparten el
patrón: `sidebarContent(mini)` en lugar de un JSX fijo, porque el panel móvil siempre se muestra
completo aunque la de escritorio esté colapsada. En modo `mini` cada entrada conserva `title` para no
perder su nombre accesible al quedarse sin texto.
### Calendario responsivo
Es la parte que más se ha roto históricamente. Tres tamaños vía
[src/lib/useBreakpoint.ts](src/lib/useBreakpoint.ts), alineados con los media queries del CSS:
| | phone (≤640) | tablet (641-1024) | desktop (≥1025) |
|---|---|---|---|
| Vista inicial | `timeGridDay` | `timeGridThreeDay` | `timeGridWeek` |
| Toolbar | prev/next/hoy + Día/Lista | + 3 días/Semana | + Mes |
El corte tablet/desktop está en 1024/1025 y **no coincide con el `lg:` de Tailwind** (también 1024):
un iPad Pro portrait mide justo 1024px, así que a ese ancho hay barra lateral fija *y* vista de 3
días. Es intencionado — con la barra desplegada quedan ~712px de calendario, y 7 columnas ahí son
~100px por día. Si cambias uno de los dos umbrales, cambia también el otro
([src/index.css](src/index.css) tiene los media queries de tablet).
- **La altura del calendario se calcula contra el viewport, no con `height="100%"`.** FullCalendar mide
su contenedor una sola vez al montar y solo re-mide en resize de ventana, así que se quedaba con
valores viejos cuando el layout cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor
de 525px). Se mide `innerHeight - rect.top` y se pasa como número; ver `CAL_BOTTOM_GAP` /
`CAL_MIN_HEIGHT` en [src/pages/CalendarPage.tsx](src/pages/CalendarPage.tsx). El `ResizeObserver`
observa la **barra de filtros**, no el `body`: el shell es `overflow-hidden` de altura fija, así que
el body nunca cambia de tamaño y observarlo no vuelve a disparar.
- **No pongas `contentHeight` fijo.** `.fc-timegrid-slot` mide 2.4rem y la grilla de 08:00-21:00 son 26
slots (~1000px), así que un `contentHeight` en píxeles hacía el calendario 1.4× más alto que la
pantalla y arrastraba la página. El calendario debe scrollear **dentro** de su tarjeta.
- **La tablet usa 3 días a propósito.** Con 7 columnas en 820px y varios especialistas a la misma hora,
`slotEventOverlap={false}` parte cada evento en columnas de ~30px y los títulos quedan en `"A.."`.
- No añadas bloques (avisos, empty states) como hermanos del calendario dentro de su tarjeta: con la
altura ya fijada no caben y se solapan con la grilla.
### PWA
`public/sw.js` cachea solo el app shell y **hace bypass explícito de `/api/`** y de cualquier request
no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un test dedicado en
`pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de
`activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`.
**`beforeinstallprompt` se captura a nivel de módulo, no en un hook.** El navegador lo dispara una
sola vez por carga y no lo repite. Como `LoginPage` es una ruta `lazy`, `InstallAppPrompt` monta
después de una ida y vuelta de red extra: un listener registrado al montar llega tarde y el botón
«Instalar AgendaMax» no aparece nunca. Por eso el listener vive en
[src/lib/installPrompt.ts](src/lib/installPrompt.ts), que `main.tsx` importa por su efecto **antes**
de montar React, y [useInstallPrompt](src/lib/useInstallPrompt.ts) solo lee de ese almacén. No
devuelvas el listener a un `useEffect`, y si vuelves eager alguna ruta no asumas que eso lo arregla.
Detalle de verificación: `npm run test:pwa` contra `localhost` **no** detecta ese defecto, porque el
chunk lazy llega antes que el evento. Solo se ve contra el dominio HTTPS real
(`PWA_BASE_URL=https://…`), donde la latencia abre la ventana. Si tocas el arranque del prompt,
prueba contra producción o retrasa el chunk a propósito con `route()`.
## Convenciones de trabajo
- **No hagas commits salvo que se pidan explícitamente** (política registrada en
`.superpowers/sdd/progress.md`). La verificación por tarea es typecheck + tests, no un commit.
- Este repo se desarrolla con un flujo spec-driven: las especificaciones y planes viven en
`docs/superpowers/{specs,plans}/` y la bitácora de ejecución con hallazgos de review en
`.superpowers/sdd/`. Léelos antes de retomar un feature a medias — `progress.md` lista los
follow-ups diferidos.
- `AUDIT.md` documenta las brechas funcionales frente al producto real que se está emulando; sirve de
backlog de producto.
- Existe un grafo de conocimiento en `graphify-out/` (ver el skill `graphify`): úsalo para orientarte
antes de leer código a ciegas; refréscalo con `graphify update` si quedó desactualizado.
- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son
regenerables; no los versiones ni los tomes como fuente de verdad.
## Persistencia en producción
`data/agendapro.db` es el estado del producto y el despliegue **no** lo recrea. Dos
piezas lo sostienen, y hay que tocarlas juntas:
- **El `Dockerfile` NO declara `VOLUME /app/data`.** Esa instrucción hace que Docker
fabrique un volumen **anónimo** en cada arranque, y Coolify los purga al recrear el
contenedor: cada despliegue estrenaba base vacía, `ensureSeed()` la resembraba y se
perdían citas, clientes y todo lo configurado en Ajustes, sin error ni aviso. Se
verificó comparando el nombre del volumen entre despliegues: cambiaba cada vez y no
quedaba ningún huérfano con datos. **No lo reintroduzcas.**
- **La persistencia la aporta el volumen con nombre declarado en Coolify**
(`s30f7egdlkx4wyjp59o1iunc-agendamax-data` → `/app/data`, fila de
`local_persistent_volumes`). Ojo: la API de Coolify 4.1.2 devuelve 404 en
`/applications/*`, así que eso se configura por la UI o por su base de datos, no por
API. Si despliegas en otro host, declara allí un volumen equivalente: sin él la base
es efímera otra vez.
Respaldo: `/root/scripts/backup-agendamax.sh` en el host Proxmox, por cron diario a las
03:30, con retención de 14 días en `/root/backups/agendamax/`. Usa `VACUUM INTO`, no
`cp`: la base corre en WAL y copiar el `.db` en caliente da una foto anterior al último
checkpoint. El script verifica `integrity_check` y descarta el respaldo si falla.
## Seguridad
Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y
`cors()` abierto. La validación de entrada es manual y ad-hoc en cada handler: `zod` figura en
`dependencies` pero **no se importa en ningún archivo**. Antes de un despliegue real haría falta
hashing (bcrypt), JWT firmados, validación estricta, rate-limiting y HTTPS. Tenlo presente antes de
proponer este código para producción.
+31 -2
View File
@@ -4,6 +4,20 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
# El login mágico y el "Ver como…" del panel viven detrás de la constante de build
# `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`, ver
# src/pages/LoginPage.tsx, src/components/AppShell.tsx y src/components/DemoSwitcher.tsx).
# En un build de producción `DEV` es false, así que sin esta variable Vite elimina esas
# ramas por dead-code elimination: el despliegue quedaba con la landing prometiendo
# "no pide registro" y un /login sin ninguna cuenta de prueba que ofrecer.
#
# Este despliegue ES la demostración del producto, de ahí el default en 1. Cuando deje
# de serlo, basta pasar `--build-arg VITE_DEMO_UI=0` (o declararlo como build variable en
# Coolify) y las cuentas demo desaparecen del bundle sin tocar código.
ARG VITE_DEMO_UI=1
ENV VITE_DEMO_UI=$VITE_DEMO_UI
RUN npm run build RUN npm run build
# ---- Runtime ---- # ---- Runtime ----
@@ -12,6 +26,11 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=3000 ENV PORT=3000
# La zona del contenedor se fija explícitamente a UTC, que es como se almacenan los
# instantes en la base. La lógica de agenda ya no depende de ella (usa
# `businesses.timezone` vía server/lib/time.ts), y dejarla en UTC hace que cualquier
# recaída a la tz del proceso se note de inmediato en vez de esconderse en dev.
ENV TZ=UTC
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
@@ -24,9 +43,19 @@ COPY server ./server
COPY shared ./shared COPY shared ./shared
COPY tsconfig.json ./ COPY tsconfig.json ./
# Persistent SQLite data # Persistent SQLite data.
#
# NO declares `VOLUME /app/data` aquí. Esa instrucción hace que Docker fabrique un
# volumen ANÓNIMO en cada arranque, y Coolify los purga al recrear el contenedor:
# cada despliegue estrenaba base vacía y `ensureSeed()` la resembraba, así que se
# perdían las citas, los clientes y todo lo configurado en Ajustes. Se verificó
# comparando el nombre del volumen entre despliegues — cambiaba cada vez.
#
# La persistencia la aporta el almacenamiento con nombre declarado en Coolify
# (`s30f7egdlkx4wyjp59o1iunc-agendamax-data` montado en /app/data), que sobrevive a
# la recreación del contenedor. Si algún día se despliega en otro sitio, hay que
# declarar allí un volumen equivalente: sin él la base es efímera.
RUN mkdir -p /app/data RUN mkdir -p /app/data
VOLUME /app/data
EXPOSE 3000 EXPOSE 3000
CMD ["npx", "tsx", "server/index.ts"] CMD ["npx", "tsx", "server/index.ts"]
+42 -11
View File
@@ -44,6 +44,12 @@ npm run dev
Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000). Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000).
| Ruta | Qué es |
|---|---|
| `/` | Landing pública. Con sesión abierta redirige al panel según el rol. |
| `/login` | Acceso. En fase demo ofrece entrar de un clic con las cuentas de abajo. |
| `/b/:slug` | Reserva pública del negocio, sin sesión. |
## Producción ## Producción
```bash ```bash
@@ -51,13 +57,37 @@ npm run build # compila TS y empaqueta el frontend en dist/
npm start # sirve la API y el frontend estático en http://localhost:3000 npm start # sirve la API y el frontend estático en http://localhost:3000
``` ```
El acceso de un clic y el selector «Ver como…» del panel viven detrás de la constante de
build `DEMO`, que en un build de producción solo se enciende con `VITE_DEMO_UI=1`. El
`Dockerfile` la declara con ese valor por defecto porque el despliegue **es** la
demostración; pasar `--build-arg VITE_DEMO_UI=0` los elimina del bundle.
## Instalar AgendaMax como app
- **Android/Chrome**: abre la URL HTTPS y selecciona la acción de instalación mostrada por AgendaMax o por la barra de direcciones del navegador.
- **iPhone/iPad Safari**: usa **Compartir -> Añadir a pantalla de inicio**; iOS no expone el aviso de instalación dentro de la página de Android.
- La app instalada puede abrirse sin la interfaz del navegador, pero los datos del negocio siguen requiriendo conexión a internet.
- Validación local: ejecuta `npm.cmd run build`, inicia producción con `npm.cmd start` y después ejecuta `npm.cmd run test:pwa`.
- La validación de producción requiere el dominio HTTPS de Coolify, no una dirección IP HTTP.
`npm.cmd run test:pwa` usa estas variables opcionales para los flujos autenticados;
si no se definen, usa las cuentas demo locales:
```text
[email protected]
[email protected]
PWA_PASSWORD=demo1234
```
## Tests y auditoría visual ## Tests y auditoría visual
```bash ```bash
npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant) npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant)
npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade) npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
npm run test:landing # WebKit: ruteo landing/login, acceso de un clic y prefers-reduced-motion
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide), npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide),
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/ # mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/
npm run audit:responsive # WebKit: iPhone/iPad reales — auto-zoom, safe areas, objetivos táctiles y canaletas
``` ```
La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow, La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow,
@@ -65,18 +95,19 @@ errores de consola y confirma que el calendario, el modal de cita y el cambio de
## Cuentas demo ## Cuentas demo
Todas usan la contraseña **`demo1234`**. Todas usan la contraseña **`demo1234`**. En `/login` se puede entrar con un clic, sin
escribirlas.
| Rol | Email | Nombre | | Rol | Email | Contraseña | Nombre |
|----------|----------------------------------|------------------| |----------|----------------------------------|------------|------------------|
| **Admin**| `admin@agendapro.demo` | Administrador | | **Admin**| `admin@agendamax.demo` | `demo1234` | Administrador |
| Dueño | `owner@agendapro.demo` | Daniela Reyes | | Dueño | `owner@agendamax.demo` | `demo1234` | Daniela Reyes |
| Empleado | `[email protected]` | Valentina Cruz | | Empleado | `[email protected]` | `demo1234` | Valentina Cruz |
| Empleado | `[email protected]` | Mateo Herrera | | Empleado | `[email protected]` | `demo1234` | Mateo Herrera |
| Empleado | `sofia.rami[email protected]` | Sofía Ramírez | | Empleado | `sofía.ramí[email protected]` | `demo1234` | Sofía Ramírez |
| Empleado | `[email protected]` | Diego Castillo | | Empleado | `[email protected]` | `demo1234` | Diego Castillo |
| Empleado | `[email protected]` | Isabela Torres | | Empleado | `[email protected]` | `demo1234` | Isabela Torres |
| Empleado | `carolina.me[email protected]` | Carolina Méndez | | Empleado | `carolina.mé[email protected]` | `demo1234` | Carolina Méndez |
> En la pantalla de login aparecen botones de acceso rápido a las cuentas demo, > En la pantalla de login aparecen botones de acceso rápido a las cuentas demo,
> y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**. > y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**.
+2 -2
View File
@@ -14,7 +14,7 @@ function check(name, cond, extra = "") {
} }
// Admin login // Admin login
const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendapro.demo", password: "demo1234" }); const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendamax.demo", password: "demo1234" });
check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100)); check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100));
const A = aLogin.token; const A = aLogin.token;
@@ -54,7 +54,7 @@ const { json: oBiz } = await req("GET", "/business", null, OT);
check("new owner business name", oBiz.business?.name === "Barbería Test"); check("new owner business name", oBiz.business?.name === "Barbería Test");
// Owner1 still sees only Lumière (isolation the other way) // Owner1 still sees only Lumière (isolation the other way)
const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" }); const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
const { json: o1Emps } = await req("GET", "/employees", null, owner1.token); const { json: o1Emps } = await req("GET", "/employees", null, owner1.token);
check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`); check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`);
+67
View File
@@ -0,0 +1,67 @@
# Desplegar agendapro en el server E3
Destino: **https://agendapro.consultoriae3.com** * Imagen: `agendapro:latest` * Puerto interno: **3100**
```powershell
$S = "$env:USERPROFILE\.claude\skills\contabo-e3\scripts"
```
## 1. Publicar la imagen
Haz push a `main`: el workflow `.github/workflows/deploy.yml` construye y publica en
`ghcr.io/urieljarethbusiness-cpu/agendamax`. Toma el tag por SHA del resumen del run y usalo en el stack
(`:latest` no fuerza el re-pull en Swarm).
El paquete de GHCR nace **privado**. Elige:
- **Publico** (recomendado si el codigo no es sensible): GitHub > Packages > el paquete >
Package settings > Change visibility > Public. El server hace pull sin credenciales.
- **Privado**: en el server, `docker login ghcr.io -u <GH_USER>` con un PAT `read:packages`,
y desplegar con `--with-registry-auth`.
## 2. A-record en SiteGround
El DNS de consultoriae3.com lo sirve SiteGround. Site Tools > Domain > DNS Zone Editor > A:
Type: A * Name: agendapro * Value: 157.173.205.217
Verifica: `Resolve-DnsName agendapro.consultoriae3.com -Type A`
## 3. Base de datos
Necesita Postgres. Provisiona DB + rol dedicados (ESCRITURA, pide confirmacion):
& "$S\New-E3Database.ps1" -AppName agendapro
Crea la DB `agendapro` con owner `agendapro_app` y guarda la password en
/root/dados_vps/dados_agendapro (root-only, nunca en git).
## 4. Preflight (lectura, no toca nada)
```powershell
& "$S\Test-E3Preflight.ps1" -ProjectPath "H:\MegaSync\Proyectos\AgendaPro" -AppName agendapro -Subdomain agendapro
```
## 5. Desplegar (ESCRITURA)
```powershell
& "$S\Deploy-E3Stack.ps1" -StackFile "H:\MegaSync\Proyectos\AgendaPro\deploy\agendapro.yml" -AppName agendapro
```
Si el YAML lleva placeholders de secretos, pasalos al desplegar:
```powershell
& "$S\Deploy-E3Stack.ps1" -StackFile "H:\MegaSync\Proyectos\AgendaPro\deploy\agendapro.yml" -AppName agendapro `
-Replace @{ '<APP_DB_PASSWORD>' = '...' }
```
## 6. Verificar (lectura)
```powershell
& "$S\Test-E3Service.ps1" -AppName agendapro -Subdomain agendapro
```
## Rollback
```powershell
& "$S\Invoke-E3Rollback.ps1" -AppName agendapro
```
+89
View File
@@ -0,0 +1,89 @@
# Stack de despliegue de 'agendapro' en el server E3 (Docker Swarm single-node + Traefik).
# Generado por New-E3Scaffold.ps1 - https://agendapro.consultoriae3.com
#
# NO EDITES A LA LIGERA:
# - Las labels de Traefik van bajo deploy.labels. Fuera de ahi se IGNORAN (servicio 1/1 + HTTP 404).
# - Sin clave 'build:' - Swarm no construye; la imagen tiene que existir ya.
# - Sin clave 'ports:' - el unico que publica al host es Traefik (80/443).
# - Los marcadores de secreto se sustituyen AL DESPLEGAR (Deploy-E3Stack.ps1 -SecretsFile).
# No los rellenes con valores reales aqui si este archivo se versiona en git.
version: "3.7"
services:
agendapro:
# Taguear por SHA y no ':latest': con un tag rodante Swarm no vuelve a
# hacer pull y un 'service update' no jala la imagen nueva.
image: agendapro:6d67b23
networks:
- network_public
# El healthcheck consulta la base, no solo el puerto: un proceso vivo con
# Postgres caido no esta sano, y sin esto Swarm lo daria por bueno.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
environment:
- NODE_ENV=production
# La variable se llama PLATFORM_PORT, no PORT: `PORT` es la del backend de
# demo de server/, que es otro proceso. Con la equivocada, el servidor
# escucharia en 3100 por defecto igualmente, pero por casualidad.
- PLATFORM_PORT=3100
- HOST=0.0.0.0
# UTC, NO America/Mexico_City.
#
# La agenda saca la zona de `businesses.timezone`, no del proceso, y las
# pruebas corren en UTC a proposito para que la del proceso y la del
# negocio nunca coincidan. Poner aqui la de Mexico haria que una recaida a
# la zona del proceso pasara desapercibida en produccion, que es
# exactamente el fallo que ya costo un incidente en este repo.
- TZ=UTC
# Postgres 14 compartido - host = nombre de servicio en network_public
# Se usa la URL completa del archivo de secretos, que New-E3Database.ps1 ya
# deja armada. El andamiaje generaba un marcador de solo-la-contrasena con
# otro nombre del que usa ese archivo, se quedaba sin resolver, y el
# servicio arrancaba sin poder conectar a Postgres.
- DATABASE_URL=<DATABASE_URL>
# Clave maestra con la que se cifran los tokens de subcuenta de cada
# negocio. Sin ella el servidor arranca pero NINGUNA credencial del CRM se
# puede descifrar. Se inyecta al desplegar; jamas va en este archivo.
- CRM_MASTER_KEY=<CRM_MASTER_KEY>
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.role == manager
resources:
limits:
cpus: "1"
memory: 512M
restart_policy:
condition: on-failure
delay: 10s
update_config:
order: start-first
failure_action: rollback
labels:
- traefik.enable=true
- traefik.docker.network=network_public
- traefik.http.routers.agendapro.rule=Host(`agendapro.consultoriae3.com`)
- traefik.http.routers.agendapro.entrypoints=websecure
- traefik.http.routers.agendapro.tls=true
- traefik.http.routers.agendapro.tls.certresolver=letsencryptresolver
- traefik.http.routers.agendapro.service=agendapro
- traefik.http.services.agendapro.loadbalancer.server.port=3100
- traefik.http.services.agendapro.loadbalancer.passHostHeader=1
networks:
network_public:
external: true
name: network_public
@@ -1113,7 +1113,7 @@ function check(name, cond, extra = "") {
else { fail++; console.log(` \u2717 ${name} ${extra}`); } else { fail++; console.log(` \u2717 ${name} ${extra}`); }
} }
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" }); const { json: login } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
const t = login.token; const t = login.token;
check("login owner", !!t); check("login owner", !!t);
@@ -0,0 +1,273 @@
# Demo Email Domain Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Standardize demo accounts on `@agendamax.demo`, make every demo login explicit in the README, and migrate local and Coolify data without deleting records.
**Architecture:** Keep the current simple demo authentication and replace only the two platform-owned demo email constants plus their test fixtures. Apply one targeted, idempotent SQLite `UPDATE` to the exact existing admin and owner rows; appointments, businesses, and related records remain unchanged. Deploy the source through the existing GitHub mirror and verify the live app through health and login endpoints.
**Tech Stack:** React/Vite, Express, TypeScript, Node 22 `node:sqlite`, SQLite, PowerShell/OpenSSH, Coolify v4.1.2.
## Global Constraints
- Keep the demo password exactly `demo1234`.
- Replace active `@agendapro.demo` references with `@agendamax.demo`; retain the old domain only in explicit negative-login assertions or migration-history documentation.
- Do not reset or delete local or production business, appointment, client, employee, ticket, or review data.
- Never commit `.env.local.ps1` or print token values.
- Production deployment must finish before the production data migration is run.
- If production deployment fails, stop before changing the production SQLite database and use the documented rollback path.
---
### Task 1: Update Demo Identity References and Access Documentation
**Files:**
- Modify: `README.md:66-82` — show email and password for every demo account.
- Modify: `server/index.ts:38-43` — use the new default owner email.
- Modify: `server/scripts/seed.ts:315-334` — use the new admin and owner emails for fresh databases.
- Modify: `src/pages/LoginPage.tsx:8-15` — use the new default development login.
- Modify: `admin-test.mjs`, `e2e-test.mjs`, `e2e-full.mjs`, `server/scripts/booking-e2e.mjs`, `visual-audit.mjs`, and any other active file returned by `rg -l 'agendapro\.demo'` — keep automated login fixtures aligned.
- Modify: active plan/report documents containing executable login examples; keep old-domain examples only as migration-history documentation or explicit negative-login assertions.
**Interfaces:**
- Fresh seed continues to create users with the existing schema and password.
- The login page continues to load `/api/auth/demo-users` and quick-login users; only the initial email constant changes.
- [ ] **Step 1: Record the current active references and credentials**
Run:
```powershell
rg -n --glob '!node_modules' --glob '!data/**' 'agendapro\.demo|demo1234' .
```
Expected: references are limited to the known seed, login defaults, tests, README, explicit negative-login assertions, and migration-history documentation; no secret token values are printed.
- [ ] **Step 2: Update the README credentials table first**
The resulting table must explicitly show:
```markdown
| **Admin**| `[email protected]` | `demo1234` | Administrador |
| Dueño | `[email protected]` | `demo1234` | Daniela Reyes |
```
Every employee row must also include the same password in its password column. Keep the note that the login screen offers quick access.
- [ ] **Step 3: Update fresh-seed and UI constants**
Use these exact values:
```ts
// server/index.ts and server/scripts/seed.ts
ownerEmail: "[email protected]"
// server/scripts/seed.ts
VALUES (NULL, '[email protected]', 'demo1234', 'Administrador', 'admin', '#0f172a')
// src/pages/LoginPage.tsx
useState(DEMO ? "[email protected]" : "")
```
- [ ] **Step 4: Update all active automated fixtures**
Replace only the email domain in existing login assertions and fixtures. Do not change employee domains or passwords.
- [ ] **Step 5: Verify the source tree has no stale active domain**
Run:
```powershell
rg -n --glob '!node_modules' --glob '!data/**' --glob '!docs/superpowers/specs/**' 'agendapro\.demo' .
```
Expected: no output from application code or positive fixtures; the intentional old-domain negative-login assertions and migration-history documents are the only allowed matches.
### Task 2: Migrate the Local SQLite Demo Users
**Files:**
- Modify: local ignored database `data/agendapro.db` only; no tracked database files.
**Interfaces:**
- Uses the existing `server/db.ts` connection and `users` table.
- Produces updated exact admin/owner email rows while preserving all row counts outside `users.email`.
- [ ] **Step 1: Confirm the local database and take a count snapshot**
Run from the repository root:
```powershell
Test-Path -LiteralPath .\data\agendapro.db
node --import tsx --input-type=module -e "import { db } from './server/db.ts'; console.log(JSON.stringify({users: db.prepare('SELECT id,email,role FROM users ORDER BY id').all(), appointments: db.prepare('SELECT COUNT(*) AS c FROM appointments').get(), clients: db.prepare('SELECT COUNT(*) AS c FROM clients').get()}));"
```
Expected: the local database exists or the command reports that a fresh seed is required; existing appointment/client counts are recorded before mutation.
Before updating, inspect the exact old/new admin and owner rows from the `users`
snapshot. For either role, if its target email already exists while its matching
old-domain row also exists, stop and resolve that unique-email collision instead
of running the update. If no old-domain rows exist and the new rows are present,
record a safe no-op and skip the update.
- [ ] **Step 2: Apply the idempotent local email update**
Run:
```powershell
node --% --import tsx --input-type=module -e "import { db } from './server/db.ts'; const r = db.prepare(\"UPDATE users SET email = CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email = '[email protected]' AND role = 'admin') OR (email = '[email protected]' AND role = 'owner')\").run(); console.log(JSON.stringify(r));"
```
Expected: only the exact email/role pairs are changed; an old-domain employee or
other role is untouched. Running the same command again reports zero additional
changes.
- [ ] **Step 3: Verify local credentials and preservation**
Run:
```powershell
node --% --import tsx --input-type=module -e "import { db } from './server/db.ts'; console.log(JSON.stringify({demo: db.prepare(\"SELECT email,role,password FROM users WHERE email IN ('[email protected]','[email protected]') ORDER BY role\").all(), appointments: db.prepare('SELECT COUNT(*) AS c FROM appointments').get(), clients: db.prepare('SELECT COUNT(*) AS c FROM clients').get()}));"
```
Expected: `[email protected]` and `[email protected]` use `demo1234`; appointment and client counts match the snapshot.
### Task 3: Run Local Regression Checks
**Files:**
- No additional files; validate Task 1 and Task 2 together.
**Interfaces:**
- Existing package scripts are the regression contract: typecheck, build, unit tests, and API tests.
- [ ] **Step 1: Run typecheck and build**
```powershell
npm run typecheck
npm run build
```
Expected: both commands exit with code 0.
- [ ] **Step 2: Run unit and admin/API tests**
```powershell
npm run test:unit
npm run test:admin
npm run test:e2e
```
Expected: typecheck, build, unit, admin, and non-slot authentication checks pass. On the
already-migrated local database, `businesses.working_hours` and employee
`working_hours` are null, so the slot-dependent e2e/booking assertions may fail
before booking (including their existing downstream dereference); record this
pre-existing limitation as unrelated to the email change and do not change
scheduling data for this task.
- [ ] **Step 3: Inspect the final diff**
```powershell
git diff --check
git status --short
```
Expected: only intended source, test, README, and specification/plan files are changed; `.env.local.ps1` and `data/agendapro.db` remain ignored.
### Task 4: Deploy AgendaMax Through Coolify
**Files:**
- Modify: the tracked AgendaPro source files from Tasks 1-3 through a normal commit.
- Do not modify: `.env.local.ps1`, local SQLite files, or Coolify credentials in Git.
**Interfaces:**
- Source mirror: the existing GitHub remote used by Coolify (`urieljarethbusiness-cpu/agendamax`).
- Coolify application: UUID `s30f7egdlkx4wyjp59o1iunc`, FQDN `https://agendamax.urieljareth.org`.
- [ ] **Step 1: Commit only the intended tracked changes**
```powershell
git add README.md server/index.ts server/scripts/seed.ts src/pages/LoginPage.tsx admin-test.mjs e2e-test.mjs e2e-full.mjs server/scripts/booking-e2e.mjs visual-audit.mjs docs/superpowers/specs/2026-07-27-demo-email-domain-design.md docs/superpowers/plans/2026-07-27-demo-email-domain.md
git diff --cached --check
git commit -m "docs: standardize AgendaMax demo access"
```
Add any additional active fixture files found by Task 1 explicitly; never use `git add .`.
- [ ] **Step 2: Push the source mirror used by Coolify**
```powershell
git push github main
```
Expected: the new commit is available in the GitHub mirror without exposing credentials in output.
- [ ] **Step 3: Trigger and monitor the Coolify deploy**
```powershell
. .\.env.local.ps1
$h = @{ Authorization = "Bearer $env:COOLIFY_TOKEN" }
$result = Invoke-RestMethod "$env:COOLIFY_API_URL/deploy?uuid=s30f7egdlkx4wyjp59o1iunc&force=true" -Headers $h
$deployment = $result.deployments[0].deployment_uuid
Invoke-RestMethod "$env:COOLIFY_API_URL/deployments/$deployment" -Headers $h
```
Poll until the status is `finished`; do not run the production SQL migration while it is queued, in progress, or failed.
### Task 5: Migrate and Verify Production Data
**Files:**
- Modify: persistent SQLite database inside the running AgendaMax Coolify app container only.
- Do not modify: source files or unrelated production tables.
**Interfaces:**
- Production app data path: `/app/data/agendapro.db`.
- Proxmox access: host `192.168.0.200`, LXC `102`; use the existing `scripts/Invoke-ProxmoxSsh.ps1`/`ProxmoxAgent.ps1` helpers and environment variables.
- [ ] **Step 1: Identify the running AgendaMax app container and verify the old rows**
Use the existing Proxmox SSH chain to run inside LXC 102:
```sh
container=$(docker ps --format '{{.ID}} {{.Names}}' | awk '$2 ~ /agendamax|s30f7/ {print $1; exit}')
test -n "$container" || { echo 'AgendaMax container not found' >&2; exit 1; }
docker exec "$container" node -e "const {DatabaseSync}=require('node:sqlite'); const db=new DatabaseSync('/app/data/agendapro.db'); console.log(db.prepare(\"SELECT id,email,role FROM users WHERE email IN ('[email protected]','[email protected]','[email protected]','[email protected]') ORDER BY id\").all())"
```
Expected: the app is running the new image and the current exact admin/owner
rows are visible before mutation. If an old row and its matching new row both
exist, stop and resolve the unique-email collision before updating.
- [ ] **Step 2: Apply the production email update**
```sh
docker exec "$container" node -e "const {DatabaseSync}=require('node:sqlite'); const db=new DatabaseSync('/app/data/agendapro.db'); const r=db.prepare(\"UPDATE users SET email=CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email='[email protected]' AND role='admin') OR (email='[email protected]' AND role='owner')\").run(); console.log(r)"
```
Expected: only the matching admin and owner rows move to `@agendamax.demo`; no
old-domain employee or other role is rewritten, and no business or appointment
rows are deleted.
If the preflight query shows an old row alongside its matching new row, stop and
resolve the unique-email collision. If it shows no old-domain rows because the
deployment seeded a fresh database with the new domain, record the update as a
safe no-op and skip it.
- [ ] **Step 3: Verify live health and credentials**
```powershell
$health = Invoke-RestMethod https://agendamax.urieljareth.org/api/health
$admin = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"[email protected]","password":"demo1234"}'
$owner = Invoke-RestMethod https://agendamax.urieljareth.org/api/auth/login -Method Post -ContentType 'application/json' -Body '{"email":"[email protected]","password":"demo1234"}'
Write-Host "health_ok=$($health.ok) admin_role=$($admin.user.role) admin_token_present=$([bool]$admin.token) owner_role=$($owner.user.role) owner_token_present=$([bool]$owner.token)"
```
Expected: health returns `ok: true`; both logins return the correct roles. Only boolean token-presence values are printed, never token contents.
- [ ] **Step 4: Verify the deployed page and repository state**
Run the existing browser/HTTP post-deploy check if available, then:
```powershell
git status --short
```
Expected: the deployed AgendaMax page loads, no uncaught frontend errors are reported, and only intentional local follow-up changes remain.
@@ -0,0 +1,503 @@
# AgendaMax PWA Installability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make AgendaMax installable as a web app on Android, iOS, and compatible desktop browsers without adding offline writes or changing the existing API/authentication model.
**Architecture:** Add a hand-maintained web manifest and service worker under `public/`, register the worker only in production, and provide a reusable React install prompt. Chromium uses `beforeinstallprompt`; iOS Safari receives explicit Share -> Add to Home Screen instructions. The worker caches only the app shell/static assets and bypasses all API and non-GET requests.
**Tech Stack:** React 18, TypeScript, Vite 5, Tailwind CSS, Express static hosting, Playwright, Node.js 22.5+.
## Global Constraints
- Use a manually maintained manifest and service worker; do not add `vite-plugin-pwa`.
- Keep the data model online-first; never cache `/api` responses or queue offline writes.
- Keep AgendaMax's existing Spanish visual language and make installation a secondary action.
- Production installation requires HTTPS; `localhost` is valid for local testing.
- Do not change authentication, tenant isolation, API routes, or database schema.
- Preserve the existing `public/favicon.svg` and brand color `#3b66ff`.
- Keep service-worker registration failure non-fatal and log it only in development.
## File Map
- Create `public/manifest.webmanifest`: browser install metadata and icon declarations.
- Create `public/sw.js`: versioned shell/static-asset cache with API bypass.
- Create `public/icon-192.png` and `public/icon-512.png`: install icons derived from the existing favicon mark.
- Create `scripts/generate-pwa-icons.mjs`: reproducible local icon generation using the existing Playwright dependency.
- Create `src/lib/useInstallPrompt.ts`: browser capability detection and deferred install event lifecycle.
- Create `src/components/InstallAppPrompt.tsx`: install button and iOS instruction modal.
- Create `pwa-e2e.mjs`: production-server PWA endpoint and browser-behavior checks.
- Modify `index.html`: manifest link, iOS metadata, and safe-area viewport setting.
- Modify `src/main.tsx`: production service-worker registration.
- Modify `src/index.css`: safe-area utility for the install dialog.
- Modify `src/pages/LoginPage.tsx`, `src/components/AppShell.tsx`, and `src/components/AdminShell.tsx`: render the reusable install action in the existing UI surfaces.
- Modify `package.json`: icon-generation and PWA test scripts.
- Modify `README.md`: document install behavior, HTTPS, and validation commands.
---
### Task 1: Add Static PWA Metadata and Icons
**Files:**
- Create: `scripts/generate-pwa-icons.mjs`
- Create: `public/icon-192.png`
- Create: `public/icon-512.png`
- Create: `public/manifest.webmanifest`
- Modify: `index.html:5-12`
- Modify: `package.json:7-25`
**Interfaces:**
- Produces `/manifest.webmanifest`, `/icon-192.png`, and `/icon-512.png` in the Vite output.
- Keeps `/favicon.svg` as the browser-tab icon.
- [ ] **Step 1: Add a reproducible icon generator.**
Create `scripts/generate-pwa-icons.mjs` using the already-installed Playwright package. It must load `public/favicon.svg` as a data URL, render it on a white 1:1 page, and screenshot exactly 192x192 and 512x512 PNG files:
```js
import { chromium } from "playwright";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const svg = await fs.readFile(path.join(root, "public", "favicon.svg"), "utf8");
const browser = await chromium.launch({ headless: true });
try {
for (const size of [192, 512]) {
const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
await page.setContent(`<!doctype html><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#fff}svg{display:block;width:100%;height:100%}</style>${svg}`);
await page.screenshot({ path: path.join(root, "public", `icon-${size}.png`), type: "png" });
await page.close();
}
} finally {
await browser.close();
}
```
- [ ] **Step 2: Run the generator and verify PNG dimensions.**
Run:
```text
node scripts/generate-pwa-icons.mjs
```
Expected: `public/icon-192.png` and `public/icon-512.png` exist. Verify their PNG signature and dimensions with a short Node check before continuing; do not substitute SVG files for the required PNG icons.
- [ ] **Step 3: Add the manifest.**
Create `public/manifest.webmanifest` with this exact contract:
```json
{
"name": "AgendaMax",
"short_name": "AgendaMax",
"description": "Gestión visual de citas, empleados e ingresos para tu negocio.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f7fb",
"theme_color": "#3b66ff",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
```
- [ ] **Step 4: Update HTML metadata and package scripts.**
In `index.html`, keep the existing favicon/theme/description and add:
```html
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
```
Change the viewport content to include `viewport-fit=cover` while preserving the current scale values. Add these scripts to `package.json`:
```json
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
"test:pwa": "node pwa-e2e.mjs"
```
- [ ] **Step 5: Build and inspect static output.**
Run:
```text
npm.cmd run build
```
Expected: exit code 0 and `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist. The worker is added in Task 2, so do not treat its absence as a failure in this task.
- [ ] **Step 6: Commit the static PWA contract.**
```text
git add package.json index.html public/manifest.webmanifest public/icon-192.png public/icon-512.png scripts/generate-pwa-icons.mjs
git commit -m "feat: add AgendaMax PWA metadata and icons"
```
---
### Task 2: Add the Production Service Worker
**Files:**
- Create: `public/sw.js`
- Modify: `src/main.tsx:18-25`
**Interfaces:**
- Browser loads `/sw.js` from the same origin in production.
- The worker owns only shell/static caching; API requests remain network-only.
- [ ] **Step 1: Add the service worker with explicit routing.**
Create `public/sw.js` with a versioned cache and these rules:
```js
const CACHE_NAME = "agendamax-shell-v1";
const SHELL_URLS = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => caches.match("/"))
);
return;
}
const cacheableDestination = new Set(["script", "style", "image", "font", "manifest", "worker"]);
if (!cacheableDestination.has(request.destination)) return;
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
});
})
);
});
```
The worker must not add a catch-all cache path, intercept non-GET requests, or cache requests whose path starts with `/api/`.
- [ ] **Step 2: Register the worker only in production.**
Append this registration after the React root render in `src/main.tsx`:
```ts
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
void navigator.serviceWorker
.register("/sw.js", { updateViaCache: "none" })
.catch((error: unknown) => {
if (import.meta.env.DEV) console.warn("AgendaMax service worker registration failed", error);
});
});
}
```
The registration failure is non-fatal and must never reject or delay React startup; the development-only warning is retained for local diagnostics if the environment condition is changed during debugging.
- [ ] **Step 3: Validate worker behavior statically.**
Run:
```text
npm.cmd run typecheck
npm.cmd run build
```
Expected: both exit 0, and `dist/sw.js` exists because Vite copies `public/sw.js`.
- [ ] **Step 4: Commit the worker.**
```text
git add public/sw.js src/main.tsx
git commit -m "feat: add production PWA service worker"
```
---
### Task 3: Add Install Detection and UI
**Files:**
- Create: `src/lib/useInstallPrompt.ts`
- Create: `src/components/InstallAppPrompt.tsx`
- Modify: `src/index.css:228-322`
- Modify: `src/pages/LoginPage.tsx:1-6` and rendered layout
- Modify: `src/components/AppShell.tsx:1-18` and mobile header
- Modify: `src/components/AdminShell.tsx:1-14` and mobile header
**Interfaces:**
- `useInstallPrompt(): { state: InstallPromptState; install: () => Promise<void>; dismiss: () => void }`.
- `InstallPromptState` is exactly `"unsupported" | "available" | "ios-instructions" | "installed"`.
- `<InstallAppPrompt />` renders nothing for `unsupported` or `installed` and owns the iOS `Modal` lifecycle.
- [ ] **Step 1: Define the deferred prompt type and write the hook contract.**
Create `src/lib/useInstallPrompt.ts` with a local event type instead of adding an unsafe global declaration:
```ts
import { useEffect, useState } from "react";
export type InstallPromptState = "unsupported" | "available" | "ios-instructions" | "installed";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
}
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && Boolean((navigator as Navigator & { standalone?: boolean }).standalone));
}
function isIOSSafari() {
const ua = navigator.userAgent;
const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
return ios && /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/i.test(ua);
}
export function useInstallPrompt() {
const [state, setState] = useState<InstallPromptState>(() => {
if (typeof window === "undefined") return "unsupported";
if (isStandalone()) return "installed";
return isIOSSafari() ? "ios-instructions" : "unsupported";
});
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (isStandalone()) {
setState("installed");
return;
}
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferred(event as BeforeInstallPromptEvent);
setState("available");
};
const onInstalled = () => {
setDeferred(null);
setState("installed");
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const install = async () => {
if (!deferred) return;
const event = deferred;
setDeferred(null);
await event.prompt();
const choice = await event.userChoice;
if (choice.outcome === "accepted") setState("installed");
else setDismissed(true);
};
return { state: dismissed ? "unsupported" : state, install, dismiss: () => setDismissed(true) };
}
```
Keep the hook browser-only and ensure event listeners are removed on unmount. The iOS state is intentionally limited to Safari; unsupported browsers remain normal web pages.
- [ ] **Step 2: Build the reusable prompt component.**
Create `src/components/InstallAppPrompt.tsx` using `useInstallPrompt`, `Modal`, and `Download`, `Share`, and `PlusSquare` icons from `lucide-react`. The component must:
```tsx
export function InstallAppPrompt() {
const { state, install } = useInstallPrompt();
const [iosOpen, setIosOpen] = useState(false);
if (state === "unsupported" || state === "installed") return null;
if (state === "ios-instructions") {
return (
<>
<button type="button" className="btn btn-secondary w-full justify-start" onClick={() => setIosOpen(true)}>
<Download className="h-4 w-4" /> Instalar AgendaMax
</button>
<Modal open={iosOpen} onClose={() => setIosOpen(false)} title="Instalar AgendaMax" subtitle="Safari lo añade a tu pantalla de inicio.">
<div className="safe-area-bottom">
<ol className="space-y-3 text-sm text-slate-600">
<li className="flex gap-3"><Share className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Toca <strong>Compartir</strong> en la barra de Safari.</span></li>
<li className="flex gap-3"><PlusSquare className="mt-0.5 h-5 w-5 shrink-0 text-brand-600" /><span>Elige <strong>Añadir a pantalla de inicio</strong> y confirma.</span></li>
</ol>
</div>
</Modal>
</>
);
}
return <button type="button" className="btn btn-secondary w-full justify-start" onClick={() => void install()}><Download className="h-4 w-4" /> Instalar AgendaMax</button>;
}
```
The final component may use `aria-label`/`aria-labelledby` as needed, but must keep the exact accessible action name `/Instalar AgendaMax/`, close the iOS modal on backdrop/Escape through the existing `Modal`, and never show an install CTA after standalone detection.
- [ ] **Step 3: Add safe-area styling.**
Append a focused utility to `src/index.css`:
```css
.safe-area-bottom {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
```
Apply it to the iOS modal content/footer surface only; do not change the global page height or reserve a permanent bottom band.
- [ ] **Step 4: Integrate one visible action per page shell.**
Import `InstallAppPrompt` and render it once in each of these existing surfaces:
- `LoginPage`: immediately below the login card's submit area, using a compact centered container so it does not affect the desktop brand panel.
- `AppShell`: in the existing mobile header beside the AgendaMax wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` responsive convention.
- `AdminShell`: in the existing mobile header beside the Admin wordmark/menu controls; keep it hidden on large screens with the same `lg:hidden` convention.
Do not put the component inside `SidebarContent`, because that JSX is rendered once for desktop and again when the mobile drawer opens. One instance per shell avoids duplicate deferred prompt listeners.
- [ ] **Step 5: Verify TypeScript and responsive rendering.**
Run:
```text
npm.cmd run typecheck
npm.cmd run build
```
Expected: exit code 0 for both. Confirm the app still loads the login page and both authenticated shells without console errors.
- [ ] **Step 6: Commit the install UI.**
```text
git add src/lib/useInstallPrompt.ts src/components/InstallAppPrompt.tsx src/index.css src/pages/LoginPage.tsx src/components/AppShell.tsx src/components/AdminShell.tsx
git commit -m "feat: add cross-platform PWA install prompt"
```
---
### Task 4: Add PWA Verification and Production Documentation
**Files:**
- Create: `pwa-e2e.mjs`
- Modify: `README.md:47-64`
**Interfaces:**
- `npm run test:pwa` checks a built production server at `PWA_BASE_URL` or `http://localhost:3000`.
- The check exits non-zero on missing assets, invalid manifest fields, unexpected API interception, or UI behavior failures.
- [ ] **Step 1: Add endpoint and manifest assertions.**
In `pwa-e2e.mjs`, fetch the base URL and assert status 200 for `/manifest.webmanifest`, `/sw.js`, `/icon-192.png`, and `/icon-512.png`. Parse the manifest and assert `display === "standalone"`, `start_url === "/"`, `scope === "/"`, and both declared icon sizes. Fetch a known SPA route such as `/calendar` and assert it returns the built HTML rather than a 404. Fetch `/api/health` and assert it remains a JSON API response.
- [ ] **Step 2: Add browser install-event checks.**
Use Playwright Chromium with `BASE = process.env.PWA_BASE_URL || "http://localhost:3000"`. Add an init script that dispatches a cancelable `beforeinstallprompt` event after load and records `prompt()` calls:
```js
await page.addInitScript(() => {
window.__installPromptCalls = 0;
setTimeout(() => {
const event = new Event("beforeinstallprompt", { cancelable: true });
event.prompt = async () => { window.__installPromptCalls += 1; };
event.userChoice = Promise.resolve({ outcome: "accepted", platform: "web" });
window.dispatchEvent(event);
}, 100);
});
```
On `/`, assert the accessible `Instalar AgendaMax` action becomes visible, click it, and assert `window.__installPromptCalls === 1`.
- [ ] **Step 3: Add iOS and standalone checks.**
Create a second context with an iPhone Safari user agent. Before page scripts run, define `Navigator.prototype.standalone` as `false`; assert the install action is visible, click it, and assert the modal contains `Compartir` and `Añadir a pantalla de inicio`. Create a third context whose `matchMedia` returns `matches: true` for `display-mode: standalone`; assert no `Instalar AgendaMax` action is visible. Create a fourth normal context without an install event and assert no install action is visible.
- [ ] **Step 4: Add service-worker/API safety checks.**
After loading the production page, wait for `navigator.serviceWorker.ready` with a bounded timeout. Inspect the worker source returned by `/sw.js` and assert it contains the `/api/` bypass and `request.method !== "GET"` guard. Use Playwright request listeners to confirm normal page/API loading still reaches `/api/health`; do not use a cached fixture as a substitute for the real API response.
- [ ] **Step 5: Document local and production validation.**
Add a `## Instalar AgendaMax como app` section to `README.md` after the production commands:
- Android/Chrome: open the HTTPS URL and select the install action shown by AgendaMax or the browser address bar.
- iPhone/iPad Safari: use Share -> Add to Home Screen; iOS does not expose Android's in-page install prompt.
- The installed shell can be reopened without browser chrome, but business data still requires an internet connection.
- Local validation: run `npm.cmd run build`, start production with `npm.cmd start`, then run `npm.cmd run test:pwa`.
- Production validation requires the Coolify HTTPS domain, not an HTTP IP address.
- [ ] **Step 6: Run the complete verification set.**
With a built production server running on port 3000, run:
```text
npm.cmd run typecheck
npm.cmd run build
npm.cmd run test:pwa
npm.cmd run audit:visual
git diff --check
```
Expected: typecheck, build, PWA checks, and visual audit exit 0; `git diff --check` reports no whitespace errors. If the pre-existing scheduling data causes API booking assertions in unrelated suites, record that limitation without weakening PWA checks.
- [ ] **Step 7: Commit verification and docs.**
```text
git add pwa-e2e.mjs README.md
git commit -m "test: verify AgendaMax PWA installation"
```
---
## Final Review Checklist
- [ ] `manifest.webmanifest` has valid name, scope, start URL, standalone display, theme/background colors, and 192/512 PNG icons.
- [ ] `index.html` includes manifest, iOS metadata, `apple-touch-icon`, and `viewport-fit=cover`.
- [ ] `sw.js` is copied into `dist`, updates by version, falls back only for navigation, and bypasses `/api` and non-GET requests.
- [ ] Service-worker registration runs only in production and cannot block React startup.
- [ ] Android/Chromium install prompt, iOS instructions, unsupported browser, dismissed prompt, and standalone states are covered.
- [ ] Login, business mobile shell, and admin mobile shell each have one install action instance.
- [ ] No authentication, tenant data, API routes, database schema, or offline writes changed.
- [ ] `npm run typecheck`, `npm run build`, `npm run test:pwa`, and the visual audit have evidence before claiming completion.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
# Demo Accesses and Email Domain
## Goal
Make the demo credentials easy to use and standardize the platform demo accounts
from `@agendapro.demo` to `@agendamax.demo` in source data, documentation, local
data, and the deployed Coolify instance.
## Scope
- Replace active positive `@agendapro.demo` references in application code,
tests, README, and operational documentation with `@agendamax.demo`; retain
the old domain only in migration-history documentation or explicit
negative-login assertions.
- Keep the existing demo password `demo1234` and expose it clearly beside each
demo account in `README.md`.
- Update the local SQLite demo users without deleting businesses, appointments,
or other seeded records.
- Deploy the code to the existing AgendaMax Coolify application.
- Update the persistent production SQLite users for the exact admin and owner
accounts with a targeted SQL change, preserving all other data.
## Data Flow
Fresh databases receive the new admin and owner emails from `server/scripts/seed.ts`
and `server/index.ts`. Existing local and production databases are migrated by
updating only these two exact rows: `[email protected]` and
`[email protected]`. If the active database is fresh and already contains the
new emails, the SQL update is a no-op and is skipped.
The employee accounts already use their business domains and are not changed.
Login verification uses `POST /api/auth/login` with the documented password and
does not expose password values through the API.
## Production Procedure
1. Run the local checks and build.
2. Push the change to the AgendaMax source mirror used by Coolify.
3. Trigger and monitor a Coolify deployment.
4. Inspect the exact old/new admin and owner rows. If an old row and its matching
new row both exist, stop and resolve the unique-email collision. If no old
rows exist and the new rows are present, record a safe no-op. Otherwise run a
targeted SQLite update inside the persistent production app volume:
`UPDATE users SET email = CASE email WHEN '[email protected]' THEN '[email protected]' WHEN '[email protected]' THEN '[email protected]' END WHERE (email = '[email protected]' AND role = 'admin') OR (email = '[email protected]' AND role = 'owner');`
5. Verify health, login for admin and owner, and the production page.
If deployment fails, do not run the production SQL migration; inspect the
deployment first and use the existing Coolify rollback procedure.
## Acceptance Criteria
- `rg "agendapro\.demo"` returns no active positive application, test, or
README references; only explicit negative-login assertions and
migration-history documentation may retain the old domain.
- README lists every demo account with email and `demo1234`.
- Fresh seed creates `[email protected]` and `[email protected]`.
- Existing local data contains the new emails and retains its records.
- Coolify deployment finishes successfully.
- Production `/api/health` returns `ok: true`.
- Production admin and owner logins succeed with `demo1234`.
- Production admin and owner logins using the old domain return `401`.
- README employee emails exactly match the accent-preserving emails generated by the seed.
- The targeted SQL pairs each old email with its matching role and never rewrites
another old-domain user.
## Known Verification Limitation
The already-migrated local database has null `businesses.working_hours` and
employee `working_hours` values. As a result, the slot-dependent `test:e2e` and
`test:booking` assertions can fail before booking, including their existing
downstream dereference. This is a pre-existing scheduling-data limitation,
unrelated to the email-domain change; do not change working-hours data for this
task.
@@ -0,0 +1,174 @@
# AgendaMax PWA Installability
## Goal
Make AgendaMax installable as a web app on mobile and desktop browsers, with a
native installation prompt where the browser supports it and an explicit iOS
installation guide where Apple does not expose that prompt API.
## Decisions
- Use a manually maintained manifest and service worker instead of adding
`vite-plugin-pwa`. This keeps the caching policy visible and avoids a new
build-time dependency for a small, already stable Vite app.
- Use an online-first data model. The service worker may recover the application
shell after a previous visit, but it must never cache `/api` responses or
attempt offline writes for appointments, clients, cash, or settings.
- Keep the existing AgendaMax visual language and Spanish copy. Installation is
an optional secondary action, not a permanent mobile banner.
- Require HTTPS in deployed environments. `localhost` remains valid for local
testing because browsers treat it as a secure context.
## Current Context
- Frontend: React 18, TypeScript, Vite, Tailwind CSS, React Router.
- Production serving: Express serves `dist/` and falls back to `index.html` for
non-API routes.
- Existing brand assets: `public/favicon.svg`, `#3b66ff` theme color, AgendaMax
name and current Spanish UI.
- Existing entry points: `src/main.tsx`, `src/App.tsx`, `LoginPage`,
`AppShell`, and `AdminShell`.
- There is no manifest, service worker, install prompt handling, or iOS-specific
home-screen metadata today.
## Architecture
### Static PWA assets
Add the following public assets, copied by Vite into `dist/`:
- `manifest.webmanifest` with `name` and `short_name` set to AgendaMax,
`start_url` `/`, `scope` `/`, `display` `standalone`, portrait orientation,
brand colors, and PNG icons at 192x192 and 512x512.
- `sw.js` with a versioned cache name.
- PNG icons derived from the existing AgendaMax favicon mark. Keep the SVG
favicon unchanged for browser tabs.
Update `index.html` with the manifest link, iOS home-screen metadata, an
explicit mobile web app title, `viewport-fit=cover`, and the existing theme and
description metadata.
### Service worker
Register the worker from `src/main.tsx` only for production builds, using
`updateViaCache: "none"` so a deployed worker is checked promptly.
The worker has these rules:
- On install, cache only the stable shell/bootstrap assets and call
`skipWaiting`.
- On navigation requests, use network-first and fall back to the cached root
document. This allows deployments to deliver fresh HTML while preserving a
previously visited shell during a temporary outage.
- On same-origin static GET requests, use cache-first for immutable or hashed
assets and add successful responses to the runtime cache.
- Bypass `/api/`, non-GET requests, cross-origin requests, and requests with
credentials or other conditions that could expose tenant data.
- On activation, remove caches from older versions and call `clients.claim`.
The worker is deliberately not a data-sync layer. If an API request fails, the
existing application error/loading behavior remains authoritative.
### Install state and UI
Create a small reusable install hook/component with these states:
1. `unsupported`: no UI.
2. `available`: show an install action backed by `beforeinstallprompt`.
3. `ios-instructions`: show a short dialog explaining Safari's Share then Add
to Home Screen flow.
4. `installed`: no UI, detected from `display-mode: standalone` and
`navigator.standalone`.
The hook must retain the deferred Android prompt only until it is used, handle
`appinstalled`, and avoid showing the CTA repeatedly after a user dismisses it
within the current browser session.
Render the reusable action in:
- `LoginPage`, so a user can install before authentication;
- the authenticated business mobile header/sidebar in `AppShell`;
- the authenticated platform-admin mobile header/sidebar in `AdminShell`.
The action should remain a compact secondary button with a download icon. The
iOS dialog is instructional and must not claim that a native prompt is
available. It must not reserve a persistent bottom band or interfere with
existing mobile navigation.
Use safe-area-aware spacing where the install dialog or mobile controls touch a
screen edge.
## Data Flow
1. Browser loads the Vite entry document.
2. Production client registers `/sw.js`; the worker installs and claims future
navigations.
3. The install hook evaluates browser capability and standalone state.
4. Android/Chromium emits `beforeinstallprompt`; the hook stores the event and
exposes the CTA.
5. User selects the CTA; the hook calls the native prompt and handles the
resulting `appinstalled` or dismissal event.
6. iOS Safari is identified when not standalone; the CTA opens the instruction
dialog and does not attempt an unsupported API.
7. All authenticated API requests continue to use the existing `fetch`/auth
path and always go to the network.
## Error Handling and Compatibility
- Failure to register the service worker is non-fatal and must not prevent the
React application from rendering; log a concise warning in development only.
- Missing or invalid install events result in hidden UI, not an exception.
- Existing browsers that cannot install PWAs continue to work as normal web
pages.
- iOS installation is supported through Safari's system flow. Other iOS
browsers may render the app but are not promised a direct install action.
- The service worker must not intercept API errors, mutate auth tokens, or
serve one user's tenant data to another user.
## Testing and Verification
### Automated
- `npm run typecheck` passes.
- `npm run build` passes and emits the manifest, worker, icons, and metadata in
`dist/`.
- Add a Playwright check for Chromium's deferred install event and `prompt()`.
- Add a Playwright check for iOS-style user-agent/standalone detection and the
instructional dialog.
- Add checks that unsupported and already-installed states do not render the
CTA.
- Verify the production Express server returns status 200 for the manifest,
worker, and icon paths, while SPA fallback still serves client routes.
- Run `npm run audit:visual` and confirm no horizontal overflow at 375, 768,
1280, or 1536 pixels.
### Manual device acceptance
- Android Chrome on HTTPS displays the native install action and opens
AgendaMax in standalone mode with the correct icon/name.
- iPhone Safari on HTTPS shows the two-step installation guide and the added
home-screen app opens without browser chrome.
- After a prior visit, temporarily disabling the network still allows the
shell to load; API-backed data correctly remains unavailable rather than
showing stale cached data.
- A new deployment replaces the old worker/cache without requiring the user to
clear browser storage manually.
## Scope Exclusions
- No offline appointment creation, edits, queued writes, conflict resolution,
or background synchronization.
- No native App Store/Play Store packaging.
- No change to authentication, tenant isolation, API routes, or database schema.
## Acceptance Criteria
- AgendaMax has a valid install manifest and 192x192/512x512 icons.
- Android/Chromium exposes an in-app install action when the native event is
available.
- iOS Safari exposes clear Share -> Add to Home Screen instructions.
- Installed standalone mode hides the install action.
- The service worker is registered in production, preserves the shell after a
prior visit, and never caches `/api` or non-GET requests.
- Production HTTPS serving works through the existing Express/Docker path.
- Typecheck, build, PWA behavior checks, and visual audit pass.
@@ -0,0 +1,309 @@
# Landing de funnel con enfoque Business Intelligence + login mágico
**Fecha:** 2026-07-28
**Estado:** aprobado para plan
**Alcance:** solo frontend (`src/`) + ajuste de tres audits Playwright. No toca servidor, esquema ni API.
## Problema
AgendaMax no tiene página de aterrizaje. `/` sirve el formulario de login
([src/App.tsx:34](../../../src/App.tsx#L34) renderiza `<LoginPage/>` inline cuando no hay sesión), así
que un visitante que llega al dominio se topa con una pared de credenciales sin haber leído nunca qué
resuelve el producto ni por qué debería importarle.
Falta la mitad de arriba del funnel: no hay nada que nombre el dolor, cuantifique su costo, muestre el
mecanismo ni empuje a probar. Y el acceso a la demo — que es la conversión real en esta fase — está
escondido al pie del login como una lista de cuentas sin jerarquía.
## Objetivo
Una landing pública en `/` que venda el ángulo **business intelligence**: el dueño de negocio en LATAM
opera a ciegas. El scroll es el vehículo narrativo, no un contenedor de secciones apiladas. Al final,
un `/login` rediseñado donde entrar a la demo cuesta un clic.
### Criterios de éxito
1. `/` sirve la landing sin sesión; con sesión redirige al panel según rol.
2. `/login` es una ruta enlazable con acceso mágico de un clic por cuenta demo.
3. `npm run typecheck` limpio.
4. `npm run audit:responsive` sin hallazgos nuevos a 390 / 440 / 744 / 1180 px, con la landing incluida
en la lista de páginas auditadas.
5. Cero scroll horizontal en cualquiera de esos anchos.
6. Con `prefers-reduced-motion: reduce` la landing es completamente legible y estática.
7. El chunk de `framer-motion` no se descarga al cargar el panel.
### Fuera de alcance
- Pasarela de pago, formulario de registro o captura de leads. La conversión en fase demo es **entrar a
la demo**, no registrarse.
- Endurecer la autenticación. El token trivial y las contraseñas en claro siguen como están; ver
«Seguridad» más abajo.
- Modo oscuro para el panel. La landing usa fondos oscuros mediante un scope propio.
- Internacionalización. Todo el copy es español de LATAM, como el resto del repo.
## Decisiones tomadas
| Decisión | Elegido | Por qué |
|---|---|---|
| Dolor del hero | «No sabes qué pasa en tu negocio» | Es el ángulo BI puro y es lo que la app ya demuestra: dashboard de ingresos, top empleados, top clientes. |
| Librería de animación | `framer-motion` | Orquestación declarativa y física de resorte que no vale la pena reimplementar. Se aísla en su propio chunk para que el panel no lo pague. |
| Ruteo | Landing en `/`, login en `/login` | Es lo que espera un visitante. No rompe `/b/:slug` ni las rutas del panel. |
| Acceso mágico | Solo en `/login` | Mantiene la landing enfocada en persuadir y concentra toda la mecánica de acceso en un lugar. |
## Arquitectura
### Ruteo
```
/ → LandingPage público; con sesión → redirige al panel
/login → LoginPage formulario + acceso mágico; con sesión → redirige al panel
/b/:slug → BookingPage sin cambios, sigue fuera de AuthProvider
/dashboard… → panel protegido sin sesión → <Navigate to="/login" replace/>
```
`src/App.tsx` se reestructura: `Protected` deja de renderizar `<LoginPage/>` como fallback y en su lugar
el árbol de rutas distingue tres zonas — pública (`/`, `/login`), pública sin auth (`/b/:slug`) y
protegida. El splash de carga (`loading === true`) se conserva tal cual.
Detalle: el destino de la redirección con sesión es el mismo que hoy calcula
[src/App.tsx:43-54](../../../src/App.tsx#L43-L54) — `admin` → `/admin`, `owner` → `/dashboard`,
`employee` → `/calendar`. Se extrae a un helper `homePathFor(user)` para no duplicar la regla en tres
sitios.
### Code splitting
`LandingPage` se monta con `React.lazy` + `Suspense`. Dos razones que van en direcciones opuestas y por
eso importan las dos: el usuario con sesión nunca ve la landing y no debe descargarla, y el visitante
en la landing no debe descargar FullCalendar ni recharts.
`framer-motion` se añade a `manualChunks` en [vite.config.ts:33](../../../vite.config.ts#L33) como chunk
`motion`. Sin esto Rollup lo mete en el chunk compartido y los ~50 KB gzip se cobran en cada carga del
panel.
### Archivos
```
src/pages/LandingPage.tsx orquestador; solo compone secciones, ~40 líneas
src/components/landing/
LandingNav.tsx nav flotante que se condensa al scrollear
HeroSection.tsx
CostSection.tsx «el costo de no saber» — 4 fugas con contadores
ShiftSection.tsx cuaderno → tablero, scrub por scroll
ProductSection.tsx 3 actos con panel sticky
ProofSection.tsx mock del dashboard real
ObjectionsSection.tsx
ClosingSection.tsx CTA final + footer
visuals/
CalendarGridVisual.tsx retícula que se puebla de citas
RevenueLineVisual.tsx línea de ingresos que se traza
TeamLoadVisual.tsx carga por especialista
NotebookVisual.tsx cuaderno con tachones (lado «antes»)
DashboardMockVisual.tsx composición del panel a escala
src/lib/motion.ts easings y variants compartidos
src/lib/useReducedMotion.ts wrapper sobre prefers-reduced-motion
```
Una sección por archivo: cada una recibe cero props, no comparte estado con sus hermanas y se puede
leer o rehacer sin abrir las demás. Los visuales viven aparte de las secciones porque son la parte que
más va a iterar y no deberían obligar a releer el copy para tocarlos.
`src/lib/motion.ts` exporta el contrato de movimiento que todas las secciones consumen:
```ts
export const EASE_EXPO = [0.16, 1, 0.3, 1] as const; // ease-out-expo
// Estado inicial → visible. `reduced` colapsa el initial al estado final.
export const revealUp = (reduced: boolean) => ({
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 },
show: { opacity: 1, y: 0, transition: { duration: 0.7, ease: EASE_EXPO } },
});
// Contenedor que escalona a sus hijos; 0 cuando hay movimiento reducido.
export const revealStagger = (reduced: boolean) => ({
hidden: {},
show: { transition: { staggerChildren: reduced ? 0 : 0.08 } },
});
```
Un solo easing en todo el sitio. Es lo que produce la sensación de que los elementos «pesan algo y
frenan solos», y mezclar curvas es lo que hace que una landing se sienta improvisada.
## Criterio visual
Cinco reglas, no una lista de efectos:
| Regla | Aplicación |
|---|---|
| Una idea por pantalla | Cada sección ocupa el viewport y afirma *una* cosa. Prohibido el grid de 6 features. |
| Tipografía protagonista | Titulares `clamp(2.75rem, 7vw, 6rem)`, `tracking-tight`, peso 700-800. El texto es la imagen. |
| Silencio | 120-200px entre secciones. El aire es lo que hace que se lea caro. |
| Color contenido | Base `#08080a` y `#fafafa` alternándose a sangre. `brand-500` solo en CTAs y datos. Sin gradientes de relleno. |
| El producto es la foto | Cero stock photos. Los visuales son el producto dibujándose: agenda que se llena, línea que se traza, KPI que cuenta. |
### Tokens de la landing
El `body` del panel es `#f6f7fb` con `color-scheme: light`
([src/index.css:5-42](../../../src/index.css#L5-L42)). La landing necesita fondos oscuros a sangre sin
alterar eso, así que se declara un scope propio en `src/index.css`:
```css
@layer components {
.landing {
--ld-ink: #08080a; /* casi-negro, no negro puro: el negro puro aplana */
--ld-paper: #fafafa;
--ld-muted: #6b6b73; /* texto secundario sobre papel */
--ld-muted-dark: #8a8a94; /* texto secundario sobre tinta */
--ld-hairline: rgba(255, 255, 255, 0.09);
}
.landing-dark { background: var(--ld-ink); color: var(--ld-paper); }
.landing-light { background: var(--ld-paper); color: var(--ld-ink); }
}
```
Va **dentro** de `@layer components`, que es el default del repo: así cualquier utilidad de Tailwind
aplicada en el JSX gana por especificidad de capa y las secciones pueden sobrescribir puntualmente. Solo
las reglas que deben ganar a las utilidades salen de la capa, y aquí ninguna lo necesita
([src/index.css:415](../../../src/index.css#L415)).
## Estructura del funnel
Orden: *problema → costo → mecanismo → prueba → objeción → cierre*.
**1 · Hero** (oscuro)
> **Tu negocio te habla todos los días. Nadie está escuchando.**
> Cada cita, cada cancelación y cada cliente que no volvió es un dato. AgendaMax los convierte en
> decisiones que te dejan dinero.
> `[Entrar a la demo]` `[Ver cómo funciona ↓]`
Movimiento: titular por líneas con stagger de 80 ms. Detrás, `CalendarGridVisual` poblándose sola,
desenfocada al 20% de opacidad. Indicador de scroll con respiración lenta.
**2 · El costo de no saber** (claro) — cuatro fugas, no cuatro features. Contadores que se animan al
entrar en viewport:
- El 34% de tus horas disponibles se van vacías.
- El cliente que no volvió hace cinco meses sigue en tu lista.
- Un servicio te está costando más de lo que cobra.
- Tu mejor empleado lo sabes por corazonada, no por dato.
**3 · El cambio** (oscuro) — sección sticky con scrub por progreso de scroll. `NotebookVisual` con
tachones se desvanece mientras el mismo día se reconstruye como tablero.
> **No es que trabajes poco. Es que trabajas sin instrumentos.**
**4 · El producto en tres actos** (claro) — panel sticky a la derecha, texto scrolleando a la izquierda,
visual que cambia por acto:
- **Agenda** → `CalendarGridVisual` con una cita arrastrándose. *«Tu día completo en una pantalla.»*
- **Inteligencia** → `RevenueLineVisual` trazándose y top clientes ordenándose. *«Los números que tu
contador te da en marzo, hoy a las 3 de la tarde.»*
- **Equipo** → `TeamLoadVisual`. *«Deja de repartir el trabajo por intuición.»*
**5 · Prueba** (claro) — `DashboardMockVisual` a escala, animándose. Sello: *«Estos son datos reales de
la cuenta demo. Puedes entrar y moverlos.»*
**6 · Objeciones** (claro) — las tres reales de un dueño en LATAM:
- *«No soy de tecnología.»* → Si sabes usar WhatsApp, sabes usar esto.
- *«Mi equipo no lo va a adoptar.»* → Cada empleado ve solo su día. Nada que aprender.
- *«Ya tengo mi cuaderno y me funciona.»* → Tu cuaderno no te dice qué servicio te está costando dinero.
**7 · Cierre** (oscuro) — cierra el círculo del hero:
> **Deja de adivinar.**
> `[Entrar a la demo →]` · Sin registro. Sin tarjeta. Cuentas ya cargadas.
**8 · Footer** — logo, «Demo · AgendaMax», enlace a `/login`.
## Login mágico
`src/pages/LoginPage.tsx` se rediseña con el lenguaje visual de la landing. El cambio de fondo es que
las tarjetas de cuenta pasan a ser el camino **principal**, no un apéndice al pie.
- Consume `GET /api/auth/demo-users`, que **ya existe**
([server/routes/auth.ts:24](../../../server/routes/auth.ts#L24)) y devuelve las cuentas ordenadas
admin → owner → empleados con `email`, `name`, `role`, `avatar_color`. **No se toca el servidor.**
- Se agrupan por rol, rotulando lo que cada uno desbloquea: **Dueña** (tablero completo), **Empleado**
(solo su agenda), **Plataforma** (consola multi-negocio).
- Un clic entra, reutilizando el `quickLogin` ya presente en
[src/pages/LoginPage.tsx:45](../../../src/pages/LoginPage.tsx#L45).
- El formulario manual se conserva **visible** bajo un separador, no colapsado tras un toggle. Es la ruta
que usan los tres audits Playwright, y esconderlo tras un clic obligaría a añadir un paso de expansión
a cada uno. Los selectores `input[type="email"]` y `input[type="password"]` deben existir en el DOM en
la carga inicial de `/login`, sin interacción previa.
Se conserva la bandera `DEMO` (`import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"`) tal como
está hoy. Es una constante de build, así que la contraseña demo se elimina del bundle de producción por
dead-code elimination. En local `npm run dev` la activa sola.
Cuando `DEMO` es falso, `/login` degrada al formulario manual expandido y sin tarjetas.
## Invariantes de responsividad
Ya documentadas en `CLAUDE.md`; romperlas reintroduce bugs conocidos.
- Alturas con `.min-h-screen-safe` / `.h-screen-safe` (`dvh` con `vh` de fallback), **nunca** `100vh`. Una
landing full-bleed es exactamente donde `100vh` falla en iOS por la barra de URL dinámica.
- Campos de formulario a 16px en `pointer: coarse` — ya lo garantiza la regla global de
[src/index.css:444](../../../src/index.css#L444); el login no debe declarar tamaños menores.
- CTAs con 44px de alto mínimo; los decide `pointer: coarse`, no breakpoints `sm:`.
- Todo `grid` con `grid-cols-1` explícito. Sin él la columna implícita se dimensiona a `max-content` y
desborda — es la causa exacta del comentario en
[src/pages/LoginPage.tsx:63-66](../../../src/pages/LoginPage.tsx#L63-L66).
- Visuales anchos scrollean dentro de su contenedor con `overflow-x: auto`; la página nunca.
- Secciones sticky: `position: sticky` con `top` calculado sobre `dvh`, no sobre `vh`.
## Movimiento reducido
`@media (prefers-reduced-motion: reduce)` y el hook `useReducedMotion` deben dejar la landing estática
**y completa**. El riesgo concreto es un elemento con `initial={{ opacity: 0 }}` que nunca anima y queda
invisible: el contenido desaparecería para quien tiene la preferencia activada. La regla es que con
movimiento reducido los `initial` se resuelven al estado final, no que las transiciones se acorten.
Las secciones con scrub por scroll (3 y 4) degradan a su estado final estático, con los visuales
apilados en vez de intercambiados.
## Verificación
| Comprobación | Comando |
|---|---|
| Gate de calidad del repo | `npm run typecheck` |
| Responsividad | `npm run audit:responsive` (requiere `npm run dev`) |
| Capturas | `npm run audit:visual` |
| API sin regresión | `npm run test:e2e`, `npm run test:admin`, `npm run test:booking` |
### Audits que se rompen y hay que arreglar
Tres scripts hacen `goto('/')` y a continuación `fill('input[type="email"]')`. Con `/` sirviendo la
landing ese `fill` falla. Deben apuntar a `/login`:
- [visual-audit.mjs:44](../../../visual-audit.mjs#L44)
- [responsive-audit.mjs:264](../../../responsive-audit.mjs#L264)
- [pwa-e2e.mjs:129](../../../pwa-e2e.mjs#L129) junto con los `goto(baseUrl)` de las líneas 187-288, que
ahora aterrizan en la landing en lugar del login.
El arreglo es un cambio de URL, no de flujo: el formulario manual sigue visible sin interacción previa en
`/login`, así que los `fill` existentes funcionan tal cual. Es exactamente la razón por la que el spec
prohíbe colapsarlo.
Caso aparte en `pwa-e2e.mjs`: el prompt de instalación PWA se probaba sobre `/`. Ahora `/` es la landing,
que no monta `InstallAppPrompt` (vive en el login y dentro del panel). Esos casos apuntan a `/login`.
Además, la landing se añade a la lista de páginas de `responsive-audit.mjs` y `visual-audit.mjs` como
página pública, sin paso de login.
Los tests de API (`test:e2e`, `test:admin`, `test:booking`, `test:unit`) no se tocan: golpean `/api`
directo y no dependen del ruteo del cliente.
## Seguridad
La landing no cambia el modelo de seguridad, pero conviene dejarlo escrito porque el cambio lo hace más
visible: `/login` expone contraseñas de cuentas **existentes** en el cliente. Está autorizado
explícitamente para esta fase demo y ya es el comportamiento actual del repo. La bandera `DEMO` es lo
único que separa eso de una fuga de credenciales si el proyecto se publica en un dominio real. Antes de
cualquier despliegue no-demo hace falta lo que ya lista `CLAUDE.md`: hashing, JWT firmados, validación
estricta y rate-limiting.
## Riesgos
| Riesgo | Mitigación |
|---|---|
| `framer-motion` se filtra al bundle del panel | Chunk manual `motion` + `React.lazy` en la landing. Verificar en la salida de `npm run build` que el chunk existe y que el panel no lo importa. |
| Las secciones sticky descuadran en iPad portrait (1024px) | El repo ya tiene el conflicto documentado entre el corte tablet/desktop y el `lg:` de Tailwind. La landing usa un layout de una columna por debajo de 1024px, evitando el borde. |
| `prefers-reduced-motion` deja contenido invisible | Los `initial` resuelven al estado final; se verifica emulando la preferencia en el audit. |
| Los contadores animados disparan reflow en móvil | Se animan con `transform`/`opacity` y el número se interpola en un `<span>` de ancho tabular (`font-variant-numeric: tabular-nums`) para no relayoutear en cada frame. |
@@ -0,0 +1,384 @@
# Propuesta técnica breve: plataforma de gestión para Yola Franco Spa
**Versión:** 0.1 — propuesta conceptual
**Objetivo:** construir una web app de operación diaria para el spa, con una experiencia extremadamente rápida para empleadas y un dashboard de control para la dueña, conectada con GoHighLevel (GHL) mediante API y webhooks.
---
## 1. Resumen de la solución
La plataforma funcionará como un sistema operativo interno del spa:
- **Dueña/administradora:** visualiza ventas, citas, rendimiento, clientes, servicios, campañas y operación.
- **Empleada:** gestiona su agenda, crea y modifica citas, consulta clientes y registra la atención con el mínimo número de clics.
- **GoHighLevel:** conserva la relación omnicanal y automatizaciones de marketing. La app sincroniza contactos, conversaciones y eventos de cita mediante API/webhooks.
- **PostgreSQL:** fuente de datos operativos de la plataforma: citas, clientes, servicios, empleadas, pagos, comisiones, auditoría y sincronizaciones.
La recomendación es no intentar clonar toda la superficie de AgendaPro en la primera versión. El MVP debe resolver primero la operación diaria, la visibilidad del negocio y la integración confiable con GHL.
## 2. Alcance funcional
### 2.1 Panel de la administradora
1. **Dashboard ejecutivo**
- Ventas del día, semana y mes.
- Citas agendadas, atendidas, canceladas y no-show.
- Ingresos por servicio, empleada y canal.
- Ticket promedio.
- Tasa de recompra y clientes nuevos.
- Ocupación de agenda por empleada.
- Top clientes por frecuencia, gasto y última visita.
- Top servicios y servicios con baja demanda.
- Fuente de adquisición: orgánico, Instagram, Facebook, WhatsApp, campañas GHL, referido u otro.
2. **Calendario global**
- Vista diaria, semanal y mensual.
- Filtros por empleada, servicio, estado y ubicación.
- Crear, mover, confirmar, reprogramar y cancelar citas.
- Bloqueos de horario, descansos, vacaciones y días no laborables.
3. **Clientes/CRM operativo**
- Búsqueda por nombre, teléfono, correo o identificador GHL.
- Historial de citas, servicios, pagos, notas y conversaciones enlazadas.
- Etiquetas: nuevo, frecuente, VIP, inactivo, campaña, referido, etc.
- Consentimiento de comunicaciones y preferencias.
- Próxima recomendación de servicio y fecha sugerida de regreso.
- Detección de posibles duplicados antes de crear un cliente.
4. **Catálogo y configuración**
- Servicios, categorías, duración, precio, buffer y empleadas habilitadas.
- Horarios de atención y reglas de disponibilidad.
- Comisiones por servicio o por empleada.
- Paquetes, promociones y tarjetas/membresías en una fase posterior.
5. **Reportes**
- Exportación CSV/XLSX de clientes, citas y ventas.
- Reporte por periodo, empleada, servicio y canal.
- Registro de cambios y actividad administrativa.
### 2.2 Panel de la empleada
Diseñado primero para móvil y tablet, con navegación reducida:
- **Hoy:** próximas citas, hora, cliente, servicio y estado.
- **Mi agenda:** día/semana con bloques visuales.
- **Nueva cita rápida:** seleccionar fecha/hora, servicio, cliente y confirmar.
- **Búsqueda de cliente:** resultados mientras se escribe; evitar duplicados.
- **Alta rápida:** nombre y teléfono obligatorios; correo y notas opcionales.
- **Acciones de una cita:** confirmar, iniciar, completar, reprogramar, cancelar y marcar no-show.
- **Ficha resumida:** historial reciente, notas relevantes, preferencias y próxima visita.
- **Rendimiento personal:** citas atendidas, ventas generadas, ticket promedio, cancelaciones y comisión estimada.
La empleada no debe ver información financiera global ni clientes ajenos a sus permisos, salvo que la administradora lo configure.
## 3. Roles y permisos
Usar autorización basada en roles (RBAC), no solamente ocultamiento visual:
| Recurso | Administradora | Empleada |
|---|---:|---:|
| Dashboard global | Sí | No |
| Dashboard personal | Sí | Sí |
| Calendario global | Sí | Según permiso |
| Mi agenda | Sí | Sí |
| Crear cita | Sí | Sí |
| Editar/cancelar cualquier cita | Sí | Solo propias, según regla |
| Ver clientes | Todos | Necesarios para operar |
| Exportar clientes/ventas | Sí | No |
| Editar precios/comisiones | Sí | No |
| Ver ventas globales | Sí | No |
| Configurar GHL | Sí | No |
| Auditoría | Sí | No |
La aplicación debe estar preparada para `tenant_id`, aunque inicialmente exista un solo spa. Esto evita rediseñar la base si después se ofrecen cuentas a otros negocios.
## 4. Arquitectura propuesta
```text
[Web app responsive]
|
v
[API Python: FastAPI]
| | |
| | +--> [Worker: Celery/RQ + Redis]
| +-----------> [GoHighLevel API]
+-------------------> [PostgreSQL]
|
+--> métricas/reportes
[GHL Webhooks] ---> [Endpoint seguro] ---> [Event inbox] ---> [Worker]
```
### Componentes
- **Frontend:** Next.js/React + TypeScript, PWA instalable, Tailwind CSS o sistema de componentes equivalente.
- **Backend:** Python 3.12+ con FastAPI, Pydantic y SQLAlchemy 2.x/SQLModel.
- **Base de datos:** PostgreSQL 16+, migraciones con Alembic.
- **Cola y caché:** Redis; workers para webhooks, sincronizaciones y mensajes sin bloquear la interfaz.
- **Autenticación:** sesiones seguras con cookies HttpOnly o JWT de corta duración con refresh rotativo. MFA para la administradora en una fase posterior.
- **Despliegue inicial:** Docker Compose en staging; producción con PostgreSQL administrado, Redis administrado y servicio web/worker separado.
- **Observabilidad:** logs estructurados, Sentry/OpenTelemetry opcional, métricas de errores de integración y tiempos de respuesta.
## 5. Modelo de datos inicial
Tablas principales:
- `tenants`: spa/cuenta, zona horaria, configuración y estado.
- `users`: usuarios internos, correo, estado y último acceso.
- `roles`, `user_roles`: administradora y empleada.
- `employees`: perfil operativo, especialidades, horarios y comisión.
- `services`: nombre, categoría, duración, precio, buffer, activo.
- `employee_services`: servicios que puede realizar cada empleada.
- `customers`: nombre, teléfono normalizado, correo, consentimiento, `ghl_contact_id`.
- `customer_tags`: etiquetas operativas y de adquisición.
- `appointments`: cliente, empleada, servicio, inicio, fin, estado, origen, notas y `ghl_appointment_id`.
- `appointment_events`: historial de cambios de una cita.
- `payments`: monto, método, estado, referencia y fecha.
- `campaign_attributions`: UTM, campaña, fuente, medio y primer/último contacto.
- `conversations`: referencia a conversación/canal en GHL; no necesariamente almacenar todo el contenido si GHL es la fuente principal.
- `integration_connections`: ubicación GHL, tokens cifrados, scopes y estado.
- `integration_events`: webhook/evento recibido, payload hash, estado, reintentos e idempotency key.
- `outbox_events`: eventos internos pendientes de enviar a GHL.
- `audit_logs`: quién cambió qué, cuándo y desde dónde.
### Reglas importantes
- Normalizar teléfonos a formato E.164 (`+52...`) antes de buscar o crear contactos.
- `UNIQUE (tenant_id, normalized_phone)` para evitar duplicados básicos.
- Guardar fechas en UTC y mostrar en `America/Mexico_City`.
- Usar `timestamptz` y rangos para impedir doble reserva.
- Crear una restricción de exclusión PostgreSQL por empleada para evitar solapamientos de citas confirmadas.
- No borrar clientes físicamente; usar estado, anonimización y política de retención.
## 6. Integración con GoHighLevel
### 6.1 Autenticación y configuración
Preferir OAuth 2.0 para una integración comercial reutilizable. Para una sola subcuenta controlada por el equipo, puede iniciarse con credenciales de ubicación/API adecuadas, almacenadas cifradas en el servidor.
No guardar tokens en frontend ni en PostgreSQL en texto plano. Usar un gestor de secretos o variables de entorno del servidor; las variables deben contener secretos, no configuración funcional.
Configurar en GHL:
- Location/Sub-account ID del spa.
- Client ID y Client Secret de la aplicación, si se usa OAuth.
- Scopes mínimos necesarios.
- URLs de redirección OAuth.
- URLs de webhooks.
- Firma/secreto de validación de webhooks, si está disponible en el evento utilizado.
### 6.2 Sincronización de contactos
**Crear desde la app:**
1. La empleada captura teléfono y nombre.
2. La API busca primero en PostgreSQL por teléfono normalizado.
3. Si existe `ghl_contact_id`, actualiza o reutiliza el contacto.
4. Si no existe, consulta GHL por teléfono/correo.
5. Si tampoco existe, crea el contacto mediante API.
6. Guarda `ghl_contact_id`, respuesta resumida y evento de sincronización.
7. La cita se crea solamente después de resolver el cliente local.
**Actualizar desde la app:** usar una cola `outbox_events`, con reintentos y clave de idempotencia. La pantalla no debe quedar bloqueada si GHL está temporalmente fuera de servicio; debe mostrar “guardado local / sincronización pendiente”.
**Recibir desde GHL:** registrar webhooks de creación/actualización de contacto, cambios relevantes y eventos de conversación disponibles para la cuenta. El webhook debe responder rápido con HTTP 2xx y procesarse en segundo plano.
### 6.3 Conversaciones y mensajes
La app puede mostrar una vista resumida de conversaciones, pero conviene mantener GHL como sistema principal de mensajería omnicanal:
- GHL recibe mensajes de WhatsApp, Facebook e Instagram.
- GHL dispara webhooks hacia la plataforma cuando exista un evento compatible.
- La plataforma almacena metadatos y referencias, no necesariamente todo el historial.
- Cuando la dueña o empleada envía un mensaje desde la app, el backend ejecuta una petición autenticada a la API de GHL.
- El frontend nunca llama directamente a GHL.
**Flujo de envío:**
```text
Frontend -> POST /api/conversations/{id}/messages
-> valida permiso y contenido
-> crea outbox_event
-> worker llama API GHL
-> guarda resultado/id externo
-> frontend recibe estado enviado/fallido
```
Debe contemplar límites de frecuencia, reintentos con backoff, mensajes duplicados, archivos multimedia y errores de permisos. Las capacidades exactas de envío deben validarse contra la versión actual de la API y los canales habilitados en la subcuenta.
### 6.4 Webhooks seguros e idempotentes
Endpoint sugerido:
```text
POST /api/integrations/gohighlevel/webhooks/{tenant_id}
```
Proceso:
1. Validar firma, secreto o mecanismo oficial disponible.
2. Validar tamaño y estructura del payload.
3. Calcular hash del evento y revisar `integration_events`.
4. Si ya fue procesado, responder 200 sin duplicar efectos.
5. Insertar el evento en la bandeja de entrada.
6. Responder 200 rápidamente.
7. Worker transforma el evento y actualiza contacto/conversación/cita.
8. Registrar resultado, duración y número de reintentos.
## 7. API interna sugerida
```text
POST /api/auth/login
GET /api/dashboard/summary?from=&to=
GET /api/calendar?from=&to=&employee_id=
POST /api/appointments
PATCH /api/appointments/{id}
POST /api/appointments/{id}/confirm
POST /api/appointments/{id}/complete
POST /api/appointments/{id}/cancel
GET /api/customers?query=
POST /api/customers
GET /api/customers/{id}
PATCH /api/customers/{id}
GET /api/services
POST /api/services
GET /api/employees
GET /api/reports/sales
GET /api/reports/performance
GET /api/conversations
POST /api/conversations/{id}/messages
POST /api/integrations/gohighlevel/connect
POST /api/integrations/gohighlevel/sync
POST /api/integrations/gohighlevel/webhooks/{tenant_id}
GET /api/integrations/jobs/{id}
```
Todos los endpoints deben validar `tenant_id`, rol, permisos de recurso y esquema de entrada. La API debe devolver errores consistentes (`code`, `message`, `details`, `request_id`).
## 8. Métricas que importan al dueño
### Ventas
- Ingresos brutos/netos por periodo.
- Ticket promedio.
- Ventas por servicio, empleada y canal.
- Métodos de pago.
- Comisiones.
### Operación
- Utilización de horas disponibles.
- Citas atendidas, canceladas, reprogramadas y no-show.
- Tiempo promedio entre citas.
- Huecos disponibles próximos 7/14 días.
### Clientes
- Clientes nuevos vs recurrentes.
- Recompra a 30/60/90 días.
- Frecuencia y valor acumulado.
- Clientes inactivos.
- Fuente de adquisición y campaña.
### Marketing/GHL
- Contactos creados.
- Conversaciones iniciadas.
- Leads que terminaron en cita.
- Citas por campaña/UTM.
- Tiempo de respuesta, si GHL expone el dato necesario.
Los dashboards deben mostrar periodo, filtros, definición de cada métrica y fuente del dato. No presentar “ROI” si no se cuenta con costo de campaña confiable.
## 9. Experiencia de usuario y responsive
Prioridad de diseño: **la empleada opera con una mano y pocos segundos disponibles**.
- Mobile-first; soportar 360 px de ancho como mínimo.
- Botón persistente “Nueva cita”.
- Búsqueda global rápida de cliente.
- Calendario con colores por estado, no solamente por empleada.
- Formularios cortos y autoguardado de notas.
- Confirmación clara antes de cancelar o modificar una cita.
- Estados offline/pending para sincronizaciones.
- Accesibilidad WCAG 2.2 AA como objetivo.
- PWA para acceso desde la pantalla de inicio; no asumir aplicación nativa en el MVP.
## 10. Seguridad, privacidad y operación
- HTTPS obligatorio y cookies `Secure`, `HttpOnly`, `SameSite`.
- Hash de contraseñas con Argon2id o bcrypt configurado correctamente.
- Rate limiting en login, búsquedas y envío de mensajes.
- Validación de permisos en backend.
- Auditoría de cambios sensibles.
- Cifrado de secretos y datos sensibles en reposo cuando el proveedor lo permita.
- Backups automáticos de PostgreSQL y prueba periódica de restauración.
- Protección contra CSRF si se usan cookies de sesión.
- Sanitización de notas y contenido de mensajes.
- Política de privacidad, consentimiento para marketing y procedimiento de eliminación/anonimización.
- No guardar datos completos de tarjeta; integrar un proveedor de pagos si después se requiere cobro en línea.
## 11. Fases recomendadas
### Fase 0 — Descubrimiento técnico
- Confirmar documentación y scopes vigentes de GHL.
- Confirmar canales disponibles y eventos de webhook.
- Levantar catálogo, horarios, empleadas, reglas de reserva y métodos de pago.
- Definir si la fuente principal de agenda será la nueva app o AgendaPro durante la transición.
### Fase 1 — MVP operativo
- Login y RBAC.
- Clientes y búsqueda anti-duplicados.
- Servicios y empleadas.
- Calendario y nueva cita rápida.
- Estados de cita.
- Dashboard básico.
- PostgreSQL, migraciones, backups y auditoría.
### Fase 2 — Integración GHL
- Conexión segura con subcuenta.
- Crear/actualizar contactos.
- Sincronización inicial controlada.
- Webhooks idempotentes.
- Outbox y workers.
- Vista de conversaciones y envío de mensajes compatible con canales habilitados.
### Fase 3 — Analítica y crecimiento
- Ventas y pagos.
- Comisiones.
- Campañas/UTM.
- Recompra y reactivación.
- Reportes exportables.
- Paquetes, promociones, recordatorios y membresías.
## 12. Criterios de aceptación del MVP
- Una empleada puede crear una cita en menos de un minuto desde móvil.
- La búsqueda por teléfono encuentra un cliente existente sin crear duplicado.
- Dos usuarios no pueden reservar el mismo horario para la misma empleada.
- La administradora puede filtrar citas, ventas y rendimiento por periodo y empleada.
- Un cliente creado localmente se sincroniza con GHL o queda claramente marcado como pendiente.
- Un webhook repetido no duplica clientes, citas ni mensajes.
- Una caída temporal de GHL no borra ni impide guardar la operación local.
- Cada cambio relevante deja registro de usuario, fecha y acción.
- Los permisos impiden a una empleada consultar reportes globales o modificar precios.
- Los datos mostrados en dashboard incluyen su periodo y fuente.
## 13. Riesgos y decisiones pendientes
1. **API de GHL:** endpoints, scopes, límites y eventos disponibles pueden variar por versión y plan; deben validarse en un spike antes de comprometer el alcance.
2. **Doble agenda:** operar simultáneamente AgendaPro y la nueva app puede producir conflictos. Se debe elegir una fuente de verdad o construir sincronización explícita.
3. **Mensajería omnicanal:** Instagram, Facebook y WhatsApp pueden tener restricciones distintas; el sistema debe degradar con gracia y mostrar el estado real.
4. **Migración de datos:** antes de importar contactos hay que normalizar teléfonos y definir política de duplicados.
5. **Privacidad:** nombre, teléfono, historial y conversaciones requieren consentimiento, controles de acceso y política de retención.
6. **Pagos:** si inicialmente solo se registra pago manual, etiquetarlo como registro operativo y no como conciliación bancaria.
## Recomendación final
Construir primero una **agenda operacional mobile-first con clientes y dashboard**, y después agregar la capa omnicanal de GHL mediante una arquitectura de eventos (`outbox`, `event inbox`, workers e idempotencia). PostgreSQL es una elección adecuada para escalar la operación y los mensajes referenciados, pero GHL debe permanecer como sistema de conversaciones mientras la app se consolida como sistema de agenda, clientes y rendimiento.
El siguiente paso técnico recomendable es un **spike de integración de 3–5 días** que pruebe: autenticación GHL, creación/búsqueda de contacto, recepción de un webhook, envío de un mensaje permitido y sincronización de una cita. El resultado debe incluir scopes reales, payloads, límites y decisiones de fuente de verdad antes de iniciar el desarrollo completo.
+15 -3
View File
@@ -24,13 +24,25 @@ console.log("\n=== HEALTH & AUTH ===");
const h = await req("/api/health"); check("health", h.json?.ok === true); const h = await req("/api/health"); check("health", h.json?.ok === true);
const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8); const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8);
const owner = await login("owner@agendapro.demo"); check("owner login", !!owner.token && owner.user.role === "owner"); const owner = await login("owner@agendamax.demo"); check("owner login", !!owner.token && owner.user.role === "owner");
const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id); const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id);
const adminLogin = await login("admin@agendapro.demo"); check("admin login", adminLogin.user.role === "admin"); const adminLogin = await login("admin@agendamax.demo"); check("admin login", adminLogin.user.role === "admin");
const legacyAdmin = await req("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "demo1234" }),
});
check("[email protected] legacy login rejected", legacyAdmin.status === 401, `status=${legacyAdmin.status}`);
const legacyOwner = await req("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "[email protected]", password: "demo1234" }),
});
check("[email protected] legacy login rejected", legacyOwner.status === 401, `status=${legacyOwner.status}`);
const t = owner.token, eT = empLogin.token, aT = adminLogin.token; const t = owner.token, eT = empLogin.token, aT = adminLogin.token;
// /me fix // /me fix
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendapro.demo"); const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendamax.demo");
console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ==="); console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ===");
const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa"); const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa");
+2 -2
View File
@@ -27,11 +27,11 @@ async function findSlot(slug, serviceId, maxDays = 30) {
} }
// /me now works (the bug we fixed) — login first to get a real token // /me now works (the bug we fixed) — login first to get a real token
const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" }); const { json: login } = await req("POST", "/auth/login", { email: "owner@agendamax.demo", password: "demo1234" });
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100)); check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
const t = login.token; const t = login.token;
const { json: me } = await req("GET", "/auth/me", null, t); const { json: me } = await req("GET", "/auth/me", null, t);
check("/api/auth/me returns user", me.user?.email === "owner@agendapro.demo", JSON.stringify(me).slice(0, 100)); check("/api/auth/me returns user", me.user?.email === "owner@agendamax.demo", JSON.stringify(me).slice(0, 100));
// Slug del negocio (para consultar slots públicos) // Slug del negocio (para consultar slots públicos)
const { json: settings } = await req("GET", "/settings", null, t); const { json: settings } = await req("GET", "/settings", null, t);
+14 -2
View File
@@ -3,12 +3,24 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, viewport-fit=cover" />
<meta name="theme-color" content="#3b66ff" /> <meta name="theme-color" content="#3b66ff" />
<meta name="description" content="AgendaMax — Gestión visual de citas, empleados e ingresos para tu negocio." /> <meta name="description" content="AgendaMax — Gestión visual de citas, empleados e ingresos para tu negocio." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="AgendaMax" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <!-- Inter es la voz de la plataforma y también el cuerpo de la landing: misma
tipografía dentro y fuera del producto. Fraunces (display cálido, con eje
WONK) se reserva para los titulares de la landing, que es lo único que
necesita personalidad de marketing. -->
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Fraunces:opsz,[email protected],600..900&display=swap"
rel="stylesheet"
/>
<title>AgendaMax — Gestión visual de tu negocio</title> <title>AgendaMax — Gestión visual de tu negocio</title>
</head> </head>
<body> <body>
+202
View File
@@ -0,0 +1,202 @@
// landing-e2e.mjs — aceptación de la landing pública y del acceso mágico.
//
// Cubre lo que ningún test existente cubre: que `/` sirva la landing en lugar del
// login, que `/login` siga siendo alcanzable con su formulario sin interacción
// previa (de eso dependen los otros tres audits), que el acceso mágico entre de un
// clic, y que la landing no desborde en horizontal ni esconda contenido cuando el
// sistema pide menos movimiento.
//
// Uso: npm run test:landing (requiere `npm run dev` corriendo)
import assert from "node:assert/strict";
const BASE = process.env.LANDING_BASE_URL || "http://localhost:5173";
const T = 20000;
async function pickBrowser() {
const pw = await import("playwright");
for (const name of ["webkit", "chromium"]) {
try {
return { browser: await pw[name].launch(), engine: name };
} catch {
/* siguiente motor */
}
}
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
}
const passed = [];
async function check(name, fn) {
await fn();
passed.push(name);
console.log(` ok ${name}`);
}
async function main() {
const { browser, engine } = await pickBrowser();
console.log(`Motor: ${engine}\nBase: ${BASE}\n`);
try {
// ---- 1. `/` sirve la landing, no el login ----
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
ctx.setDefaultTimeout(T);
ctx.setDefaultNavigationTimeout(T);
const page = await ctx.newPage();
const pageErrors = [];
page.on("pageerror", (e) => pageErrors.push(e.message));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await check("`/` monta la landing", async () => {
await page.waitForSelector("[data-ld-hero-title]", { state: "visible" });
});
await check("`/` no expone el formulario de login", async () => {
assert.equal(await page.locator('input[type="email"]').count(), 0);
});
await check("la landing tiene las 7 secciones del funnel", async () => {
assert.equal(await page.locator("[data-ld-section]").count(), 7);
});
await check("la landing no lanza errores de runtime", async () => {
assert.deepEqual(pageErrors, []);
});
// ---- 2. `/login` conserva el formulario sin interacción previa ----
// Es un requisito duro: visual-audit, responsive-audit y pwa-e2e rellenan
// estos campos directo tras el goto. Colapsarlos rompería los tres.
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await check("`/login` muestra el formulario manual sin abrir nada", async () => {
await page.waitForSelector('input[type="email"]', { state: "visible" });
await page.waitForSelector('input[type="password"]', { state: "visible" });
await page.waitForSelector('button[type="submit"]', { state: "visible" });
});
await check("`/login` ofrece al menos 3 cuentas de acceso mágico", async () => {
await page.waitForSelector("[data-magic-login]", { state: "visible" });
const n = await page.locator("[data-magic-login]").count();
assert.ok(n >= 3, `esperaba >= 3 tarjetas, encontré ${n}`);
});
// ---- 3. El acceso mágico entra de un clic ----
await check("un clic en la tarjeta de dueña entra al panel", async () => {
await page.locator('[data-magic-login][data-role="owner"]').first().click();
await page.waitForURL(/\/dashboard/, { timeout: T });
});
// ---- 4. Con sesión, `/` redirige al panel ----
await check("`/` redirige al panel cuando hay sesión", async () => {
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForURL(/\/dashboard/, { timeout: T });
});
await ctx.close();
// ---- 5. Sin sesión, una ruta protegida manda al login ----
const anon = await browser.newContext({ viewport: { width: 1280, height: 900 } });
anon.setDefaultTimeout(T);
anon.setDefaultNavigationTimeout(T);
const anonPage = await anon.newPage();
await check("`/dashboard` sin sesión redirige a `/login`", async () => {
await anonPage.goto(`${BASE}/dashboard`, { waitUntil: "networkidle" });
await anonPage.waitForURL(/\/login/, { timeout: T });
});
await anon.close();
// ---- 6. Sin desborde horizontal en el teléfono más estrecho del objetivo ----
const phone = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
hasTouch: true,
});
phone.setDefaultTimeout(T);
phone.setDefaultNavigationTimeout(T);
const phonePage = await phone.newPage();
await phonePage.goto(`${BASE}/`, { waitUntil: "networkidle" });
await phonePage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
await check("la landing no desborda en horizontal a 390px", async () => {
// Se recorre toda la página: las secciones sticky y los visuales anchos solo
// desbordan una vez que entran en viewport y arrancan su animación.
const overflow = await phonePage.evaluate(async () => {
const doc = document.documentElement;
let worst = 0;
const steps = Math.ceil(doc.scrollHeight / window.innerHeight) + 1;
for (let i = 0; i < steps; i += 1) {
window.scrollTo(0, i * window.innerHeight);
await new Promise((r) => setTimeout(r, 350));
worst = Math.max(
worst,
Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth
);
}
return worst;
});
assert.ok(overflow <= 1, `desborde de ${overflow}px`);
});
await phone.close();
// ---- 7. Movimiento reducido no esconde contenido ----
// El fallo que se busca: un initial={{opacity:0}} cuyo whileInView nunca corre
// deja el bloque invisible de forma permanente.
const calm = await browser.newContext({
viewport: { width: 1280, height: 900 },
reducedMotion: "reduce",
});
calm.setDefaultTimeout(T);
calm.setDefaultNavigationTimeout(T);
const calmPage = await calm.newPage();
await calmPage.goto(`${BASE}/`, { waitUntil: "networkidle" });
await calmPage.waitForSelector("[data-ld-hero-title]", { state: "visible" });
await check("con movimiento reducido el hero es visible", async () => {
const o = await calmPage
.locator("[data-ld-hero-title]")
.evaluate((el) => Number(getComputedStyle(el).opacity));
assert.ok(o >= 0.99, `opacity del hero = ${o}`);
});
await check("con movimiento reducido el CTA de cierre es visible", async () => {
const cta = calmPage.locator("[data-ld-closing-cta]");
await cta.scrollIntoViewIfNeeded();
const o = await cta.evaluate((el) => Number(getComputedStyle(el).opacity));
assert.ok(o >= 0.99, `opacity del CTA de cierre = ${o}`);
});
await check("con movimiento reducido todas las secciones son visibles", async () => {
const hidden = await calmPage.evaluate(async () => {
const out = [];
const nodes = [...document.querySelectorAll("[data-ld-section]")];
for (const [i, el] of nodes.entries()) {
el.scrollIntoView();
await new Promise((r) => setTimeout(r, 250));
const invisibles = [...el.querySelectorAll("*")].filter(
(n) => Number(getComputedStyle(n).opacity) < 0.05 && n.textContent?.trim()
);
if (invisibles.length)
out.push({
section: i,
count: invisibles.length,
sample: invisibles[0].textContent.trim().slice(0, 50),
});
}
return out;
});
assert.deepEqual(hidden, []);
});
await calm.close();
console.log(`\n${passed.length} comprobaciones OK`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(`\nlanding-e2e falló: ${error.message}`);
process.exitCode = 1;
});
+203 -14
View File
@@ -20,7 +20,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0", "express": "^4.21.0",
"framer-motion": "^11.18.2",
"lucide-react": "^0.451.0", "lucide-react": "^0.451.0",
"pg": "^8.23.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@@ -32,6 +34,7 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"@types/pg": "^8.23.1",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
@@ -91,7 +94,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -369,7 +371,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -997,7 +998,6 @@
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"preact": "~10.12.1" "preact": "~10.12.1"
} }
@@ -1817,6 +1817,18 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1844,7 +1856,6 @@
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1933,7 +1944,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2188,7 +2198,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.44", "baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806", "caniuse-lite": "^1.0.30001806",
@@ -2894,7 +2903,6 @@
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3309,6 +3317,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^11.18.1",
"motion-utils": "^11.18.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -3649,7 +3684,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -3940,6 +3974,21 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^11.18.1"
}
},
"node_modules/motion-utils": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4161,6 +4210,95 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4268,7 +4406,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4412,6 +4549,45 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/preact": { "node_modules/preact": {
"version": "10.12.1", "version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
@@ -4538,7 +4714,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -4551,7 +4726,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -5050,6 +5224,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -5260,7 +5443,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5319,7 +5501,6 @@
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.28.0" "esbuild": "~0.28.0"
}, },
@@ -5483,7 +5664,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -6012,6 +6192,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+14 -2
View File
@@ -22,7 +22,16 @@
"test:admin": "node admin-test.mjs", "test:admin": "node admin-test.mjs",
"test:unit": "node --import tsx --test server/lib/scheduling.test.ts server/lib/time.test.ts server/lib/metrics.test.ts", "test:unit": "node --import tsx --test server/lib/scheduling.test.ts server/lib/time.test.ts server/lib/metrics.test.ts",
"test:booking": "node server/scripts/booking-e2e.mjs", "test:booking": "node server/scripts/booking-e2e.mjs",
"audit:visual": "node visual-audit.mjs" "test:landing": "node landing-e2e.mjs",
"audit:visual": "node visual-audit.mjs",
"audit:responsive": "node responsive-audit.mjs",
"generate:pwa-icons": "node scripts/generate-pwa-icons.mjs",
"test:pwa": "node pwa-e2e.mjs",
"pg:up": "docker compose -f platform/docker-compose.yml up -d",
"pg:down": "docker compose -f platform/docker-compose.yml down",
"pg:migrate": "node scripts/run-tsx.mjs platform/db/migrate.ts",
"platform": "node scripts/run-tsx.mjs platform/index.ts",
"test:platform": "cross-env DATABASE_URL=postgres://yola:[email protected]:5434/yola_test node --import tsx --test --test-concurrency=1 platform/test/*.test.ts platform/lib/*.test.ts platform/crm/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
@@ -37,11 +46,14 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0", "express": "^4.21.0",
"framer-motion": "^11.18.2",
"lucide-react": "^0.451.0", "lucide-react": "^0.451.0",
"pg": "^8.23.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"recharts": "^2.12.7", "recharts": "^2.12.7",
"tsx": "^4.19.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -49,6 +61,7 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"@types/pg": "^8.23.1",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
@@ -59,7 +72,6 @@
"playwright": "^1.62.0", "playwright": "^1.62.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"tsx": "^4.19.1",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^5.4.8" "vite": "^5.4.8"
}, },
+31
View File
@@ -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
+53
View File
@@ -0,0 +1,53 @@
# ---- Frontend compilado ----
FROM node:22-slim AS web-build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime ----
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PLATFORM_PORT=3100
# La zona del contenedor se fija a UTC, que es como Postgres almacena los
# instantes. La lógica de agenda no depende de ella —usa `businesses.timezone`—
# y dejarla en UTC hace que una recaída a la zona del proceso se note aquí en
# vez de esconderse en el equipo de desarrollo, que está en México.
ENV TZ=UTC
COPY package*.json ./
# `--omit=dev` a secas dejaría fuera a `tsx`, que este backend NECESITA en
# tiempo de ejecución porque corre TypeScript directo. Por eso `tsx` está en
# `dependencies` y no en `devDependencies`: arrancar con `npx tsx` lo bajaría
# de npm en cada arranque, sin versión fijada y sobre todo el código del
# servidor. Es un riesgo de cadena de suministro que no compensa.
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=web-build /app/dist ./dist
COPY platform ./platform
COPY shared ./shared
COPY scripts ./scripts
COPY tsconfig.json ./
# `platform/.env` está gitignorado y NO se copia: las credenciales llegan como
# variables de entorno al desplegar. Si alguna vez aparece dentro de la imagen,
# es un fallo del `.dockerignore`.
EXPOSE 3100
# Consulta la base, no solo el puerto: un proceso vivo con Postgres caido no
# esta sano, y sin esto el orquestador lo daria por bueno y no reiniciaria.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 CMD node -e "fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# El contenedor no corre como root.
USER node
# Las migraciones NO se ejecutan aquí: se lanzan como paso explícito del
# despliegue. Correrlas al arrancar hace que dos réplicas migren a la vez sobre
# la misma base, y que un arranque fallido deje el esquema a medias.
CMD ["node", "scripts/run-tsx.mjs", "platform/index.ts"]
+259
View File
@@ -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.
+288
View File
@@ -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.
+206
View File
@@ -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 |
+46
View File
@@ -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");
});
+206
View File
@@ -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,
},
});
}
+69
View File
@@ -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);
});
+221
View File
@@ -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;
}
}
+106
View File
@@ -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];
}
+63
View File
@@ -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");
});
+220
View File
@@ -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;
}
}
+49
View File
@@ -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");
});
+173
View File
@@ -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;
}
}
+126
View File
@@ -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 };
}
+48
View File
@@ -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,
},
});
}
+33
View File
@@ -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);
});
+215
View File
@@ -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;
}
}
+179
View File
@@ -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;
}
+148
View File
@@ -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 };
}
+100
View File
@@ -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}`,
};
}
+226
View File
@@ -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;
}
});
}
+225
View File
@@ -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;
}
}
+109
View File
@@ -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>,
};
}
}
}
+48
View File
@@ -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;
}
+65
View File
@@ -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);
});
}
+4
View File
@@ -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;
+199
View File
@@ -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)
);
+157
View File
@@ -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';
+32
View File
@@ -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();
}
}
+22
View File
@@ -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:
+104
View File
@@ -0,0 +1,104 @@
import express from "express";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pool } from "./db/pool.ts";
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";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export function createApp() {
const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));
/**
* Salud del servicio.
*
* Consulta la base a propósito. Un health check que solo responde `{ok:true}`
* sigue en verde con Postgres caído o el disco lleno — justo el escenario en
* que el orquestador debería reiniciar y no lo haría.
*/
app.get("/api/health", async (_req, res) => {
try {
await pool.query("SELECT 1");
res.json({ ok: true, db: "ok", ts: Date.now() });
} catch (e: any) {
res.status(503).json({ ok: false, db: "error", error: String(e?.message ?? e) });
}
});
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);
// En producción este proceso también sirve el frontend compilado. El fallback
// de SPA excluye `/api/` para que una ruta de API inexistente devuelva 404 y
// no el index.html, que el cliente no sabría interpretar.
const dist = path.resolve(__dirname, "..", "dist");
if (fs.existsSync(dist)) {
app.use(express.static(dist));
app.get(/^(?!\/api\/).*/, (_req, res) => res.sendFile(path.join(dist, "index.html")));
}
// 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;
// 0.0.0.0 explícito: dentro de un contenedor, escuchar solo en localhost deja
// el servicio inalcanzable desde la red del orquestador.
const host = process.env.HOST || "0.0.0.0";
const server = createApp().listen(port, host, () =>
console.log(`[platform] escuchando en ${host}:${port}`)
);
// Apagado ordenado: sin esto, cada redespliegue corta las peticiones en vuelo.
for (const senal of ["SIGTERM", "SIGINT"] as const) {
process.on(senal, () => {
console.log(`[platform] ${senal} recibida, cerrando…`);
server.close(() => {
pool.end().finally(() => process.exit(0));
});
// Si algo se atasca, no se cuelga para siempre.
setTimeout(() => process.exit(1), 10_000).unref();
});
}
// 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);
}
+36
View File
@@ -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,
]
);
}
+90
View File
@@ -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);
};
}
+54
View File
@@ -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}`;
}
}
+51
View File
@@ -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;
});
+69
View File
@@ -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);
}
+50
View File
@@ -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;
}
+46
View File
@@ -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);
});
+53
View File
@@ -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}`;
}
+156
View File
@@ -0,0 +1,156 @@
// platform/lib/time.ts
//
// Helpers puros de zona horaria del negocio.
//
// Son una COPIA de `server/lib/time.ts`, y es deliberado: los dos backends
// conviven sin compartir código —misma decisión que `businessDefaults.ts`— y
// cruzarlos ataría la evolución de uno a la del otro. El coste es duplicar unas
// líneas de funciones puras; el beneficio es que `platform/` se pueda empaquetar
// solo, sin arrastrar el backend de demo dentro de su imagen.
//
// Se descubrió al contenerizar: `dayClose.ts` importaba de `../../server/`, la
// imagen no copiaba `server/`, y el proceso moría al arrancar con
// ERR_MODULE_NOT_FOUND. Un typecheck limpio no lo detecta.
/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */
export function bizDateISO(instant: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz || "UTC",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(instant); // en-CA yields "YYYY-MM-DD"
}
/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */
export function bizTodayISO(tz: string, now: Date = new Date()): string {
return bizDateISO(now, tz);
}
/** tz offset (in minutes) of the given instant, east of UTC positive. */
function tzOffsetMinutes(instant: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz || "UTC",
timeZoneName: "longOffset",
}).formatToParts(instant);
const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00";
// e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC
const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!m) return 0; // bare "GMT" → UTC
const sign = m[1] === "-" ? -1 : 1;
const h = parseInt(m[2], 10);
const min = m[3] ? parseInt(m[3], 10) : 0;
return sign * (h * 60 + min);
}
/**
* Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date.
* We first treat the wall-clock as if it were UTC, read the tz offset at that instant,
* then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same
* wall-clock happens later in UTC → UTC = wall − offset.
*/
export function wallToUtcDate(
tz: string,
y: number,
mo: number,
d: number,
hh: number,
mm: number,
ss: number
): Date {
const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss));
const offsetMin = tzOffsetMinutes(guess, tz);
return new Date(guess.getTime() - offsetMin * 60000);
}
/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */
export function bizDayBounds(
tz: string,
dayOffset = 0,
now: Date = new Date()
): { start: Date; end: Date } {
const today = bizDateISO(now, tz);
const [y, m, d] = today.split("-").map(Number);
const base = new Date(Date.UTC(y, m - 1, d));
base.setUTCDate(base.getUTCDate() + dayOffset);
const ty = base.getUTCFullYear();
const tm = base.getUTCMonth() + 1;
const td = base.getUTCDate();
return {
start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0),
end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59),
};
}
/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */
export function toSqliteUtc(d: Date): string {
return d.toISOString().slice(0, 19).replace("T", " ");
}
/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */
export function toIsoUtc(d: Date): string {
return d.toISOString().slice(0, 19) + "Z";
}
/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */
export function bizDayBoundsSqlite(
tz: string,
dayOffset = 0,
now: Date = new Date()
): { start: string; end: string } {
const b = bizDayBounds(tz, dayOffset, now);
return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) };
}
/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */
export function bizDayBoundsIso(
tz: string,
dayOffset = 0,
now: Date = new Date()
): { start: string; end: string } {
const b = bizDayBounds(tz, dayOffset, now);
return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) };
}
/** Convenience: convert a business-local wall-clock to a UTC ISO string. */
export function wallToUtcISO(
tz: string,
y: number,
mo: number,
d: number,
hh: number,
mm: number,
ss: number
): string {
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
}
/** Zona horaria por defecto de los negocios (México). Única definición. */
export const DEFAULT_TZ = "America/Mexico_City";
/**
* Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD".
* Deriva del string, nunca de un `Date` local, así que es independiente de
* la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede
* caer en el día anterior).
*/
export function isoDowFromDateStr(dateIso: string): number {
const [y, m, d] = dateIso.split("-").map(Number);
const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb
return j === 0 ? 7 : j;
}
/**
* Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato
* ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación
* lexicográfica sobre el formato canónico almacenado).
*
* A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto
* de "hoy", este acepta la fecha explícita que pide el cliente.
*/
export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } {
const [y, m, d] = dateIso.split("-").map(Number);
return {
start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)),
end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)),
};
}
+245
View File
@@ -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] });
})
);
+290
View File
@@ -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 });
})
);
+123
View File
@@ -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);
})
);
+48
View File
@@ -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 });
})
);
+22
View File
@@ -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] });
})
);
+170
View File
@@ -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;
}
})
);
+213
View File
@@ -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}`);
}
})
);
+140
View File
@@ -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 "../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 });
})
);
+203
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+73
View File
@@ -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);
+52
View File
@@ -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);
});
+66
View File
@@ -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);
});
+63
View File
@@ -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();
+53
View File
@@ -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."
);
}
+101
View File
@@ -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);
});
+110
View File
@@ -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);
}
+177
View File
@@ -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);
});
+117
View File
@@ -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);
});
+138
View File
@@ -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);
});
+231
View File
@@ -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);
});
+130
View File
@@ -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);
});
+158
View File
@@ -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);
});
+153
View File
@@ -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);
});
+136
View File
@@ -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);
});

Some files were not shown because too many files have changed in this diff Show More