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:
co-authored by
Claude Opus 5
parent
0069d23744
commit
f48a9ac3bf
+15
-10
@@ -5,6 +5,7 @@ import {
|
||||
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
||||
type WorkingHoursMap,
|
||||
} from "../lib/scheduling.ts";
|
||||
import { DEFAULT_TZ } from "../lib/time.ts";
|
||||
|
||||
export const bookingRouter = Router();
|
||||
|
||||
@@ -39,8 +40,10 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
||||
const serviceId = Number(req.query.service_id);
|
||||
const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null;
|
||||
const date = req.query.date as string; // YYYY-MM-DD
|
||||
const date = req.query.date as string; // YYYY-MM-DD, en la tz del negocio
|
||||
if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" });
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: "Fecha inválida (formato AAAA-MM-DD)" });
|
||||
const tz = biz.timezone || DEFAULT_TZ;
|
||||
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any;
|
||||
if (!service) return res.status(400).json({ error: "Servicio no válido" });
|
||||
|
||||
@@ -64,17 +67,16 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
|
||||
const useRanking = !employeeId;
|
||||
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
|
||||
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();
|
||||
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date, tz) : new Map();
|
||||
|
||||
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
|
||||
const dateObj = new Date(`${date}T12:00:00`);
|
||||
const singleEmpRow = employeeId
|
||||
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
|
||||
: null;
|
||||
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
|
||||
const window = employeeId
|
||||
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
|
||||
: getWorkingHoursForDate(null, bizWh, dateObj);
|
||||
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, date, tz) ?? getWorkingHoursForDate(null, bizWh, date, tz))
|
||||
: getWorkingHoursForDate(null, bizWh, date, tz);
|
||||
|
||||
const now = new Date();
|
||||
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
||||
@@ -88,7 +90,7 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
|
||||
// citas existentes del día para el caso de especialista fijo
|
||||
const existing = employeeId
|
||||
? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
|
||||
? (getExistingBusy(db, [employeeId], date, tz).get(employeeId) ?? [])
|
||||
: [];
|
||||
|
||||
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
|
||||
@@ -97,7 +99,7 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
const slotEnd = t + dur * 60000;
|
||||
let chosen: { id: number; name: string } | null = null;
|
||||
if (useRanking) {
|
||||
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
|
||||
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd, tz);
|
||||
} else {
|
||||
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
||||
if (!busy) {
|
||||
@@ -107,7 +109,9 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
}
|
||||
if (chosen) {
|
||||
slots.push({
|
||||
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
|
||||
// La etiqueta se rinde en la tz del NEGOCIO: sin `timeZone` salía en la del
|
||||
// proceso (UTC en producción) y no coincidía con la hora real del `iso`.
|
||||
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true, timeZone: tz }),
|
||||
iso: new Date(t).toISOString(),
|
||||
employee_id: chosen.id,
|
||||
employee_name: chosen.name,
|
||||
@@ -126,6 +130,7 @@ function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: numbe
|
||||
bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const biz = publicBusiness(req.params.slug);
|
||||
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
||||
const tz = biz.timezone || DEFAULT_TZ;
|
||||
const { service_id, employee_id, start_at, client } = req.body ?? {};
|
||||
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
|
||||
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
|
||||
@@ -149,7 +154,7 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as any;
|
||||
const aa = autoAssign(db, {
|
||||
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||
startMs, endMs, bizWh,
|
||||
startMs, endMs, bizWh, tz,
|
||||
});
|
||||
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
empId = aa.employeeId;
|
||||
@@ -159,7 +164,7 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
|
||||
if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
|
||||
// guard anti doble reserva
|
||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
if (!isAvailable(db, empId, startMs, endMs, tz)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
reasons = ["Tu especialista elegido"];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user