feat(04-04): slug resolution + multi-project client dashboard + slug edit

- validate-slug API route: resolves clients.slug for Edge middleware
- proxy.ts: slug-first resolution (D-06) — tries slug then falls back to token
- client-view.ts: complete rewrite — getClientWithProjectsByToken + getProjectView
  - No quote_items, no payment amounts in client API (CLAUDE.md security invariants)
- client/[token]/page.tsx: multi-project dashboard — 1 project = direct view,
  2+ projects = shadcn Tabs with project names (D-09/D-10)
- edit/page.tsx: slug field with link preview + unique constraint error handling
- actions.ts: updateClient now persists slug, redirects on success/slug_taken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:31:32 +02:00
parent 1d9fa10961
commit 5d785a1c1c
8 changed files with 562 additions and 175 deletions
+212 -130
View File
@@ -1,14 +1,11 @@
import { eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { clients, projects, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
import { eq, inArray, asc } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments } from "@/db/schema";
/**
* ClientView: The ONLY data shape returned to client-facing routes.
* ClientView: Legacy shape used by ClientDashboard component.
* Kept for backward compatibility — new code uses ProjectView.
* 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: {
@@ -16,34 +13,33 @@ export interface ClientView {
name: string;
brand_name: string;
brief: string;
accepted_total: string; // only total, never breakdown
accepted_total: string;
};
phases: Array<{
id: string;
title: string;
status: 'upcoming' | 'active' | 'done';
status: "upcoming" | "active" | "done";
sort_order: number;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: 'todo' | 'in_progress' | 'done';
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
status: "pending" | "submitted" | "approved";
approved_at: string | null;
}>;
}>;
progress_pct: number; // % of tasks done in this phase
progress_pct: number;
}>;
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
label: string;
status: "da_saldare" | "inviata" | "saldato";
}>;
documents: Array<{
id: string;
@@ -53,62 +49,160 @@ export interface ClientView {
notes: Array<{
id: string;
body: string;
created_at: string; // ISO timestamp
created_at: string;
}>;
global_progress_pct: number;
}
export interface ProjectView {
project: {
id: string;
name: string;
client_id: string;
accepted_total: string;
};
phases: Array<{
id: string;
title: string;
status: string;
sort_order: number;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: string;
sort_order: number;
deliverables: Array<{
id: string;
title: string;
url: string | null;
status: string;
approved_at: Date | null;
task_id: string;
}>;
}>;
progress_pct: number;
}>;
payments: Array<{
id: string;
label: string;
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
status: string;
}>;
documents: Array<{
id: string;
label: string;
url: string;
created_at: Date;
}>;
notes: Array<{
id: string;
body: string;
created_at: Date;
}>;
comments: Array<{
id: string;
entity_type: string;
entity_id: string;
author: string;
body: string;
created_at: Date;
}>;
global_progress_pct: number;
}
export interface ClientProjectSummary {
client: {
id: string;
name: string;
brand_name: string;
token: string;
slug: string | null;
};
projects: Array<{
id: string;
name: string;
archived: boolean;
}>;
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.
* Resolves a token-or-slug to a client and returns the client's active projects.
* Lookup order: slug first, then token — mirrors middleware order (D-06).
*/
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()
export async function getClientWithProjectsByToken(
tokenOrSlug: string
): Promise<ClientProjectSummary | null> {
// Try slug first
let clientRows = await db
.select({
id: clients.id,
name: clients.name,
brand_name: clients.brand_name,
token: clients.token,
slug: clients.slug,
})
.from(clients)
.where(eq(clients.token, token))
.where(eq(clients.slug, tokenOrSlug))
.limit(1);
if (clientRow.length === 0) {
return null;
// Fall back to token
if (clientRows.length === 0) {
clientRows = await db
.select({
id: clients.id,
name: clients.name,
brand_name: clients.brand_name,
token: clients.token,
slug: clients.slug,
})
.from(clients)
.where(eq(clients.token, tokenOrSlug))
.limit(1);
}
const client = clientRow[0];
if (clientRows.length === 0) return null;
const client = clientRows[0];
// Get all projects for this client (data is now project-scoped)
const projectRows = await db
.select({ id: projects.id })
.select({ id: projects.id, name: projects.name, archived: projects.archived })
.from(projects)
.where(eq(projects.client_id, client.id));
const projectIds = projectRows.map((p) => p.id);
.where(eq(projects.client_id, client.id))
.orderBy(asc(projects.created_at));
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,
};
}
const activeProjects = projectRows.filter((p) => !p.archived);
return { client, projects: activeProjects };
}
/**
* Returns full project data for the client dashboard.
* CRITICAL: Does NOT include quote_items — client API never exposes them (CLAUDE.md constraint).
* payments include status only, NOT amount or unit_price (DASH-07).
*/
export async function getProjectView(projectId: string): Promise<ProjectView | null> {
const projectRows = await db
.select({
id: projects.id,
name: projects.name,
client_id: projects.client_id,
accepted_total: projects.accepted_total,
})
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (projectRows.length === 0) return null;
const project = projectRows[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);
.where(eq(phases.project_id, projectId))
.orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id);
const tasksRows =
phaseIds.length === 0
? []
@@ -116,108 +210,96 @@ export async function getClientView(token: string): Promise<ClientView | null> {
.select()
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds))
.orderBy(tasks.sort_order);
.orderBy(asc(tasks.sort_order));
const taskIds = tasksRows.map((t) => t.id);
// approved_at included — immutable audit trail (CLAUDE.md constraint LOCKED)
const deliverablesRows =
taskIds.length === 0
? []
: await db
.select()
.select({
id: deliverables.id,
title: deliverables.title,
url: deliverables.url,
status: deliverables.status,
approved_at: deliverables.approved_at,
task_id: deliverables.task_id,
})
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
// Fetch payments — label and status only, amount is intentionally excluded from ClientView
// Payments — status only, NO amount (DASH-07 / CLAUDE.md)
const paymentsRows = await db
.select()
.select({
id: payments.id,
label: payments.label,
status: payments.status,
// amount intentionally excluded — client API never exposes payment amounts
})
.from(payments)
.where(inArray(payments.project_id, projectIds));
.where(eq(payments.project_id, projectId));
const documentsRows = await db
.select()
.select({
id: documents.id,
label: documents.label,
url: documents.url,
created_at: documents.created_at,
})
.from(documents)
.where(inArray(documents.project_id, projectIds));
.where(eq(documents.project_id, projectId))
.orderBy(asc(documents.created_at));
const notesRows = await db
.select()
.select({ id: notes.id, body: notes.body, created_at: notes.created_at })
.from(notes)
.where(inArray(notes.project_id, projectIds))
.orderBy(notes.created_at);
.where(eq(notes.project_id, projectId))
.orderBy(asc(notes.created_at));
// Build hierarchical structure: phases → tasks deliverables
const phasesList = phasesRows.map((phase) => {
const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id);
// Comments scoped to tasks and deliverables of this project
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows =
allEntityIds.length === 0
? []
: await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
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,
}));
const phasesWithTasks = phasesRows.map((phase) => {
const phaseTasks = tasksRows
.filter((t) => t.phase_id === phase.id)
.map((task) => ({
...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
}));
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 doneCount = phaseTasks.filter((t) => t.status === "done").length;
const progress_pct =
phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0;
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,
};
return { ...phase, tasks: phaseTasks, 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(),
}));
const doneTasks = tasksRows.filter((t) => t.status === "done").length;
const global_progress_pct =
tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0;
return {
client: {
id: client.id,
name: client.name,
brand_name: client.brand_name,
brief: client.brief,
accepted_total: client.accepted_total ?? '0',
project: {
id: project.id,
name: project.name,
client_id: project.client_id,
accepted_total: project.accepted_total ?? "0",
},
phases: phasesList,
payments: paymentsList,
documents: documentsList,
notes: notesList,
global_progress_pct: globalProgressPct,
phases: phasesWithTasks,
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
global_progress_pct,
};
}