diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts new file mode 100644 index 0000000..39c7356 --- /dev/null +++ b/src/lib/client-view.ts @@ -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 { + // 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, + }; +} \ No newline at end of file