import { eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { clients, projects, 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. * Aggregates data across all projects for this client. */ 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]; // 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(inArray(phases.project_id, projectIds)) .orderBy(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(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)); // Fetch payments — label and status only, amount is intentionally excluded from ClientView const paymentsRows = await db .select() .from(payments) .where(inArray(payments.project_id, projectIds)); const documentsRows = await db .select() .from(documents) .where(inArray(documents.project_id, projectIds)); const notesRows = await db .select() .from(notes) .where(inArray(notes.project_id, projectIds)) .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, }; }); 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, }; }); const allDoneCount = tasksRows.filter((t) => t.status === 'done').length; const globalProgressPct = tasksRows.length === 0 ? 0 : Math.round((allDoneCount / tasksRows.length) * 100); const paymentsList = paymentsRows.map((p) => ({ id: p.id, label: p.label, status: p.status as 'da_saldare' | 'inviata' | 'saldato', })); const documentsList = documentsRows.map((d) => ({ id: d.id, label: d.label, url: d.url, })); 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, accepted_total: client.accepted_total ?? '0', }, phases: phasesList, payments: paymentsList, documents: documentsList, notes: notesList, global_progress_pct: globalProgressPct, }; }