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
+17 -1
View File
@@ -47,6 +47,12 @@ const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
slug: z
.string()
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
.optional()
.or(z.literal(""))
.transform((v) => (v === "" || v === undefined ? null : v)),
});
export async function updateClient(clientId: string, formData: FormData) {
@@ -54,11 +60,21 @@ export async function updateClient(clientId: string, formData: FormData) {
name: formData.get("name"),
brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "",
slug: formData.get("slug") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
try {
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("unique") || msg.includes("duplicate")) {
redirect(`/admin/clients/${clientId}/edit?error=slug_taken`);
}
throw e;
}
revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
}
export async function deleteClient(clientId: string) {
+30
View File
@@ -11,10 +11,13 @@ import Link from "next/link";
export default async function EditClientPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { id } = await params;
const { error } = await searchParams;
const [client] = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
if (!client) notFound();
@@ -31,6 +34,12 @@ export default async function EditClientPage({
<h1 className="text-xl font-bold text-[#1a1a1a] mb-6">Modifica cliente</h1>
{error === "slug_taken" && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
Slug già in uso da un altro cliente. Scegline uno diverso.
</div>
)}
<form
action={async (fd: FormData) => {
"use server";
@@ -64,6 +73,27 @@ export default async function EditClientPage({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slug">Slug personalizzato (opzionale)</Label>
<p className="text-xs text-[#71717a]">
Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri.
</p>
<Input
id="slug"
name="slug"
type="text"
defaultValue={client.slug ?? ""}
pattern="[a-z0-9-]{3,50}"
placeholder="mario-rossi"
/>
<p className="text-xs text-[#71717a]">
Link cliente:{" "}
<span className="font-mono text-[#1a1a1a]">
/client/{client.slug || client.token}
</span>
</p>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">Salva modifiche</Button>
<Button asChild variant="ghost">
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { clients } from "@/db/schema";
export async function GET(request: NextRequest) {
const slug = request.nextUrl.searchParams.get("slug");
if (!slug) {
return NextResponse.json({ valid: false }, { status: 400 });
}
try {
const rows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.slug, slug))
.limit(1);
if (rows.length === 0) {
return NextResponse.json({ valid: false }, { status: 404 });
}
return NextResponse.json({ valid: true }, { status: 200 });
} catch {
return NextResponse.json({ valid: false }, { status: 500 });
}
}
+155 -37
View File
@@ -1,16 +1,73 @@
import { cache } from 'react';
import { getClientView } from '@/lib/client-view';
import { ClientDashboard } from '@/components/client-dashboard';
import { notFound } from 'next/navigation';
import { db } from '@/db';
import { comments } from '@/db/schema';
import { inArray } from 'drizzle-orm';
import type { Comment } from '@/db/schema';
import { cache } from "react";
import { notFound } from "next/navigation";
import {
getClientWithProjectsByToken,
getProjectView,
type ProjectView,
type ClientView,
type ClientProjectSummary,
} from "@/lib/client-view";
import { ClientDashboard } from "@/components/client-dashboard";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Comment } from "@/db/schema";
export const revalidate = 0; // Always revalidate — comments and approvals must be fresh
export const revalidate = 0;
// React cache deduplicates DB calls within the same render
const getCachedClientView = cache(getClientView);
const getCachedClientData = cache(getClientWithProjectsByToken);
// Adapter: converts ProjectView + client info into ClientView shape for ClientDashboard reuse
function projectViewToClientView(
client: ClientProjectSummary["client"],
view: ProjectView
): ClientView {
return {
client: {
id: view.project.client_id,
name: client.name,
brand_name: client.brand_name,
brief: "",
accepted_total: view.project.accepted_total,
},
phases: view.phases.map((phase) => ({
id: phase.id,
title: phase.title,
status: phase.status as "upcoming" | "active" | "done",
sort_order: phase.sort_order,
tasks: phase.tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
status: task.status as "todo" | "in_progress" | "done",
sort_order: task.sort_order,
deliverables: task.deliverables.map((d) => ({
id: d.id,
title: d.title,
url: d.url,
status: d.status as "pending" | "submitted" | "approved",
// approved_at is immutable once set — CLAUDE.md constraint LOCKED
approved_at: d.approved_at instanceof Date ? d.approved_at.toISOString() : null,
})),
})),
progress_pct: phase.progress_pct,
})),
payments: view.payments.map((p) => ({
id: p.id,
label: p.label,
status: p.status as "da_saldare" | "inviata" | "saldato",
})),
documents: view.documents.map((d) => ({
id: d.id,
label: d.label,
url: d.url,
})),
notes: view.notes.map((n) => ({
id: n.id,
body: n.body,
created_at: n.created_at instanceof Date ? n.created_at.toISOString() : String(n.created_at),
})),
global_progress_pct: view.global_progress_pct,
};
}
export async function generateMetadata({
params,
@@ -18,15 +75,11 @@ export async function generateMetadata({
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const view = await getCachedClientView(token);
if (!view) {
return { title: 'Not Found' };
}
const clientData = await getCachedClientData(token);
if (!clientData) return { title: "Not Found" };
return {
title: `${view.client.brand_name} — Stato Progetto | iamcavalli`,
description: view.client.brief || 'Dashboard stato progetto',
title: `${clientData.client.brand_name} — Stato Progetto | iamcavalli`,
description: "Dashboard stato progetto",
};
}
@@ -36,26 +89,91 @@ export default async function ClientPage({
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const view = await getCachedClientView(token);
if (!view) {
notFound();
const clientData = await getCachedClientData(token);
if (!clientData) notFound();
const { client, projects } = clientData;
if (projects.length === 0) {
return (
<div className="min-h-screen bg-[#f9f9f9] flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-[#71717a] mt-2">Nessun progetto disponibile al momento.</p>
</div>
</div>
);
}
// Fetch comments: tasks, deliverables, and general (entity_id = clientId)
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
const allDeliverableIds = view.phases.flatMap((p) =>
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
if (projects.length === 1) {
// D-09: single project → direct view without selector
const view = await getProjectView(projects[0].id);
if (!view) notFound();
return (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={token}
comments={view.comments as unknown as Comment[]}
/>
);
}
// D-10: 2+ projects → tabs with project names
const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id)));
return (
<div className="min-h-screen bg-white">
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<div className="flex items-center">
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
iamcavalli
</span>
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
{client.brand_name}
</h1>
<div className="w-28 shrink-0" />
</div>
</div>
</header>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<Tabs defaultValue={projects[0].id} className="w-full">
<TabsList className="mb-6">
{projects.map((p) => (
<TabsTrigger key={p.id} value={p.id}>
{p.name}
</TabsTrigger>
))}
</TabsList>
{projects.map((p, i) => {
const view = projectViews[i];
return (
<TabsContent key={p.id} value={p.id}>
{view ? (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={token}
comments={view.comments as unknown as Comment[]}
/>
) : (
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
)}
</TabsContent>
);
})}
</Tabs>
</div>
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<p className="text-xs text-[#999999] text-center">
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
</footer>
</div>
);
const allEntityIds = [view.client.id, ...allTaskIds, ...allDeliverableIds];
const allComments: Comment[] =
allEntityIds.length > 0
? await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
: [];
return <ClientDashboard view={view} token={token} comments={allComments} />;
}
+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,
};
}
+18 -7
View File
@@ -28,23 +28,34 @@ export async function proxy(request: NextRequest) {
return NextResponse.next();
}
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
// ── CLIENT TOKEN/SLUG GUARD ──────────────────────────────────────────────
if (pathname.startsWith("/client/")) {
const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
if (!tokenMatch) {
const slugOrTokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
if (!slugOrTokenMatch) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
const clientToken = tokenMatch[1];
const slugOrToken = slugOrTokenMatch[1];
try {
// Call internal Node.js API route — Edge middleware cannot use postgres-js directly
// postgres-js requires Node.js net/tls which are unavailable in the Edge runtime
const validateUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`,
// Try slug first (D-06) — user-friendly slugs before token fallback
const validateSlugUrl = new URL(
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
request.url
);
const res = await fetch(validateUrl.toString());
let res = await fetch(validateSlugUrl.toString());
// If slug not found, fall back to token validation (existing links continue to work)
if (!res.ok) {
const validateTokenUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
request.url
);
res = await fetch(validateTokenUrl.toString());
}
if (!res.ok) {
return NextResponse.rewrite(new URL("/not-found", request.url));