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

31 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-progetti-multi-project 04 execute 3
04-02
04-03
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
false
PROJ-02
PROJ-04
truths artifacts key_links
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)
path provides contains
src/app/api/internal/validate-slug/route.ts API route che risolve slug → clientId clients.slug
path provides contains
src/proxy.ts Middleware con slug-first resolution validate-slug
path provides exports
src/lib/client-view.ts Query functions per dashboard multi-progetto
getClientWithProjectsByToken
getProjectView
path provides contains
src/app/c/[token]/page.tsx Dashboard cliente con logica single/multi-project projects.length === 1
path provides contains
src/app/admin/clients/[id]/edit/page.tsx Form edit con campo slug e link preview slug
from to via pattern
src/proxy.ts src/app/api/internal/validate-slug/route.ts fetch /api/internal/validate-slug?slug=... validate-slug
from to via pattern
src/app/c/[token]/page.tsx src/lib/client-view.ts getClientWithProjectsByToken(token) getClientWithProjectsByToken
from to via pattern
src/lib/client-view.ts src/db/schema.ts query phases/payments/etc con project_id 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.

<execution_context> @/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/.claude/get-shit-done/templates/summary.md </execution_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

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

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

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

**A. Creare src/app/api/internal/validate-slug/route.ts**

Clonare validate-token/route.ts sostituendo il lookup token con slug:

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

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.

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.

npx tsc --noEmit 2>&1 | head -20

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

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

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

**A. Riscrivere src/app/c/[token]/page.tsx**

Logica D-09/D-10: se 1 progetto → vista diretta; se 2+ → tabs con nomi brand.

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:

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:

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

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

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

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.

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