63c9f750df
- 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>
223 lines
6.3 KiB
TypeScript
223 lines
6.3 KiB
TypeScript
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<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];
|
|
|
|
// 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,
|
|
};
|
|
} |