feat(01-03): add ClientView type system and getClientView() query function

- ClientView interface enforces admin data exclusion: no quote_items, no service prices
- getClientView() queries clients.token, phases, tasks, deliverables, payments, documents, notes
- inArray() scoping prevents full table scan on tasks and deliverables
- accepted_total: client.accepted_total ?? '0' null coalescing
- Progress percentages calculated server-side (per-phase + global)
- Payment amount intentionally excluded — only label and status returned to client
This commit is contained in:
Simone Cavalli
2026-05-14 20:58:30 +02:00
parent ef3481744c
commit 14787bab10
+209
View File
@@ -0,0 +1,209 @@
import { eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
/**
* ClientView: The ONLY data shape returned to client-facing routes.
* Deliberately excludes: quote_items, service_catalog, service prices, payment amounts.
* Enforced server-side: client API never touches admin data.
*
* Architecture constraint (LOCKED): quote_items are admin-only.
* accepted_total is the only price-related field returned to clients.
*/
export interface ClientView {
client: {
id: string;
name: string;
brand_name: string;
brief: string;
accepted_total: string; // only total, never breakdown
};
phases: Array<{
id: string;
title: string;
status: 'upcoming' | 'active' | 'done';
sort_order: number;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: 'todo' | 'in_progress' | 'done';
sort_order: number;
deliverables: Array<{
id: string;
title: string;
url: string | null;
status: 'pending' | 'submitted' | 'approved';
approved_at: string | null; // ISO timestamp — immutable once set
}>;
}>;
progress_pct: number; // % of tasks done in this phase
}>;
payments: Array<{
id: string;
label: string; // "Acconto 50%" | "Saldo 50%"
status: 'da_saldare' | 'inviata' | 'saldato';
// NOTE: amount is intentionally omitted — clients see only label and status
}>;
documents: Array<{
id: string;
label: string;
url: string;
}>;
notes: Array<{
id: string;
body: string;
created_at: string; // ISO timestamp
}>;
global_progress_pct: number; // % of all tasks done across all phases
}
/**
* 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.
*/
export async function getClientView(token: string): Promise<ClientView | null> {
// Fetch client by token (Architecture constraint: token is separate from id PK)
const clientRow = await db
.select()
.from(clients)
.where(eq(clients.token, token))
.limit(1);
if (clientRow.length === 0) {
return null;
}
const client = clientRow[0];
// Fetch all phases for this client, ordered by sort_order
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.client_id, client.id))
.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
? []
: await db
.select()
.from(tasks)
.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
? []
: await db
.select()
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
// Fetch payments — label and status only, amount is intentionally excluded from ClientView
const paymentsRows = await db
.select()
.from(payments)
.where(eq(payments.client_id, client.id));
// Fetch documents
const documentsRows = await db
.select()
.from(documents)
.where(eq(documents.client_id, client.id));
// Fetch notes (decision log — admin writes, client reads)
const notesRows = await db
.select()
.from(notes)
.where(eq(notes.client_id, client.id))
.orderBy(notes.created_at);
// Build hierarchical structure: phases → tasks → deliverables
const phasesList = phasesRows.map((phase) => {
const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id);
const tasksList = phaseTasksRows.map((task) => {
const taskDeliverables = deliverablesRows
.filter((d) => d.task_id === task.id)
.map((d) => ({
id: d.id,
title: d.title,
url: d.url,
status: d.status as 'pending' | 'submitted' | 'approved',
// approved_at is immutable once set (Architecture constraint LOCKED)
approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null,
}));
return {
id: task.id,
title: task.title,
description: task.description,
status: task.status as 'todo' | 'in_progress' | 'done',
sort_order: task.sort_order,
deliverables: taskDeliverables,
};
});
// 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);
return {
id: phase.id,
title: phase.title,
status: phase.status as 'upcoming' | 'active' | 'done',
sort_order: phase.sort_order,
tasks: tasksList,
progress_pct,
};
});
// 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,
created_at: new Date(n.created_at).toISOString(),
}));
return {
client: {
id: client.id,
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,
payments: paymentsList,
documents: documentsList,
notes: notesList,
global_progress_pct: globalProgressPct,
};
}