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:
2026-05-21 21:58:15 +02:00
parent 44d4fde0a5
commit 63c9f750df
14 changed files with 673 additions and 156 deletions
+41 -12
View File
@@ -10,9 +10,10 @@ import {
documents,
payments,
clients,
projects,
comments,
} from "@/db/schema";
import { eq } from "drizzle-orm";
import { eq, asc, inArray } from "drizzle-orm";
import { z } from "zod";
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
@@ -49,18 +50,31 @@ export async function setClientArchived(clientId: string, archived: boolean) {
// ── PHASES ────────────────────────────────────────────────────────────────────
async function getDefaultProjectId(clientId: string): Promise<string | null> {
const rows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, clientId))
.orderBy(asc(projects.created_at))
.limit(1);
return rows[0]?.id ?? null;
}
export async function addPhase(clientId: string, formData: FormData) {
const title = (formData.get("title") as string)?.trim();
if (!title) throw new Error("Titolo fase richiesto");
const projectId = await getDefaultProjectId(clientId);
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
const existingPhases = await db
.select({ sort_order: phases.sort_order })
.from(phases)
.where(eq(phases.client_id, clientId));
.where(eq(phases.project_id, projectId));
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
await db.insert(phases).values({
client_id: clientId,
project_id: projectId,
title,
sort_order: maxOrder + 1,
status: "upcoming",
@@ -146,7 +160,11 @@ export async function addDocument(clientId: string, formData: FormData) {
url: formData.get("url"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(documents).values({ client_id: clientId, ...parsed.data });
const projectId = await getDefaultProjectId(clientId);
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
await db.insert(documents).values({ project_id: projectId, ...parsed.data });
revalidatePath(`/admin/clients/${clientId}`);
}
@@ -193,20 +211,31 @@ export async function updateAcceptedTotal(clientId: string, formData: FormData)
const raw = (formData.get("accepted_total") as string)?.trim();
const val = parseFloat(raw);
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
// Update accepted_total on client row — denormalized field, quote_items never exposed
await db
.update(clients)
.set({ accepted_total: val.toFixed(2) })
.where(eq(clients.id, clientId));
// Split evenly between two payment rows (Acconto 50% + Saldo 50%)
const half = (val / 2).toFixed(2);
const paymentsRows = await db
.select()
.from(payments)
.where(eq(payments.client_id, clientId));
for (const p of paymentsRows) {
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
// Get all projects for this client, then update their payment stubs
const projectRows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, clientId));
if (projectRows.length > 0) {
const projectIds = projectRows.map((p) => p.id);
const half = (val / 2).toFixed(2);
const paymentsRows = await db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds));
for (const p of paymentsRows) {
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
}
}
revalidatePath(`/admin/clients/${clientId}`);
}