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
+363 -29
View File
@@ -1,6 +1,7 @@
import { db } from "@/db";
import {
clients,
projects,
payments,
phases,
tasks,
@@ -11,10 +12,12 @@ import {
time_entries,
quote_items,
service_catalog,
settings,
} from "@/db/schema";
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
import type {
Client,
Project,
Phase,
Task,
Deliverable,
@@ -25,6 +28,8 @@ import type {
ServiceCatalog,
} from "@/db/schema";
// ── ClientWithPayments — used by /admin clients list ─────────────────────────
export type ClientWithPayments = {
id: string;
name: string;
@@ -55,37 +60,79 @@ export async function getAllClientsWithPayments(
const clientIds = visible.map((c) => c.id);
const [allPayments, activeEntries, totals] = await Promise.all([
db.select().from(payments),
// Get all projects for these clients (payments/time_entries now scoped to project)
const clientProjects = await db
.select({ id: projects.id, client_id: projects.client_id })
.from(projects)
.where(inArray(projects.client_id, clientIds));
const projectIds = clientProjects.map((p) => p.id);
const projectToClient = new Map(clientProjects.map((p) => [p.id, p.client_id]));
if (projectIds.length === 0) {
return visible.map((c) => ({
id: c.id,
name: c.name,
brand_name: c.brand_name,
token: c.token,
accepted_total: c.accepted_total ?? "0",
archived: c.archived ?? false,
created_at: c.created_at,
payments: [],
activeTimerEntryId: null,
activeTimerStartedAt: null,
totalTrackedSeconds: 0,
}));
}
const [allPayments, activeEntries, totals] = await Promise.all([
db.select().from(payments).where(inArray(payments.project_id, projectIds)),
// Running timer sessions (ended_at IS NULL)
db
.select({
id: time_entries.id,
client_id: time_entries.client_id,
project_id: time_entries.project_id,
started_at: time_entries.started_at,
})
.from(time_entries)
.where(isNull(time_entries.ended_at)),
// Total tracked seconds per client (completed sessions)
db
.select({
client_id: time_entries.client_id,
project_id: time_entries.project_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(inArray(time_entries.client_id, clientIds))
.groupBy(time_entries.client_id),
.where(inArray(time_entries.project_id, projectIds))
.groupBy(time_entries.project_id),
]);
const totalMap = new Map(totals.map((r) => [r.client_id, parseInt(r.total)]));
const activeMap = new Map(
activeEntries.map((r) => [r.client_id, { id: r.id, started_at: r.started_at }])
);
// Aggregate project-scoped data back to client level
const clientPaymentsMap = new Map<string, typeof allPayments>();
for (const payment of allPayments) {
const clientId = projectToClient.get(payment.project_id);
if (!clientId) continue;
if (!clientPaymentsMap.has(clientId)) clientPaymentsMap.set(clientId, []);
clientPaymentsMap.get(clientId)!.push(payment);
}
const clientTimerMap = new Map<string, { id: string; started_at: Date }>();
for (const entry of activeEntries) {
const clientId = projectToClient.get(entry.project_id);
if (!clientId || clientTimerMap.has(clientId)) continue;
clientTimerMap.set(clientId, { id: entry.id, started_at: entry.started_at });
}
const clientTotalsMap = new Map<string, number>();
for (const row of totals) {
const clientId = projectToClient.get(row.project_id);
if (!clientId) continue;
clientTotalsMap.set(clientId, (clientTotalsMap.get(clientId) ?? 0) + parseInt(row.total));
}
return visible.map((c) => {
const active = activeMap.get(c.id);
const active = clientTimerMap.get(c.id);
const clientPayments = clientPaymentsMap.get(c.id) ?? [];
return {
id: c.id,
name: c.name,
@@ -94,12 +141,15 @@ export async function getAllClientsWithPayments(
accepted_total: c.accepted_total ?? "0",
archived: c.archived ?? false,
created_at: c.created_at,
payments: allPayments
.filter((p) => p.client_id === c.id)
.map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })),
payments: clientPayments.map((p) => ({
id: p.id,
label: p.label,
status: p.status,
amount: String(p.amount),
})),
activeTimerEntryId: active?.id ?? null,
activeTimerStartedAt: active?.started_at ?? null,
totalTrackedSeconds: totalMap.get(c.id) ?? 0,
totalTrackedSeconds: clientTotalsMap.get(c.id) ?? 0,
};
});
}
@@ -138,10 +188,36 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
if (clientRows.length === 0) return null;
const client = clientRows[0];
// Get all projects for this client (data is now project-scoped)
const projectRows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, id));
const projectIds = projectRows.map((p) => p.id);
const activeServiceRows = await db
.select()
.from(service_catalog)
.where(eq(service_catalog.active, true))
.orderBy(asc(service_catalog.name));
if (projectIds.length === 0) {
return {
client,
phases: [],
payments: [],
documents: [],
notes: [],
comments: [],
quoteItems: [],
activeServices: activeServiceRows,
};
}
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.client_id, id))
.where(inArray(phases.project_id, projectIds))
.orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id);
@@ -162,18 +238,21 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
? []
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
const paymentsRows = await db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds));
const documentsRows = await db
.select()
.from(documents)
.where(eq(documents.client_id, id))
.where(inArray(documents.project_id, projectIds))
.orderBy(asc(documents.created_at));
const notesRows = await db
.select()
.from(notes)
.where(eq(notes.client_id, id))
.where(inArray(notes.project_id, projectIds))
.orderBy(asc(notes.created_at));
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
@@ -209,15 +288,9 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
})
.from(quote_items)
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
.where(eq(quote_items.client_id, id))
.where(inArray(quote_items.project_id, projectIds))
.orderBy(asc(quote_items.id));
const activeServiceRows = await db
.select()
.from(service_catalog)
.where(eq(service_catalog.active, true))
.orderBy(asc(service_catalog.name));
return {
client,
phases: phasesWithTasks,
@@ -235,4 +308,265 @@ export async function getAllServices(): Promise<ServiceCatalog[]> {
.select()
.from(service_catalog)
.orderBy(asc(service_catalog.name));
}
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
export type ProjectWithPayments = {
id: string;
name: string;
client: { id: string; name: string; slug: string | null };
accepted_total: string;
archived: boolean;
created_at: Date;
payments: Array<{ id: string; label: string; status: string; amount: string }>;
activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
};
export async function getAllProjectsWithPayments(
includeArchived = false
): Promise<ProjectWithPayments[]> {
const allProjects = await db
.select({
id: projects.id,
name: projects.name,
client_id: projects.client_id,
accepted_total: projects.accepted_total,
archived: projects.archived,
created_at: projects.created_at,
})
.from(projects)
.orderBy(projects.created_at);
const visible = includeArchived
? allProjects
: allProjects.filter((p) => !p.archived);
if (visible.length === 0) return [];
const projectIds = visible.map((p) => p.id);
const clientIds = [...new Set(visible.map((p) => p.client_id))];
const [allPayments, activeEntries, totals, parentClients] = await Promise.all([
db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds)),
db
.select({
id: time_entries.id,
project_id: time_entries.project_id,
started_at: time_entries.started_at,
})
.from(time_entries)
.where(isNull(time_entries.ended_at)),
db
.select({
project_id: time_entries.project_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(inArray(time_entries.project_id, projectIds))
.groupBy(time_entries.project_id),
db
.select({ id: clients.id, name: clients.name, slug: clients.slug })
.from(clients)
.where(inArray(clients.id, clientIds)),
]);
return visible.map((project) => {
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
const activeEntry = activeEntries.find((e) => e.project_id === project.id);
const totalRow = totals.find((t) => t.project_id === project.id);
const parentClient = parentClients.find((c) => c.id === project.client_id);
return {
id: project.id,
name: project.name,
client: parentClient ?? { id: project.client_id, name: "—", slug: null },
accepted_total: project.accepted_total ?? "0",
archived: project.archived,
created_at: project.created_at,
payments: projectPayments.map((p) => ({
id: p.id,
label: p.label,
status: p.status,
amount: String(p.amount),
})),
activeTimerEntryId: activeEntry?.id ?? null,
activeTimerStartedAt: activeEntry?.started_at ?? null,
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
};
});
}
// ── ProjectFullDetail — used by /admin/projects/[id] workspace ───────────────
export type ProjectFullDetail = {
project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } };
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
quoteItems: QuoteItemWithLabel[];
activeServices: ServiceCatalog[];
activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
};
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
const projectRows = await db
.select()
.from(projects)
.where(eq(projects.id, id))
.limit(1);
if (projectRows.length === 0) return null;
const project = projectRows[0];
const clientRows = await db
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, slug: clients.slug })
.from(clients)
.where(eq(clients.id, project.client_id))
.limit(1);
const client = clientRows[0] ?? { id: project.client_id, name: "—", brand_name: "—", slug: null };
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.project_id, id))
.orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id);
const tasksRows =
phaseIds.length === 0
? []
: await db
.select()
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds))
.orderBy(asc(tasks.sort_order));
const taskIds = tasksRows.map((t) => t.id);
const deliverablesRows =
taskIds.length === 0
? []
: await db
.select()
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes] =
await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
db
.select({
id: quote_items.id,
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
custom_label: quote_items.custom_label,
service_id: quote_items.service_id,
quantity: quote_items.quantity,
unit_price: quote_items.unit_price,
subtotal: quote_items.subtotal,
})
.from(quote_items)
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
.where(eq(quote_items.project_id, id))
.orderBy(asc(quote_items.id)),
db.select().from(service_catalog).where(eq(service_catalog.active, true)).orderBy(asc(service_catalog.name)),
db
.select({ id: time_entries.id, started_at: time_entries.started_at })
.from(time_entries)
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
.limit(1),
db
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries)
.where(eq(time_entries.project_id, id)),
]);
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows =
allEntityIds.length === 0
? []
: await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const phasesWithTasks = phasesRows.map((phase) => ({
...phase,
tasks: tasksRows
.filter((t) => t.phase_id === phase.id)
.map((task) => ({
...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
})),
}));
return {
project: { ...project, client } as ProjectFullDetail["project"],
phases: phasesWithTasks,
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
quoteItems: quoteItemRows as QuoteItemWithLabel[],
activeServices: activeServiceRows,
activeTimerEntryId: activeEntryRows[0]?.id ?? null,
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
};
}
// ── ClientWithProjects — used by client detail showing project cards ──────────
export type ClientWithProjects = Client & {
projects: Array<{
id: string;
name: string;
accepted_total: string;
archived: boolean;
created_at: Date;
}>;
};
export async function getClientWithProjects(clientId: string): Promise<ClientWithProjects | null> {
const clientRows = await db
.select()
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (clientRows.length === 0) return null;
const client = clientRows[0];
const projectRows = await db
.select()
.from(projects)
.where(eq(projects.client_id, clientId))
.orderBy(asc(projects.created_at));
return {
...client,
projects: projectRows.map((p) => ({
id: p.id,
name: p.name,
accepted_total: p.accepted_total ?? "0",
archived: p.archived,
created_at: p.created_at,
})),
};
}
+25 -9
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { clients, payments, time_entries } from "@/db/schema";
import { sql, and, eq } from "drizzle-orm";
import { clients, payments, time_entries, projects } from "@/db/schema";
import { sql, and, eq, inArray } from "drizzle-orm";
export async function getAnalyticsByYear(year: number) {
const [contracted] = await db
@@ -81,7 +81,7 @@ export type ClientTimeRow = {
export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
const rows = await db
.select({
client_id: time_entries.client_id,
project_id: time_entries.project_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
@@ -89,18 +89,34 @@ export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
sql`${time_entries.ended_at} is not null
and extract(year from ${time_entries.started_at}) = ${year}`
)
.groupBy(time_entries.client_id);
.groupBy(time_entries.project_id);
if (rows.length === 0) return [];
// Map project_id → client_id
const projectIds = rows.map((r) => r.project_id);
const projectRows = await db
.select({ id: projects.id, client_id: projects.client_id })
.from(projects)
.where(inArray(projects.id, projectIds));
const projectToClient = new Map(projectRows.map((p) => [p.id, p.client_id]));
const allClients = await db.select({ id: clients.id, name: clients.name }).from(clients);
const nameMap = new Map(allClients.map((c) => [c.id, c.name]));
return rows
.map((r) => ({
clientId: r.client_id,
clientName: nameMap.get(r.client_id) ?? r.client_id,
totalSeconds: parseInt(r.total),
// Aggregate by client_id (a client may have multiple projects)
const clientTotals = new Map<string, number>();
for (const row of rows) {
const clientId = projectToClient.get(row.project_id);
if (!clientId) continue;
clientTotals.set(clientId, (clientTotals.get(clientId) ?? 0) + parseInt(row.total));
}
return [...clientTotals.entries()]
.map(([clientId, totalSeconds]) => ({
clientId,
clientName: nameMap.get(clientId) ?? clientId,
totalSeconds,
}))
.sort((a, b) => b.totalSeconds - a.totalSeconds);
}
+31 -17
View File
@@ -1,6 +1,6 @@
import { eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
import { clients, projects, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
/**
* ClientView: The ONLY data shape returned to client-facing routes.
@@ -61,7 +61,7 @@ export interface ClientView {
/**
* getClientView: Fetch all client data and return only the ClientView shape.
* NEVER queries quote_items, service_catalog, or service prices.
* Uses inArray() to scope tasks/deliverables to this client's phases only.
* Aggregates data across all projects for this client.
*/
export async function getClientView(token: string): Promise<ClientView | null> {
// Fetch client by token (Architecture constraint: token is separate from id PK)
@@ -77,14 +77,37 @@ export async function getClientView(token: string): Promise<ClientView | null> {
const client = clientRow[0];
// Fetch all phases for this client, ordered by sort_order
// Get all projects for this client (data is now project-scoped)
const projectRows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, client.id));
const projectIds = projectRows.map((p) => p.id);
if (projectIds.length === 0) {
return {
client: {
id: client.id,
name: client.name,
brand_name: client.brand_name,
brief: client.brief,
accepted_total: client.accepted_total ?? '0',
},
phases: [],
payments: [],
documents: [],
notes: [],
global_progress_pct: 0,
};
}
// Fetch all phases across all projects, ordered by sort_order
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.client_id, client.id))
.where(inArray(phases.project_id, projectIds))
.orderBy(phases.sort_order);
// Fetch tasks scoped to this client's phases only (inArray prevents full table scan)
const phaseIds = phasesRows.map((p) => p.id);
const tasksRows =
phaseIds.length === 0
@@ -95,7 +118,6 @@ export async function getClientView(token: string): Promise<ClientView | null> {
.where(inArray(tasks.phase_id, phaseIds))
.orderBy(tasks.sort_order);
// Fetch deliverables scoped to this client's tasks only
const taskIds = tasksRows.map((t) => t.id);
const deliverablesRows =
taskIds.length === 0
@@ -109,19 +131,17 @@ export async function getClientView(token: string): Promise<ClientView | null> {
const paymentsRows = await db
.select()
.from(payments)
.where(eq(payments.client_id, client.id));
.where(inArray(payments.project_id, projectIds));
// Fetch documents
const documentsRows = await db
.select()
.from(documents)
.where(eq(documents.client_id, client.id));
.where(inArray(documents.project_id, projectIds));
// Fetch notes (decision log — admin writes, client reads)
const notesRows = await db
.select()
.from(notes)
.where(eq(notes.client_id, client.id))
.where(inArray(notes.project_id, projectIds))
.orderBy(notes.created_at);
// Build hierarchical structure: phases → tasks → deliverables
@@ -150,7 +170,6 @@ export async function getClientView(token: string): Promise<ClientView | null> {
};
});
// Calculate progress for this phase
const taskCount = tasksList.length;
const doneCount = tasksList.filter((t) => t.status === 'done').length;
const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100);
@@ -165,26 +184,22 @@ export async function getClientView(token: string): Promise<ClientView | null> {
};
});
// Calculate global progress across all phases
const allDoneCount = tasksRows.filter((t) => t.status === 'done').length;
const globalProgressPct =
tasksRows.length === 0 ? 0 : Math.round((allDoneCount / tasksRows.length) * 100);
// Map payments — only label and status (no amount exposed to client)
const paymentsList = paymentsRows.map((p) => ({
id: p.id,
label: p.label,
status: p.status as 'da_saldare' | 'inviata' | 'saldato',
}));
// Map documents
const documentsList = documentsRows.map((d) => ({
id: d.id,
label: d.label,
url: d.url,
}));
// Map notes — ISO timestamps for JSON serialization
const notesList = notesRows.map((n) => ({
id: n.id,
body: n.body,
@@ -197,7 +212,6 @@ export async function getClientView(token: string): Promise<ClientView | null> {
name: client.name,
brand_name: client.brand_name,
brief: client.brief,
// null coalescing: accepted_total is nullable in schema, default '0'
accepted_total: client.accepted_total ?? '0',
},
phases: phasesList,
+35
View File
@@ -0,0 +1,35 @@
import { db } from "@/db";
import { settings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export const SETTINGS_KEYS = {
TARGET_HOURLY_RATE: "target_hourly_rate",
} as const;
export async function getSetting(key: string): Promise<string | null> {
const rows = await db
.select({ value: settings.value })
.from(settings)
.where(eq(settings.key, key))
.limit(1);
return rows[0]?.value ?? null;
}
export async function updateSetting(key: string, value: string): Promise<void> {
const existing = await getSetting(key);
if (existing !== null) {
await db
.update(settings)
.set({ value, updated_at: new Date() })
.where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
revalidatePath("/admin/impostazioni");
}
export async function getTargetHourlyRate(): Promise<number> {
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
return value ? parseFloat(value) : 50;
}