Compare commits

3 Commits
Author SHA1 Message Date
AgendaPro DevandClaude Opus 5 4f566ddde7 fix: quitar el VOLUME anónimo que borraba la base en cada despliegue
`VOLUME /app/data` hacía que Docker fabricara un volumen anónimo en cada arranque,
y Coolify los purga al recrear el contenedor. Cada despliegue estrenaba una base
vacía que `ensureSeed()` volvía a sembrar: se perdían las citas, los clientes y
todo lo configurado desde Ajustes, en silencio y sin error.

Verificado en el host: el volumen montado era 991d42df… y el día anterior era
3cff8c0d…, y no queda ningún volumen huérfano con agendapro.db — los anteriores
fueron destruidos. La base se creaba un minuto después del contenedor.

La persistencia la aporta ahora un volumen con nombre declarado como Persistent
Storage en Coolify y montado en /app/data, precargado con un snapshot consistente
de la base actual (155 citas, 15 clientes, 8 usuarios, integrity_check ok).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-29 00:33:11 -06:00
AgendaPro DevandClaude Opus 5 521c206ba7 fix: el paso de especialista solo ofrece a quien da ese servicio
Elegir "Corte + arreglo de barba" y luego cualquier especialista que no fuera el
barbero dejaba el asistente atascado: /slots responde 400 "El especialista no
ofrece este servicio" y la UI lo pintaba como "No pudimos cargar los horarios.
Intenta otra fecha" — un mensaje que manda a probar días al azar cuando ninguna
fecha lo resuelve.

La raíz es que el payload público listaba todos los empleados activos sin decir
qué servicios ofrece cada uno, así que el cliente no tenía con qué filtrar.

- GET /api/public/:slug devuelve ahora `service_ids` por empleado. El JOIN contra
  employees filtra por negocio: employee_services no tiene business_id propio.
- El paso 2 lista solo a los elegibles, y si nadie ofrece el servicio en concreto
  lo dice en vez de mostrar una lista vacía.
- Cambiar de servicio suelta un especialista que ya no aplique, en lugar de
  arrastrar una combinación que el servidor va a rechazar.
- El estado de error muestra el motivo real del servidor.

