diff --git a/CLAUDE.md b/CLAUDE.md index 2069111..ac90017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/db/migrations/0017_audits.sql b/src/db/migrations/0017_audits.sql new file mode 100644 index 0000000..1dfd8e5 --- /dev/null +++ b/src/db/migrations/0017_audits.sql @@ -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); diff --git a/src/db/schema.ts b/src/db/schema.ts index 427c89e..979c4e4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), + 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; \ No newline at end of file +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; \ No newline at end of file