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
+9 -3
View File
@@ -3,6 +3,7 @@ import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
import { DEFAULT_TZ } from "../lib/time.ts";
export const appointmentsRouter = Router();
@@ -138,18 +139,23 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
empId = req.user!.employee_id;
}
// La tz del negocio manda: la ventana laboral y el rango del día se anclan a
// ella, no a la del proceso (el contenedor de producción corre en UTC).
const bizRow = db
.prepare(`SELECT working_hours wh, timezone tz FROM businesses WHERE id = ?`)
.get(req.user!.business_id) as { wh?: string; tz?: string } | undefined;
const tz = bizRow?.tz || DEFAULT_TZ;
if (!empId) {
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
const aa = autoAssign(db, {
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any, tz,
});
empId = aa?.employeeId ?? null;
}
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
// Guard anti doble reserva
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
if (!isAvailable(db, empId, startMs, endMs, tz)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
const inserted = db
.prepare(