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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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 }) => ({
|
||||
@@ -863,3 +1174,17 @@ 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 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;
|
||||
Reference in New Issue
Block a user