Files
AgendaPro/server/db.ts
T
AgendaPro Dev 9d78663cfe feat(db): v4 migration + shared types for specialist auto-assignment
Adds auto_assign_specialist + working_hours to businesses, and specialties + working_hours + efficiency_score to employees, with idempotent migrateV3ToV4 (backfills default Lun-Vie 09:00-20:00 working hours). Updates Business/Employee types and adds WorkingHoursMap.
2026-07-26 15:59:02 -06:00

356 lines
14 KiB
TypeScript

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");
}
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
function migrateV3ToV4() {
if (getMeta("schema_version") >= "4") return;
const bizCols: [string, string][] = [
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
["working_hours", "TEXT"],
];
for (const [col, def] of bizCols) {
if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
}
const empCols: [string, string][] = [
["specialties", "TEXT"],
["working_hours", "TEXT"],
["efficiency_score", "REAL NOT NULL DEFAULT 50"],
];
for (const [col, def] of empCols) {
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
}
// Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
const DEFAULT_WH = JSON.stringify({
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
});
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
setMeta("schema_version", "4");
}
export function runMigrations() {
// Ensure base schema exists (no-op on existing tables)
db.exec(SCHEMA);
migrateV1ToV2();
migrateV2ToV3();
migrateV3ToV4();
}
/** 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();