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:
+31
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user