/** * Semina `checklist_items` dalla rubrica in scripts/data/checklist.json. * * npx tsx scripts/seed-checklist.ts valida soltanto, non emette nulla * npx tsx scripts/seed-checklist.ts --sql emette le INSERT su stdout * * Il Postgres di produzione non è esposto: l'SQL si applica via SSH + docker * exec, come le migration. La diagnostica va su stderr apposta, così `--sql` * si può reindirizzare senza sporcare l'output. * * La rubrica è la fonte di verità del MOTORE, non la struttura del documento: * ogni voce è un'asserzione binaria e falsificabile che l'agent verifica sulle * pagine scaricate. Nel documento non compare mai come elenco. * * IDEMPOTENTE, e per una ragione precisa: `audit_checklist_results` referenzia * le voci con ON DELETE RESTRICT, perché un audit consegnato deve restare * leggibile per sempre. Quindi le voci non si cancellano e non si ri-creano — * si aggiornano in place. L'id è derivato deterministicamente da (step + testo) * così una ri-esecuzione ritrova le stesse righe invece di duplicarle. * * Conseguenza da tenere a mente: cambiare il TESTO di una voce nel JSON la fa * diventare una voce NUOVA (id diverso). È voluto — il testo è l'asserzione, e * un'asserzione diversa è una domanda diversa. La vecchia resta, con la sua * storia di risultati. */ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { AuditProfilo } from "@/db/schema"; type Voce = { step: string; sezione: string | null; focus: string | null; testo: string; impatto_default: number | null; confidenza_default: number | null; registro: string; profili: string[]; }; const REGISTRI = new Set(["volume", "premium", "neutro"]); const PROFILI = new Set(["ecommerce", "servizi"]); /** Id stabile fra esecuzioni: 21 caratteri come un nanoid, ma deterministico. */ function idVoce(step: string, testo: string): string { return createHash("sha256") .update(`${step}::${testo}`) .digest("base64url") .slice(0, 21); } async function main() { const flag = process.argv.indexOf("--out"); const out = flag === -1 ? null : process.argv[flag + 1]; if (flag !== -1 && !out) throw new Error("--out richiede un percorso file"); const file = path.join(process.cwd(), "scripts", "data", "checklist.json"); const voci: Voce[] = JSON.parse(readFileSync(file, "utf8")); if (!Array.isArray(voci) || voci.length === 0) { throw new Error(`checklist.json vuoto o non è un array: ${file}`); } // Validazione prima di scrivere: un valore fuori dai CHECK del DB farebbe // fallire la transazione a metà, dopo aver già scritto parte delle righe. const visti = new Map(); voci.forEach((v, i) => { if (!v.step || !v.testo) throw new Error(`voce ${i}: step o testo mancante`); if (!REGISTRI.has(v.registro)) { throw new Error(`voce ${i}: registro "${v.registro}" non ammesso`); } if (!Array.isArray(v.profili) || v.profili.length === 0) { throw new Error(`voce ${i}: profili vuoto`); } for (const p of v.profili) { if (!PROFILI.has(p)) throw new Error(`voce ${i}: profilo "${p}" non ammesso`); } const id = idVoce(v.step, v.testo); const gia = visti.get(id); if (gia !== undefined) { throw new Error( `voci ${gia} e ${i} hanno lo stesso (step, testo) — collisione di id:\n ${v.testo}` ); } visti.set(id, String(i)); }); console.error(`${voci.length} voci lette, nessun duplicato.`); const perStep = new Map(); for (const v of voci) perStep.set(v.step, (perStep.get(v.step) ?? 0) + 1); console.error( [...perStep.entries()] .sort((a, b) => b[1] - a[1]) .map(([s, n]) => ` ${s}: ${n}`) .join("\n") ); if (!out) { console.error("\nNiente scritto. Usa --out per generare le INSERT, es.:"); console.error(" npx tsx scripts/seed-checklist.ts --out /tmp/checklist.sql"); return; } // Si emette SQL invece di scrivere via Drizzle perché il Postgres di // produzione non è esposto pubblicamente: l'unico percorso è SSH + docker // exec, lo stesso delle migration. const righe = voci.map((v, i) => { const profili = v.profili as AuditProfilo[]; return [ q(idVoce(v.step, v.testo)), `${q(JSON.stringify(profili))}::jsonb`, q(v.step), q(v.sezione), q(v.focus), q(v.testo), n(v.impatto_default), n(v.confidenza_default), q(v.registro), String(i), ].join(", "); }); // ON CONFLICT DO UPDATE, mai DELETE: audit_checklist_results referenzia le // voci con RESTRICT e un audit consegnato deve restare leggibile per sempre. // Su file, non su stdout: la scrittura su pipe è asincrona e uscire prima // del flush tronca l'SQL a metà riga. writeFileSync( out, "-- Generato da scripts/seed-checklist.ts — non modificare a mano.\n" + "INSERT INTO checklist_items\n" + " (id, profili, step, sezione, focus, testo, impatto_default,\n" + " confidenza_default, registro, sort_order)\nVALUES\n" + righe.map((r) => ` (${r})`).join(",\n") + "\nON CONFLICT (id) DO UPDATE SET\n" + [ "profili", "step", "sezione", "focus", "testo", "impatto_default", "confidenza_default", "registro", "sort_order", ] .map((c) => ` ${c} = EXCLUDED.${c}`) .join(",\n") + ";\n" ); console.error(`\nScritto ${out}. Applicare con:`); console.error( ` cat ${out} | ssh root@… "docker exec -i … psql -U clienthub -d clienthub \\\n` + ` -v ON_ERROR_STOP=1 --single-transaction"` ); } /** Letterale stringa per Postgres, con gli apici raddoppiati. */ function q(v: string | null): string { return v === null ? "NULL" : `'${v.replace(/'/g, "''")}'`; } function n(v: number | null): string { return v === null ? "NULL" : String(v); } // NB: niente process.exit(0) in coda. Su stdout-pipe la scrittura è asincrona // e uscire subito tronca l'SQL a metà riga — è successo davvero, e solo il // --single-transaction di psql ha evitato un seed parziale in produzione. // Si esce da soli quando i buffer sono vuoti; process.exit resta solo sull'errore. main().catch((e) => { console.error(e); process.exit(1); });