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]>
50 lines
2.0 KiB
TypeScript
50 lines
2.0 KiB
TypeScript
// server/lib/businessDefaults.ts
|
|
//
|
|
// Valores por defecto de un negocio recién creado. Existen porque el backfill de
|
|
// `slug` y `working_hours` vivía SOLO dentro de las migraciones (migrateV2ToV3 y
|
|
// migrateV3ToV4), y las migraciones corren al importar db.ts, es decir **antes** de
|
|
// que `ensureSeed()` inserte ningún negocio. Resultado en una instalación nueva:
|
|
//
|
|
// • `working_hours = NULL` → `getWorkingHoursForDate` devuelve null para todos los
|
|
// días → el endpoint de slots responde `[]` en cualquier fecha y no se puede
|
|
// reservar nada.
|
|
// • `slug = NULL` → `publicBusiness()` nunca encuentra el negocio → /b/:slug da 404.
|
|
//
|
|
// Un backfill de migración no puede cubrir filas que aún no existen: el default tiene
|
|
// que aplicarse en el INSERT. Este módulo es la única definición, compartida por el
|
|
// seed, el alta desde /api/admin y las propias migraciones.
|
|
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
|
|
/** Lun-Vie 09:00-20:00, fin de semana cerrado. */
|
|
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(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 60) || "negocio";
|
|
}
|
|
|
|
/** Slug único para `name`, añadiendo sufijo numérico si ya está tomado. */
|
|
export function uniqueSlug(db: DatabaseSync, name: string, excludeId: number | null = null): string {
|
|
const base = slugify(name);
|
|
let slug = base;
|
|
let n = 2;
|
|
const taken = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id IS NOT ?`);
|
|
while (taken.get(slug, excludeId)) slug = `${base}-${n++}`;
|
|
return slug;
|
|
}
|