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
+41 -9
View File
@@ -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][] = [
+18 -3
View File
@@ -1,4 +1,6 @@
import express from "express";
import { DEFAULT_WORKING_HOURS } from "./lib/businessDefaults.ts";
import { DEFAULT_TZ } from "./lib/time.ts";
import cors from "cors";
import path from "node:path";
import fs from "node:fs";
@@ -29,12 +31,25 @@ function ensureSeed() {
const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number };
if (row.c === 0) {
console.log("[seed] Base de datos vacía — creando negocio demo y admin…");
// slug, working_hours y timezone se fijan AQUÍ, en el INSERT. Los backfills de
// migración corren al importar db.ts, o sea antes de que exista esta fila: un
// negocio sembrado sin ellos queda sin página pública (/b/:slug → 404) y sin
// ninguna franja agendable. Ver server/lib/businessDefaults.ts.
const NAME = "Lumière Estética & Spa";
// Slug FIJO, no derivado del nombre: es la URL pública ya publicada
// (/b/mi-negocio-demo) y el volumen de producción se recrea en cada
// redespliegue, así que un slug derivado la rompería en cada deploy.
// Cámbialo desde Ajustes si el negocio deja de ser el demo.
const DEMO_SLUG = "mi-negocio-demo";
const biz = db
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status)
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status, slug, working_hours, timezone)
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id`
)
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
.get(
NAME, "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX",
DEMO_SLUG, DEFAULT_WORKING_HOURS, DEFAULT_TZ
) as { id: number };
seedBusiness({
businessId: biz.id,
template: getTemplate(DEFAULT_TEMPLATE_KEY)!,
+49
View File
@@ -0,0 +1,49 @@
// 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;
}
+79 -16
View File
@@ -1,4 +1,12 @@
// server/lib/scheduling.test.ts
//
// La suite corre con TZ=UTC A PROPÓSITO, y los casos usan negocios en
// America/Mexico_City: la tz del proceso y la del negocio nunca coinciden, así que
// cualquier cálculo que se ancle a la del proceso falla aquí y no en producción.
// (Se fija en runtime en vez de en el script de npm porque `TZ=x cmd` no es
// portable a cmd.exe en Windows.)
process.env.TZ = "UTC";
import { test } from "node:test";
import assert from "node:assert/strict";
import {
@@ -8,6 +16,13 @@ import {
} from "./scheduling.ts";
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
const MX = "America/Mexico_City";
/** Instante UTC de un reloj de pared mexicano. Los tests no deben depender de la tz del proceso. */
function mx(dateIso: string, hh: number, mm = 0): number {
const [y, mo, d] = dateIso.split("-").map(Number);
return Date.UTC(y, mo - 1, d, hh + 6, mm); // México = UTC-6 todo el año desde 2022
}
test("parseWorkingHours: json válido → mapa 1..7", () => {
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
assert.equal(m?.[1]?.start, "09:00");
@@ -26,16 +41,9 @@ test("isoDayOfWeek: lunes=1, domingo=7", () => {
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
});
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => {
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes
assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime());
});
test("getWorkingHoursForDate: día cerrado → null", () => {
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado
assert.equal(getWorkingHoursForDate(null, biz, "2026-07-25", MX), null); // sábado
});
test("normalizeText/tokens: quita acentos y lowercase", () => {
@@ -106,8 +114,8 @@ test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
const startMs = mx("2026-07-27", 13); // lunes, dentro de horario
const endMs = mx("2026-07-27", 14);
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
// → 40·1 + 20·0.5 + 40·0.5 = 70
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
@@ -115,23 +123,23 @@ test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
const busy = new Map<number, BusyWindow[]>([
[1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]],
[1, [{ startMs: mx("2026-07-27", 9), endMs: mx("2026-07-27", 13) }]],
[2, []],
]);
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
});
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
const startMs = mx("2026-07-27", 9);
const endMs = mx("2026-07-27", 10);
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
});
@@ -143,3 +151,58 @@ test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)"
assert.equal(s.includes("no_show"), false);
assert.equal(s.length, 2);
});
// ── Regresión: la ventana laboral debe anclarse a la tz del NEGOCIO ────────────
// Estos tests fallaban en producción (contenedor node:22-slim = UTC) y pasaban en
// dev (Windows = America/Mexico_City), porque getWorkingHoursForDate construía la
// ventana con `new Date(y, m, d, hh, mm)`, es decir en la tz del *proceso*.
// Corren bajo `TZ=UTC` en test:unit precisamente para que el desfase no se esconda.
test("getWorkingHoursForDate: ancla la ventana a la tz del negocio, no a la del proceso", () => {
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const r = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City"); // lunes
assert.ok(r, "el lunes debe estar abierto");
// 09:00 CST = 15:00Z ; 20:00 CST = 02:00Z del día siguiente
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T15:00:00.000Z");
assert.equal(new Date(r!.endMs).toISOString(), "2026-09-01T02:00:00.000Z");
});
test("getWorkingHoursForDate: la misma fecha en otra tz da otra ventana absoluta", () => {
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const mx = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City");
const utc = getWorkingHoursForDate(null, biz, "2026-08-31", "UTC");
assert.notEqual(mx!.startMs, utc!.startMs);
assert.equal(new Date(utc!.startMs).toISOString(), "2026-08-31T09:00:00.000Z");
});
test("getWorkingHoursForDate: el día de la semana sale de la fecha natural, no de un Date local", () => {
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null, 1: { start: "09:00", end: "20:00" } }));
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-29", "America/Mexico_City"), null); // sábado
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-30", "America/Mexico_City"), null); // domingo
assert.ok(getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City")); // lunes
});
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio (tz-explícito)", () => {
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const r = getWorkingHoursForDate(emp, biz, "2026-08-31", "America/Mexico_City");
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T16:00:00.000Z"); // 10:00 CST
});
test("pickBestSlotEmployee: un slot de la tarde del negocio sigue dentro de la ventana", () => {
// 19:00 México = 01:00Z del día siguiente. Con la ventana mal anclada este slot
// quedaba fuera de rango y el endpoint devolvía la tarde vacía.
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const cand: CandidateInfo = {
id: 1, name: "Ana", color: "#000", role: "Estilista",
specialties: ["Cabello"], efficiency_score: 80, empWh: null,
};
const startMs = Date.parse("2026-09-01T01:00:00Z"); // lunes 31/08 19:00 México
const endMs = startMs + 60 * 60000;
const busy = new Map<number, BusyWindow[]>();
const best = pickBestSlotEmployee(
[cand], busy, bizWh, { name: "Corte", category: "Cabello" },
startMs, endMs, "America/Mexico_City"
);
assert.equal(best?.id, 1);
});
+59 -18
View File
@@ -2,6 +2,8 @@
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.
import { bizDateISO, bizDayBoundsIsoFor, isoDowFromDateStr, wallToUtcDate, DEFAULT_TZ } from "./time.ts";
export interface WorkingDay { start: string; end: string; } // "HH:mm"
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
@@ -39,24 +41,39 @@ export function isoDayOfWeek(date: Date): number {
return j === 0 ? 7 : j;
}
/** @deprecated Fecha natural en la tz del PROCESO. Para lógica de negocio usa
* `bizDateISO(instant, tz)` de time.ts — esta desplaza el día en un contenedor UTC. */
export function isoDateStr(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */
/**
* Resuelve el horario (empleado tiene prioridad; si no, negocio) para una fecha
* natural del negocio, y lo devuelve como instantes absolutos (ms UTC).
*
* `dateIso` es "YYYY-MM-DD" **en la tz del negocio** y `tz` su zona IANA. Ambos
* son obligatorios a propósito: la versión anterior recibía un `Date` y
* construía la ventana con `new Date(y, m, d, hh, mm)`, o sea en la tz del
* *proceso*. En dev (Windows, America/Mexico_City) coincidía; en el contenedor
* de producción (node:22-slim = UTC) desplazaba toda la jornada 6 horas, de modo
* que un negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México.
*/
export function getWorkingHoursForDate(
empWh: WorkingHoursMap | null,
bizWh: WorkingHoursMap | null,
date: Date
dateIso: string,
tz: string
): { startMs: number; endMs: number } | null {
const dow = isoDayOfWeek(date);
const zone = tz || DEFAULT_TZ;
const dow = isoDowFromDateStr(dateIso);
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
if (!day) return null;
const [y, mo, d] = dateIso.split("-").map(Number);
const [sh, sm] = day.start.split(":").map(Number);
const [eh, em] = day.end.split(":").map(Number);
const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
const startMs = wallToUtcDate(zone, y, mo, d, sh, sm, 0).getTime();
const endMs = wallToUtcDate(zone, y, mo, d, eh, em, 0).getTime();
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
return { startMs, endMs };
}
@@ -156,11 +173,24 @@ export function getCandidates(db: DatabaseSync, businessId: number, serviceId: n
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
/**
* Citas que ocupan a cada empleado durante el día natural `dateIso` **del negocio**.
*
* Los límites se calculan con `bizDayBoundsIsoFor`, no concatenando
* `${dateIso}T00:00:00`: `start_at` se almacena en UTC, así que una cita de las
* 19:00 de México vive en el día UTC *siguiente* (01:00Z) y la versión anterior
* la dejaba fuera del rango — el guard anti doble-reserva no veía ninguna cita
* de la tarde.
*/
export function getExistingBusy(
db: DatabaseSync,
employeeIds: number[],
dateIso: string,
tz: string
): Map<number, BusyWindow[]> {
const map = new Map<number, BusyWindow[]>();
if (employeeIds.length === 0) return map;
const startOfDay = `${dateIso}T00:00:00`;
const endOfDay = `${dateIso}T23:59:59`;
const { start: startOfDay, end: endOfDay } = bizDayBoundsIsoFor(tz || DEFAULT_TZ, dateIso);
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
const rows = db
.prepare(
@@ -177,9 +207,16 @@ export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso
return map;
}
export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
const dateIso = isoDateStr(new Date(startMs));
const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
export function isAvailable(
db: DatabaseSync,
employeeId: number,
startMs: number,
endMs: number,
tz: string
): boolean {
const zone = tz || DEFAULT_TZ;
const dateIso = bizDateISO(new Date(startMs), zone);
const busy = getExistingBusy(db, [employeeId], dateIso, zone).get(employeeId) ?? [];
return !hasConflict(busy, startMs, endMs);
}
@@ -245,13 +282,14 @@ export function pickBestSlotEmployee(
bizWh: WorkingHoursMap | null,
service: { name: string; category: string },
startMs: number,
endMs: number
endMs: number,
tz: string
): { id: number; name: string } | null {
const date = new Date(startMs);
const dateIso = isoDateStr(date);
const zone = tz || DEFAULT_TZ;
const dateIso = bizDateISO(new Date(startMs), zone);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
const wh = getWorkingHoursForDate(c.empWh, bizWh, dateIso, zone);
if (!wh) continue;
if (startMs < wh.startMs || endMs > wh.endMs) continue;
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
@@ -278,17 +316,20 @@ export interface AutoAssignCtx {
startMs: number;
endMs: number;
bizWh: WorkingHoursMap | null;
/** Zona IANA del negocio. Sin ella la ventana laboral se ancla a la tz del proceso. */
tz: string;
}
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
if (candidates.length === 0) return null;
const dateIso = isoDateStr(new Date(ctx.startMs));
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
const zone = ctx.tz || DEFAULT_TZ;
const dateIso = bizDateISO(new Date(ctx.startMs), zone);
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso, zone);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, dateIso, zone);
if (!wh) continue;
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
+31
View File
@@ -115,3 +115,34 @@ export function wallToUtcISO(
): string {
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
}
/** Zona horaria por defecto de los negocios (México). Única definición. */
export const DEFAULT_TZ = "America/Mexico_City";
/**
* Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD".
* Deriva del string, nunca de un `Date` local, así que es independiente de
* la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede
* caer en el día anterior).
*/
export function isoDowFromDateStr(dateIso: string): number {
const [y, m, d] = dateIso.split("-").map(Number);
const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb
return j === 0 ? 7 : j;
}
/**
* Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato
* ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación
* lexicográfica sobre el formato canónico almacenado).
*
* A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto
* de "hoy", este acepta la fecha explícita que pide el cliente.
*/
export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } {
const [y, m, d] = dateIso.split("-").map(Number);
return {
start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)),
end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)),
};
}
+10 -3
View File
@@ -1,4 +1,6 @@
import { Router } from "express";
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "../lib/businessDefaults.ts";
import { DEFAULT_TZ } from "../lib/time.ts";
import { db } from "../db.ts";
import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts";
import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts";
@@ -103,8 +105,8 @@ adminRouter.post("/businesses", (req: AuthedRequest, res) => {
const biz = db
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template)
VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id`
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template, slug, working_hours, timezone)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) RETURNING id`
)
.get(
name,
@@ -112,7 +114,12 @@ adminRouter.post("/businesses", (req: AuthedRequest, res) => {
currency || tpl.currency || "MXN",
currency_symbol || tpl.currency_symbol || "$",
plan || "trial",
tpl.key
tpl.key,
// Sin estos tres, el tenant nace sin página pública (/b/:slug → 404) y sin
// ninguna franja agendable, porque el backfill solo corre en migraciones.
uniqueSlug(db, name),
DEFAULT_WORKING_HOURS,
DEFAULT_TZ
) as { id: number };
const result = seedBusiness({
+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(
+15 -10
View File
@@ -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"];
}
+14 -6
View File
@@ -27,13 +27,21 @@ check("tiene slug", !!slug, String(slug));
const { json: pub } = await req("GET", `/public/${slug}`);
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
const svc = pub.services[0];
const tomorrow = new Date(Date.now() + 86400000);
const date = tomorrow.toISOString().slice(0, 10);
// 3) Slots disponibles
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
check("slots devuelve lista", Array.isArray(slotsRes.slots));
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
// 3) Slots disponibles.
// Se busca el próximo día ABIERTO en vez de asumir "mañana": el negocio demo cierra
// sábado y domingo, así que fijar mañana hacía que la suite fallara cada viernes y
// sábado por calendario, no por un defecto del producto.
let date = null;
let slotsRes = null;
for (let i = 1; i <= 8; i++) {
const d = new Date(Date.now() + i * 86400000).toISOString().slice(0, 10);
const r = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${d}`);
if (i === 1) check("slots devuelve lista", Array.isArray(r.json.slots));
if (Array.isArray(r.json.slots) && r.json.slots.length > 0) { date = d; slotsRes = r.json; break; }
}
check("hay slots en algún día abierto de la próxima semana", !!slotsRes, date ? `fecha ${date}` : "ningún día con slots");
if (!slotsRes) { console.error("Sin slots en 8 días: revisa working_hours del negocio demo."); process.exit(1); }
// 4) Reservar (auto-asignación, sin employee_id)
const slot = slotsRes.slots[0];
+10 -2
View File
@@ -1,4 +1,6 @@
import { db } from "../db.ts";
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "../lib/businessDefaults.ts";
import { DEFAULT_TZ } from "../lib/time.ts";
import { pathToFileURL } from "node:url";
import { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY, type TemplateDef } from "../lib/templates.ts";
@@ -328,8 +330,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
let biz = db.prepare(`SELECT id FROM businesses WHERE name = ?`).get("Lumière Estética & Spa") as { id: number } | undefined;
if (!biz) {
biz = db
.prepare(`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`)
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status, slug, working_hours, timezone)
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id`
)
.get(
"Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX",
uniqueSlug(db, "Lumière Estética & Spa"), DEFAULT_WORKING_HOURS, DEFAULT_TZ
) as { id: number };
seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "[email protected]", ownerName: "Daniela Reyes" });
console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`);
} else {