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:
+212
-130
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user