134 lines
6.7 KiB
TypeScript
134 lines
6.7 KiB
TypeScript
import Database from 'better-sqlite3';
|
||
import { mkdirSync } from 'node:fs';
|
||
import { dirname } from 'node:path';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { contentSeed } from '../data/content-seed';
|
||
|
||
const SCHEMA = `
|
||
CREATE TABLE IF NOT EXISTS posts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
title TEXT NOT NULL,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
category TEXT NOT NULL,
|
||
excerpt TEXT NOT NULL DEFAULT '',
|
||
body_html TEXT NOT NULL DEFAULT '',
|
||
cover TEXT,
|
||
draft INTEGER NOT NULL DEFAULT 1,
|
||
published_at TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token TEXT PRIMARY KEY,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
expires_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
|
||
CREATE TABLE IF NOT EXISTS content_blocks (
|
||
tag TEXT PRIMARY KEY,
|
||
type TEXT NOT NULL CHECK (type IN ('text','html','image')),
|
||
value TEXT NOT NULL DEFAULT '',
|
||
styles TEXT NOT NULL DEFAULT '{}',
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_by TEXT
|
||
);
|
||
`;
|
||
|
||
export function createDb(path?: string): Database.Database {
|
||
const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db';
|
||
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
|
||
const db = new Database(p);
|
||
db.pragma('journal_mode = WAL');
|
||
db.pragma('foreign_keys = ON');
|
||
db.exec(SCHEMA);
|
||
// Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role.
|
||
const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
|
||
if (!userCols.some((c) => c.name === 'role')) {
|
||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
|
||
}
|
||
// Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
|
||
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
|
||
|
||
// GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
|
||
const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
|
||
if (!contentCols.some((c) => c.name === 'guid')) {
|
||
db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
|
||
}
|
||
|
||
// Autore articoli: colonna nullable (post storici restano senza autore).
|
||
const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
|
||
if (!postCols.some((c) => c.name === 'author_id')) {
|
||
db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
|
||
}
|
||
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
|
||
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
|
||
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
|
||
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
|
||
const TAG_RENAMES: [string, string][] = [
|
||
['home.programs.', 'home.services.'],
|
||
['footer.programs.', 'footer.services.'],
|
||
['programs.', 'services.'],
|
||
['program.', 'service.'],
|
||
];
|
||
const renameUpd = db.prepare('UPDATE OR IGNORE content_blocks SET tag = ? || substr(tag, ?) WHERE tag LIKE ?');
|
||
const renameDel = db.prepare('DELETE FROM content_blocks WHERE tag LIKE ?');
|
||
const renameTx = db.transaction(() => {
|
||
for (const [oldP, newP] of TAG_RENAMES) {
|
||
renameUpd.run(newP, oldP.length + 1, `${oldP}%`);
|
||
renameDel.run(`${oldP}%`);
|
||
}
|
||
// Tag singolo, non coperto dai prefissi: uguaglianza esatta.
|
||
db.prepare("UPDATE OR IGNORE content_blocks SET tag = 'footer.col.services-title' WHERE tag = 'footer.col.programs-title'").run();
|
||
db.prepare("DELETE FROM content_blocks WHERE tag = 'footer.col.programs-title'").run();
|
||
|
||
// I valori di default che nominavano la sezione cambiano con la rinomina. Il seed è
|
||
// INSERT OR IGNORE e non tocca le righe esistenti, quindi si aggiornano qui — ma solo
|
||
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
|
||
// il nuovo valore è quello del seed corrente per lo stesso tag.
|
||
const OLD_SECTION_DEFAULTS: [string, string][] = [
|
||
['global.nav.3.label', 'Programmi'],
|
||
['footer.col.services-title', 'Programmi'],
|
||
['home.services.title', 'Programmi'],
|
||
['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'],
|
||
['services.meta.title', 'Programmi'],
|
||
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
|
||
['services.hero.title', 'Programmi'],
|
||
['services.intro.title', 'Programmi'],
|
||
["services.intro.body", "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."],
|
||
['service.side.others-label', 'Altri programmi'],
|
||
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
|
||
];
|
||
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
|
||
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
|
||
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
|
||
const next = seedValue.get(tag);
|
||
if (next !== undefined) updValue.run(next, tag, oldValue);
|
||
}
|
||
});
|
||
renameTx();
|
||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
|
||
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
|
||
syncTx();
|
||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
||
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
||
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||
const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
|
||
const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
|
||
bfTx();
|
||
// Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
|
||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
|
||
return db;
|
||
}
|
||
|
||
let singleton: Database.Database | null = null;
|
||
|
||
export function getDb(): Database.Database {
|
||
if (!singleton) singleton = createDb();
|
||
return singleton;
|
||
}
|