Files
simone 49ef45da83 fix(04): revision 1 — depends_on format + D-12 client list coverage
- 04-02/03/04: change depends_on from filename format to plan ID format
  ('04-01-PLAN.md' → '04-01') to match orchestrator expectations and
  Phase 3 precedent
- 04-02: add Task 3 implementing D-12 — /admin/clients list shows brand
  names below client name and LTV as sum of project accepted_totals;
  update getAllClientsWithPayments query and ClientRow component;
  add src/app/admin/page.tsx, src/lib/admin-queries.ts,
  src/components/admin/ClientRow.tsx to files_modified

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:10:25 +02:00

830 lines
31 KiB
Markdown

---
phase: 04-progetti-multi-project
plan: "04"
type: execute
wave: 3
depends_on:
- "04-02"
- "04-03"
files_modified:
- src/app/api/internal/validate-slug/route.ts
- src/proxy.ts
- src/lib/client-view.ts
- src/app/c/[token]/page.tsx
- src/app/admin/clients/[id]/edit/page.tsx
autonomous: false
requirements:
- PROJ-02
- PROJ-04
must_haves:
truths:
- "Accedendo a /c/mario-rossi (dove mario-rossi è lo slug di un cliente) la dashboard si apre correttamente"
- "Accedendo a /c/[token] (token storico) la dashboard continua a funzionare come prima"
- "Se il cliente ha 1 progetto la dashboard mostra direttamente il workspace senza tabs"
- "Se il cliente ha 2+ progetti la dashboard mostra tabs con i nomi dei progetti"
- "Lo slug è impostabile da /admin/clients/[id]/edit con preview del link risultante"
- "La dashboard cliente NON espone mai quote_items (CLAUDE.md constraint)"
artifacts:
- path: "src/app/api/internal/validate-slug/route.ts"
provides: "API route che risolve slug → clientId"
contains: "clients.slug"
- path: "src/proxy.ts"
provides: "Middleware con slug-first resolution"
contains: "validate-slug"
- path: "src/lib/client-view.ts"
provides: "Query functions per dashboard multi-progetto"
exports: ["getClientWithProjectsByToken", "getProjectView"]
- path: "src/app/c/[token]/page.tsx"
provides: "Dashboard cliente con logica single/multi-project"
contains: "projects.length === 1"
- path: "src/app/admin/clients/[id]/edit/page.tsx"
provides: "Form edit con campo slug e link preview"
contains: "slug"
key_links:
- from: "src/proxy.ts"
to: "src/app/api/internal/validate-slug/route.ts"
via: "fetch /api/internal/validate-slug?slug=..."
pattern: "validate-slug"
- from: "src/app/c/[token]/page.tsx"
to: "src/lib/client-view.ts"
via: "getClientWithProjectsByToken(token)"
pattern: "getClientWithProjectsByToken"
- from: "src/lib/client-view.ts"
to: "src/db/schema.ts"
via: "query phases/payments/etc con project_id"
pattern: "project_id"
---
<objective>
Slug resolution middleware, dashboard cliente multi-progetto, e campo slug nell'edit cliente. Consegna la funzionalità lato cliente: link personalizzato /c/mario-rossi, dashboard con tabs per 2+ progetti o vista diretta per 1 progetto.
Purpose: Completa il ciclo end-to-end della fase 4 — l'admin imposta lo slug, il cliente accede con il link personalizzato, vede i propri progetti organizzati per tab.
Output: Middleware slug-first, client-view.ts riscritto per multi-project, dashboard cliente con tabs, edit page con slug field.
</objective>
<execution_context>
@/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md
@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-01-SUMMARY.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md
<interfaces>
<!-- Tutto il necessario per implementare senza esplorare il codebase. -->
Middleware attuale (src/proxy.ts):
- Check admin: getToken → redirect a /admin/login se assente
- Check client: match /c/[token], chiama /api/internal/validate-token?token=...
- Se validate-token risponde !ok → rewrite /not-found
- MODIFICA: before validate-token, try validate-slug first (D-06)
API route validate-token (usato come template esatto per validate-slug):
- Path: src/app/api/internal/validate-token/route.ts (leggere per avere il pattern preciso)
- Pattern: GET, query param, db.select where eq(clients.token, token), return 200/404 json
Schema clients (da 04-01):
```typescript
clients: { id, name, brand_name, brief, token, slug (nullable unique), accepted_total, archived, created_at }
```
client-view.ts attuale:
- getClientView(token: string) → ClientView (fasi, pagamenti, documenti, note per il cliente)
- DA RISCRIVERE COMPLETAMENTE per multi-project model
Nuove funzioni necessarie in client-view.ts:
1. getClientWithProjectsByToken(tokenOrSlug: string) — trova il client (via token), restituisce { client, projects[] }
NOTA: il param si chiama tokenOrSlug perché la page /c/[token] riceve il valore del path — potrebbe essere token o slug. Il middleware ha già validato l'accesso, ma la page deve fare il lookup corretto.
Lookup order: prima per slug, poi per token.
2. getProjectView(projectId: string) → ProjectView — dati di un singolo progetto per la dashboard cliente
CRITICAL: NON includere quote_items. Includere: phases+tasks+deliverables, payments (solo status, NON unit_price/subtotal), documents, notes.
shadcn Tabs già presente per multi-project tabs (D-10):
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
- Tabs è un Client Component (ha "use client" internamente)
Slug validation rule (D-04, Pitfall 5):
- Regex: /^[a-z0-9-]{3,50}$/
- Formato: lowercase, numeri, hyphens, min 3 max 50 chars
- Zod: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().nullable()
Edit page cliente attuale (src/app/admin/clients/[id]/edit/page.tsx):
- Leggere il file per capire il form attuale e aggiungere il campo slug
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Slug API route + middleware slug-first resolution + client-view.ts rewrite</name>
<files>
src/app/api/internal/validate-slug/route.ts
src/proxy.ts
src/lib/client-view.ts
</files>
<read_first>
- src/app/api/internal/validate-token/route.ts — template ESATTO per validate-slug (stesso pattern, stesso formato risposta)
- src/proxy.ts — leggere INTERAMENTE: capire la struttura attuale del client token guard per inserire slug-first prima del token check
- src/lib/client-view.ts — leggere INTERAMENTE prima di riscriverlo: capire ClientView type e getClientView pattern, specialmente cosa è incluso/escluso
- CLAUDE.md Architecture Constraints — ricordare: quote_items MAI esposti via client API; deliverables.approved_at immutable
</read_first>
<action>
**A. Creare src/app/api/internal/validate-slug/route.ts**
Clonare validate-token/route.ts sostituendo il lookup token con slug:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { clients } from "@/db/schema";
import { eq } from "drizzle-orm";
// Called by Edge middleware to resolve slug → client existence
// Returns 200 + { clientId } if found, 404 if not
export async function GET(request: NextRequest) {
const slug = request.nextUrl.searchParams.get("slug");
if (!slug) {
return NextResponse.json({ error: "slug required" }, { status: 400 });
}
const rows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.slug, slug))
.limit(1);
if (rows.length === 0) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
return NextResponse.json({ clientId: rows[0].id }, { status: 200 });
}
```
**B. Aggiornare src/proxy.ts — slug-first resolution (D-06)**
Modificare il blocco `if (pathname.startsWith("/c/"))` esistente:
PRIMA (attuale):
```
const clientToken = tokenMatch[1];
// chiama solo validate-token
```
DOPO (nuovo):
```typescript
if (pathname.startsWith("/c/")) {
const slugOrTokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
if (!slugOrTokenMatch) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
const slugOrToken = slugOrTokenMatch[1];
try {
// TRY SLUG FIRST (D-06) — slug lookup before token fallback
// Rationale: slugs are user-friendly names; tokens are fallback for existing links
const validateSlugUrl = new URL(
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
request.url
);
let res = await fetch(validateSlugUrl.toString());
// If slug not found, fall back to TOKEN validation (existing pattern)
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));
}
return NextResponse.next();
} catch {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
}
```
Il resto del file (admin guard, config) rimane invariato.
**C. Riscrivere src/lib/client-view.ts per multi-project model**
Riscrivere COMPLETAMENTE il file. Le nuove funzioni sostituiscono getClientView.
```typescript
import { db } from "@/db";
import {
clients,
projects,
phases,
tasks,
deliverables,
payments,
documents,
notes,
comments,
} from "@/db/schema";
import { eq, inArray, asc, or } from "drizzle-orm";
// ── TYPES ────────────────────────────────────────────────────────────────────
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; // immutable once set — CLAUDE.md constraint
}>;
}>;
progress_pct: number;
}>;
payments: Array<{
id: string;
label: string;
status: string;
// amount and unit_price are NOT included — client sees only status (DASH-07)
}>;
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;
}>;
}
// ── QUERIES ───────────────────────────────────────────────────────────────────
/**
* Resolves a token-or-slug to a client and returns the client's active projects.
* Called by /c/[token] page to determine: 1 project (direct view) vs 2+ (tabs).
* Lookup order: slug first, then token — mirrors middleware order (D-06).
*/
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.slug, tokenOrSlug))
.limit(1);
// 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);
}
if (clientRows.length === 0) return null;
const client = clientRows[0];
const projectRows = await db
.select({ id: projects.id, name: projects.name, archived: projects.archived })
.from(projects)
.where(eq(projects.client_id, client.id))
.orderBy(asc(projects.created_at));
// Only active (non-archived) projects shown to client
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];
// Phases scoped to THIS project
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.project_id, projectId))
.orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id);
// Tasks scoped to this project's phases
const tasksRows = phaseIds.length === 0
? []
: await db
.select()
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds))
.orderBy(asc(tasks.sort_order));
const taskIds = tasksRows.map((t) => t.id);
// Deliverables — approved_at included (immutable audit trail — CLAUDE.md)
const deliverablesRows = taskIds.length === 0
? []
: await db
.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));
// Payments — status only, NO amount (D-07 / DASH-07)
const paymentsRows = await db
.select({
id: payments.id,
label: payments.label,
status: payments.status,
// amount intentionally excluded — client sees only status
})
.from(payments)
.where(eq(payments.project_id, projectId));
// Documents
const documentsRows = await db
.select({
id: documents.id,
label: documents.label,
url: documents.url,
created_at: documents.created_at,
})
.from(documents)
.where(eq(documents.project_id, projectId))
.orderBy(asc(documents.created_at));
// Notes (decision log — admin writes, client reads)
const notesRows = await db
.select({ id: notes.id, body: notes.body, created_at: notes.created_at })
.from(notes)
.where(eq(notes.project_id, projectId))
.orderBy(asc(notes.created_at));
// Comments (polymorphic — tasks and deliverables for 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));
// Rebuild hierarchy + calculate per-phase progress
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),
}));
const doneCount = phaseTasks.filter((t) => t.status === "done").length;
const progress_pct = phaseTasks.length > 0
? Math.round((doneCount / phaseTasks.length) * 100)
: 0;
return { ...phase, tasks: phaseTasks, progress_pct };
});
// Global progress across all phases
const allTasks = tasksRows;
const doneTasks = allTasks.filter((t) => t.status === "done").length;
const global_progress_pct = allTasks.length > 0
? Math.round((doneTasks / allTasks.length) * 100)
: 0;
return {
project: {
id: project.id,
name: project.name,
client_id: project.client_id,
accepted_total: project.accepted_total ?? "0",
},
phases: phasesWithTasks,
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
global_progress_pct,
};
}
```
NOTA CRITICA sulla security: In getProjectView, il select di payments NON include `amount`. Aggiungere un commento esplicito: `// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md constraint + DASH-07)`. Questo è l'invariante principale da non rompere.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- src/app/api/internal/validate-slug/route.ts exists e contains `clients.slug` (grep)
- src/proxy.ts contains `validate-slug` (grep — slug check aggiunto)
- src/proxy.ts contains slug check BEFORE token check nell'ordine del codice (grep -n "validate-slug\|validate-token" src/proxy.ts — slug deve avere numero di riga inferiore a token)
- src/lib/client-view.ts contains `getClientWithProjectsByToken` (grep)
- src/lib/client-view.ts contains `getProjectView` (grep)
- src/lib/client-view.ts does NOT contain `quote_items` (grep — security invariant)
- src/lib/client-view.ts payments select does NOT contain `amount` field (grep: `grep "amount" src/lib/client-view.ts` deve essere assente nel select payments)
- TypeScript compila senza errori
</acceptance_criteria>
<done>Slug API route e middleware aggiornato; client-view.ts riscritto per multi-project senza quote_items e senza payment amounts</done>
</task>
<task type="auto">
<name>Task 2: Dashboard cliente multi-project (/c/[token]/page.tsx) + slug field in edit cliente</name>
<files>
src/app/c/[token]/page.tsx
src/app/admin/clients/[id]/edit/page.tsx
</files>
<read_first>
- src/app/c/[token]/page.tsx — leggere INTERAMENTE: capire la struttura attuale (ClientView types, componenti usati, come vengono passati i dati ai componenti UI della dashboard)
- src/app/admin/clients/[id]/edit/page.tsx — leggere INTERAMENTE: capire il form esistente (campi attuali, actions usate, pattern Zod/form)
- src/lib/client-view.ts — appena riscritto in Task 1: capire i tipi ProjectView e ClientProjectSummary
- src/components/ui/tabs.tsx — verificare che il componente Tabs sia disponibile e capirne le props (TabsList, TabsTrigger, TabsContent)
</read_first>
<action>
**A. Riscrivere src/app/c/[token]/page.tsx**
Logica D-09/D-10: se 1 progetto → vista diretta; se 2+ → tabs con nomi brand.
```typescript
import { notFound } from "next/navigation";
import { getClientWithProjectsByToken, getProjectView } from "@/lib/client-view";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export const revalidate = 0;
export default async function ClientPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
// Resolve token or slug to client + projects list (D-08/D-09)
const clientData = await getClientWithProjectsByToken(token);
if (!clientData) notFound();
const { client, projects } = clientData;
if (projects.length === 0) {
// No active projects — show placeholder
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>
);
}
if (projects.length === 1) {
// D-09: 1 project → direct view without selector
const view = await getProjectView(projects[0].id);
if (!view) notFound();
return <ClientDashboardView client={client} view={view} token={token} />;
}
// D-10: 2+ projects → tabs with brand names
// Fetch all project views in parallel
const projectViews = await Promise.all(
projects.map(async (p) => ({
project: p,
view: await getProjectView(p.id),
}))
);
return (
<div className="min-h-screen bg-[#f9f9f9]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
</div>
<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>
{projectViews.map(({ project, view }) => (
<TabsContent key={project.id} value={project.id}>
{view ? (
<ClientDashboardView client={client} view={view} token={token} />
) : (
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
)}
</TabsContent>
))}
</Tabs>
</div>
</div>
);
}
```
Per `ClientDashboardView`: leggere il file attuale di /c/[token]/page.tsx per capire come è strutturata la dashboard corrente. Il componente `ClientDashboardView` è probabilmente già esistente o il rendering è inline. Adattare seguendo ESATTAMENTE la struttura attuale:
- Se il file corrente ha un componente separato (es. ClientDashboard o simile) → riutilizzarlo, passando `view` invece di `clientView`
- Se il rendering è inline → estrarlo in una funzione helper `ClientDashboardView` nello stesso file
- I dati che `ClientDashboardView` riceve vengono ora da `ProjectView` invece di `ClientView` — adattare le prop references
CRITICO: verificare che `ClientDashboardView` NON abbia accesso a quote_items — deve usare solo i dati di `ProjectView` (phases, payments con solo status, documents, notes, comments).
Il campo `accepted_total` da mostrare viene da `view.project.accepted_total` (non dal client-level).
**B. Aggiornare src/app/admin/clients/[id]/edit/page.tsx**
Aggiungere il campo slug con:
1. Input field con label "Slug personalizzato"
2. Validazione Zod: `slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal(""))` — stringa vuota = nessuno slug
3. Preview del link risultante: `/{slug || client.token}`
4. Testo help: "Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri."
Leggere il file per trovare il form attuale e aggiungere il campo slug nel form esistente. L'action di salvataggio deve aggiornare `clients.slug` oltre ai campi esistenti.
Schema Zod da aggiungere/aggiornare per il campo slug:
```typescript
const updateClientSchema = z.object({
// ... existing fields ...
slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal("")).transform(v => v === "" ? null : v),
});
```
Nel form HTML:
```html
<div>
<label htmlFor="slug">Slug personalizzato (opzionale)</label>
<p className="text-xs text-[#71717a] mb-1">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"
className="..."
/>
{/* Link preview */}
<p className="text-xs text-[#71717a] mt-1">
Link cliente: /c/{client.slug || client.token}
</p>
</div>
```
Nella server action che salva, aggiungere l'update di `clients.slug`:
```typescript
// Se slug è stringa vuota, settarlo a null (rimuove lo slug)
await db.update(clients).set({
// ...existing fields...
slug: parsed.slug ?? null,
}).where(eq(clients.id, clientId));
```
Aggiungere anche gestione errore per unique constraint violation (se lo slug è già usato da un altro cliente), mostrando un messaggio user-friendly.
</action>
<verify>
<automated>npm run build 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- src/app/c/[token]/page.tsx contains `getClientWithProjectsByToken` (grep)
- src/app/c/[token]/page.tsx contains `projects.length === 1` (grep — single project direct view logic)
- src/app/c/[token]/page.tsx contains `Tabs` import (grep — multi-project tabs)
- src/app/c/[token]/page.tsx does NOT contain `quote_items` anywhere (grep)
- src/app/admin/clients/[id]/edit/page.tsx contains `slug` input field (grep: `grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx`)
- src/app/admin/clients/[id]/edit/page.tsx contains `/^[a-z0-9-]{3,50}$/` validation pattern (grep)
- `npm run build` completa senza errori TypeScript
</acceptance_criteria>
<done>Dashboard cliente funziona con singolo progetto (vista diretta) e multi-progetto (tabs); slug impostabile dall'admin</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Funzionalità complete di Phase 04:
1. Schema multi-project con FK migrate (04-01)
2. Admin projects list + create + client detail con project cards (04-02)
3. Admin project workspace con timer project-scoped e analytics profittabilità (04-03)
4. Slug resolution middleware + dashboard cliente multi-project + slug edit (questo piano)
</what-built>
<how-to-verify>
Eseguire `npm run dev` e verificare manualmente:
**Test 1 — Admin projects list (/admin/projects)**
- Aprire /admin/projects
- Verificare che la pagina carichi senza errori
- Verificare colonne: Progetto (con nome cliente sotto), Valore, Acconto, Saldo, Timer, €/h
**Test 2 — Creazione progetto**
- Aprire /admin e cliccare su un cliente
- Verificare che /admin/clients/[id] mostri project cards (non più il workspace tab)
- Cliccare "+ Nuovo Progetto" e creare un progetto
- Verificare che il redirect vada a /admin/projects/[id]
**Test 3 — Workspace progetto (/admin/projects/[id])**
- Aprire /admin/projects/[id] per il progetto appena creato
- Verificare tutti i tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer
- Nel tab Timer: verificare play/stop funziona, ProfitabilityCard mostra ore lavorate, €/h, costo ideale, delta
**Test 4 — Impostazioni (/admin/impostazioni)**
- Aprire /admin/impostazioni
- Verificare form con campo tariffa oraria target (default 50.00)
- Cambiare il valore, salvare, ricaricare — verificare che il nuovo valore sia persistito
- Aprire /admin/projects/[id] → tab Timer → verificare che la tariffa target aggiornata appaia nella ProfitabilityCard
**Test 5 — Slug cliente**
- Aprire /admin/clients/[id]/edit per un cliente
- Impostare slug "mario-rossi" (o simile)
- Salvare e verificare che non ci siano errori
- Aprire /c/mario-rossi → verificare che carichi la dashboard del cliente corretto
**Test 6 — Fallback token**
- Con lo stesso cliente che ha lo slug impostato, aprire /c/[token-originale]
- Verificare che carichi correttamente (fallback token deve funzionare)
**Test 7 — Dashboard multi-progetto**
- Per il cliente di test, creare un secondo progetto
- Aprire /c/[token-o-slug] del cliente
- Verificare che appaiano le tabs con i nomi dei due progetti
- Cliccare tra i tabs e verificare che i dati siano scoped al progetto corretto
**Test 8 — Dashboard singolo progetto**
- Per un cliente con 1 solo progetto, aprire /c/[token]
- Verificare che NON appaiano tabs — la dashboard si apre direttamente sul progetto
</how-to-verify>
<resume-signal>
Digitare "approvato" se tutti i test passano, oppure descrivere gli errori trovati per correzione.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Public internet → /c/[slug-or-token] | Chiunque con il link accede alla dashboard; il middleware valida prima slug poi token — accesso bloccato se entrambi falliscono |
| Client dashboard → DB | getProjectView NON espone quote_items né payment amounts — invarianti CLAUDE.md + DASH-07 |
| Admin edit → clients.slug | Il campo slug è validato con regex e aggiornato solo in sessione admin autenticata |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-14 | Information Disclosure | getProjectView — payments | mitigate | SELECT include solo id, label, status — amount escluso esplicitamente. Commento nel codice documenta il motivo (DASH-07 + CLAUDE.md). grep di test in acceptance criteria verifica l'assenza di amount |
| T-04-15 | Information Disclosure | getProjectView — quote_items | mitigate | quote_items NON importato in client-view.ts. Acceptance criteria include grep check `grep "quote_items" src/lib/client-view.ts` → deve essere assente |
| T-04-16 | Tampering | clients.slug — unique constraint | mitigate | DB unique constraint su clients.slug previene slug duplicati; server action cattura unique violation e mostra errore user-friendly |
| T-04-17 | Spoofing | Slug collisione con token esistente | accept | Slug regex [a-z0-9-]{3,50} non può collidere con nanoid tokens (che usano anche maiuscole e caratteri speciali); middleware prova prima slug poi token nell'ordine corretto (D-06) |
| T-04-18 | Information Disclosure | Dashboard multi-project tabs — dati cross-project | mitigate | Ogni getProjectView(projectId) è scoped con WHERE eq(phases.project_id, projectId) — un cliente non può vedere dati di un altro cliente perché l'accesso è gate-kept dal client.id risolto dal token |
</threat_model>
<verification>
```bash
# 1. Slug API route exists
ls src/app/api/internal/validate-slug/route.ts
# 2. Middleware has slug-first
grep -n "validate-slug\|validate-token" src/proxy.ts
# 3. client-view.ts has new functions
grep "export async function" src/lib/client-view.ts
# 4. client-view.ts security invariants
grep "quote_items" src/lib/client-view.ts # must be empty
grep "amount" src/lib/client-view.ts # must not appear in payments select
# 5. Dashboard has tabs logic
grep "projects.length === 1" src/app/c/\[token\]/page.tsx
# 6. Edit page has slug field
grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx
# 7. Build clean
npm run build
```
</verification>
<success_criteria>
- /c/[slug] risolve correttamente alla dashboard del cliente → stesso comportamento di /c/[token]
- /c/[token] continua a funzionare come fallback per i link esistenti
- Dashboard con 1 progetto → nessun selettore/tabs, vista diretta
- Dashboard con 2+ progetti → shadcn Tabs con nomi brand, switch funziona
- /admin/impostazioni persiste il target_hourly_rate e la ProfitabilityCard nel workspace progetto lo usa
- `npm run build` → 0 errori TypeScript
- `grep "quote_items" src/lib/client-view.ts` → nessun output (security invariant verificato)
</success_criteria>
<output>
After completion, create `.planning/phases/04-progetti-multi-project/04-04-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`.
Key items to document:
- Come è stata implementata la logica single/multi-project nella dashboard
- Come la edit page gestisce slug vuoto → null (rimozione slug)
- Eventuali adattamenti al componente ClientDashboardView per lavorare con ProjectView invece di ClientView
- Conferma dei security invariants (no quote_items, no payment amounts in client-view.ts)
</output>