Files
AgendaPro/server/index.ts
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

112 lines
4.8 KiB
TypeScript

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";
import { fileURLToPath } from "node:url";
import { db } from "./db.ts";
import { ensurePlatformAdmin, seedBusiness, getTemplate, DEFAULT_TEMPLATE_KEY } from "./scripts/seed.ts";
import { authRouter } from "./routes/auth.ts";
import { businessRouter } from "./routes/business.ts";
import { employeesRouter } from "./routes/employees.ts";
import { servicesRouter } from "./routes/services.ts";
import { clientsRouter } from "./routes/clients.ts";
import { appointmentsRouter } from "./routes/appointments.ts";
import { dashboardRouter } from "./routes/dashboard.ts";
import { adminRouter } from "./routes/admin.ts";
import { settingsRouter } from "./routes/settings.ts";
import { cashRouter } from "./routes/cash.ts";
import { notificationsRouter, scheduleReminders } from "./routes/notifications.ts";
import { bookingRouter } from "./routes/booking.ts";
import { meRouter } from "./routes/me.ts";
import { authRequired } from "./lib/auth.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
// Auto-seed demo data if the database is empty (first run). Preserves existing data.
function ensureSeed() {
ensurePlatformAdmin();
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, slug, working_hours, timezone)
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id`
)
.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)!,
ownerEmail: "[email protected]",
ownerName: "Daniela Reyes",
});
}
}
ensureSeed();
// On startup, refresh pending reminders for all active businesses (idempotent).
try {
const bids = db.prepare(`SELECT id FROM businesses WHERE status = 'active'`).all() as { id: number }[];
for (const b of bids) scheduleReminders(b.id);
} catch (e) {
console.warn("[reminders] no se pudieron generar al inicio:", (e as Error).message);
}
const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.use("/api/auth", authRouter);
// Public booking (no auth)
app.use("/api/public", bookingRouter);
// Protected APIs
app.use("/api/business", authRequired, businessRouter);
app.use("/api/employees", authRequired, employeesRouter);
app.use("/api/services", authRequired, servicesRouter);
app.use("/api/clients", authRequired, clientsRouter);
app.use("/api/appointments", authRequired, appointmentsRouter);
app.use("/api/me", authRequired, meRouter);
app.use("/api/dashboard", authRequired, dashboardRouter);
app.use("/api/admin", authRequired, adminRouter);
app.use("/api/settings", authRequired, settingsRouter);
app.use("/api/cash", authRequired, cashRouter);
app.use("/api/notifications", authRequired, notificationsRouter);
app.get("/api/health", (_req, res) => res.json({ ok: true, ts: Date.now() }));
// Serve built frontend in production
const distDir = path.join(ROOT, "dist");
if (fs.existsSync(distDir)) {
app.use(express.static(distDir));
app.get("*", (req, res, next) => {
if (req.path.startsWith("/api/")) return next();
res.sendFile(path.join(distDir, "index.html"));
});
}
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error("[server error]", err);
res.status(err.status || 500).json({ error: err.message || "Error interno" });
});
const port = Number(process.env.PORT) || 3000;
const host = process.env.HOST || "0.0.0.0";
app.listen(port, host, () => {
console.log(`AgendaMax API en http://${host}:${port}`);
});