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
+4 -3
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { eq, and } from "drizzle-orm";
import { db } from "@/db";
import { clients, deliverables, tasks, phases } from "@/db/schema";
import { clients, deliverables, tasks, phases, projects } from "@/db/schema";
export async function POST(request: NextRequest) {
try {
@@ -26,12 +26,13 @@ export async function POST(request: NextRequest) {
const clientId = clientRows[0].id;
// Verify deliverable belongs to this client (prevents cross-client approval)
// deliverable → task → phase → client
// deliverable → task → phase → project → client
const ownershipCheck = await db
.select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at })
.from(deliverables)
.innerJoin(tasks, eq(deliverables.task_id, tasks.id))
.innerJoin(phases, and(eq(tasks.phase_id, phases.id), eq(phases.client_id, clientId)))
.innerJoin(phases, eq(tasks.phase_id, phases.id))
.innerJoin(projects, and(eq(phases.project_id, projects.id), eq(projects.client_id, clientId)))
.where(eq(deliverables.id, deliverableId))
.limit(1);
+33 -29
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { clients, comments, tasks, phases, deliverables } from "@/db/schema";
import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema";
const commentSchema = z.object({
token: z.string().min(1),
@@ -43,46 +43,50 @@ export async function POST(request: NextRequest) {
if (entity_id !== clientId) {
return NextResponse.json({ error: "Entity non valida" }, { status: 403 });
}
} else if (entity_type === "task") {
const phasesForClient = await db
.select({ id: phases.id })
.from(phases)
.where(eq(phases.client_id, clientId));
const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
}
const taskRows = await db
.select({ id: tasks.id })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
if (!taskRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
}
} else {
// deliverable
// Scope phases through projects → client
const clientProjects = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, clientId));
const projectIds = clientProjects.map((p) => p.id);
if (projectIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
}
const phasesForClient = await db
.select({ id: phases.id })
.from(phases)
.where(eq(phases.client_id, clientId));
.where(inArray(phases.project_id, projectIds));
const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
}
const taskRows = await db
.select({ id: tasks.id })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
const taskIds = taskRows.map((r) => r.id);
if (taskIds.length === 0) {
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
}
const delivRows = await db
.select({ id: deliverables.id })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
if (!delivRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
if (entity_type === "task") {
if (!taskRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
}
} else {
// deliverable
const taskIds = taskRows.map((r) => r.id);
if (taskIds.length === 0) {
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
}
const delivRows = await db
.select({ id: deliverables.id })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
if (!delivRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
}
}
}