---
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>