feat(04-01): multi-project schema migration — projects, settings, FK pivot
- schema.ts: add projects table, settings kv table, slug on clients; migrate 6 FK from client_id to project_id (phases, payments, documents, notes, time_entries, quote_items); update all relations and TypeScript types - admin-queries.ts: fix getAllClientsWithPayments + getClientFullDetail to aggregate through projects; add getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects, ClientWithProjects type - settings.ts: new file — getSetting, updateSetting, getTargetHourlyRate, SETTINGS_KEYS - Fix all downstream files: actions.ts, quote-actions.ts, new/actions.ts, timer-actions.ts, approve/route.ts, comment/route.ts, TimerCell.tsx, analytics-queries.ts, client-view.ts, seed.ts - DB migration applied to Coolify Postgres (all test data cleared, schema rebuilt) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+63
-26
@@ -22,7 +22,9 @@ export const clients = pgTable("clients", {
|
||||
.notNull()
|
||||
.unique()
|
||||
.$defaultFn(() => nanoid()),
|
||||
// accepted_total is DENORMALIZED — client API never exposes quote_items
|
||||
// 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"
|
||||
),
|
||||
@@ -32,14 +34,28 @@ export const clients = pgTable("clients", {
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
// ============ PHASES ============
|
||||
export const phases = pgTable("phases", {
|
||||
// ============ 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),
|
||||
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
|
||||
@@ -93,9 +109,9 @@ export const payments = pgTable("payments", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
.references(() => projects.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
|
||||
@@ -107,9 +123,9 @@ export const documents = pgTable("documents", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
.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 })
|
||||
@@ -122,23 +138,23 @@ export const notes = pgTable("notes", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
body: text("body").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
// ============ TIME ENTRIES (admin time tracking per client) ============
|
||||
// ============ TIME ENTRIES (admin time tracking per project) ============
|
||||
export const time_entries = pgTable("time_entries", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
.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
|
||||
@@ -160,9 +176,9 @@ export const quote_items = pgTable("quote_items", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
service_id: text("service_id")
|
||||
.references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref
|
||||
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
|
||||
@@ -171,18 +187,31 @@ export const quote_items = pgTable("quote_items", {
|
||||
custom_label: text("custom_label"), // free-form item label (when service_id is null)
|
||||
});
|
||||
|
||||
// ============ 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(),
|
||||
});
|
||||
|
||||
// ============ RELATIONS ============
|
||||
|
||||
export const clientsRelations = relations(clients, ({ many }) => ({
|
||||
projects: many(projects),
|
||||
}));
|
||||
|
||||
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||
client: one(clients, { fields: [projects.client_id], references: [clients.id] }),
|
||||
phases: many(phases),
|
||||
payments: many(payments),
|
||||
documents: many(documents),
|
||||
notes: many(notes),
|
||||
quote_items: many(quote_items),
|
||||
time_entries: many(time_entries),
|
||||
}));
|
||||
|
||||
export const phasesRelations = relations(phases, ({ one, many }) => ({
|
||||
client: one(clients, { fields: [phases.client_id], references: [clients.id] }),
|
||||
project: one(projects, { fields: [phases.project_id], references: [projects.id] }),
|
||||
tasks: many(tasks),
|
||||
}));
|
||||
|
||||
@@ -200,27 +229,31 @@ export const commentsRelations = relations(comments, (_) => ({
|
||||
}));
|
||||
|
||||
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||
client: one(clients, {
|
||||
fields: [payments.client_id],
|
||||
references: [clients.id],
|
||||
project: one(projects, {
|
||||
fields: [payments.project_id],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||
client: one(clients, {
|
||||
fields: [documents.client_id],
|
||||
references: [clients.id],
|
||||
project: one(projects, {
|
||||
fields: [documents.project_id],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const notesRelations = relations(notes, ({ one }) => ({
|
||||
client: one(clients, { fields: [notes.client_id], references: [clients.id] }),
|
||||
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 }) => ({
|
||||
client: one(clients, {
|
||||
fields: [quote_items.client_id],
|
||||
references: [clients.id],
|
||||
project: one(projects, {
|
||||
fields: [quote_items.project_id],
|
||||
references: [projects.id],
|
||||
}),
|
||||
service: one(service_catalog, {
|
||||
fields: [quote_items.service_id],
|
||||
@@ -239,6 +272,8 @@ export const serviceCatalogRelations = relations(
|
||||
|
||||
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;
|
||||
@@ -258,4 +293,6 @@ 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 NewTimeEntry = typeof time_entries.$inferInsert;
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
export type NewSetting = typeof settings.$inferInsert;
|
||||
Reference in New Issue
Block a user