Files
InsanityLab-web/src/lib/db.ts
T
2026-07-31 16:24:02 +02:00

265 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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][] = [
// La voce "Servizi" (ex "Programmi") è la 2ª del menu dopo il riordino (commit 5ff4771).
['global.nav.2.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 unesperienza 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.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();
// Modifiche 03: "Personal Training" → "One to One" (nuovo slug one-to-one). Rinomina i tag
// esistenti prima del seed per preservare le modifiche da pannello; il title di default viene
// aggiornato solo se non modificato. La nuova pagina "Small Group" (slug small-group) è un
// servizio nuovo e viene inserita dal seed più sotto. Idempotente.
const oneToOneTx = db.transaction(() => {
renameUpd.run('service.one-to-one.', 'service.personal-training.'.length + 1, 'service.personal-training.%');
renameDel.run('service.personal-training.%');
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal Training'").run();
});
oneToOneTx();
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();
// Metodo (home): step riscritti dal pannello in dev ma mai propagati in produzione
// (il DB non è versionato: gli edit vivono solo nel volume). Qui si allineano le righe
// di prod ancora ai vecchi default del seed, senza toccare eventuali modifiche successive.
const METODO: [string, string, string][] = [
['method.step.1.text', 'Dove raccogliamo unanamnesi iniziale per comprendere il cliente, i suoi obiettivi e guidarlo verso il percorso più adatto.', 'La base di partenza per definire le priorità del percorso di ciascun cliente.'],
['method.step.2.title', 'Prima seduta operativa', 'Check-up fisico'],
['method.step.2.text', 'In cui si effettua la profilazione iniziale e la valutazione sul campo per definire il punto di partenza e impostare un percorso personalizzato.', 'Profilazione fisica per definire lo stato di salute iniziale del cliente.'],
['method.step.3.title', 'Plicometria', 'Insanity plan'],
['method.step.3.text', 'Test che misura il grasso sottocutaneo per stimare la massa grassa, disponibile su richiesta o come servizio aggiuntivo.', 'Definizione del programma personalizzato con protocolli scientificamente validati costruiti sul cliente.'],
['method.step.4.title', 'Analisi antropometriche', 'Monitoraggio costante'],
['method.step.4.text', 'Le analisi antropometriche sono misurazioni del corpo utili a valutare la composizione fisica e lo stato di forma.', 'Monitoraggio live durante le sessioni di allenamento attraverso strumentazione e sensoristica avanzata. Misurazione periodica dei progressi.'],
];
const updMetodoTx = db.transaction(() => { for (const [tag, oldV, newV] of METODO) upd02.run(newV, tag, oldV); });
updMetodoTx();
// Modifiche promo (commit 1522d97): la freccia "→" dei bottoni è passata al CSS (::after) e il
// maiuscolo a text-transform. Il seed è ora minuscolo e senza freccia. Le righe di prod ancora
// al vecchio default vanno allineate, altrimenti il CSS ::after produce una DOPPIA freccia.
// Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
const PROMO_FRECCE: [string, string, string][] = [
['promo.hero.cta', 'LE NOSTRE PROPOSTE →', 'Le nostre proposte'],
['promo.buy-label', 'ACQUISTA →', 'Acquista'],
];
const updPromoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_FRECCE) upd02.run(newV, tag, oldV); });
updPromoTx();
// Listino promo agosto 2026 (finestra 119 agosto): nuovi prezzi comunicati dal cliente e nota
// di validità con le date. I prezzi barrati (price-old) restano invariati. UPDATE guardato dal
// vecchio valore → idempotente e non sovrascrive eventuali ritocchi fatti dal pannello.
const PROMO_AGOSTO_2026: [string, string, string][] = [
['promo.card.1.price', '163 €', '200 €'],
['promo.card.2.price', '110 €', '125 €'],
['promo.card.3.price', '100 €', '115 €'],
['promo.card.4.price', '50 €', '75 €'],
['promo.validity-note',
"Disponibile solo per l'apertura · Valido entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026",
'Promo valida dal 1° al 19 agosto 2026 · Incontri validi entro 2 mesi dal primo incontro e comunque non oltre il 1° dicembre 2026'],
];
const updAgostoTx = db.transaction(() => { for (const [tag, oldV, newV] of PROMO_AGOSTO_2026) upd02.run(newV, tag, oldV); });
updAgostoTx();
// Il servizio "Performance Class" torna a chiamarsi solo "Class" (titolo card /services,
// H1 pagina dettaglio, link footer). Inverte la precedente normalizzazione Class→Performance
// Class. Guardato dal vecchio valore → idempotente, non tocca eventuali modifiche del pannello.
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'service.performance-class.title' AND value = 'Performance Class'").run();
db.prepare("UPDATE content_blocks SET value = 'Class' WHERE tag = 'footer.services.2.label' AND value = 'Performance Class'").run();
// Titolo del servizio One to One: in prod era stato editato da pannello a "Personal training"
// (t minuscola → la vecchia migrazione, guardata su 'Personal Training', non l'agganciava),
// così l'hero e l'H2 di /services/one-to-one mostravano "PERSONAL TRAINING". Riportiamo il
// nome canonico "One to One" (già nel seed). Guardato sul valore reale corrente → idempotente;
// la descrizione lunga editata da pannello NON viene toccata.
db.prepare("UPDATE content_blocks SET value = 'One to One' WHERE tag = 'service.one-to-one.title' AND value = 'Personal training'").run();
// Pulizia tag orfani: il riquadro training "performance-class" è stato rimosso dal seed
// (commit 9966ec3, "Training: 6 riquadri"), ma i suoi tag restano nei DB esistenti — orfani,
// non renderizzati da nessuna pagina. Rimuoviamo l'intero gruppo. Idempotente. Il seed non li
// reinserisce (trainings.ts non ha più questo id).
db.prepare("DELETE FROM content_blocks WHERE tag LIKE 'training.performance-class.%'").run();
// Modifiche 03: due ingressi dello stesso stabile → indirizzo esteso in footer e contatti.
// Il tag global.contact.map-query è nuovo (inserito dal seed); qui si aggiorna solo l'indirizzo
// già esistente, se non modificato dal pannello. Idempotente.
db.prepare("UPDATE content_blocks SET value = 'Via Leandro Alberti 76 e Via De Mattiolo 5' WHERE tag = 'global.contact.address' AND value = 'Via Leandro Alberti, 76'").run();
// Orario sabato corretto: 817, non 819. Guardato dal vecchio valore di default →
// idempotente, non tocca eventuali modifiche del pannello.
db.prepare("UPDATE content_blocks SET value = 'Sab 8.00 17.00 · Dom chiuso' WHERE tag = 'global.hours.2' AND value = 'Sab 8.00 19.00 · Dom chiuso'").run();
// 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();
// Il ruolo `campus` diventa `piattaforme`: non dà più accesso alla sola sezione Biohacking
// ma a tutte le piattaforme riservate (Campus, Stress Index). Rinomina una tantum delle
// righe esistenti; il vincolo sui valori ammessi vive in auth.ts (isRole), non nello schema.
db.prepare("UPDATE users SET role = 'piattaforme' WHERE role = 'campus'").run();
// Rimossa la variante di test /promo-eventi2 e la sua voce di menu (era la settima,
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
// 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;
}