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]>
112 lines
4.8 KiB
TypeScript
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}`);
|
|
});
|