--- 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" --- 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. @/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/.claude/get-shit-done/templates/summary.md @/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 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 Task 1: Slug API route + middleware slug-first resolution + client-view.ts rewrite src/app/api/internal/validate-slug/route.ts src/proxy.ts src/lib/client-view.ts - 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 **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 { // 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 { 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. npx tsc --noEmit 2>&1 | head -20 - 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 Slug API route e middleware aggiornato; client-view.ts riscritto per multi-project senza quote_items e senza payment amounts Task 2: Dashboard cliente multi-project (/c/[token]/page.tsx) + slug field in edit cliente src/app/c/[token]/page.tsx src/app/admin/clients/[id]/edit/page.tsx - 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) **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 (

{client.name}

Nessun progetto disponibile al momento.

); } if (projects.length === 1) { // D-09: 1 project → direct view without selector const view = await getProjectView(projects[0].id); if (!view) notFound(); return ; } // 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 (

{client.name}

{projects.map((p) => ( {p.name} ))} {projectViews.map(({ project, view }) => ( {view ? ( ) : (

Progetto non disponibile.

)}
))}
); } ``` 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

Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri.

{/* Link preview */}

Link cliente: /c/{client.slug || client.token}

``` 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.
npm run build 2>&1 | tail -20 - 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 Dashboard cliente funziona con singolo progetto (vista diretta) e multi-progetto (tabs); slug impostabile dall'admin
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) 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 Digitare "approvato" se tutti i test passano, oppure descrivere gli errori trovati per correzione.
## 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 | ```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 ``` - /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) 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)