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. Scrivici →'], ]; 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(); // Modifiche 02 (pagina Servizi): "Pilates Flow" → "Profilazione" e "Rehab" → "Osteopatia". // I nuovi servizi hanno slug nuovi (tag service.profilazione.* / service.osteopatia.*) e // vengono inseriti dal seed qui sopra. Restano da aggiornare le righe EXISTING il cui valore // cambia: label del footer e meta description. UPDATE guardato dal vecchio valore → idempotente // e non sovrascrive eventuali modifiche fatte dal pannello. const SERVIZI_02: [string, string, string][] = [ ['footer.services.4.label', 'Rehab', 'Profilazione'], ['footer.services.5.label', 'Pilates Flow', 'Osteopatia'], ['services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Profilazione, Osteopatia e Nutrizione.'], // Pagina Training: riquadri passati a One to One, Small Group, Rehab, Holistic Class, // Reformer Class, Coaching. Label footer + meta aggiornate (righe 5-6 sono nuovi tag). ['footer.training.2.label', 'Small groups', 'Small group'], ['footer.training.3.label', 'Performance class', 'Rehab'], ['footer.training.4.label', 'Fit remote', 'Holistic class'], ['training.meta.description', 'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.', 'One to One, Small Group, Rehab, Holistic Class, Reformer Class e Coaching: trova il training InsanityLab più adatto a te.'], ]; const upd02 = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?'); const upd02Tx = db.transaction(() => { for (const [tag, oldV, newV] of SERVIZI_02) upd02.run(newV, tag, oldV); }); upd02Tx(); // Modifiche 02 (pagina Insanitylab/about): testi riscritti. Guardia via frammento LIKE // distintivo del vecchio testo → robusto agli apostrofi tipografici, idempotente e // rispettoso delle eventuali riscritture da pannello (se il frammento non c'è più, salta). const ABOUT_02: [string, string, string][] = [ ['about.about.body', '%unire fitness e salute in un unico percorso%', "InsanityLab nasce dalla visione di creare uno spazio dove il fitness incontrasse la salute in modo reale e profondo, non superficiale. L'idea di partenza era semplice ma rivoluzionaria nel settore: il movimento non dovrebbe essere vissuto come un sacrificio estetico, ma come un atto di cura verso se stessi. Da questa convinzione è nato un laboratorio esclusivo di benessere, pensato da chi conosce il corpo in profondità — non solo come semplici trainer, ma come osteopati e chinesiologi esperti del movimento."], ['about.convenzioni.item1', '%small group training e classi di gruppo%', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati.'], ['about.convenzioni.item2', '%consulenza gratuita preliminare%', 'Ogni collaborazione parte da una consulenza preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'], ['about.studios.body1', '%della performance. Più che un semplice insieme%', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute e del benessere."], ]; const updAbout = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value LIKE ?'); const updAboutTx = db.transaction(() => { for (const [tag, frag, newV] of ABOUT_02) updAbout.run(newV, tag, frag); }); updAboutTx(); // 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; }