AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking

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.
This commit is contained in:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
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 };