344 lines
14 KiB
TypeScript
344 lines
14 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, 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) 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 };
|