import { pgTable, text, integer, numeric, timestamp, date, boolean, jsonb, primaryKey, uniqueIndex, index, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { nanoid } from "nanoid"; // ============ CLIENTS ============ export const clients = pgTable("clients", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), name: text("name").notNull(), brand_name: text("brand_name").notNull(), brief: text("brief").notNull(), // Contact fields (Phase v2.3) — carried over from a converted lead and used // by the Email & Accesso (OTP) milestone. Admin-only, never exposed publicly. email: text("email"), phone: text("phone"), // token is SEPARATE from id — rotatable secret for client link access token: text("token") .notNull() .unique() .$defaultFn(() => nanoid()), // slug è opzionale, univoco, URL-safe (es. mario-rossi) — se assente, il link usa il token slug: text("slug").unique(), // accepted_total rimane per compatibilità — il valore authoritative si sposta su projects accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default( "0" ), archived: boolean("archived").notNull().default(false), // Conversazioni inbox: timestamp of the admin's last read of this client's // conversation. NULL = never read (treated as unread). Set via markConversationRead. admin_last_read_at: timestamp("admin_last_read_at", { withTimezone: true }), // OTP gate (v2.3): revoca in blocco delle sessioni portale già emesse. // Una sessione è valida solo se firmata DOPO questo istante. NULL = mai revocate. sessions_valid_from: timestamp("sessions_valid_from", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ CLIENT ACCESS (OTP) ============ // Whitelist admin-gestita: nessuna auto-registrazione. Un cliente può avere più // email (i soci del progetto accedono allo stesso portale). export const client_emails = pgTable( "client_emails", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), client_id: text("client_id") .notNull() .references(() => clients.id, { onDelete: "cascade" }), email: text("email").notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (table) => [ // L'indice reale è su (client_id, lower(email)) — vedi 0015_otp_access.sql. // Drizzle non modella le expression index: qui serve solo a documentarlo. uniqueIndex("client_emails_client_email_idx").on(table.client_id, table.email), ] ); // Codici OTP emessi. Si persiste solo l'hash: il codice in chiaro vive nell'email. export const otp_codes = pgTable( "otp_codes", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), client_id: text("client_id") .notNull() .references(() => clients.id, { onDelete: "cascade" }), email: text("email").notNull(), code_hash: text("code_hash").notNull(), expires_at: timestamp("expires_at", { withTimezone: true }).notNull(), consumed_at: timestamp("consumed_at", { withTimezone: true }), attempts: integer("attempts").notNull().default(0), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (table) => [index("otp_codes_client_email_idx").on(table.client_id, table.email)] ); // ============ PROJECTS ============ export const projects = pgTable("projects", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), client_id: text("client_id") .notNull() .references(() => clients.id, { onDelete: "cascade" }), name: text("name").notNull(), // brand/project name accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), archived: boolean("archived").notNull().default(false), offer_id: text("offer_id") .references(() => offer_micros.id, { onDelete: "set null" }), created_from_lead_id: text("created_from_lead_id"), // Consegna attesa. Normalmente si DERIVA da project_offers.start_date + // offer_micros.duration_months; questa colonna è l'override manuale e vince // sulla derivata quando è valorizzata (migration 0018). due_date: timestamp("due_date", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // ============ PHASES ============ export const phases = pgTable("phases", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), title: text("title").notNull(), sort_order: integer("sort_order").notNull().default(0), status: text("status").notNull().default("upcoming"), // upcoming | active | done offer_phase_id: text("offer_phase_id") .references(() => offer_phases.id, { onDelete: "set null" }), }); // ============ TASKS ============ export const tasks = pgTable("tasks", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), phase_id: text("phase_id") .notNull() .references(() => phases.id, { onDelete: "cascade" }), title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("todo"), // todo | in_progress | done sort_order: integer("sort_order").notNull().default(0), }); // ============ DELIVERABLES ============ export const deliverables = pgTable("deliverables", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), task_id: text("task_id") .notNull() .references(() => tasks.id, { onDelete: "cascade" }), title: text("title").notNull(), url: text("url"), // external link only — no file hosting in v1 status: text("status").notNull().default("pending"), // pending | submitted | approved // approved_at is IMMUTABLE once set — audit trail, cannot be unset by client approved_at: timestamp("approved_at", { withTimezone: true }), }); // ============ COMMENTS ============ export const comments = pgTable("comments", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), entity_type: text("entity_type").notNull(), // task | deliverable entity_id: text("entity_id").notNull(), author: text("author").notNull(), // client | admin body: text("body").notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ TAGS (polymorphic — services now, leads in Phase 14) ============ // entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are // separate pools even though they share this table. No `color` column — badge // color is derived deterministically from `name` via hash (D-07). export const tags = pgTable( "tags", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14) entity_id: text("entity_id").notNull(), name: text("name").notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ entityTagUnique: uniqueIndex("tags_entity_name_unique").on( t.entity_type, t.entity_id, t.name ), entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id), }) ); // ============ PAYMENTS ============ export const payments = pgTable("payments", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%" amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato paid_at: timestamp("paid_at", { withTimezone: true }), }); // ============ DOCUMENTS ============ export const documents = pgTable("documents", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), label: text("label").notNull(), url: text("url").notNull(), // external URL only — no file hosting in v1 created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ NOTES (Decision Log — admin writes, client reads) ============ export const notes = pgTable("notes", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), body: text("body").notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ TIME ENTRIES (admin time tracking per project) ============ // project_id è obbligatoria: ogni entry è sempre attribuita a un progetto. // phase_id/task_id (migration 0018) sono il dettaglio facoltativo — NULL su // tutte le righe precedenti, che sono "tempo di progetto" e restano tali. // // ON DELETE SET NULL, non cascade: cancellare un task non deve cancellare le // ore lavorate su di esso. L'entry ricade a livello progetto, il totale non // cambia mai. export const time_entries = pgTable( "time_entries", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), phase_id: text("phase_id").references(() => phases.id, { onDelete: "set null" }), task_id: text("task_id").references(() => tasks.id, { onDelete: "set null" }), started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), ended_at: timestamp("ended_at", { withTimezone: true }), duration_seconds: integer("duration_seconds"), // set on stop }, (t) => [ index("time_entries_phase_idx").on(t.phase_id), index("time_entries_task_idx").on(t.task_id), ] ); // ============ SERVICE CATALOG (admin-only, used for quote generation) ============ export const service_catalog = pgTable("service_catalog", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), name: text("name").notNull(), description: text("description"), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), active: boolean("active").notNull().default(true), }); // ============ SERVICES (UNIFIED CATALOG — replaces service_catalog + offer_services) ============ // migrated_from/migrated_id provide audit trail for safe rollback during the // expand-contract migration (Phase 7 expand; Phase 8 begins contract on offer side). export const services = pgTable("services", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), name: text("name").notNull(), description: text("description"), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), category: text("category"), // single-select option (shared pool derived from distinct values) fase: text("fase"), // single-select option — offer lifecycle phase (Notion-style) active: boolean("active").notNull().default(true), migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null (new rows after Phase 7) migrated_id: text("migrated_id"), // original id from source table, null for new rows created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const servicesRelations = relations(services, (_) => ({ // No FK relations yet in Phase 7 — quote_items and offer_micro_services // continue to reference service_catalog/offer_services until Phase 8. })); // ============ QUOTE ITEMS (admin-only — NEVER exposed via client API) ============ export const quote_items = pgTable("quote_items", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .references(() => projects.id, { onDelete: "cascade" }), // legacy: for old quote_items tied to projects (Phase 1-8) quote_id: text("quote_id") .references(() => quotes.id, { onDelete: "cascade" }), // which quote owns this item (nullable for legacy) offer_phase_id: text("offer_phase_id") .references(() => offer_phases.id, { onDelete: "restrict" }), // which phase this service is in (nullable for legacy) service_id: text("service_id") .references(() => services.id, { onDelete: "restrict" }), // nullable for custom items quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), // snapshot at time of quote subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), custom_label: text("custom_label"), // for custom items without catalog entry created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ SETTINGS (global admin settings — key-value store) ============ export const settings = pgTable("settings", { key: text("key").primaryKey(), value: text("value").notNull(), updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); // ============ OFFER MACROS ============ // Phase 12 additive columns (Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico): // description, category, ticket, is_archived, and the 5 structured // transformation-promise fields (cliente_ideale/risultato/tempo/pain/metodo). // transformation_promise (legacy, free-text) stays untouched. export const offer_macros = pgTable("offer_macros", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), internal_name: text("internal_name").notNull(), public_name: text("public_name").notNull(), transformation_promise: text("transformation_promise"), // Phase 12 additive fields below description: text("description"), // short description shown on offer cards category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18 ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15 offer_type: text("offer_type").notNull().default("una_tantum"), // una_tantum | retainer (v2.3) is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]" risultato: text("risultato"), // "A ottenere: [Risultato]" tempo: text("tempo"), // "In: [tempo]" pain: text("pain"), // "Senza: [Pain]" metodo: text("metodo"), // "Grazie a: [Metodo]" sort_order: integer("sort_order").notNull().default(0), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // ============ OFFER MICROS ============ export const offer_micros = pgTable("offer_micros", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), macro_id: text("macro_id") .notNull() .references(() => offer_macros.id, { onDelete: "cascade" }), internal_name: text("internal_name").notNull(), public_name: text("public_name").notNull(), transformation_promise: text("transformation_promise"), duration_months: integer("duration_months").notNull().default(1), sort_order: integer("sort_order").notNull().default(0), // Phase 12 additive fields below. // tier_letter: nullable, values constrained to 'A'|'B'|'C' via a CHECK // constraint at the SQL level (migration 0008) — not enforced in Drizzle // (no native CHECK helper in this project's pg-core version); validated in // Zod at the server-action layer (Plan 03). tier_letter: text("tier_letter"), // public_price: manual public price per tier (D-5/OFFER-16), independent of // the computed services total (computed at query time, never stored). public_price: numeric("public_price", { precision: 10, scale: 2 }), }); // ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============ export const offer_services = pgTable("offer_services", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), name: text("name").notNull(), price: numeric("price", { precision: 10, scale: 2 }).notNull(), transformation_description: text("transformation_description"), active: boolean("active").notNull().default(true), }); // ============ OFFER MICRO SERVICES (junction: offer_micros <-> offer_services) ============ export const offer_micro_services = pgTable( "offer_micro_services", { micro_id: text("micro_id") .notNull() .references(() => offer_micros.id, { onDelete: "cascade" }), service_id: text("service_id") .notNull() .references(() => offer_services.id, { onDelete: "cascade" }), }, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }), }) ); // ============ OFFER TIER SERVICES (Phase 12 — junction: offer_micros <-> services) ============ // Additive replacement for the legacy offer_micro_services (which points to the // deprecated offer_services table). This junction connects a tier (offer_micros row // with tier_letter set) to the unified services catalog (Phase 11). Do NOT modify // offer_micro_services — it remains untouched for legacy data. export const offer_tier_services = pgTable( "offer_tier_services", { tier_id: text("tier_id") .notNull() .references(() => offer_micros.id, { onDelete: "cascade" }), service_id: text("service_id") .notNull() .references(() => services.id, { onDelete: "cascade" }), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ pk: primaryKey({ columns: [t.tier_id, t.service_id] }), tierIdx: index("offer_tier_services_tier_idx").on(t.tier_id), }) ); // ============ PROJECT OFFERS (assignment of micro-offer to project) ============ export const project_offers = pgTable("project_offers", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), micro_id: text("micro_id") .notNull() .references(() => offer_micros.id, { onDelete: "restrict" }), // NOT NULL with defaultNow — required for forecast computation (null start_date breaks forecast) start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(), // Offer-level accepted total — separate from projects.accepted_total (quote builder total) accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), // Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato. // Rilevante soprattutto per i retainer, che senza uno stato restavano nel // forecast per sempre. CHECK sui valori in 0016_retainer_lifecycle.sql. status: text("status").notNull().default("attivo"), // NULL = continuativo (semantica implicita prima della 0016). end_date: timestamp("end_date", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); /** Stati del ciclo di vita di un'offerta assegnata a un progetto. */ export const PROJECT_OFFER_STATUSES = ["attivo", "sospeso", "cessato"] as const; export type ProjectOfferStatus = (typeof PROJECT_OFFER_STATUSES)[number]; // ============ OFFER PHASES (hierarchical breakdown of offer micros) ============ export const offer_phases = pgTable("offer_phases", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), micro_id: text("micro_id") .notNull() .references(() => offer_micros.id, { onDelete: "cascade" }), title: text("title").notNull(), description: text("description"), sort_order: integer("sort_order").notNull().default(0), duration_weeks: integer("duration_weeks"), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ OFFER PHASE SERVICES (junction: offer_phases <-> services) ============ export const offer_phase_services = pgTable( "offer_phase_services", { phase_id: text("phase_id") .notNull() .references(() => offer_phases.id, { onDelete: "cascade" }), service_id: text("service_id") .notNull() .references(() => services.id, { onDelete: "cascade" }), }, (t) => ({ pk: primaryKey({ columns: [t.phase_id, t.service_id] }), }) ); // ============ QUOTES (separate header from line items) ============ export const quotes = pgTable("quotes", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), lead_id: text("lead_id") .references(() => leads.id, { onDelete: "cascade" }), client_id: text("client_id") .references(() => clients.id, { onDelete: "cascade" }), token: text("token") .notNull() .unique() .$defaultFn(() => nanoid()), offer_micro_id: text("offer_micro_id") .notNull() .references(() => offer_micros.id, { onDelete: "restrict" }), state: text("state").notNull().default("draft"), // draft | sent | viewed | accepted | rejected accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).notNull(), accepted_at: timestamp("accepted_at", { withTimezone: true }), // immutable once set; NULL means not yet accepted client_email: text("client_email"), // captured on accept client_notes: text("client_notes"), // captured on accept created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ LEADS TABLE (CRM — Phase 10) ============ export const leads = pgTable("leads", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), name: text("name").notNull(), email: text("email"), phone: text("phone"), company: text("company"), status: text("status") .notNull() .default("contacted"), // contacted | qualified | proposal_sent | negotiating | won | lost last_contact_date: timestamp("last_contact_date", { withTimezone: true }), next_action: text("next_action"), next_action_date: timestamp("next_action_date", { withTimezone: true }), notes: text("notes"), // Archive flag (v2.3) — a converted lead is hidden from the pipeline while // keeping its "won" status, so the board doesn't fill up over time. archived: boolean("archived").notNull().default(false), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ ACTIVITIES TABLE (CRM interaction history) ============ export const activities = pgTable("activities", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), lead_id: text("lead_id") .notNull() .references(() => leads.id, { onDelete: "cascade" }), type: text("type") .notNull(), // call | email | meeting | note duration_minutes: integer("duration_minutes"), notes: text("notes").notNull(), activity_date: timestamp("activity_date", { withTimezone: true }) .notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ REMINDERS TABLE (CRM follow-up reminders) ============ export const reminders = pgTable("reminders", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), lead_id: text("lead_id") .notNull() .references(() => leads.id, { onDelete: "cascade" }), title: text("title").notNull(), description: text("description"), due_date: timestamp("due_date", { withTimezone: true }) .notNull(), completed_at: timestamp("completed_at", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ CLIENT TRANSCRIPTS TABLE (Knowledge Base — Phase 20) ============ // Transcript datati delle call, multipli per lead o cliente. // lead_id e client_id sono entrambi nullable (D-01): un transcript può appartenere // a un lead pre-conversione (Phase 20 UI) o a un cliente post-conversione (futuro). export const clientTranscripts = pgTable("client_transcripts", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), lead_id: text("lead_id") .references(() => leads.id, { onDelete: "cascade" }), client_id: text("client_id") .references(() => clients.id, { onDelete: "cascade" }), title: text("title"), content: text("content").notNull(), call_date: text("call_date").notNull(), // "YYYY-MM-DD" — coerente con created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); // ============ PROPOSALS TABLE (Preventivo AI — Phase 21/22) ============ // Preventivo generato dall'agente AI e pubblicato come pagina pubblica // navigabile su /preventivo/[slug]. content (jsonb) contiene l'intero // ProposalContent assemblato (sezioni AI + offerta + config consulente). // lead_id/client_id nullable (ON DELETE SET NULL — non perdere la proposta se // il lead viene rimosso). offer_macro_id RESTRICT — la proposta riferisce // l'offerta sorgente. accepted_at IMMUTABILE una volta valorizzato. export const proposals = pgTable("proposals", { 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" }), offer_macro_id: text("offer_macro_id") .notNull() .references(() => offer_macros.id, { onDelete: "restrict" }), title: text("title"), // Intero ProposalContent assemblato. Validato a runtime via Zod // (src/lib/proposal/schema.ts); jsonb gestito da postgres-js (parse automatico). content: jsonb("content").notNull(), model: text("model"), // es. "claude-opus-4-8" state: text("state").notNull().default("draft"), // draft | published | accepted | rejected selected_tier: text("selected_tier"), // 'A' | 'B' | 'C' — CHECK a livello SQL (migration 0010) accepted_at: timestamp("accepted_at", { withTimezone: true }), // immutable once set client_email: text("client_email"), client_notes: text("client_notes"), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), 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 }) => ({ client: one(clients, { fields: [projects.client_id], references: [clients.id] }), offer: one(offer_micros, { fields: [projects.offer_id], references: [offer_micros.id] }), phases: many(phases), payments: many(payments), documents: many(documents), notes: many(notes), quote_items: many(quote_items), time_entries: many(time_entries), projectOffers: many(project_offers), })); export const phasesRelations = relations(phases, ({ one, many }) => ({ project: one(projects, { fields: [phases.project_id], references: [projects.id] }), offerPhase: one(offer_phases, { fields: [phases.offer_phase_id], references: [offer_phases.id] }), tasks: many(tasks), })); export const tasksRelations = relations(tasks, ({ one, many }) => ({ phase: one(phases, { fields: [tasks.phase_id], references: [phases.id] }), deliverables: many(deliverables), })); export const deliverablesRelations = relations(deliverables, ({ one }) => ({ task: one(tasks, { fields: [deliverables.task_id], references: [tasks.id] }), })); export const commentsRelations = relations(comments, (_) => ({ // Polymorphic: no direct FK relation — entity_type + entity_id used at query time })); export const tagsRelations = relations(tags, (_) => ({ // Polymorphic: no direct FK relation — entity_type + entity_id used at query time })); export const paymentsRelations = relations(payments, ({ one }) => ({ project: one(projects, { fields: [payments.project_id], references: [projects.id], }), })); export const documentsRelations = relations(documents, ({ one }) => ({ project: one(projects, { fields: [documents.project_id], references: [projects.id], }), })); export const notesRelations = relations(notes, ({ one }) => ({ project: one(projects, { fields: [notes.project_id], references: [projects.id] }), })); export const timeEntriesRelations = relations(time_entries, ({ one }) => ({ project: one(projects, { fields: [time_entries.project_id], references: [projects.id] }), })); export const quoteItemsRelations = relations(quote_items, ({ one }) => ({ project: one(projects, { fields: [quote_items.project_id], references: [projects.id], }), quote: one(quotes, { fields: [quote_items.quote_id], references: [quotes.id], }), service: one(services, { fields: [quote_items.service_id], references: [services.id], }), offerPhase: one(offer_phases, { fields: [quote_items.offer_phase_id], references: [offer_phases.id], }), })); export const serviceCatalogRelations = relations( service_catalog, ({ many }) => ({ quote_items: many(quote_items), }) ); export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({ micros: many(offer_micros), })); export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({ macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }), services: many(offer_micro_services), tierServices: many(offer_tier_services), projectOffers: many(project_offers), })); export const offerServicesRelations = relations(offer_services, ({ many }) => ({ microAssignments: many(offer_micro_services), })); export const offerMicroServicesRelations = relations(offer_micro_services, ({ one }) => ({ micro: one(offer_micros, { fields: [offer_micro_services.micro_id], references: [offer_micros.id] }), service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }), })); export const offerTierServicesRelations = relations(offer_tier_services, ({ one }) => ({ tier: one(offer_micros, { fields: [offer_tier_services.tier_id], references: [offer_micros.id] }), service: one(services, { fields: [offer_tier_services.service_id], references: [services.id] }), })); export const projectOffersRelations = relations(project_offers, ({ one }) => ({ project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }), micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }), })); export const offerPhasesRelations = relations(offer_phases, ({ one, many }) => ({ micro: one(offer_micros, { fields: [offer_phases.micro_id], references: [offer_micros.id] }), services: many(offer_phase_services), quoteItems: many(quote_items), })); export const offerPhaseServicesRelations = relations(offer_phase_services, ({ one }) => ({ phase: one(offer_phases, { fields: [offer_phase_services.phase_id], references: [offer_phases.id] }), service: one(services, { fields: [offer_phase_services.service_id], references: [services.id] }), })); export const leadsRelations = relations(leads, ({ many }) => ({ quotes: many(quotes), activities: many(activities), reminders: many(reminders), transcripts: many(clientTranscripts), proposals: many(proposals), })); export const activitiesRelations = relations(activities, ({ one }) => ({ lead: one(leads, { fields: [activities.lead_id], references: [leads.id] }), })); export const remindersRelations = relations(reminders, ({ one }) => ({ lead: one(leads, { fields: [reminders.lead_id], references: [leads.id] }), })); export const clientTranscriptsRelations = relations(clientTranscripts, ({ one }) => ({ lead: one(leads, { fields: [clientTranscripts.lead_id], references: [leads.id], }), client: one(clients, { fields: [clientTranscripts.client_id], references: [clients.id], }), })); export const quotesRelations = relations(quotes, ({ one, many }) => ({ lead: one(leads, { fields: [quotes.lead_id], references: [leads.id] }), client: one(clients, { fields: [quotes.client_id], references: [clients.id] }), offerMicro: one(offer_micros, { fields: [quotes.offer_micro_id], references: [offer_micros.id] }), quoteItems: many(quote_items), })); export const proposalsRelations = relations(proposals, ({ one }) => ({ lead: one(leads, { fields: [proposals.lead_id], references: [leads.id] }), client: one(clients, { fields: [proposals.client_id], references: [clients.id] }), offerMacro: one(offer_macros, { fields: [proposals.offer_macro_id], references: [offer_macros.id], }), })); // ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============ export type Client = typeof clients.$inferSelect; export type NewClient = typeof clients.$inferInsert; export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; export type Phase = typeof phases.$inferSelect; export type NewPhase = typeof phases.$inferInsert; export type Task = typeof tasks.$inferSelect; export type NewTask = typeof tasks.$inferInsert; export type Deliverable = typeof deliverables.$inferSelect; export type NewDeliverable = typeof deliverables.$inferInsert; export type Comment = typeof comments.$inferSelect; export type NewComment = typeof comments.$inferInsert; export type Tag = typeof tags.$inferSelect; export type NewTag = typeof tags.$inferInsert; export type Payment = typeof payments.$inferSelect; export type NewPayment = typeof payments.$inferInsert; export type Document = typeof documents.$inferSelect; export type NewDocument = typeof documents.$inferInsert; export type Note = typeof notes.$inferSelect; export type NewNote = typeof notes.$inferInsert; export type Service = typeof services.$inferSelect; export type NewService = typeof services.$inferInsert; export type ServiceCatalog = typeof service_catalog.$inferSelect; export type NewServiceCatalog = typeof service_catalog.$inferInsert; export type QuoteItem = typeof quote_items.$inferSelect; export type NewQuoteItem = typeof quote_items.$inferInsert; export type TimeEntry = typeof time_entries.$inferSelect; export type NewTimeEntry = typeof time_entries.$inferInsert; export type Setting = typeof settings.$inferSelect; export type NewSetting = typeof settings.$inferInsert; export type OfferMacro = typeof offer_macros.$inferSelect; export type NewOfferMacro = typeof offer_macros.$inferInsert; export type OfferMicro = typeof offer_micros.$inferSelect; export type NewOfferMicro = typeof offer_micros.$inferInsert; export type OfferService = typeof offer_services.$inferSelect; export type NewOfferService = typeof offer_services.$inferInsert; export type OfferMicroService = typeof offer_micro_services.$inferSelect; export type NewOfferMicroService = typeof offer_micro_services.$inferInsert; export type OfferTierService = typeof offer_tier_services.$inferSelect; export type NewOfferTierService = typeof offer_tier_services.$inferInsert; export type ProjectOffer = typeof project_offers.$inferSelect; export type NewProjectOffer = typeof project_offers.$inferInsert; export type OfferPhase = typeof offer_phases.$inferSelect; export type NewOfferPhase = typeof offer_phases.$inferInsert; export type OfferPhaseService = typeof offer_phase_services.$inferSelect; export type NewOfferPhaseService = typeof offer_phase_services.$inferInsert; export type Quote = typeof quotes.$inferSelect; export type NewQuote = typeof quotes.$inferInsert; export type Lead = typeof leads.$inferSelect; export type NewLead = typeof leads.$inferInsert; export type Activity = typeof activities.$inferSelect; export type NewActivity = typeof activities.$inferInsert; export type Reminder = typeof reminders.$inferSelect; export type NewReminder = typeof reminders.$inferInsert; export type ClientTranscript = typeof clientTranscripts.$inferSelect; export type NewClientTranscript = typeof clientTranscripts.$inferInsert; export type Proposal = typeof proposals.$inferSelect; 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 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;