---
plan: "04-04"
phase: "04-progetti-multi-project"
status: complete
completed_at: "2026-05-22"
---

# Summary: 04-04 — Slug Resolution + Multi-Project Dashboard + Slug Edit

## What Was Built

**`/api/internal/validate-slug/route.ts`** — Internal API route for Edge middleware. Queries `clients.slug`, returns 200 `{ valid: true }` if found, 404 otherwise. Same pattern as `validate-token`.

**`src/proxy.ts`** — Updated client guard with slug-first resolution (D-06): tries `/api/internal/validate-slug` first, then falls back to `/api/internal/validate-token`. Route path is `/client/[token]` (not `/c/` as referenced in the plan — adapted accordingly).

**`src/lib/client-view.ts`** — Complete rewrite for multi-project model. Exports:
- `getClientWithProjectsByToken(tokenOrSlug)` — resolves slug → client (slug first, then token fallback), returns `ClientProjectSummary` with active (non-archived) projects only
- `getProjectView(projectId)` — returns full `ProjectView` scoped to one project; **payments select excludes amount** (DASH-07 + CLAUDE.md); **no quote_items** anywhere in the file

**`src/app/client/[token]/page.tsx`** — Rewritten for multi-project logic:
- 0 active projects → placeholder screen
- 1 project → `ClientDashboard` directly (no selector), using `projectViewToClientView` adapter
- 2+ projects → shared header + shadcn `Tabs` (project names as triggers) + `ClientDashboard` per tab

**`src/app/admin/clients/[id]/edit/page.tsx`** — Added slug field with:
- Pattern hint: `[a-z0-9-]{3,50}`
- Link preview showing `/client/{slug || token}`
- Error banner when redirected back with `?error=slug_taken`

**`src/app/admin/clients/[id]/actions.ts`** — Updated `clientSchema` to include `slug` (Zod regex + `.transform(v => v === "" ? null : v)` to convert empty string to null). `updateClient` now:
1. Parses slug from form data
2. Persists to `clients.slug` via db.update
3. Catches unique constraint violation → redirects to edit page with `?error=slug_taken`
4. On success: redirects to `/admin/clients/[id]` (previously no redirect)

## Key Architectural Decisions

**Adapter pattern `projectViewToClientView`**: `ClientDashboard` was written for `ClientView` shape. Rather than modifying it, an adapter function in the page converts `ProjectView` + `ClientProjectSummary.client` → `ClientView`. Key mappings:
- `view.project.accepted_total` → `clientView.client.accepted_total` (project-level, not client-level)
- `view.project.client_id` → `clientView.client.id` (used by ChatSection for comment threading)
- `deliverables[].approved_at: Date | null` → `.toISOString() | null` (ClientView expects string)
- `documents[].created_at` stripped (ClientView.documents only has id/label/url)

**Multi-project tabs with full ClientDashboard**: Each tab content renders a complete `ClientDashboard` (including its header/footer). shadcn Tabs shows only the active one via CSS. This is MVP-acceptable: the outer page provides the tab navigation, and within each tab the full dashboard renders.

**Slug empty string → null**: Zod schema transforms `""` to `null` so saving with empty slug removes it. This lets admins clear a slug without special UI.

**Security invariants verified** (grep confirmed):
- `grep "quote_items" src/lib/client-view.ts` → only in comments, not in queries
- `grep "amount" src/lib/client-view.ts` → only in comments documenting the exclusion

## Files Changed

| File | Action |
|------|--------|
| `src/app/api/internal/validate-slug/route.ts` | Created |
| `src/proxy.ts` | Updated — slug-first resolution in client guard |
| `src/lib/client-view.ts` | Complete rewrite — getClientWithProjectsByToken + getProjectView |
| `src/app/client/[token]/page.tsx` | Complete rewrite — multi-project dashboard |
| `src/app/admin/clients/[id]/edit/page.tsx` | Updated — slug field + error banner |
| `src/app/admin/clients/[id]/actions.ts` | Updated — clientSchema + slug in updateClient |

## Notes

- The plan referenced `/c/[token]` path but actual route has always been `/client/[token]`. Proxy and all references use `/client/` consistently.
- `ClientDashboard` component unchanged — the adapter absorbs all type differences.
- `getClientWithProjectsByToken` filters out archived projects; archived projects are invisible to the client dashboard.