Files
AgendaPro/server/scripts/seed.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

352 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
// ---- helpers ----
function mulberry32(seed: number) {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function isoOffsetDays(days: number, hour = 10, minute = 0) {
const d = new Date();
d.setHours(hour, minute, 0, 0);
d.setDate(d.getDate() + days);
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
function addMinutes(iso: string, mins: number) {
const d = new Date(iso);
d.setMinutes(d.getMinutes() + mins);
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
const PAYMENT_METHODS = ["card", "cash", "transfer"];
const REVIEW_COMMENTS = [
"Excelente servicio, muy profesional.",
"Me encantó el resultado, volveré pronto.",
"Súper recomendable, ambiente agradable.",
"Muy puntual y atento(a).",
"Buen trabajo pero podría mejorar la puntualidad.",
"El mejor servicio que he recibido.",
"",
"",
];
export interface SeedBusinessOptions {
businessId: number;
template: TemplateDef;
ownerEmail?: string;
ownerName?: string;
ownerPassword?: string;
ownerAvatarColor?: string;
clearFirst?: boolean;
withHistory?: boolean; // past appointments + tickets (default true)
withUpcoming?: boolean; // upcoming appointments (default true)
seed?: number; // deterministic PRNG seed
}
/** Clear business demo data EXCEPT user accounts (owner/employees survive resets).
* User employee_id links are nulled to avoid dangling FKs; seedBusiness re-links them. */
export function clearBusinessData(businessId: number) {
db.exec("PRAGMA foreign_keys = OFF;");
db.prepare(`DELETE FROM reviews WHERE appointment_id IN (SELECT id FROM appointments WHERE business_id = ?)`).run(businessId);
db.prepare(`DELETE FROM tickets WHERE business_id = ?`).run(businessId);
db.prepare(`DELETE FROM appointments WHERE business_id = ?`).run(businessId);
db.prepare(
`DELETE FROM employee_services WHERE employee_id IN (SELECT id FROM employees WHERE business_id = ?) OR service_id IN (SELECT id FROM services WHERE business_id = ?)`
).run(businessId, businessId);
db.prepare(`DELETE FROM services WHERE business_id = ?`).run(businessId);
db.prepare(`DELETE FROM clients WHERE business_id = ?`).run(businessId);
// detach employee users from soon-to-be-deleted employees, then delete employees
db.prepare(`UPDATE users SET employee_id = NULL WHERE business_id = ? AND role = 'employee'`).run(businessId);
db.prepare(`DELETE FROM employees WHERE business_id = ?`).run(businessId);
db.exec("PRAGMA foreign_keys = ON;");
}
/**
* Seed a business with template demo data. Idempotent per call when clearFirst=true.
* Creates owner + employee users, services, clients, and procedural appointments/tickets/reviews.
*/
export function seedBusiness(opts: SeedBusinessOptions) {
const {
businessId,
template,
ownerEmail,
ownerName,
ownerPassword = "demo1234",
ownerAvatarColor = "#3b66ff",
clearFirst = true,
withHistory = true,
withUpcoming = true,
seed = 20260725 + businessId * 7,
} = opts;
if (clearFirst) clearBusinessData(businessId);
const rnd = mulberry32(seed);
const pick = <T>(arr: T[]): T => arr[Math.floor(rnd() * arr.length)];
const pickN = <T>(arr: T[], n: number): T[] => {
const copy = [...arr];
const out: T[] = [];
for (let i = 0; i < n && copy.length; i++) out.push(copy.splice(Math.floor(rnd() * copy.length), 1)[0]);
return out;
};
const rint = (min: number, max: number) => Math.floor(rnd() * (max - min + 1)) + min;
// stamp template + currency on the business
db.prepare(`UPDATE businesses SET template = ?, currency = ?, currency_symbol = ? WHERE id = ?`).run(
template.key,
template.currency,
template.currency_symbol,
businessId
);
// ---- employees ----
const empIds: number[] = [];
for (const e of template.employees) {
const r = db
.prepare(
`INSERT INTO employees (business_id, name, role, color, phone, email, hire_date)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
businessId,
e.name,
e.role,
e.color,
`+52 55 ${rint(1000, 9999)} ${rint(1000, 9999)}`,
e.name.toLowerCase().replace(/\s+/g, ".") + template.emailDomain,
isoOffsetDays(-rint(180, 900), 9)
) as { id: number };
empIds.push(r.id);
}
// ---- services ----
const svcIds: number[] = [];
for (const s of template.services) {
// derive a demo commission rate by price tier (higher-priced services → higher commission)
const commissionPct = s.price >= 1500 ? 15 : s.price >= 800 ? 12 : s.price >= 400 ? 10 : 8;
const r = db
.prepare(
`INSERT INTO services (business_id, name, description, category, duration_min, price, color, commission_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(businessId, s.name, `Servicio profesional de ${s.category.toLowerCase()}.`, s.category, s.duration_min, s.price, s.color, commissionPct) as {
id: number;
};
svcIds.push(r.id);
}
for (let i = 0; i < template.services.length; i++) {
const s = template.services[i];
const sid = svcIds[i];
for (const ei of s.employees) {
if (empIds[ei]) db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`).run(empIds[ei], sid);
}
}
// ---- clients ----
const clientIds: number[] = [];
const clientTags = ["VIP", "Frecuente", "Nuevo", "Referido"];
for (const c of template.clients) {
const r = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, tags, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
businessId,
c.name,
c.name.toLowerCase().replace(/\s+/g, ".") + "@email.com",
`+52 55 ${rint(1000, 9999)} ${rint(1000, 9999)}`,
c.tags ?? pickN(clientTags, rint(0, 2)).join(","),
c.notes ?? pick(["Prefiere horario matutino", "Alergia a cierto producto", "Cliente puntual", ""]),
isoOffsetDays(-rint(1, 540))
) as { id: number };
clientIds.push(r.id);
}
if (clientIds.length === 0) {
// blank template: create a couple of placeholder clients so appointments work if added later
for (const name of ["Cliente Demo", "Cliente Ejemplo"]) {
const r = db
.prepare(`INSERT INTO clients (business_id, name, created_at) VALUES (?, ?, ?) RETURNING id`)
.get(businessId, name, isoOffsetDays(-30)) as { id: number };
clientIds.push(r.id);
}
}
// ---- owner + employee users (UPSERT: preserve existing accounts so tokens survive resets) ----
if (ownerEmail) {
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, avatar_color)
VALUES (?, ?, ?, ?, 'owner', ?)
ON CONFLICT(email) DO UPDATE SET business_id = excluded.business_id, name = excluded.name, role = 'owner'`
).run(businessId, ownerEmail.toLowerCase(), ownerPassword, ownerName ?? "Dueño", ownerAvatarColor);
}
for (let i = 0; i < template.employees.length; i++) {
const e = template.employees[i];
const empEmail = e.name.toLowerCase().replace(/\s+/g, ".") + template.emailDomain;
// re-link existing employee user to the freshly created employee record
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, employee_id, avatar_color)
VALUES (?, ?, ?, ?, 'employee', ?, ?)
ON CONFLICT(email) DO UPDATE SET business_id = excluded.business_id, name = excluded.name, role = 'employee', employee_id = excluded.employee_id, avatar_color = excluded.avatar_color`
).run(businessId, empEmail, "demo1234", e.name, empIds[i], e.color);
}
// ---- appointments + tickets + reviews ----
let pastCount = 0;
let upCount = 0;
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null, fixedHour?: number) => {
const svc = template.services[svcIdx];
if (!svc) return null;
const empLocalIdx = pick(svc.employees);
const empId = empIds[empLocalIdx];
const svcId = svcIds[svcIdx];
const clientId = pick(clientIds);
const hour = fixedHour ?? pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
const minute = pick([0, 30]);
const start = isoOffsetDays(dayOffset, hour, minute);
const end = addMinutes(start, svc.duration_min);
const appt = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, created_by_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(businessId, svcId, empId, clientId, start, end, status, svc.price, createdBy, start) as { id: number };
if (status === "completed") {
const tip = rnd() < 0.4 ? pick([20, 50, 50, 100, 100, 150]) : 0;
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(businessId, appt.id, clientId, empId, svcId, svc.price, tip, pick(PAYMENT_METHODS), end);
if (rnd() < 0.35) {
db.prepare(
`INSERT INTO reviews (appointment_id, client_id, employee_id, rating, comment, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(appt.id, clientId, empId, rint(3, 5), pick(REVIEW_COMMENTS), end);
}
}
return appt.id;
};
// resolve owner user id for created_by
const ownerRow = ownerEmail
? (db.prepare(`SELECT id FROM users WHERE email = ?`).get(ownerEmail.toLowerCase()) as { id: number } | undefined)
: undefined;
if (withHistory) {
for (let dayOffset = -60; dayOffset <= -1; dayOffset++) {
const wd = new Date();
wd.setDate(wd.getDate() + dayOffset);
if (wd.getDay() === 0) continue; // skip Sundays
const n = rint(0, 4);
for (let k = 0; k < n; k++) {
let status = "completed";
const roll = rnd();
if (roll < 0.08) status = "cancelled";
else if (roll < 0.12) status = "no_show";
if (makeAppt(dayOffset, rint(0, template.services.length - 1), status, ownerRow?.id ?? null)) pastCount++;
}
}
}
if (withUpcoming) {
for (let dayOffset = 1; dayOffset <= 14; dayOffset++) {
const n = rint(0, 5);
for (let k = 0; k < n; k++) {
if (makeAppt(dayOffset, rint(0, template.services.length - 1), "scheduled", ownerRow?.id ?? null)) upCount++;
}
}
// a few today
for (let k = 0; k < 4; k++) {
const done = rnd() < 0.4;
if (makeAppt(0, rint(0, template.services.length - 1), done ? "completed" : "scheduled", ownerRow?.id ?? null)) upCount++;
}
}
// Guarantee visible appointments across the current week (Mon–Sat) regardless of today's weekday
{
const now = new Date();
const dow = now.getDay(); // 0=Sun ... 6=Sat
const mondayOffset = dow === 0 ? -6 : 1 - dow;
for (let d = 0; d < 6; d++) {
const dayOffset = mondayOffset + d;
if (dayOffset < -60) continue;
const past = dayOffset < 0;
const isToday_ = dayOffset === 0;
const hours = pickN([10, 12, 14, 16, 18], rint(2, 4));
for (const hour of hours) {
const svcIdx = rint(0, template.services.length - 1);
const status = past ? "completed" : (isToday_ && hour <= now.getHours()) ? "completed" : "scheduled";
if (makeAppt(dayOffset, svcIdx, status, ownerRow?.id ?? null, hour)) upCount++;
}
}
}
// recompute ratings
db.exec(`
UPDATE employees SET rating = COALESCE(
(SELECT AVG(r.rating) FROM reviews r WHERE r.employee_id = employees.id),
4.7 + (employees.id % 4) * 0.07
) WHERE business_id = ${businessId};
`);
// backfill commission on tickets generated during seeding (services already have commission_pct)
db.exec(`
UPDATE tickets SET commission = ROUND(tickets.amount * COALESCE(s.commission_pct,0) / 100.0, 2)
FROM services s WHERE s.id = tickets.service_id
AND tickets.business_id = ${businessId}
AND (tickets.commission IS NULL OR tickets.commission = 0)
AND COALESCE(s.commission_pct,0) > 0;
`);
return { employees: empIds.length, services: svcIds.length, clients: clientIds.length, pastCount, upCount };
}
export function ensurePlatformAdmin() {
const exists = db.prepare(`SELECT id FROM users WHERE role = 'admin'`).get();
if (exists) return;
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, avatar_color)
VALUES (NULL, '[email protected]', 'demo1234', 'Administrador', 'admin', '#0f172a')`
).run();
}
// Run directly as a script: ensure admin + default demo business, preserving existing data.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
ensurePlatformAdmin();
// Ensure default demo business exists (don't touch existing data)
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, 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 {
console.log(`[seed] Default business #${biz.id} already exists — data preserved.`);
}
// expose templates info
const all = db.prepare(`SELECT id, name, industry, plan, status, template FROM businesses`).all();
console.log(`[seed] Businesses: ${JSON.stringify(all)}`);
}
export { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY };