La reserva pública no ofrecía horarios en producción. La ventana laboral se
construía con `new Date(y, m, d, hh, mm)`, que resuelve el reloj de pared en la
tz del proceso. El Dockerfile no fijaba TZ y node:22-slim arranca en UTC,
mientras que la máquina de desarrollo está en America/Mexico_City: por eso solo
fallaba desplegado. Un negocio de 09:00-20:00 se publicaba como 09:00-20:00 UTC
(03:00-14:00 de México), y como el generador descarta lo anterior a ahora+30min,
a partir de la 1 PM la lista quedaba vacía.
Toda la API de scheduling.ts lleva ahora `tz` explícita y resuelve el reloj de
pared con wallToUtcDate/bizDateISO de time.ts, que ya existían para esto.
Arrastraba cinco defectos más en la misma ruta:
- getExistingBusy acotaba el día concatenando `${fecha}T00:00:00`. Como start_at
se guarda en UTC, una cita de las 19:00 de México vive en el día UTC siguiente
y quedaba fuera del rango: el guard anti doble-reserva no veía la tarde entera.
Ahora usa bizDayBoundsIsoFor.
- Un negocio recién sembrado nacía con working_hours y slug en NULL, o sea con
cero franjas agendables y /b/:slug en 404: el backfill vivía solo dentro de las
migraciones, que corren antes de que exista la fila. Los defaults se fijan en el
INSERT (server/lib/businessDefaults.ts) en los tres sitios que crean negocios, y
migrateV4ToV5 repara los ya rotos. El demo usa slug fijo `mi-negocio-demo`
porque es la URL ya publicada y el volumen se recrea en cada despliegue.
- El chip mostraba la hora formateada por el servidor y el resumen la del
navegador: dos horas distintas para el mismo slot. Ambas salen ahora del
instante resuelto en la tz del negocio.
- La separación mañana/tarde usaba /PM/i sobre un texto ya localizado, y es-MX
rinde "05:00 p.m." con puntos: nunca casaba, así que el grupo "Tarde"
desaparecía y toda la tarde se agrupaba bajo "Mañana".
- MonthCalendar comparaba canPrev contra el día 1 del mes visible en vez de
contra minDate, de modo que la flecha de mes anterior nunca se podía pulsar.
Las guardas de migración comparaban la versión como texto ("10" >= "2" es false),
lo que habría reejecutado migrateV1ToV2 y su DROP TABLE users al llegar a dos
dígitos; ahora comparan números.
Verificación: scheduling.test.ts fija TZ=UTC y usa negocios en America/Mexico_City
para que la tz del proceso y la del negocio nunca coincidan; el Dockerfile fija
ENV TZ=UTC por lo mismo. 43 unitarias + 33 e2e + 12 booking + 17 admin en verde
con el servidor en UTC y base recién sembrada; typecheck limpio. booking-e2e.mjs
busca el próximo día abierto en vez de asumir "mañana", que lo hacía fallar cada
viernes y sábado por calendario.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
388 lines
16 KiB
TypeScript
388 lines
16 KiB
TypeScript
import { DatabaseSync } from "node:sqlite";
|
|
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "./lib/businessDefaults.ts";
|
|
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 (schemaVersion() >= 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 (schemaVersion() >= 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 (misma definición que usan el seed y el alta:
|
|
// server/lib/businessDefaults.ts — no la dupliques aquí).
|
|
const DEFAULT_WH = DEFAULT_WORKING_HOURS;
|
|
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();
|
|
migrateV4ToV5();
|
|
}
|
|
|
|
/** Versión de esquema como NÚMERO. Compararla como texto hacía que `"10" >= "2"`
|
|
* fuera false y reejecutara las migraciones antiguas al llegar a dos dígitos. */
|
|
function schemaVersion(): number {
|
|
return Number(getMeta("schema_version") || "0") || 0;
|
|
}
|
|
|
|
/**
|
|
* v5: repara los negocios que nacieron sin `slug` ni `working_hours`.
|
|
*
|
|
* Los backfills de v3 (slug) y v4 (working_hours) solo alcanzaban a las filas que ya
|
|
* existían cuando corrió la migración. Cualquier negocio creado después —el demo de
|
|
* una instalación nueva, o un alta desde /api/admin— quedaba con ambos campos en NULL:
|
|
* sin página pública de reservas (404 en /b/:slug) y sin ninguna franja agendable.
|
|
* El default ya se aplica en el INSERT (server/lib/businessDefaults.ts); esta
|
|
* migración arregla los que quedaron rotos. Idempotente: solo toca filas NULL/vacías.
|
|
*/
|
|
function migrateV4ToV5() {
|
|
if (schemaVersion() >= 5) return;
|
|
|
|
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL OR working_hours = ''`).all() as { id: number }[];
|
|
const updWh = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
|
for (const b of noWh) updWh.run(DEFAULT_WORKING_HOURS, b.id);
|
|
|
|
const noSlug = db.prepare(`SELECT id, name FROM businesses WHERE slug IS NULL OR slug = ''`).all() as { id: number; name: string }[];
|
|
const updSlug = db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`);
|
|
for (const b of noSlug) updSlug.run(uniqueSlug(db, b.name, b.id), b.id);
|
|
|
|
if (noWh.length || noSlug.length) {
|
|
console.log(`[migrate v5] reparados ${noWh.length} horarios y ${noSlug.length} slugs de negocio.`);
|
|
}
|
|
setMeta("schema_version", "5");
|
|
}
|
|
|
|
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
|
|
function migrateV2ToV3() {
|
|
if (schemaVersion() >= 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();
|