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 = (arr: T[]): T => arr[Math.floor(rnd() * arr.length)]; const pickN = (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, 'admin@agendapro.demo', '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: "owner@agendapro.demo", 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 };