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:
+326
-1
@@ -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;
|
||||
Reference in New Issue
Block a user