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]>
This commit is contained in:
AgendaPro Dev
2026-08-28 10:47:59 -06:00
co-authored by Claude Opus 5
parent 0069d23744
commit f48a9ac3bf
17 changed files with 420 additions and 92 deletions
+37 -5
View File
@@ -112,19 +112,32 @@ No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `a
[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()` **en ese orden**,
cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4`
- `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: **4**.
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 `migrateV4ToV5()` nueva que
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`.
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`,
@@ -147,6 +160,25 @@ de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
- 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