feat(01-02): create complete Drizzle schema with all 10 tables
- 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)
This commit is contained in:
@@ -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;
|
||||||
Reference in New Issue
Block a user