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
+41
-9
@@ -1,4 +1,5 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "./lib/businessDefaults.ts";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
@@ -167,7 +168,7 @@ function columnExists(table: string, column: string): boolean {
|
||||
|
||||
/** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */
|
||||
function migrateV1ToV2() {
|
||||
if (getMeta("schema_version") >= "2") return;
|
||||
if (schemaVersion() >= 2) return;
|
||||
if (!tableExists("users")) {
|
||||
setMeta("schema_version", "2");
|
||||
return;
|
||||
@@ -224,7 +225,7 @@ function migrateV1ToV2() {
|
||||
|
||||
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
|
||||
function migrateV3ToV4() {
|
||||
if (getMeta("schema_version") >= "4") return;
|
||||
if (schemaVersion() >= 4) return;
|
||||
|
||||
const bizCols: [string, string][] = [
|
||||
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
|
||||
@@ -243,12 +244,9 @@ function migrateV3ToV4() {
|
||||
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
|
||||
}
|
||||
|
||||
// Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
|
||||
const DEFAULT_WH = 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,
|
||||
});
|
||||
// Backfill business working_hours (misma definición que usan el seed y el alta:
|
||||
// server/lib/businessDefaults.ts — no la dupliques aquí).
|
||||
const DEFAULT_WH = DEFAULT_WORKING_HOURS;
|
||||
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
|
||||
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
||||
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
|
||||
@@ -262,11 +260,45 @@ export function runMigrations() {
|
||||
migrateV1ToV2();
|
||||
migrateV2ToV3();
|
||||
migrateV3ToV4();
|
||||
migrateV4ToV5();
|
||||
}
|
||||
|
||||
/** Versión de esquema como NÚMERO. Compararla como texto hacía que `"10" >= "2"`
|
||||
* fuera false y reejecutara las migraciones antiguas al llegar a dos dígitos. */
|
||||
function schemaVersion(): number {
|
||||
return Number(getMeta("schema_version") || "0") || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* v5: repara los negocios que nacieron sin `slug` ni `working_hours`.
|
||||
*
|
||||
* Los backfills de v3 (slug) y v4 (working_hours) solo alcanzaban a las filas que ya
|
||||
* existían cuando corrió la migración. Cualquier negocio creado después —el demo de
|
||||
* una instalación nueva, o un alta desde /api/admin— quedaba con ambos campos en NULL:
|
||||
* sin página pública de reservas (404 en /b/:slug) y sin ninguna franja agendable.
|
||||
* El default ya se aplica en el INSERT (server/lib/businessDefaults.ts); esta
|
||||
* migración arregla los que quedaron rotos. Idempotente: solo toca filas NULL/vacías.
|
||||
*/
|
||||
function migrateV4ToV5() {
|
||||
if (schemaVersion() >= 5) return;
|
||||
|
||||
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL OR working_hours = ''`).all() as { id: number }[];
|
||||
const updWh = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
||||
for (const b of noWh) updWh.run(DEFAULT_WORKING_HOURS, b.id);
|
||||
|
||||
const noSlug = db.prepare(`SELECT id, name FROM businesses WHERE slug IS NULL OR slug = ''`).all() as { id: number; name: string }[];
|
||||
const updSlug = db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`);
|
||||
for (const b of noSlug) updSlug.run(uniqueSlug(db, b.name, b.id), b.id);
|
||||
|
||||
if (noWh.length || noSlug.length) {
|
||||
console.log(`[migrate v5] reparados ${noWh.length} horarios y ${noSlug.length} slugs de negocio.`);
|
||||
}
|
||||
setMeta("schema_version", "5");
|
||||
}
|
||||
|
||||
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
|
||||
function migrateV2ToV3() {
|
||||
if (getMeta("schema_version") >= "3") return;
|
||||
if (schemaVersion() >= 3) return;
|
||||
|
||||
// Business-level settings (cancellation policy + deposit + booking enabled)
|
||||
const bizCols: [string, string][] = [
|
||||
|
||||
Reference in New Issue
Block a user