From 1bdbe7ab5d05eec0582c45fad78935775284a2c9 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Wed, 13 May 2026 22:46:30 +0200 Subject: [PATCH] feat(01-02): create complete Drizzle schema with all 10 tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clients: token as separate text field (notNull, unique, nanoid) — never PK - accepted_total denormalized on clients — client API never touches quote_items - deliverables.approved_at immutable timestamp (TIMESTAMPTZ) — audit trail - payments: label (Acconto 50% / Saldo 50%), status (da_saldare/inviata/saldato) - comments: polymorphic entity_type+entity_id pattern - service_catalog + quote_items: admin-only, never exposed to client API - Full relations defined for all FK chains - TypeScript types exported: Client, Phase, Task, Deliverable, etc. - ID strategy: text + nanoid() via $defaultFn (cryptographically secure, URL-safe) --- src/db/schema.ts | 245 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/db/schema.ts diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..e4a368e --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,245 @@ +import { + pgTable, + text, + integer, + numeric, + timestamp, + boolean, +} 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(), + // token is SEPARATE from id — rotatable secret for client link access + token: text("token") + .notNull() + .unique() + .$defaultFn(() => nanoid()), + // accepted_total is DENORMALIZED — client API never exposes quote_items + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default( + "0" + ), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// ============ PHASES ============ +export const phases = pgTable("phases", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + title: text("title").notNull(), + sort_order: integer("sort_order").notNull().default(0), + status: text("status").notNull().default("upcoming"), // upcoming | active | done +}); + +// ============ 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(), +}); + +// ============ PAYMENTS ============ +export const payments = pgTable("payments", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%" + amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), + 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()), + client_id: text("client_id") + .notNull() + .references(() => clients.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()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + body: text("body").notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// ============ 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), +}); + +// ============ QUOTE ITEMS (admin-only — NEVER exposed via client API) ============ +export const quote_items = pgTable("quote_items", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + service_id: text("service_id") + .notNull() + .references(() => service_catalog.id, { onDelete: "restrict" }), + 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(), +}); + +// ============ RELATIONS ============ + +export const clientsRelations = relations(clients, ({ many }) => ({ + phases: many(phases), + payments: many(payments), + documents: many(documents), + notes: many(notes), + quote_items: many(quote_items), +})); + +export const phasesRelations = relations(phases, ({ one, many }) => ({ + client: one(clients, { fields: [phases.client_id], references: [clients.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 paymentsRelations = relations(payments, ({ one }) => ({ + client: one(clients, { + fields: [payments.client_id], + references: [clients.id], + }), +})); + +export const documentsRelations = relations(documents, ({ one }) => ({ + client: one(clients, { + fields: [documents.client_id], + references: [clients.id], + }), +})); + +export const notesRelations = relations(notes, ({ one }) => ({ + client: one(clients, { fields: [notes.client_id], references: [clients.id] }), +})); + +export const quoteItemsRelations = relations(quote_items, ({ one }) => ({ + client: one(clients, { + fields: [quote_items.client_id], + references: [clients.id], + }), + service: one(service_catalog, { + fields: [quote_items.service_id], + references: [service_catalog.id], + }), +})); + +export const serviceCatalogRelations = relations( + service_catalog, + ({ many }) => ({ + quote_items: many(quote_items), + }) +); + +// ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============ + +export type Client = typeof clients.$inferSelect; +export type NewClient = typeof clients.$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 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 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; \ No newline at end of file