booking-e2e.mjs cubre el contrato: cada empleado publica service_ids, quien
ofrece el servicio responde 200 y quien no responde 400. 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 15:24:10 -06:00
AgendaPro DevandClaude Opus 5 f48a9ac3bf 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]>
2026-08-28 10:47:59 -06:00
17 changed files with 512 additions and 98 deletions
+37 -5
View File
@@ -112,19 +112,32 @@ No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `a
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` **en ese orden**,
cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4`
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` → `migrateV4ToV5()` **en ese orden**,
cada una idempotente y guardada por `schemaVersion()`. Ojo: en el archivo `migrateV3ToV4`
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
archivo. Versión actual: **4**.
archivo. Versión actual: **5**. Las guardas comparan con `schemaVersion()` (número): la comparación
de texto que había hacía que `"10" >= "2"` fuera false y reejecutara `migrateV1ToV2`, que contiene
un `DROP TABLE users`.
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV4ToV5()` nueva que
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`.
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV5ToV6()` nueva que
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "6")`.
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
### Siembra y plantillas
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
**Todo negocio nuevo debe nacer con `slug`, `working_hours` y `timezone` en el propio `INSERT`.**
Los valores viven en [server/lib/businessDefaults.ts](server/lib/businessDefaults.ts)
(`DEFAULT_WORKING_HOURS`, `uniqueSlug`) y hay **tres** sitios que crean negocios —
`server/index.ts` (`ensureSeed`), `server/scripts/seed.ts` (`npm run seed`) y
`server/routes/admin.ts` (alta desde la consola): si añades otro, usa el mismo módulo. El backfill
de esos campos vivía solo dentro de `migrateV2ToV3`/`migrateV3ToV4`, y las migraciones corren al
importar `db.ts`, o sea **antes** de que exista la fila: un negocio sembrado después quedaba con
`working_hours = NULL` (cero franjas agendables en cualquier fecha) y `slug = NULL`
(`/b/:slug` → 404). `migrateV4ToV5()` repara los que ya quedaron rotos.
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
@@ -147,6 +160,25 @@ de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
explícito, por eso son testeables (`server/lib/time.test.ts`).
- **`new Date(y, m, d, hh, mm)` está prohibido en lógica de negocio**, igual que `date('now')`.
Ese constructor resuelve el reloj de pared en la tz del **proceso**, no en la del negocio. En dev
(Windows en `America/Mexico_City`) coincide y todo pasa; en el contenedor de producción
(`node:22-slim`, sin `TZ`) el proceso corre en UTC y la jornada entera se desplaza 6 h — un
negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México, de modo que a partir de la
1 PM el endpoint de slots devolvía la lista vacía y no se podía reservar. Usa
`wallToUtcDate(tz, …)` y `bizDateISO(instant, tz)` de [server/lib/time.ts](server/lib/time.ts).
`isoDateStr`/`isoDayOfWeek` de `scheduling.ts` quedan marcados `@deprecated` por lo mismo.
- Por eso **toda la API de `scheduling.ts` lleva `tz` explícita**: `getWorkingHoursForDate(empWh,
bizWh, dateIso, tz)`, `getExistingBusy(db, ids, dateIso, tz)`, `isAvailable(db, emp, s, e, tz)`,
`pickBestSlotEmployee(…, tz)` y `AutoAssignCtx.tz`. No añadas sobrecargas sin `tz`.
- `getExistingBusy` acota el día con `bizDayBoundsIsoFor(tz, dateIso)`, no concatenando
`` `${dateIso}T00:00:00` ``: `start_at` se guarda en UTC, así que una cita de las 19:00 de México
vive en el día UTC siguiente y la versión anterior no la veía — el guard anti doble-reserva era
ciego a toda la tarde.
- `server/lib/scheduling.test.ts` fija `process.env.TZ = "UTC"` en su primera línea y usa negocios
en `America/Mexico_City`: la tz del proceso y la del negocio **nunca** coinciden, así que una
recaída falla en el test y no en producción. `Dockerfile` fija `ENV TZ=UTC` por lo mismo. No las
alinees "para que sea más simple".
### Agendado, auto-asignación y guard anti doble-reserva
+17 -2
View File
@@ -26,6 +26,11 @@ WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# La zona del contenedor se fija explícitamente a UTC, que es como se almacenan los
# instantes en la base. La lógica de agenda ya no depende de ella (usa
# `businesses.timezone` vía server/lib/time.ts), y dejarla en UTC hace que cualquier
# recaída a la tz del proceso se note de inmediato en vez de esconderse en dev.
ENV TZ=UTC
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
@@ -38,9 +43,19 @@ COPY server ./server
COPY shared ./shared
COPY tsconfig.json ./
# Persistent SQLite data
# Persistent SQLite data.
#
# NO declares `VOLUME /app/data` aquí. Esa instrucción hace que Docker fabrique un
# volumen ANÓNIMO en cada arranque, y Coolify los purga al recrear el contenedor:
# cada despliegue estrenaba base vacía y `ensureSeed()` la resembraba, así que se
# perdían las citas, los clientes y todo lo configurado en Ajustes. Se verificó
# comparando el nombre del volumen entre despliegues — cambiaba cada vez.
#
# La persistencia la aporta el almacenamiento con nombre declarado en Coolify
# (`s30f7egdlkx4wyjp59o1iunc-agendamax-data` montado en /app/data), que sobrevive a
# la recreación del contenedor. Si algún día se despliega en otro sitio, hay que
# declarar allí un volumen equivalente: sin él la base es efímera.
RUN mkdir -p /app/data
VOLUME /app/data
EXPOSE 3000
CMD ["npx", "tsx", "server/index.ts"]
+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(
+38 -12
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();
@@ -27,9 +28,30 @@ bookingRouter.get("/:slug", (req, res) => {
const services = db
.prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`)
.all(biz.id);
const employees = db
const employeeRows = db
.prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`)
.all(biz.id);
.all(biz.id) as { id: number; name: string; role: string; color: string }[];
// Qué servicios ofrece cada especialista. Sin esto el cliente no puede filtrar el
// paso "Elige un especialista" y deja escoger a alguien que no da ese servicio: el
// siguiente paso responde 400 y la UI lo muestra como "no pudimos cargar los
// horarios, intenta otra fecha", que nunca lo arregla.
// El JOIN contra employees filtra por negocio: employee_services no tiene
// business_id propio, así que es la única forma de no cruzar tenants aquí.
const links = db
.prepare(
`SELECT es.employee_id, es.service_id
FROM employee_services es
JOIN employees e ON e.id = es.employee_id
WHERE e.business_id = ? AND e.active = 1`
)
.all(biz.id) as { employee_id: number; service_id: number }[];
const byEmployee = new Map<number, number[]>();
for (const l of links) {
const arr = byEmployee.get(l.employee_id) ?? [];
arr.push(l.service_id);
byEmployee.set(l.employee_id, arr);
}
const employees = employeeRows.map((e) => ({ ...e, service_ids: byEmployee.get(e.id) ?? [] }));
res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees });
});
@@ -39,8 +61,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 +88,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 +111,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 +120,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 +130,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 +151,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 +175,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 +185,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"];
}
+35 -6
View File
@@ -27,13 +27,42 @@ 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}`);
// El paso "Elige un especialista" solo puede ofrecer a quien realmente da el servicio:
// si ofrece a otro, /slots responde 400 y el cliente queda atascado. Estas dos
// comprobaciones fijan el contrato que hace posible ese filtro en el cliente.
check(
"cada especialista publica sus service_ids",
pub.employees.every((e) => Array.isArray(e.service_ids)),
JSON.stringify(pub.employees.map((e) => e.service_ids)).slice(0, 120)
);
{
const target = pub.services.find((x) => pub.employees.some((e) => e.service_ids.includes(x.id)));
const offers = pub.employees.filter((e) => e.service_ids.includes(target.id));
const denies = pub.employees.filter((e) => !e.service_ids.includes(target.id));
const probe = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const okRes = await req("GET", `/public/${slug}/slots?service_id=${target.id}&employee_id=${offers[0].id}&date=${probe}`);
check("especialista que SÍ ofrece el servicio → 200", okRes.status === 200, `status=${okRes.status}`);
if (denies.length) {
const badRes = await req("GET", `/public/${slug}/slots?service_id=${target.id}&employee_id=${denies[0].id}&date=${probe}`);
check("especialista que NO lo ofrece → 400 (por eso se filtra en la UI)", badRes.status === 400, `status=${badRes.status}`);
}
}
// 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 {
+6 -2
View File
@@ -59,8 +59,12 @@ export function MonthCalendar({
return true;
};
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
// `new Date(y, m, 0)` es el ÚLTIMO día del mes anterior. Se comparaba contra el
// día 1 del mes visible, que siempre es mayor, así que `canPrev` era false
// permanentemente: como BookingPage siempre pasa `min={todayStr()}`, la flecha de
// "mes anterior" nunca se podía pulsar y el cliente que avanzaba de mes quedaba
// atrapado. La comparación correcta es contra `minDate`.
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= minDate;
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
return (
+15 -2
View File
@@ -24,11 +24,24 @@ export function formatDate(iso: string | null | undefined, opts: Intl.DateTimeFo
return d.toLocaleDateString("es-MX", { day: "numeric", month: "short", year: "numeric", ...opts });
}
export function formatTime(iso: string | null) {
/**
* Hora de un instante. `tz` fija la zona: en el panel se omite (el usuario está en
* el negocio), pero en la reserva pública es obligatoria — un cliente que reserva
* desde otra zona horaria debe ver la hora **del negocio**, no la suya.
*/
export function formatTime(iso: string | null, tz?: string) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" });
return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", ...(tz ? { timeZone: tz } : {}) });
}
/** Hora del día (0-23) de un instante en la zona `tz`. Para agrupar mañana/tarde
* sin parsear la cadena ya localizada (que trae "p.m." y rompe un /PM/i). */
export function hourInTz(iso: string, tz: string): number {
const h = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", hour12: false, timeZone: tz }).format(new Date(iso));
const n = Number(h);
return Number.isFinite(n) ? n % 24 : 0;
}
export function formatRelative(iso: string | null) {
+4
View File
@@ -10,6 +10,8 @@ export interface PublicBusiness {
currency_symbol: string;
phone: string | null;
address: string | null;
/** Zona IANA del negocio. Toda hora mostrada al cliente se rinde en ella. */
timezone: string;
booking_enabled: boolean;
cancel_window_hours: number;
cancel_penalty_pct: number;
@@ -33,6 +35,8 @@ export interface PublicEmployee {
name: string;
role: string;
color: string;
/** Servicios que este especialista sí ofrece. El paso 2 filtra por aquí. */
service_ids: number[];
}
export interface PublicBusinessResponse {
+54 -15
View File
@@ -28,7 +28,7 @@ import {
} from "../../lib/publicApi";
import { Spinner } from "../../components/ui";
import { MonthCalendar } from "../../components/MonthCalendar";
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
import { cn, formatCurrency, formatDate, formatTime, hourInTz } from "../../lib/format";
function todayStr() {
const d = new Date();
@@ -82,6 +82,9 @@ export default function BookingPage() {
const services = data?.services ?? [];
const employees = data?.employees ?? [];
// Toda hora que ve el cliente se rinde en la zona del NEGOCIO. Si se cae a la del
// navegador, alguien que reserva desde otro huso ve una hora que no es la de su cita.
const tz = business?.timezone || "America/Mexico_City";
const autoAssign = !!business?.auto_assign_specialist;
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
const stepCount = steps.length;
@@ -92,6 +95,14 @@ export default function BookingPage() {
[services, serviceId]
);
// Un especialista que no ofrece el servicio hace que /slots responda 400. Antes se
// listaban todos, así que el cliente podía elegir a alguien imposible y solo se
// enteraba un paso después, con un mensaje que le pedía cambiar la fecha.
const eligibleEmployees = useMemo(
() => (serviceId == null ? [] : employees.filter((e) => e.service_ids.includes(serviceId))),
[employees, serviceId]
);
const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
@@ -211,6 +222,10 @@ export default function BookingPage() {
setServiceId(id);
setSelectedSlot(null);
setBookError(null);
// El especialista elegido puede no ofrecer el servicio nuevo: soltarlo en vez
// de arrastrar una combinación que el servidor rechazará.
const stillValid = employees.some((e) => e.id === employeeId && e.service_ids.includes(id));
if (!stillValid) setEmployeeId(null);
}
};
@@ -285,9 +300,10 @@ export default function BookingPage() {
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
>
<EmployeeList
employees={employees}
employees={eligibleEmployees}
selectedId={employeeId}
onPick={pickEmployee}
serviceName={selectedService?.name}
/>
</StepShell>
)}
@@ -310,8 +326,10 @@ export default function BookingPage() {
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
errorMessage={slotsQuery.error instanceof Error ? slotsQuery.error.message : undefined}
selectedSlot={selectedSlot}
onPick={pickSlot}
tz={tz}
/>
</StepShell>
)}
@@ -349,8 +367,8 @@ export default function BookingPage() {
summary={
<>
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long", timeZone: tz })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso, tz)} />
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
</>
}
@@ -373,7 +391,7 @@ export default function BookingPage() {
selectedService ? formatCurrency(selectedService.price, currency) : undefined
}
serviceName={selectedService?.name}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short", timeZone: tz })} · ${formatTime(selectedSlot.iso, tz)}` : undefined}
/>
<FooterNote name={business.name} />
@@ -564,10 +582,12 @@ function EmployeeList({
employees,
selectedId,
onPick,
serviceName,
}: {
employees: { id: number; name: string; role: string; color: string }[];
selectedId: number | null;
onPick: (id: number | null) => void;
serviceName?: string;
}) {
const anyActive = selectedId === null;
return (
@@ -596,6 +616,14 @@ function EmployeeList({
)}
</button>
{employees.length === 0 && (
<p className="rounded-2xl border border-slate-200 bg-slate-50 p-3.5 text-sm text-slate-600">
{serviceName
? `Para ${serviceName} asignamos al especialista disponible.`
: "Asignamos al especialista disponible."}
</p>
)}
{employees.map((e) => {
const active = e.id === selectedId;
return (
@@ -640,8 +668,10 @@ function DateTimePicker({
slots,
loading,
isError,
errorMessage,
selectedSlot,
onPick,
tz,
}: {
date: string;
onDate: (d: string) => void;
@@ -650,21 +680,22 @@ function DateTimePicker({
slots: PublicSlot[];
loading: boolean;
isError: boolean;
errorMessage?: string;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
tz: string;
}) {
// La hora sale del instante `iso` resuelto en la tz del negocio. Antes se parseaba
// `s.time`, ya localizado: es-MX rinde "05:00 p.m." y `/PM/i` no casa con "p.m.",
// así que TODOS los slots de la tarde se agrupaban bajo "Mañana".
const { morning, afternoon } = useMemo(() => {
const morning: PublicSlot[] = [];
const afternoon: PublicSlot[] = [];
for (const s of slots) {
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
const h = m ? Number(m[1]) : 0;
const pm = /PM/i.test(s.time);
const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
(hour24 < 12 ? morning : afternoon).push(s);
(hourInTz(s.iso, tz) < 12 ? morning : afternoon).push(s);
}
return { morning, afternoon };
}, [slots]);
}, [slots, tz]);
return (
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
@@ -695,7 +726,13 @@ function DateTimePicker({
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
{/* Se muestra el motivo real del servidor. El texto fijo anterior
("intenta otra fecha") mandaba al cliente a probar días al azar
cuando el problema era el especialista, y ninguna fecha lo resolvía. */}
<p className="text-sm font-medium text-slate-700">
{errorMessage || "No pudimos cargar los horarios."}
</p>
<p className="text-xs text-slate-400">Prueba con otro especialista u otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (
@@ -707,8 +744,8 @@ function DateTimePicker({
)}
{!loading && !isError && slots.length > 0 && (
<div className="space-y-4">
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
</div>
)}
</div>
@@ -722,11 +759,13 @@ function SlotGroup({
slots,
selectedSlot,
onPick,
tz,
}: {
title: string;
slots: PublicSlot[];
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
tz: string;
}) {
if (slots.length === 0) return null;
return (
@@ -750,7 +789,7 @@ function SlotGroup({
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
)}
>
{slot.time}
{formatTime(slot.iso, tz)}
</button>
);
})}