import { DatabaseSync } from "node:sqlite"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dataDir = path.resolve(__dirname, "..", "data"); fs.mkdirSync(dataDir, { recursive: true }); const dbPath = path.join(dataDir, "agendapro.db"); if (process.env.RESET_DB === "1" && fs.existsSync(dbPath)) { fs.unlinkSync(dbPath); } export const db = new DatabaseSync(dbPath); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); // Fresh-install schema (multi-tenant). For existing v1 DBs, runMigrations() transforms them. export const SCHEMA = ` CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS businesses ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, industry TEXT NOT NULL, currency TEXT NOT NULL DEFAULT 'MXN', currency_symbol TEXT NOT NULL DEFAULT '$', phone TEXT, address TEXT, slug TEXT, plan TEXT NOT NULL DEFAULT 'trial', status TEXT NOT NULL DEFAULT 'active', template TEXT, trial_ends_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS employees ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), name TEXT NOT NULL, email TEXT, phone TEXT, color TEXT NOT NULL DEFAULT '#3b66ff', role TEXT NOT NULL DEFAULT 'specialist', active INTEGER NOT NULL DEFAULT 1, rating REAL NOT NULL DEFAULT 5.0, hire_date TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), name TEXT NOT NULL, description TEXT, category TEXT NOT NULL DEFAULT 'General', duration_min INTEGER NOT NULL DEFAULT 60, price REAL NOT NULL DEFAULT 0, color TEXT NOT NULL DEFAULT '#3b66ff', active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS employee_services ( employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE, service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, PRIMARY KEY (employee_id, service_id) ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, business_id INTEGER REFERENCES businesses(id), email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin','owner','employee')), employee_id INTEGER REFERENCES employees(id), avatar_color TEXT NOT NULL DEFAULT '#3b66ff', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), name TEXT NOT NULL, email TEXT, phone TEXT, notes TEXT, tags TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS appointments ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), service_id INTEGER NOT NULL REFERENCES services(id), employee_id INTEGER NOT NULL REFERENCES employees(id), client_id INTEGER NOT NULL REFERENCES clients(id), start_at TEXT NOT NULL, end_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','completed','cancelled','no_show')), price REAL NOT NULL DEFAULT 0, notes TEXT, created_by_user_id INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_appointments_start ON appointments(start_at); CREATE INDEX IF NOT EXISTS idx_appointments_employee ON appointments(employee_id); CREATE INDEX IF NOT EXISTS idx_appointments_client ON appointments(client_id); CREATE INDEX IF NOT EXISTS idx_appointments_status ON appointments(status); CREATE TABLE IF NOT EXISTS reviews ( id INTEGER PRIMARY KEY, appointment_id INTEGER NOT NULL REFERENCES appointments(id) ON DELETE CASCADE, client_id INTEGER NOT NULL REFERENCES clients(id), employee_id INTEGER NOT NULL REFERENCES employees(id), rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), comment TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), appointment_id INTEGER REFERENCES appointments(id), client_id INTEGER NOT NULL REFERENCES clients(id), employee_id INTEGER NOT NULL REFERENCES employees(id), service_id INTEGER NOT NULL REFERENCES services(id), amount REAL NOT NULL, tip REAL NOT NULL DEFAULT 0, payment_method TEXT NOT NULL DEFAULT 'card', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_tickets_created ON tickets(created_at); CREATE INDEX IF NOT EXISTS idx_tickets_employee ON tickets(employee_id); CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets(client_id); `; export function getMeta(key: string, fallback = ""): string { const row = db.prepare(`SELECT value FROM meta WHERE key = ?`).get(key) as { value: string } | undefined; return row?.value ?? fallback; } export function setMeta(key: string, value: string) { db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run( key, value ); } function tableExists(name: string): boolean { return !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(name); } function columnExists(table: string, column: string): boolean { const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; return rows.some((r) => r.name === column); } /** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */ function migrateV1ToV2() { if (getMeta("schema_version") >= "2") return; if (!tableExists("users")) { setMeta("schema_version", "2"); return; } // Detect if users table is already v2 (CHECK includes 'admin') const usersSql = (db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='users'`).get() as { sql: string } | undefined)?.sql ?? ""; const isAlreadyV2 = usersSql.includes("'admin'"); if (!isAlreadyV2) { const fkWasOn = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number }; db.exec("PRAGMA foreign_keys = OFF;"); try { db.exec(` CREATE TABLE IF NOT EXISTS users_v2 ( id INTEGER PRIMARY KEY, business_id INTEGER REFERENCES businesses(id), email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin','owner','employee')), employee_id INTEGER REFERENCES employees(id), avatar_color TEXT NOT NULL DEFAULT '#3b66ff', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const existing = db .prepare(`SELECT id, business_id, email, password, name, role, employee_id, avatar_color, created_at FROM users`) .all() as any[]; const ins = db.prepare( `INSERT OR IGNORE INTO users_v2 (id, business_id, email, password, name, role, employee_id, avatar_color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ); for (const u of existing) { ins.run(u.id, u.business_id ?? null, u.email, u.password, u.name, u.role, u.employee_id ?? null, u.avatar_color ?? "#3b66ff", u.created_at); } db.exec(`DROP TABLE users; ALTER TABLE users_v2 RENAME TO users;`); } finally { db.exec(`PRAGMA foreign_keys = ${fkWasOn.foreign_keys ? "ON" : "OFF"};`); } } // Add new business columns if missing if (!columnExists("businesses", "slug")) db.exec(`ALTER TABLE businesses ADD COLUMN slug TEXT;`); if (!columnExists("businesses", "plan")) db.exec(`ALTER TABLE businesses ADD COLUMN plan TEXT NOT NULL DEFAULT 'trial';`); if (!columnExists("businesses", "status")) db.exec(`ALTER TABLE businesses ADD COLUMN status TEXT NOT NULL DEFAULT 'active';`); if (!columnExists("businesses", "template")) db.exec(`ALTER TABLE businesses ADD COLUMN template TEXT;`); if (!columnExists("businesses", "trial_ends_at")) db.exec(`ALTER TABLE businesses ADD COLUMN trial_ends_at TEXT;`); // Meta table (created by SCHEMA on fresh; ensure here for migrated DBs) db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`); setMeta("schema_version", "2"); } export function runMigrations() { // Ensure base schema exists (no-op on existing tables) db.exec(SCHEMA); migrateV1ToV2(); migrateV2ToV3(); } /** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */ function migrateV2ToV3() { if (getMeta("schema_version") >= "3") return; // Business-level settings (cancellation policy + deposit + booking enabled) const bizCols: [string, string][] = [ ["booking_enabled", "INTEGER NOT NULL DEFAULT 1"], ["cancel_window_hours", "INTEGER NOT NULL DEFAULT 24"], ["cancel_penalty_pct", "REAL NOT NULL DEFAULT 0"], ["require_deposit", "INTEGER NOT NULL DEFAULT 0"], ["deposit_pct", "REAL NOT NULL DEFAULT 0"], ["timezone", "TEXT NOT NULL DEFAULT 'America/Mexico_City'"], ]; for (const [col, def] of bizCols) { if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`); } // Commission rates if (!columnExists("services", "commission_pct")) db.exec(`ALTER TABLE services ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;`); if (!columnExists("employees", "commission_pct")) db.exec(`ALTER TABLE employees ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;`); // Commission snapshot on tickets (computed at completion) if (!columnExists("tickets", "commission")) db.exec(`ALTER TABLE tickets ADD COLUMN commission REAL NOT NULL DEFAULT 0;`); // Cash register db.exec(` CREATE TABLE IF NOT EXISTS cash_sessions ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), opened_by_user_id INTEGER REFERENCES users(id), opened_at TEXT NOT NULL DEFAULT (datetime('now')), closed_at TEXT, opening_amount REAL NOT NULL DEFAULT 0, closing_amount REAL, expected_amount REAL, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')), note TEXT ); CREATE INDEX IF NOT EXISTS idx_cash_sessions_business ON cash_sessions(business_id); CREATE INDEX IF NOT EXISTS idx_cash_sessions_status ON cash_sessions(status); CREATE TABLE IF NOT EXISTS cash_entries ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), session_id INTEGER REFERENCES cash_sessions(id) ON DELETE CASCADE, ticket_id INTEGER REFERENCES tickets(id), type TEXT NOT NULL CHECK (type IN ('income','expense')), amount REAL NOT NULL, concept TEXT NOT NULL, payment_method TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_cash_entries_session ON cash_entries(session_id); CREATE INDEX IF NOT EXISTS idx_cash_entries_business ON cash_entries(business_id); `); // Reminders / notifications center db.exec(` CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY, business_id INTEGER NOT NULL REFERENCES businesses(id), appointment_id INTEGER REFERENCES appointments(id) ON DELETE CASCADE, client_id INTEGER REFERENCES clients(id), channel TEXT NOT NULL CHECK (channel IN ('whatsapp','sms','email')), kind TEXT NOT NULL CHECK (kind IN ('confirmation','reminder','cancellation','follow_up','review')), send_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','canceled')), message TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_notif_send ON notifications(send_at); CREATE INDEX IF NOT EXISTS idx_notif_business ON notifications(business_id); CREATE INDEX IF NOT EXISTS idx_notif_status ON notifications(status); `); // Ensure every business has a slug (for public booking /b/:slug) const bizs = db.prepare(`SELECT id, name, slug FROM businesses WHERE slug IS NULL OR slug = ''`).all() as any[]; const slugify = (s: string) => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || `negocio`; for (const b of bizs) { let slug = slugify(b.name); let n = 1; while (db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id != ?`).get(slug, b.id)) { slug = `${slugify(b.name)}-${n++}`; } db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`).run(slug, b.id); } setMeta("schema_version", "3"); } runMigrations();