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:
@@ -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 };
|
||||
Reference in New Issue
Block a user