Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import { db } from "../db.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) => {
|
|
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 = 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++;
|
|
}
|
|
}
|
|
|
|
// 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) 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 };
|
|
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 };
|