31237da11c
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>
1190 lines
49 KiB
TypeScript
1190 lines
49 KiB
TypeScript
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"),
|
|
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) ============
|
|
export const time_entries = pgTable("time_entries", {
|
|
id: text("id")
|
|
.primaryKey()
|
|
.$defaultFn(() => nanoid()),
|
|
project_id: text("project_id")
|
|
.notNull()
|
|
.references(() => projects.id, { onDelete: "cascade" }),
|
|
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
|
ended_at: timestamp("ended_at", { withTimezone: true }),
|
|
duration_seconds: integer("duration_seconds"), // set on stop
|
|
});
|
|
|
|
// ============ 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 <input type="date">
|
|
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<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 }) => ({
|
|
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; |