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
+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;