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]>
218 lines
8.6 KiB
TypeScript
218 lines
8.6 KiB
TypeScript
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";
|
|
import { seedBusiness, clearBusinessData } from "../scripts/seed.ts";
|
|
|
|
export const adminRouter = Router();
|
|
|
|
adminRouter.use(adminOnly);
|
|
|
|
function businessStats(businessId: number) {
|
|
const scalar = (sql: string) => (db.prepare(sql).get(businessId) as any)?.v ?? 0;
|
|
return {
|
|
users: scalar(`SELECT COUNT(*) v FROM users WHERE business_id = ?`),
|
|
employees: scalar(`SELECT COUNT(*) v FROM employees WHERE business_id = ?`),
|
|
services: scalar(`SELECT COUNT(*) v FROM services WHERE business_id = ?`),
|
|
clients: scalar(`SELECT COUNT(*) v FROM clients WHERE business_id = ?`),
|
|
appointments: scalar(`SELECT COUNT(*) v FROM appointments WHERE business_id = ?`),
|
|
appointments_upcoming: scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status='scheduled' AND start_at >= datetime('now')`
|
|
),
|
|
revenue_30d: scalar(
|
|
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
|
|
),
|
|
tickets: scalar(`SELECT COUNT(*) v FROM tickets WHERE business_id = ?`),
|
|
};
|
|
}
|
|
|
|
function businessRow(id: number) {
|
|
return db
|
|
.prepare(
|
|
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
|
|
FROM businesses WHERE id = ?`
|
|
)
|
|
.get(id) as any;
|
|
}
|
|
|
|
// Platform overview
|
|
adminRouter.get("/overview", (_req: AuthedRequest, res) => {
|
|
const scalar = (sql: string) => (db.prepare(sql).get() as any)?.v ?? 0;
|
|
res.json({
|
|
businesses: scalar(`SELECT COUNT(*) v FROM businesses`),
|
|
active_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE status='active'`),
|
|
trial_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE plan='trial'`),
|
|
total_users: scalar(`SELECT COUNT(*) v FROM users WHERE role IN ('owner','employee')`),
|
|
total_appointments: scalar(`SELECT COUNT(*) v FROM appointments`),
|
|
revenue_30d: scalar(`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE created_at >= datetime('now','-30 days')`),
|
|
upcoming_appointments: scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE status='scheduled' AND start_at >= datetime('now')`
|
|
),
|
|
templates: TEMPLATES.length,
|
|
});
|
|
});
|
|
|
|
// List templates
|
|
adminRouter.get("/templates", (_req: AuthedRequest, res) => {
|
|
const out = TEMPLATES.map((t) => ({
|
|
key: t.key,
|
|
name: t.name,
|
|
industry: t.industry,
|
|
description: t.description,
|
|
currency: t.currency,
|
|
currency_symbol: t.currency_symbol,
|
|
employees: t.employees.length,
|
|
services: t.services.length,
|
|
clients: t.clients.length,
|
|
}));
|
|
res.json({ templates: out });
|
|
});
|
|
|
|
// List all businesses with stats
|
|
adminRouter.get("/businesses", (_req: AuthedRequest, res) => {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
|
|
FROM businesses ORDER BY created_at DESC`
|
|
)
|
|
.all() as any[];
|
|
const out = rows.map((b) => ({ business: b, stats: businessStats(b.id) }));
|
|
res.json({ businesses: out });
|
|
});
|
|
|
|
// Business detail
|
|
adminRouter.get("/businesses/:id", (req: AuthedRequest, res) => {
|
|
const id = Number(req.params.id);
|
|
const b = businessRow(id);
|
|
if (!b) return err(res, 404, "Negocio no encontrado");
|
|
res.json({ business: b, stats: businessStats(id) });
|
|
});
|
|
|
|
// Create business + owner + template seed
|
|
adminRouter.post("/businesses", (req: AuthedRequest, res) => {
|
|
const { name, industry, template, ownerName, ownerEmail, ownerPassword, plan, currency, currency_symbol } = req.body ?? {};
|
|
if (!name) return err(res, 400, "El nombre del negocio es obligatorio");
|
|
if (!ownerEmail) return err(res, 400, "El correo del dueño es obligatorio");
|
|
|
|
const tpl: TemplateDef | undefined = template ? getTemplate(template) : getTemplate("blank");
|
|
if (!tpl) return err(res, 400, "Plantilla no válida");
|
|
|
|
const email = String(ownerEmail).toLowerCase().trim();
|
|
const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email);
|
|
if (existing) return err(res, 409, "Ya existe un usuario con ese correo");
|
|
|
|
const biz = db
|
|
.prepare(
|
|
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template, slug, working_hours, timezone)
|
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) RETURNING id`
|
|
)
|
|
.get(
|
|
name,
|
|
industry || tpl.industry,
|
|
currency || tpl.currency || "MXN",
|
|
currency_symbol || tpl.currency_symbol || "$",
|
|
plan || "trial",
|
|
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({
|
|
businessId: biz.id,
|
|
template: tpl,
|
|
ownerEmail: email,
|
|
ownerName: ownerName || "Dueño",
|
|
ownerPassword: ownerPassword || "demo1234",
|
|
withHistory: tpl.key !== "blank",
|
|
withUpcoming: tpl.key !== "blank",
|
|
});
|
|
|
|
res.status(201).json({
|
|
business: businessRow(biz.id),
|
|
stats: businessStats(biz.id),
|
|
seeded: result,
|
|
message: `Negocio "${name}" creado con plantilla "${tpl.name}"`,
|
|
});
|
|
});
|
|
|
|
// Update business
|
|
adminRouter.patch("/businesses/:id", (req: AuthedRequest, res) => {
|
|
const id = Number(req.params.id);
|
|
const existing = businessRow(id);
|
|
if (!existing) return err(res, 404, "Negocio no encontrado");
|
|
const { name, industry, phone, address, slug, plan, status, trial_ends_at } = req.body ?? {};
|
|
db.prepare(
|
|
`UPDATE businesses SET name=?, industry=?, phone=?, address=?, slug=?, plan=?, status=?, trial_ends_at=? WHERE id=?`
|
|
).run(
|
|
name ?? existing.name,
|
|
industry ?? existing.industry,
|
|
phone ?? existing.phone,
|
|
address ?? existing.address,
|
|
slug ?? existing.slug,
|
|
plan ?? existing.plan,
|
|
status ?? existing.status,
|
|
trial_ends_at ?? existing.trial_ends_at,
|
|
id
|
|
);
|
|
res.json({ business: businessRow(id), stats: businessStats(id) });
|
|
});
|
|
|
|
// Delete business + all its data
|
|
adminRouter.delete("/businesses/:id", (req: AuthedRequest, res) => {
|
|
const id = Number(req.params.id);
|
|
const existing = businessRow(id);
|
|
if (!existing) return err(res, 404, "Negocio no encontrado");
|
|
clearBusinessData(id);
|
|
// clearBusinessData preserves user accounts; a full delete removes them too
|
|
db.prepare(`DELETE FROM users WHERE business_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM businesses WHERE id = ?`).run(id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Load template data into an existing business (clearFirst optional)
|
|
adminRouter.post("/businesses/:id/seed-template", (req: AuthedRequest, res) => {
|
|
const id = Number(req.params.id);
|
|
const b = businessRow(id);
|
|
if (!b) return err(res, 404, "Negocio no encontrado");
|
|
const { template, clearFirst = true, ownerEmail, ownerName, ownerPassword, withHistory = true, withUpcoming = true } = req.body ?? {};
|
|
const tpl: TemplateDef | undefined = template ? getTemplate(template) : b.template ? getTemplate(b.template) : getTemplate("blank");
|
|
if (!tpl) return err(res, 400, "Plantilla no válida");
|
|
const result = seedBusiness({
|
|
businessId: id,
|
|
template: tpl,
|
|
ownerEmail: ownerEmail ?? (db.prepare(`SELECT email FROM users WHERE business_id=? AND role='owner'`).get(id) as any)?.email,
|
|
ownerName: ownerName ?? "Dueño",
|
|
ownerPassword: ownerPassword ?? "demo1234",
|
|
clearFirst: clearFirst !== false,
|
|
withHistory,
|
|
withUpcoming,
|
|
});
|
|
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
|
|
});
|
|
|
|
// Reset demo data (clear + reseed with current template)
|
|
adminRouter.post("/businesses/:id/reset-demo", (req: AuthedRequest, res) => {
|
|
const id = Number(req.params.id);
|
|
const b = businessRow(id);
|
|
if (!b) return err(res, 404, "Negocio no encontrado");
|
|
const tplKey = req.body?.template || b.template || "blank";
|
|
const tpl = getTemplate(tplKey);
|
|
if (!tpl) return err(res, 400, "Plantilla no válida");
|
|
const owner = db.prepare(`SELECT email, name FROM users WHERE business_id=? AND role='owner'`).get(id) as any;
|
|
const result = seedBusiness({
|
|
businessId: id,
|
|
template: tpl,
|
|
ownerEmail: owner?.email,
|
|
ownerName: owner?.name ?? "Dueño",
|
|
clearFirst: true,
|
|
withHistory: tpl.key !== "blank",
|
|
withUpcoming: tpl.key !== "blank",
|
|
});
|
|
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
|
|
});
|