registri dei piani in git (AI-OS#309)

Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:36:26 +02:00
parent 9126af28ce
commit b913a2bf90
94 changed files with 40294 additions and 0 deletions
@@ -0,0 +1,187 @@
# 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<string, string> }
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<string, Entry> | null = null;
const load = (): Map<string, Entry> => {
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. <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) 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);
+ });
+});