feat(audit): schema del documento di restituzione (migration 0017)

Sette tabelle additive per la milestone v2.5 "Audit": audits, audit_findings,
audit_optimizations, checklist_items, audit_checklist_results, audit_runs e
audit_visits. Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa.

Tre scelte che vale la pena spiegare:

- Colonne scalari su audits, non un jsonb unico. A differenza di proposals non
  c'e' snapshot da congelare: il copy fisso sta in moduli TS versionati e
  l'editor mappa 1:1 sui campi. Tutti i campi di contenuto sono NULLABLE — e'
  cio' che rende possibile "si salva sempre, anche a meta'".
- checklist_items.profili e' jsonb: 71 voci su 264 valgono per entrambi i
  profili, una colonna singola costringerebbe a duplicarle.
- audit_checklist_results.esito ammette 'non_verificabile'. E' l'esito piu'
  frequente misurato sullo spike (107 su 204) e serve a sapere quanto il motore
  NON riesce a vedere: buttarlo via renderebbe impossibile misurare se le
  rilevazioni Lighthouse stanno recuperando terreno.

Migration gia' applicata in produzione il 2026-08-18, dati esistenti intatti.
CLAUDE.md annota la deroga al vincolo LOCKED #5, limitata agli asset di audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:48:11 +02:00
parent ee47f35e97
commit 31237da11c
3 changed files with 600 additions and 2 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ Next.js 16 App Router · Neon Postgres · Drizzle ORM · Auth.js v4 · Tailwind
3. `deliverables.approved_at` immutable once set
4. Auth: `/client/[token]/*` → middleware token check + gate OTP | `/admin/*` → Auth.js session.
**Unica deroga (Phase 26, 2026-08-08):** `getClientGate()` legge anche `getServerSession` per l'anteprima admin in sola lettura, e solo se `?preview=1` è presente. Non estendere questa lettura ad altre route client.
5. No file hosting v1 — documenti come URL esterni
5. No file hosting per i documenti — restano URL esterni.
**Unica deroga (Phase 27, 2026-08-18):** le immagini dell'audit (screenshot delle rilevazioni e redesign prima/dopo) sono caricate su volume persistente Coolify via Server Action e servite da `/api/uploads/[...path]`, con whitelist MIME e limite di dimensione. Non estendere l'upload ad altre entità senza modificare questo vincolo.
## Conventions
- **Mutations are Server Actions**, colocated as `actions.ts` (or `*-actions.ts`) inside the route folder. There is no REST API for admin: `src/app/api/` holds only NextAuth, the two internal validation routes, and two client endpoints.
+272
View File
@@ -0,0 +1,272 @@
-- Additive: documento di restituzione dell'audit (v2.5 Phase 27).
--
-- Sette tabelle nuove, nessuna esistente toccata. Il servizio di analisi sito
-- (tre livelli: Radiografia / Prima-Dopo / Rotta) diventa un documento privato
-- su /audit/[slug], generato da un motore multi-agente e rifinito a mano.
--
-- Impianto:
-- audits il record: config, rilevazioni, contenuto, stato
-- audit_findings blocco 4 — i problemi, ordinati per impatto
-- audit_optimizations blocco 7 — le ottimizzazioni (solo livello "rotta")
-- checklist_items la rubrica del motore (264 voci), NON il documento
-- audit_checklist_results esito voce per voce di un singolo audit
-- audit_runs esecuzioni del motore, con heartbeat e output grezzo
-- audit_visits registro delle aperture del documento consegnato
--
-- Tre scelte che vale la pena spiegare:
--
-- 1. Colonne scalari su audits, non un jsonb unico. A differenza di proposals
-- non c'e' snapshot da congelare: il copy fisso sta in moduli TS versionati
-- (template_version) e l'editor mappa 1:1 sui campi. Tutti i campi di
-- contenuto sono NULLABLE — e' cio' che rende possibile "si salva sempre,
-- anche a meta'". La validazione di completezza scatta solo alla consegna.
--
-- 2. checklist_items.profili e' jsonb, non un text singolo. Nel file sorgente
-- 71 voci su 264 valgono per ENTRAMBI i profili (ecommerce e servizi): una
-- colonna singola costringerebbe a duplicarle.
--
-- 3. audit_checklist_results.esito ammette anche 'non_verificabile'. E' l'esito
-- piu' frequente misurato sullo spike (107 voci su 204) e serve a sapere
-- quanto il motore NON riesce a vedere: buttarlo via renderebbe impossibile
-- misurare se le rilevazioni Lighthouse stanno recuperando terreno.
--
-- Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa o modificata.
-- Applicare a prod via SSH+docker exec PRIMA di pushare il codice dipendente.
-- Idempotente: safe to re-run.
-- ---------------------------------------------------------------- audits
CREATE TABLE IF NOT EXISTS audits (
id text PRIMARY KEY,
slug text NOT NULL UNIQUE,
-- Provenienza. Entrambi nullable e SET NULL: un audit consegnato non deve
-- sparire se il lead viene rimosso.
lead_id text REFERENCES leads(id) ON DELETE SET NULL,
client_id text REFERENCES clients(id) ON DELETE SET NULL,
-- Configurazione. Il livello determina quali blocchi esistono nel DOM.
livello text NOT NULL CHECK (livello IN ('radiografia', 'prima_dopo', 'rotta')),
template_version text NOT NULL DEFAULT 'v1',
profilo text NOT NULL DEFAULT 'ecommerce' CHECK (profilo IN ('ecommerce', 'servizi')),
cliente_nome text,
cliente_referente text,
sito_url text NOT NULL,
importo_pagato numeric(10, 2),
data_consegna date,
-- Ingresso: manuale ora, webhook Whop dopo. Predisposto, non costruito.
origin text NOT NULL DEFAULT 'manuale' CHECK (origin IN ('manuale', 'whop')),
external_ref text,
-- Stato. Etichettati "Bozza" / "Consegnata" nella UI. Entrambe le
-- transizioni sono reversibili.
state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'published')),
published_at timestamptz,
-- Tracking di sintesi. Il dettaglio sta in audit_visits; queste tre sono la
-- lettura veloce per la lista admin, aggiornate insieme alla riga di visita.
first_viewed_at timestamptz,
last_viewed_at timestamptz,
view_count integer NOT NULL DEFAULT 0,
-- Rilevazioni (blocco 3). perf_* sono i punteggi Lighthouse 0-100.
-- I campi *_field vengono da CrUX e sono dati di utenti REALI: restano NULL
-- quando il sito non ha traffico sufficiente, e quel NULL e' esso stesso
-- un'informazione da dire nel documento, non un buco da nascondere.
perf_mobile integer,
perf_desktop integer,
lcp numeric(6, 2),
cls numeric(5, 3),
inp integer,
lcp_field numeric(6, 2),
cls_field numeric(5, 3),
inp_field integer,
pagine_indicizzate integer,
screenshot_desktop_url text,
screenshot_mobile_url text,
measured_at timestamptz,
-- Contenuto (blocchi 2, 2b, 5, 8). punti_forza = lista, blocco 2b.
-- direzione (blocco 8) resta MANUALE: e' il blocco che giustifica il prezzo.
sintesi text,
punti_forza jsonb,
analisi_struttura text,
analisi_messaggio text,
analisi_conversione text,
direzione text,
-- Redesign (blocchi 6 e 6b). Le due immagini le carica l'utente a mano.
-- Il link Figma e' opzionale e non finisce nel PDF (un iframe in stampa non
-- produce nulla): le immagini restano la rappresentazione canonica.
redesign_sezione text,
redesign_prima_url text,
redesign_dopo_url text,
redesign_razionale text,
redesign_limiti text,
redesign_figma_url text,
-- Dati condivisi dal cliente. Forma ancora da definire: jsonb accoglie
-- qualunque forma prendera' senza una migration aggiuntiva.
intake jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audits_client_id_idx ON audits (client_id);
CREATE INDEX IF NOT EXISTS audits_lead_id_idx ON audits (lead_id);
CREATE INDEX IF NOT EXISTS audits_state_idx ON audits (state);
-- ---------------------------------------------------------- audit_findings
-- Blocco 4. Regola di collocazione: se una cosa ha un impatto E una
-- conseguenza, e' un finding e sta qui, numerata e ordinata. Il blocco 5
-- (analisi) resta discorsivo e resta a tre aree. La forza del documento e'
-- la selezione: una seconda lista non ordinata la annullerebbe.
CREATE TABLE IF NOT EXISTS audit_findings (
id text PRIMARY KEY,
audit_id text NOT NULL REFERENCES audits(id) ON DELETE CASCADE,
titolo text NOT NULL,
-- Tre soli valori. La sfumatura sta nell'ordine DENTRO il gruppo
-- (sort_order): con cinque gradazioni l'ordinamento automatico non funziona.
impatto text NOT NULL CHECK (impatto IN ('alto', 'medio', 'basso')),
area text NOT NULL CHECK (area IN ('struttura', 'messaggio', 'conversione', 'performance')),
descrizione text,
conseguenza text,
screenshot_url text,
sort_order integer NOT NULL DEFAULT 0,
origin text NOT NULL DEFAULT 'agent' CHECK (origin IN ('agent', 'manuale')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_findings_audit_sort_idx
ON audit_findings (audit_id, sort_order);
-- ----------------------------------------------------- audit_optimizations
-- Blocco 7, solo livello "rotta". impegno = stima in giornate.
CREATE TABLE IF NOT EXISTS audit_optimizations (
id text PRIMARY KEY,
audit_id text NOT NULL REFERENCES audits(id) ON DELETE CASCADE,
intervento text NOT NULL,
priorita text NOT NULL CHECK (priorita IN ('alta', 'media', 'bassa')),
motivazione text,
impegno text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_optimizations_audit_sort_idx
ON audit_optimizations (audit_id, sort_order);
-- --------------------------------------------------------- checklist_items
-- La rubrica del MOTORE, non la struttura del documento. Ogni voce e'
-- un'asserzione binaria e falsificabile ("Il checkout consente l'acquisto come
-- ospite"): verificarne 264 e' molto piu' affidabile che chiedere a un modello
-- "analizza questo sito". Nel documento la checklist non compare mai come
-- elenco — al massimo il grado di conformita' per step di funnel.
--
-- registro: molte voci sono tattiche da ecommerce a volume (scarsita', urgenza,
-- countdown). Su un brand premium DANNEGGIANO — abbassano il segnale di prezzo
-- mentre il cliente vende il contrario. L'audit di un brand premium non deve
-- proporre le voci 'volume'.
--
-- Le voci non si cancellano mai: audit_checklist_results le referenzia con
-- RESTRICT, e un audit consegnato deve restare leggibile per sempre. Correggere
-- la rubrica significa aggiungere voci, non riscrivere le vecchie.
CREATE TABLE IF NOT EXISTS checklist_items (
id text PRIMARY KEY,
-- Array dei profili a cui la voce si applica, es. ["ecommerce","servizi"].
profili jsonb NOT NULL,
step text NOT NULL,
sezione text,
focus text,
testo text NOT NULL,
impatto_default numeric(3, 1),
confidenza_default numeric(3, 1),
registro text NOT NULL DEFAULT 'neutro'
CHECK (registro IN ('volume', 'premium', 'neutro')),
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS checklist_items_step_idx ON checklist_items (step);
-- ------------------------------------------------- audit_checklist_results
-- 'non_verificabile' e' un esito di prima classe, non un errore: dice che
-- l'informazione non era raggiungibile con le fonti disponibili. E' la misura
-- che dice se il motore sta migliorando.
CREATE TABLE IF NOT EXISTS audit_checklist_results (
id text PRIMARY KEY,
audit_id text NOT NULL REFERENCES audits(id) ON DELETE CASCADE,
item_id text NOT NULL REFERENCES checklist_items(id) ON DELETE RESTRICT,
esito text NOT NULL CHECK (
esito IN ('conforme', 'non_conforme', 'non_rilevante', 'non_verificabile')
),
note text,
evidenza text,
origin text NOT NULL DEFAULT 'agent' CHECK (origin IN ('agent', 'manuale')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS audit_checklist_results_audit_item_idx
ON audit_checklist_results (audit_id, item_id);
-- -------------------------------------------------------------- audit_runs
-- Esecuzioni del motore. Con output: "standalone" su singolo container Coolify
-- un redeploy UCCIDE un job in corso e lascerebbe una riga bloccata su
-- 'running': heartbeat_at viene aggiornato a ogni passo, le run senza battito
-- da N minuti vanno in 'error', e "Rilancia" riparte dall'ultimo passo
-- completato. Non e' un sistema a code: e' deliberatamente il minimo che regge
-- ~50 audit/anno.
--
-- raw: output grezzo di tutte le fonti e di tutti i sub-agent. Serve al blocco 8
-- (materiale a fianco del foglio bianco) ed e' la fonte di verita' per la
-- disciplina sui numeri — ogni numero nel documento consegnato deve essere
-- rintracciabile qui dentro.
CREATE TABLE IF NOT EXISTS audit_runs (
id text PRIMARY KEY,
audit_id text NOT NULL REFERENCES audits(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'done', 'error')),
step text,
started_at timestamptz,
finished_at timestamptz,
heartbeat_at timestamptz,
error text,
raw jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_runs_audit_started_idx
ON audit_runs (audit_id, started_at DESC);
-- ------------------------------------------------------------ audit_visits
-- Registro delle aperture del documento consegnato. Serve a sapere se il
-- cliente l'ha aperto tre volte in due giorni o una volta e mai piu' — e'
-- informazione commerciale, non statistica.
--
-- L'IP NON si salva in chiaro: ip_hash e' SHA-256 di (ip + NEXTAUTH_SECRET).
-- Serve a distinguere due aperture dello stesso lettore da due lettori
-- diversi, non a identificare qualcuno.
--
-- L'anteprima admin non scrive qui: la server action controlla la sessione
-- Auth.js e, se c'e', non registra nulla. Altrimenti i numeri li inquineremmo
-- noi stessi rileggendo le bozze.
CREATE TABLE IF NOT EXISTS audit_visits (
id text PRIMARY KEY,
audit_id text NOT NULL REFERENCES audits(id) ON DELETE CASCADE,
occurred_at timestamptz NOT NULL DEFAULT now(),
event text NOT NULL DEFAULT 'view' CHECK (event IN ('view', 'print')),
referrer text,
user_agent text,
ip_hash text
);
CREATE INDEX IF NOT EXISTS audit_visits_audit_occurred_idx
ON audit_visits (audit_id, occurred_at DESC);
+326 -1
View File
@@ -4,6 +4,7 @@ import {
integer,
numeric,
timestamp,
date,
boolean,
jsonb,
primaryKey,
@@ -624,12 +625,322 @@ export const proposals = pgTable("proposals", {
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// ============ AUDIT (v2.5 Phase 27) ============
// Documento di restituzione del servizio di analisi sito, su /audit/[slug].
// Tre livelli che sono CONFIGURAZIONI di un unico documento: i blocchi non
// pertinenti non esistono nel DOM, non sono nascosti via CSS.
// Migration: 0017_audits.sql (i CHECK vivono lì, non qui).
export const AUDIT_LIVELLI = ["radiografia", "prima_dopo", "rotta"] as const;
export type AuditLivello = (typeof AUDIT_LIVELLI)[number];
export const AUDIT_PROFILI = ["ecommerce", "servizi"] as const;
export type AuditProfilo = (typeof AUDIT_PROFILI)[number];
export const AUDIT_STATES = ["draft", "published"] as const;
export type AuditState = (typeof AUDIT_STATES)[number];
export const AUDIT_IMPATTI = ["alto", "medio", "basso"] as const;
export type AuditImpatto = (typeof AUDIT_IMPATTI)[number];
export const AUDIT_AREE = ["struttura", "messaggio", "conversione", "performance"] as const;
export type AuditArea = (typeof AUDIT_AREE)[number];
export const CHECKLIST_ESITI = [
"conforme",
"non_conforme",
"non_rilevante",
"non_verificabile",
] as const;
export type ChecklistEsito = (typeof CHECKLIST_ESITI)[number];
export const CHECKLIST_REGISTRI = ["volume", "premium", "neutro"] as const;
export type ChecklistRegistro = (typeof CHECKLIST_REGISTRI)[number];
export const AUDIT_RUN_STATUSES = ["queued", "running", "done", "error"] as const;
export type AuditRunStatus = (typeof AUDIT_RUN_STATUSES)[number];
export const audits = pgTable(
"audits",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
slug: text("slug")
.notNull()
.unique()
.$defaultFn(() => nanoid()),
lead_id: text("lead_id").references(() => leads.id, { onDelete: "set null" }),
client_id: text("client_id").references(() => clients.id, { onDelete: "set null" }),
// Config — il livello determina quali blocchi esistono
livello: text("livello").notNull(), // radiografia | prima_dopo | rotta
// Congelata alla creazione: un audit consegnato resta sulla sua versione
// per sempre, così migliorare il documento non retro-modifica i consegnati.
template_version: text("template_version").notNull().default("v1"),
profilo: text("profilo").notNull().default("ecommerce"), // ecommerce | servizi
cliente_nome: text("cliente_nome"),
cliente_referente: text("cliente_referente"),
sito_url: text("sito_url").notNull(),
importo_pagato: numeric("importo_pagato", { precision: 10, scale: 2 }),
data_consegna: date("data_consegna"),
// Ingresso: manuale ora, webhook Whop dopo (predisposto, non costruito)
origin: text("origin").notNull().default("manuale"), // manuale | whop
external_ref: text("external_ref"),
state: text("state").notNull().default("draft"), // draft | published
published_at: timestamp("published_at", { withTimezone: true }),
// Tracking di sintesi — il dettaglio sta in audit_visits
first_viewed_at: timestamp("first_viewed_at", { withTimezone: true }),
last_viewed_at: timestamp("last_viewed_at", { withTimezone: true }),
view_count: integer("view_count").notNull().default(0),
// Rilevazioni (blocco 3). I campi *_field vengono da CrUX e sono dati di
// utenti REALI: NULL quando il sito non ha traffico sufficiente perché
// Google li raccolga — ed è un'informazione da dire, non un buco.
perf_mobile: integer("perf_mobile"),
perf_desktop: integer("perf_desktop"),
lcp: numeric("lcp", { precision: 6, scale: 2 }),
cls: numeric("cls", { precision: 5, scale: 3 }),
inp: integer("inp"),
lcp_field: numeric("lcp_field", { precision: 6, scale: 2 }),
cls_field: numeric("cls_field", { precision: 5, scale: 3 }),
inp_field: integer("inp_field"),
pagine_indicizzate: integer("pagine_indicizzate"),
// Scritti dalla pipeline, non dall'utente: arrivano dallo screenshot
// renderizzato di PageSpeed.
screenshot_desktop_url: text("screenshot_desktop_url"),
screenshot_mobile_url: text("screenshot_mobile_url"),
measured_at: timestamp("measured_at", { withTimezone: true }),
// Contenuto (blocchi 2, 2b, 5, 8)
sintesi: text("sintesi"),
punti_forza: jsonb("punti_forza"), // lista, blocco 2b "Cosa funziona già"
analisi_struttura: text("analisi_struttura"),
analisi_messaggio: text("analisi_messaggio"),
analisi_conversione: text("analisi_conversione"),
// Blocco 8: MANUALE, foglio bianco. È il blocco che giustifica il prezzo —
// se diventa formula, il cliente lo sente.
direzione: text("direzione"),
// Redesign (blocchi 6 e 6b) — le due immagini le carica l'utente
redesign_sezione: text("redesign_sezione"),
redesign_prima_url: text("redesign_prima_url"),
redesign_dopo_url: text("redesign_dopo_url"),
redesign_razionale: text("redesign_razionale"),
redesign_limiti: text("redesign_limiti"), // blocco 6b
redesign_figma_url: text("redesign_figma_url"),
intake: jsonb("intake"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index("audits_client_id_idx").on(table.client_id),
index("audits_lead_id_idx").on(table.lead_id),
index("audits_state_idx").on(table.state),
]
);
// Blocco 4 — i problemi. Tre soli livelli di impatto: la sfumatura sta
// nell'ordine dentro il gruppo (sort_order), perché con cinque gradazioni
// l'ordinamento automatico su tre non funziona.
export const audit_findings = pgTable(
"audit_findings",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
audit_id: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
titolo: text("titolo").notNull(),
impatto: text("impatto").notNull(), // alto | medio | basso
area: text("area").notNull(), // struttura | messaggio | conversione | performance
descrizione: text("descrizione"),
conseguenza: text("conseguenza"),
screenshot_url: text("screenshot_url"),
sort_order: integer("sort_order").notNull().default(0),
origin: text("origin").notNull().default("agent"), // agent | manuale
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("audit_findings_audit_sort_idx").on(table.audit_id, table.sort_order)]
);
// Blocco 7 — solo livello "rotta". impegno = stima in giornate.
export const audit_optimizations = pgTable(
"audit_optimizations",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
audit_id: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
intervento: text("intervento").notNull(),
priorita: text("priorita").notNull(), // alta | media | bassa
motivazione: text("motivazione"),
impegno: text("impegno"),
sort_order: integer("sort_order").notNull().default(0),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index("audit_optimizations_audit_sort_idx").on(table.audit_id, table.sort_order),
]
);
// La rubrica del MOTORE, non la struttura del documento. Le voci non si
// cancellano mai (audit_checklist_results le referenzia con RESTRICT): un
// audit consegnato deve restare leggibile per sempre.
export const checklist_items = pgTable(
"checklist_items",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
// Array: 71 voci su 264 valgono per ENTRAMBI i profili, quindi una colonna
// singola costringerebbe a duplicarle. Es. ["ecommerce","servizi"].
profili: jsonb("profili").notNull().$type<AuditProfilo[]>(),
step: text("step").notNull(),
sezione: text("sezione"),
focus: text("focus"),
testo: text("testo").notNull(),
impatto_default: numeric("impatto_default", { precision: 3, scale: 1 }),
confidenza_default: numeric("confidenza_default", { precision: 3, scale: 1 }),
// Le voci 'volume' (scarsità, urgenza, countdown) DANNEGGIANO un brand
// premium: abbassano il segnale di prezzo mentre il cliente vende il
// contrario. Un audit premium non le propone mai.
registro: text("registro").notNull().default("neutro"), // volume | premium | neutro
sort_order: integer("sort_order").notNull().default(0),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("checklist_items_step_idx").on(table.step)]
);
// 'non_verificabile' è un esito di prima classe, non un errore: è la misura
// che dice quanto il motore NON riesce a vedere.
export const audit_checklist_results = pgTable(
"audit_checklist_results",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
audit_id: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
item_id: text("item_id")
.notNull()
.references(() => checklist_items.id, { onDelete: "restrict" }),
esito: text("esito").notNull(), // conforme | non_conforme | non_rilevante | non_verificabile
note: text("note"),
evidenza: text("evidenza"),
origin: text("origin").notNull().default("agent"), // agent | manuale
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("audit_checklist_results_audit_item_idx").on(table.audit_id, table.item_id),
]
);
// Esecuzioni del motore. heartbeat_at è la mitigazione del redeploy che uccide
// un job in corso: senza battito da N minuti la run va in 'error' e "Rilancia"
// riparte dall'ultimo passo completato.
// raw = output grezzo di tutte le fonti e di tutti i sub-agent. È la fonte di
// verità della disciplina sui numeri: ogni numero nel documento consegnato
// deve essere rintracciabile qui dentro.
export const audit_runs = pgTable(
"audit_runs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
audit_id: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
status: text("status").notNull().default("queued"), // queued | running | done | error
step: text("step"),
started_at: timestamp("started_at", { withTimezone: true }),
finished_at: timestamp("finished_at", { withTimezone: true }),
heartbeat_at: timestamp("heartbeat_at", { withTimezone: true }),
error: text("error"),
raw: jsonb("raw"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("audit_runs_audit_started_idx").on(table.audit_id, table.started_at)]
);
// Registro delle aperture. L'IP non si salva in chiaro: ip_hash è SHA-256 di
// (ip + NEXTAUTH_SECRET) — serve a distinguere due aperture dello stesso
// lettore da due lettori diversi, non a identificare qualcuno.
// L'anteprima admin NON scrive qui (la server action controlla la sessione).
export const audit_visits = pgTable(
"audit_visits",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
audit_id: text("audit_id")
.notNull()
.references(() => audits.id, { onDelete: "cascade" }),
occurred_at: timestamp("occurred_at", { withTimezone: true }).notNull().defaultNow(),
event: text("event").notNull().default("view"), // view | print
referrer: text("referrer"),
user_agent: text("user_agent"),
ip_hash: text("ip_hash"),
},
(table) => [index("audit_visits_audit_occurred_idx").on(table.audit_id, table.occurred_at)]
);
// ============ RELATIONS ============
export const clientsRelations = relations(clients, ({ many }) => ({
projects: many(projects),
transcripts: many(clientTranscripts),
proposals: many(proposals),
audits: many(audits),
}));
export const auditsRelations = relations(audits, ({ one, many }) => ({
client: one(clients, { fields: [audits.client_id], references: [clients.id] }),
lead: one(leads, { fields: [audits.lead_id], references: [leads.id] }),
findings: many(audit_findings),
optimizations: many(audit_optimizations),
checklistResults: many(audit_checklist_results),
runs: many(audit_runs),
visits: many(audit_visits),
}));
export const auditFindingsRelations = relations(audit_findings, ({ one }) => ({
audit: one(audits, { fields: [audit_findings.audit_id], references: [audits.id] }),
}));
export const auditOptimizationsRelations = relations(audit_optimizations, ({ one }) => ({
audit: one(audits, { fields: [audit_optimizations.audit_id], references: [audits.id] }),
}));
export const auditChecklistResultsRelations = relations(
audit_checklist_results,
({ one }) => ({
audit: one(audits, {
fields: [audit_checklist_results.audit_id],
references: [audits.id],
}),
item: one(checklist_items, {
fields: [audit_checklist_results.item_id],
references: [checklist_items.id],
}),
})
);
export const auditRunsRelations = relations(audit_runs, ({ one }) => ({
audit: one(audits, { fields: [audit_runs.audit_id], references: [audits.id] }),
}));
export const auditVisitsRelations = relations(audit_visits, ({ one }) => ({
audit: one(audits, { fields: [audit_visits.audit_id], references: [audits.id] }),
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
@@ -862,4 +1173,18 @@ export type NewProposal = typeof proposals.$inferInsert;
export type ClientEmail = typeof client_emails.$inferSelect;
export type NewClientEmail = typeof client_emails.$inferInsert;
export type OtpCode = typeof otp_codes.$inferSelect;
export type NewOtpCode = typeof otp_codes.$inferInsert;
export type NewOtpCode = typeof otp_codes.$inferInsert;
export type Audit = typeof audits.$inferSelect;
export type NewAudit = typeof audits.$inferInsert;
export type AuditFinding = typeof audit_findings.$inferSelect;
export type NewAuditFinding = typeof audit_findings.$inferInsert;
export type AuditOptimization = typeof audit_optimizations.$inferSelect;
export type NewAuditOptimization = typeof audit_optimizations.$inferInsert;
export type ChecklistItem = typeof checklist_items.$inferSelect;
export type NewChecklistItem = typeof checklist_items.$inferInsert;
export type AuditChecklistResult = typeof audit_checklist_results.$inferSelect;
export type NewAuditChecklistResult = typeof audit_checklist_results.$inferInsert;
export type AuditRun = typeof audit_runs.$inferSelect;
export type NewAuditRun = typeof audit_runs.$inferInsert;
export type AuditVisit = typeof audit_visits.$inferSelect;
export type NewAuditVisit = typeof audit_visits.$inferInsert;