# Review package: d4cf359..5cea895 ## Commits 5cea895 feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id ## Files changed src/lib/content.ts | 5 +++-- src/lib/db.ts | 27 +++++++++++++++++++++++++-- tests/content-db.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) ## Diff diff --git a/src/lib/content.ts b/src/lib/content.ts index b873685..dbdafee 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -1,20 +1,21 @@ import type Database from 'better-sqlite3'; +import { randomUUID } from 'node:crypto'; import { getDb } from './db'; import { contentSeed, seedByTag, type ContentType } from '../data/content-seed'; import { stylesToClasses } from './style-presets'; interface Entry { value: string; type: ContentType; styles: Record } export function syncSeed(db: Database.Database): void { - const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)'); - const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); + const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)'); + const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); }); tx(); } export function makeContentStore(dbGetter: () => Database.Database) { let cache: Map | null = null; const load = (): Map => { if (cache) return cache; const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as { tag: string; type: ContentType; value: string; styles: string }[]; diff --git a/src/lib/db.ts b/src/lib/db.ts index cf8c82c..7260f13 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,13 +1,14 @@ 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 '', @@ -43,20 +44,34 @@ export function createDb(path?: string): Database.Database { 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.'], ]; @@ -89,22 +104,30 @@ export function createDb(path?: string): Database.Database { ['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) VALUES (?, ?, ?)'); - const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); }); + 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; } diff --git a/tests/content-db.test.ts b/tests/content-db.test.ts index facfe9a..9be071e 100644 --- a/tests/content-db.test.ts +++ b/tests/content-db.test.ts @@ -1,15 +1,16 @@ import { describe, it, expect } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createDb } from '../src/lib/db'; +import { createUser } from '../src/lib/auth'; describe('schema content_blocks e ruoli', () => { it('crea la tabella content_blocks', () => { const db = createDb(':memory:'); const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[]; expect(cols.map((c) => c.name)).toEqual( expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by']) ); }); @@ -82,10 +83,38 @@ describe('schema content_blocks e ruoli', () => { }); it('migra un DB esistente senza colonna role', () => { // simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile, // quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia) const db = createDb(':memory:'); expect(() => createDb(':memory:')).not.toThrow(); expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4); }); }); + +describe('migrazioni ruoli/guid/autore', () => { + it('ogni content_block ha un guid non vuoto e univoco', () => { + const db = createDb(':memory:'); + const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[]; + expect(rows.length).toBeGreaterThan(100); + expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true); + const guids = new Set(rows.map((r) => r.guid)); + expect(guids.size).toBe(rows.length); + }); + + it('lo statement di migrazione unifica editor in superuser', () => { + // La migrazione gira dentro createDb, ma :memory: non persiste tra due boot: qui + // verifichiamo l'invariante SQL applicata dalla migrazione (editor → superuser). + // L'end-to-end su DB pre-esistente è coperto dallo smoke di produzione (Task 15). + const db = createDb(':memory:'); + db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run(); + db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run(); + const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string }; + expect(row.role).toBe('superuser'); + }); + + it('posts ha la colonna author_id', () => { + const db = createDb(':memory:'); + const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[]; + expect(cols.some((c) => c.name === 'author_id')).toBe(true); + }); +});