Files

344 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 { 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 (MonSat) 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 };