diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index aa4fa2b..698efa2 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -15,7 +15,8 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: Foundation & Client Dashboard** - DB schema, token API, dashboard read-only per il cliente con link segreto condivisibile
- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti
- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale)
-- [ ] **Phase 4: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
+- [ ] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h
+- [ ] **Phase 5: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
## Phase Details
@@ -77,10 +78,30 @@ Decimal phases appear between their surrounding integers in numeric order.
**UI hint**: yes
**Status**: Planned — ready for execution
-### Phase 4: Claude AI Onboarding (v2)
-**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
+### Phase 4: Progetti — Multi-Project per Cliente
+**Goal**: Ogni cliente può avere N progetti indipendenti; l'admin gestisce i progetti separatamente; la dashboard cliente mostra tabs per progetti multipli; analytics di profittabilità per progetto
**Mode:** mvp
**Depends on**: Phase 3
+**Requirements**: PROJ-01, PROJ-02, PROJ-03, PROJ-04, PROJ-05
+**Success Criteria** (what must be TRUE):
+ 1. L'admin può creare più progetti per un cliente; ogni progetto ha il proprio workspace (fasi, pagamenti, preventivo, timer) accessibile da /admin/projects/[id]
+ 2. La dashboard cliente mostra tabs per 2+ progetti; con 1 solo progetto mostra direttamente il workspace senza selettore
+ 3. La pagina /admin/projects elenca tutti i progetti con €/h calcolato e bottone timer play/stop
+ 4. Il link cliente supporta slug personalizzato (/c/mario-rossi) con fallback al token; slug impostabile da /admin/clients/[id]/edit
+ 5. Il tab Timer di ogni progetto mostra analytics profittabilità: ore lavorate, accepted_total, €/h reale vs target_hourly_rate globale
+**Plans**: 4 plans
+**Plan list**:
+ - [ ] 04-01-PLAN.md — Schema migration (projects, slug, settings, FK migration) + drizzle-kit push + query layer
+ - [ ] 04-02-PLAN.md — Admin projects list (/admin/projects) + ProjectRow + client detail project cards
+ - [ ] 04-03-PLAN.md — Admin project workspace (/admin/projects/[id]) + TimerTab + ProfitabilityCard + /admin/impostazioni
+ - [ ] 04-04-PLAN.md — Slug resolution middleware + client dashboard multi-project + slug edit
+**UI hint**: yes
+**Status**: Planning complete
+
+### Phase 5: Claude AI Onboarding (v2)
+**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
+**Mode:** mvp
+**Depends on**: Phase 4
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
**Success Criteria** (what must be TRUE):
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
@@ -98,6 +119,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
-| 2. Admin Area & Interactive Features | 4/4 | Planned | - |
-| 3. Service Catalog & Quote Builder | 4/4 | Planned | - |
-| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
\ No newline at end of file
+| 2. Admin Area & Interactive Features | 4/4 | ✅ Done | 2026-05-15 |
+| 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 |
+| 4. Progetti — Multi-Project per Cliente | 0/TBD | Pending | - |
+| 5. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
\ No newline at end of file
diff --git a/.planning/phases/04-progetti-multi-project/04-01-PLAN.md b/.planning/phases/04-progetti-multi-project/04-01-PLAN.md
new file mode 100644
index 0000000..9370014
--- /dev/null
+++ b/.planning/phases/04-progetti-multi-project/04-01-PLAN.md
@@ -0,0 +1,761 @@
+---
+phase: 04-progetti-multi-project
+plan: "01"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/db/schema.ts
+ - src/lib/admin-queries.ts
+ - src/lib/settings.ts
+autonomous: true
+requirements:
+ - PROJ-01
+ - PROJ-03
+ - PROJ-05
+
+must_haves:
+ truths:
+ - "La tabella projects esiste nel DB con colonne id, client_id, name, accepted_total, archived, created_at"
+ - "Le tabelle phases, payments, quote_items, time_entries, documents, notes usano project_id (non client_id) come FK"
+ - "La tabella clients ha il campo slug opzionale e univoco"
+ - "La tabella settings esiste nel DB con colonne key, value, updated_at"
+ - "drizzle-kit push completes con exit code 0"
+ - "getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects sono funzioni esportate da admin-queries.ts"
+ - "getSetting, updateSetting, getTargetHourlyRate sono funzioni esportate da settings.ts"
+ artifacts:
+ - path: "src/db/schema.ts"
+ provides: "Schema completo con projects table, slug su clients, settings table, FK migrated"
+ contains: "export const projects = pgTable"
+ - path: "src/lib/admin-queries.ts"
+ provides: "Query layer per progetti"
+ exports:
+ - getAllProjectsWithPayments
+ - getProjectFullDetail
+ - getClientWithProjects
+ - path: "src/lib/settings.ts"
+ provides: "Utility per settings key-value"
+ exports:
+ - getSetting
+ - updateSetting
+ - getTargetHourlyRate
+ key_links:
+ - from: "src/lib/admin-queries.ts"
+ to: "src/db/schema.ts"
+ via: "import projects, settings from @/db/schema"
+ pattern: "from.*@/db/schema"
+ - from: "src/lib/settings.ts"
+ to: "src/db/schema.ts"
+ via: "import settings from @/db/schema"
+ pattern: "import.*settings.*from"
+---
+
+
+Migrazione schema database da modello single-project a multi-project. Aggiunge la tabella `projects` come contenitore principale del lavoro, migra 6 FK da `client_id` a `project_id`, aggiunge `slug` ai clients, crea la tabella `settings`, ed esegue il push al DB Neon. Refactora il query layer admin per supportare le nuove relazioni.
+
+Purpose: Fondamenta bloccanti per tutte le Wave 2 e 3. Nessuna UI può essere costruita finché il DB non ha la struttura corretta e le query non restituiscono dati project-scoped.
+
+Output: Schema applicato al DB live, tre nuove funzioni query esportate, utility settings.
+
+
+
+@/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/.planning/STATE.md
+@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
+
+
+
+
+Tabelle con FK da clients che devono diventare project_id (D-02):
+- phases: client_id → project_id (references projects.id)
+- payments: client_id → project_id (references projects.id)
+- quote_items: client_id → project_id (references projects.id)
+- time_entries: client_id → project_id (references projects.id)
+- documents: client_id → project_id (references projects.id)
+- notes: client_id → project_id (references projects.id)
+
+clients mantiene: id, name, brand_name, brief, token, archived, created_at
+clients perde: accepted_total (si sposta su projects — il campo clients.accepted_total rimane ma diventa unused)
+clients aggiunge: slug text().unique() (D-04)
+
+comments rimane su entity_id generico — NON toccato (D-02).
+tasks rimane su phase_id — NON toccato.
+deliverables rimane su task_id — NON toccato.
+service_catalog rimane invariato.
+
+
+
+
+
+
+ Task 1: Schema migration — projects table, slug, settings, FK migration
+ src/db/schema.ts
+
+
+ - src/db/schema.ts — leggere l'intero file prima di modificarlo; capire l'ordine attuale delle tabelle, i pattern di import, la sezione RELATIONS e la sezione TYPESCRIPT TYPES
+ - CLAUDE.md — Architecture Constraints: token mai PK, quote_items mai esposti via client API, deliverables.approved_at immutable
+
+
+
+Modificare src/db/schema.ts con le seguenti operazioni PRECISE, nell'ordine:
+
+**1. Aggiungere slug a clients (D-04)**
+Dopo la riga `token: text("token").notNull().unique().$defaultFn(() => nanoid()),` aggiungere:
+```
+ // slug è opzionale, univoco, URL-safe (es. mario-rossi) — se assente, il link usa il token
+ slug: text("slug").unique(),
+```
+LASCIARE accepted_total su clients — rimane nel schema per compatibilità ma diventa unused (D-03 dice che accepted_total si sposta su projects, non che viene rimosso da clients).
+
+**2. Inserire la tabella projects DOPO clients e PRIMA di phases (D-01)**
+```typescript
+// ============ PROJECTS ============
+export const projects = pgTable("projects", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => nanoid()),
+ client_id: text("client_id")
+ .notNull()
+ .references(() => clients.id, { onDelete: "cascade" }),
+ name: text("name").notNull(), // brand/project name
+ accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
+ archived: boolean("archived").notNull().default(false),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+```
+
+**3. Migrare FK in phases (D-02)** — cambiare:
+```typescript
+ client_id: text("client_id")
+ .notNull()
+ .references(() => clients.id, { onDelete: "cascade" }),
+```
+con:
+```typescript
+ project_id: text("project_id")
+ .notNull()
+ .references(() => projects.id, { onDelete: "cascade" }),
+```
+
+**4. Migrare FK in payments (D-02)** — stesso pattern: sostituire `client_id` → `project_id` con references(() => projects.id, { onDelete: "cascade" })
+
+**5. Migrare FK in documents (D-02)** — stesso pattern
+
+**6. Migrare FK in notes (D-02)** — stesso pattern
+
+**7. Migrare FK in time_entries (D-19)** — stesso pattern: `client_id` → `project_id`
+
+**8. Migrare FK in quote_items (D-02)** — stesso pattern. MANTENERE il commento "NEVER exposed via client API" sul campo.
+
+**9. Aggiungere tabella settings ALLA FINE prima della sezione RELATIONS (D-21 — Claude's Discretion: key-value)**
+```typescript
+// ============ SETTINGS (global admin settings — key-value store) ============
+export const settings = pgTable("settings", {
+ key: text("key").primaryKey(),
+ value: text("value").notNull(),
+ updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+});
+```
+
+**10. Aggiornare la sezione RELATIONS**
+
+Aggiungere projectsRelations:
+```typescript
+export const projectsRelations = relations(projects, ({ one, many }) => ({
+ client: one(clients, { fields: [projects.client_id], references: [clients.id] }),
+ phases: many(phases),
+ payments: many(payments),
+ documents: many(documents),
+ notes: many(notes),
+ quote_items: many(quote_items),
+ time_entries: many(time_entries),
+}));
+```
+
+Aggiornare clientsRelations — aggiungere `projects: many(projects)`, rimuovere le relazioni che si spostano (phases, payments, documents, notes, quote_items rimangono su clients? NO — seguire il nuovo schema: phases/payments/etc. puntano ora a projects, non a clients). clientsRelations diventa:
+```typescript
+export const clientsRelations = relations(clients, ({ many }) => ({
+ projects: many(projects),
+}));
+```
+
+Aggiornare phasesRelations — cambiare `client` in `project`:
+```typescript
+export const phasesRelations = relations(phases, ({ one, many }) => ({
+ project: one(projects, { fields: [phases.project_id], references: [projects.id] }),
+ tasks: many(tasks),
+}));
+```
+
+Aggiornare paymentsRelations:
+```typescript
+export const paymentsRelations = relations(payments, ({ one }) => ({
+ project: one(projects, { fields: [payments.project_id], references: [projects.id] }),
+}));
+```
+
+Aggiornare documentsRelations:
+```typescript
+export const documentsRelations = relations(documents, ({ one }) => ({
+ project: one(projects, { fields: [documents.project_id], references: [projects.id] }),
+}));
+```
+
+Aggiornare notesRelations:
+```typescript
+export const notesRelations = relations(notes, ({ one }) => ({
+ project: one(projects, { fields: [notes.project_id], references: [projects.id] }),
+}));
+```
+
+Aggiornare quoteItemsRelations:
+```typescript
+export const quoteItemsRelations = relations(quote_items, ({ one }) => ({
+ project: one(projects, { fields: [quote_items.project_id], references: [projects.id] }),
+ service: one(service_catalog, {
+ fields: [quote_items.service_id],
+ references: [service_catalog.id],
+ }),
+}));
+```
+
+Aggiungere timeEntriesRelations:
+```typescript
+export const timeEntriesRelations = relations(time_entries, ({ one }) => ({
+ project: one(projects, { fields: [time_entries.project_id], references: [projects.id] }),
+}));
+```
+
+**11. Aggiornare la sezione TYPESCRIPT TYPES**
+Aggiungere dopo le type esistenti:
+```typescript
+export type Project = typeof projects.$inferSelect;
+export type NewProject = typeof projects.$inferInsert;
+export type Setting = typeof settings.$inferSelect;
+export type NewSetting = typeof settings.$inferInsert;
+```
+
+
+
+ npx tsc --noEmit 2>&1 | head -30
+
+
+
+ - src/db/schema.ts contains `export const projects = pgTable("projects"`
+ - src/db/schema.ts contains `slug: text("slug").unique()`
+ - src/db/schema.ts contains `export const settings = pgTable("settings"`
+ - src/db/schema.ts contains `project_id: text("project_id")` (at least 6 occurrences for the 6 migrated tables)
+ - src/db/schema.ts does NOT contain `client_id` in phases, payments, documents, notes, time_entries, quote_items tables (grep: `grep -n "client_id" src/db/schema.ts` — solo clients table e projects table devono avere client_id)
+ - src/db/schema.ts contains `export type Project = typeof projects.$inferSelect`
+ - TypeScript compila senza errori su src/db/schema.ts
+
+
+ schema.ts aggiornato con tutte le nuove tabelle e FK migrate, TypeScript pulito
+
+
+
+ Task 2: [BLOCKING] drizzle-kit push — apply schema to Neon DB
+ src/db/schema.ts
+
+
+ - drizzle.config.ts — verificare la configurazione del push (URL, schema path, out dir)
+
+
+
+Eseguire il push del nuovo schema al database Neon live.
+
+IMPORTANTE: All data is test data — hard migration (drop/recreate tables) is acceptable per A2 dell'RESEARCH.md Assumptions Log. Confermare eventuali prompt interattivi di drizzle-kit con "yes" se richiesto.
+
+```bash
+npx drizzle-kit push
+```
+
+Se drizzle-kit chiede conferma per operazioni distruttive (drop/recreate di colonne), rispondere "yes" — tutti i dati sono di test.
+
+Dopo il push, verificare che le tabelle esistano nel DB con la struttura corretta.
+
+
+
+ npx drizzle-kit push 2>&1; echo "Exit code: $?"
+
+
+
+ - Il comando `npx drizzle-kit push` completa con exit code 0
+ - L'output non contiene "error" o "failed" (case-insensitive)
+ - L'output conferma che le tabelle sono state create/aggiornate
+
+
+ Schema applicato al DB Neon live, tutte le nuove tabelle presenti
+
+
+
+ Task 3: Query layer — getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects + settings.ts
+
+ src/lib/admin-queries.ts
+ src/lib/settings.ts
+
+
+
+ - src/lib/admin-queries.ts — leggere l'intero file per capire i pattern di query, i tipi esistenti (ClientWithPayments, QuoteItemWithLabel), getAllClientsWithPayments e getClientFullDetail che sono i template esatti per le nuove funzioni
+ - src/db/schema.ts — verificare i nuovi tipi disponibili: Project, Setting, e le FK corrette (project_id)
+
+
+
+**A. Aggiornare src/lib/admin-queries.ts**
+
+Aggiungere in cima agli import `projects` e `settings` dalla schema:
+```typescript
+import {
+ clients,
+ projects,
+ payments,
+ phases,
+ tasks,
+ deliverables,
+ comments,
+ documents,
+ notes,
+ time_entries,
+ quote_items,
+ service_catalog,
+ settings,
+} from "@/db/schema";
+```
+
+Aggiungere import dei nuovi tipi:
+```typescript
+import type {
+ Client,
+ Project,
+ Phase,
+ Task,
+ Deliverable,
+ Payment,
+ Document,
+ Note,
+ Comment,
+ ServiceCatalog,
+} from "@/db/schema";
+```
+
+**1. Aggiungere tipo ProjectWithPayments e funzione getAllProjectsWithPayments**
+
+Seguire ESATTAMENTE il pattern di ClientWithPayments e getAllClientsWithPayments (linee 28-105 del file corrente), sostituendo clients con projects e client_id con project_id:
+
+```typescript
+export type ProjectWithPayments = {
+ id: string;
+ name: string;
+ client: { id: string; name: string; slug: string | null };
+ accepted_total: string;
+ archived: boolean;
+ created_at: Date;
+ payments: Array<{ id: string; label: string; status: string; amount: string }>;
+ activeTimerEntryId: string | null;
+ activeTimerStartedAt: Date | null;
+ totalTrackedSeconds: number;
+};
+
+export async function getAllProjectsWithPayments(
+ includeArchived = false
+): Promise {
+ // 1. Fetch all projects with their parent client
+ const allProjects = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ client_id: projects.client_id,
+ accepted_total: projects.accepted_total,
+ archived: projects.archived,
+ created_at: projects.created_at,
+ })
+ .from(projects)
+ .orderBy(projects.created_at);
+
+ const visible = includeArchived
+ ? allProjects
+ : allProjects.filter((p) => !p.archived);
+
+ if (visible.length === 0) return [];
+
+ const projectIds = visible.map((p) => p.id);
+ const clientIds = [...new Set(visible.map((p) => p.client_id))];
+
+ // 2. Parallel: payments, active timer, totals, parent clients
+ const [allPayments, activeEntries, totals, parentClients] = await Promise.all([
+ db
+ .select()
+ .from(payments)
+ .where(inArray(payments.project_id, projectIds)),
+
+ db
+ .select({
+ id: time_entries.id,
+ project_id: time_entries.project_id,
+ started_at: time_entries.started_at,
+ })
+ .from(time_entries)
+ .where(isNull(time_entries.ended_at)),
+
+ db
+ .select({
+ project_id: time_entries.project_id,
+ total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`,
+ })
+ .from(time_entries)
+ .where(inArray(time_entries.project_id, projectIds))
+ .groupBy(time_entries.project_id),
+
+ db
+ .select({ id: clients.id, name: clients.name, slug: clients.slug })
+ .from(clients)
+ .where(inArray(clients.id, clientIds)),
+ ]);
+
+ // 3. Build result map
+ return visible.map((project) => {
+ const projectPayments = allPayments.filter((p) => p.project_id === project.id);
+ const activeEntry = activeEntries.find((e) => e.project_id === project.id);
+ const totalRow = totals.find((t) => t.project_id === project.id);
+ const parentClient = parentClients.find((c) => c.id === project.client_id);
+
+ return {
+ id: project.id,
+ name: project.name,
+ client: parentClient ?? { id: project.client_id, name: "—", slug: null },
+ accepted_total: project.accepted_total ?? "0",
+ archived: project.archived,
+ created_at: project.created_at,
+ payments: projectPayments.map((p) => ({
+ id: p.id,
+ label: p.label,
+ status: p.status,
+ amount: String(p.amount),
+ })),
+ activeTimerEntryId: activeEntry?.id ?? null,
+ activeTimerStartedAt: activeEntry?.started_at ?? null,
+ totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
+ };
+ });
+}
+```
+
+**2. Aggiungere tipo ProjectFullDetail e funzione getProjectFullDetail**
+
+Seguire il pattern di getClientFullDetail (template completo nel RESEARCH.md alle linee 455-586). Ogni query DEVE avere `.where(eq(table.project_id, id))` — verificare uno per uno:
+
+```typescript
+export type ProjectFullDetail = {
+ project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } };
+ phases: Array }>;
+ payments: Payment[];
+ documents: Document[];
+ notes: Note[];
+ comments: Comment[];
+ quoteItems: QuoteItemWithLabel[];
+ activeServices: ServiceCatalog[];
+ activeTimerEntryId: string | null;
+ activeTimerStartedAt: Date | null;
+ totalTrackedSeconds: number;
+};
+
+export async function getProjectFullDetail(id: string): Promise {
+ // 1. Fetch project
+ const projectRows = await db
+ .select()
+ .from(projects)
+ .where(eq(projects.id, id))
+ .limit(1);
+
+ if (projectRows.length === 0) return null;
+ const project = projectRows[0];
+
+ // 2. Fetch parent client
+ const clientRows = await db
+ .select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, slug: clients.slug })
+ .from(clients)
+ .where(eq(clients.id, project.client_id))
+ .limit(1);
+ const client = clientRows[0] ?? { id: project.client_id, name: "—", brand_name: "—", slug: null };
+
+ // 3. Phases scoped to THIS project (not client)
+ const phasesRows = await db
+ .select()
+ .from(phases)
+ .where(eq(phases.project_id, id))
+ .orderBy(asc(phases.sort_order));
+
+ const phaseIds = phasesRows.map((p) => p.id);
+
+ // 4. 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);
+
+ // 5. Deliverables scoped to this project's tasks
+ const deliverablesRows = taskIds.length === 0
+ ? []
+ : await db
+ .select()
+ .from(deliverables)
+ .where(inArray(deliverables.task_id, taskIds));
+
+ // 6. Parallel: payments, documents, notes, comments, quote items, active services, timer
+ const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntry, totalRes] =
+ await Promise.all([
+ db.select().from(payments).where(eq(payments.project_id, id)),
+ db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
+ db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
+ db
+ .select({
+ id: quote_items.id,
+ label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
+ custom_label: quote_items.custom_label,
+ service_id: quote_items.service_id,
+ quantity: quote_items.quantity,
+ unit_price: quote_items.unit_price,
+ subtotal: quote_items.subtotal,
+ project_id: quote_items.project_id,
+ })
+ .from(quote_items)
+ .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
+ .where(eq(quote_items.project_id, id))
+ .orderBy(asc(quote_items.id)),
+ db.select().from(service_catalog).where(eq(service_catalog.active, true)).orderBy(asc(service_catalog.name)),
+ db
+ .select({ id: time_entries.id, started_at: time_entries.started_at })
+ .from(time_entries)
+ .where(eq(time_entries.project_id, id))
+ .where(isNull(time_entries.ended_at))
+ .limit(1),
+ db
+ .select({ total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)` })
+ .from(time_entries)
+ .where(eq(time_entries.project_id, id)),
+ ]);
+
+ // 7. Comments (polymorphic) — collect entity IDs from this project
+ const allEntityIds = [id, ...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));
+
+ // 8. Rebuild hierarchy
+ const phasesWithTasks = phasesRows.map((phase) => ({
+ ...phase,
+ tasks: tasksRows
+ .filter((t) => t.phase_id === phase.id)
+ .map((task) => ({
+ ...task,
+ deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
+ })),
+ }));
+
+ return {
+ project: { ...project, client } as any,
+ phases: phasesWithTasks,
+ payments: paymentsRows,
+ documents: documentsRows,
+ notes: notesRows,
+ comments: commentsRows,
+ quoteItems: quoteItemRows as QuoteItemWithLabel[],
+ activeServices: activeServiceRows,
+ activeTimerEntryId: activeEntry[0]?.id ?? null,
+ activeTimerStartedAt: activeEntry[0]?.started_at ?? null,
+ totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
+ };
+}
+```
+
+NOTA: La chiamata `.where()` doppia nella query activeEntry non è valida in Drizzle — combinare con `and()`:
+```typescript
+import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
+// ...
+.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
+```
+
+**3. Aggiungere tipo ClientWithProjects e funzione getClientWithProjects**
+
+```typescript
+export type ClientWithProjects = Client & {
+ projects: Array<{
+ id: string;
+ name: string;
+ accepted_total: string;
+ archived: boolean;
+ created_at: Date;
+ }>;
+};
+
+export async function getClientWithProjects(clientId: string): Promise {
+ const clientRows = await db
+ .select()
+ .from(clients)
+ .where(eq(clients.id, clientId))
+ .limit(1);
+
+ if (clientRows.length === 0) return null;
+ const client = clientRows[0];
+
+ const projectRows = await db
+ .select()
+ .from(projects)
+ .where(eq(projects.client_id, clientId))
+ .orderBy(asc(projects.created_at));
+
+ return {
+ ...client,
+ projects: projectRows.map((p) => ({
+ id: p.id,
+ name: p.name,
+ accepted_total: p.accepted_total ?? "0",
+ archived: p.archived,
+ created_at: p.created_at,
+ })),
+ };
+}
+```
+
+**B. Creare src/lib/settings.ts (nuovo file)**
+
+```typescript
+import { db } from "@/db";
+import { settings } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+
+// Constant for all known settings keys — prevents typos at call sites
+export const SETTINGS_KEYS = {
+ TARGET_HOURLY_RATE: "target_hourly_rate",
+} as const;
+
+export async function getSetting(key: string): Promise {
+ const rows = await db
+ .select({ value: settings.value })
+ .from(settings)
+ .where(eq(settings.key, key))
+ .limit(1);
+ return rows[0]?.value ?? null;
+}
+
+export async function updateSetting(key: string, value: string): Promise {
+ const existing = await getSetting(key);
+ if (existing !== null) {
+ await db
+ .update(settings)
+ .set({ value, updated_at: new Date() })
+ .where(eq(settings.key, key));
+ } else {
+ await db.insert(settings).values({ key, value });
+ }
+ revalidatePath("/admin/impostazioni");
+}
+
+export async function getTargetHourlyRate(): Promise {
+ const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
+ // Default 50€/h if never set by admin
+ return value ? parseFloat(value) : 50;
+}
+```
+
+
+
+ npx tsc --noEmit 2>&1 | head -40
+
+
+
+ - src/lib/admin-queries.ts exports `getAllProjectsWithPayments` (grep: `grep "export async function getAllProjectsWithPayments" src/lib/admin-queries.ts`)
+ - src/lib/admin-queries.ts exports `getProjectFullDetail` (grep: `grep "export async function getProjectFullDetail" src/lib/admin-queries.ts`)
+ - src/lib/admin-queries.ts exports `getClientWithProjects` (grep: `grep "export async function getClientWithProjects" src/lib/admin-queries.ts`)
+ - src/lib/settings.ts exists (grep: `ls src/lib/settings.ts`)
+ - src/lib/settings.ts exports `getSetting`, `updateSetting`, `getTargetHourlyRate` (grep: `grep "export async function" src/lib/settings.ts`)
+ - src/lib/settings.ts contains `SETTINGS_KEYS` constant (grep: `grep "SETTINGS_KEYS" src/lib/settings.ts`)
+ - `npx tsc --noEmit` completa senza errori
+ - `npm run build` completa senza errori TypeScript
+
+
+ Query layer aggiornato con tutte le funzioni project-scoped; settings.ts creato con SETTINGS_KEYS constant
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin → DB | Tutte le operazioni di schema e query avvengono lato server; nessun dato raw esposto al browser in questo piano |
+| quote_items isolation | quote_items ora scoped a project_id — getProjectFullDetail li include solo nelle query admin, mai nel client-view path |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-01 | Information Disclosure | getProjectFullDetail | mitigate | Ogni sub-query ha WHERE eq(table.project_id, id) — verificare che nessuna query usi client_id al posto di project_id come scope; commento esplicito su ogni query |
+| T-04-02 | Information Disclosure | quote_items | mitigate | quote_items inclusi SOLO in getProjectFullDetail (admin path); la funzione client-view (Wave 3) NON deve mai includere quote_items — invariante CLAUDE.md preserved |
+| T-04-03 | Tampering | drizzle-kit push | accept | Hard migration su dati di test; nessun dato production a rischio (A2 assunto e verificato) |
+| T-04-04 | Elevation of Privilege | settings table | accept | Settings contengono solo target_hourly_rate (non dati sensibili); letta in admin context; nessun dato cliente esposto |
+
+
+
+Dopo il completamento di tutti e 3 i task:
+
+```bash
+# 1. Schema check
+grep -c "project_id" src/db/schema.ts
+
+# 2. Verify no client_id FK in migrated tables (solo clients e projects devono averlo)
+grep -n "client_id" src/db/schema.ts
+
+# 3. New tables present
+grep "export const projects\|export const settings" src/db/schema.ts
+
+# 4. Query functions exported
+grep "export async function" src/lib/admin-queries.ts
+
+# 5. Settings utility complete
+grep "export" src/lib/settings.ts
+
+# 6. TypeScript clean
+npx tsc --noEmit
+
+# 7. Build clean
+npm run build
+```
+
+
+
+- `grep "export const projects = pgTable" src/db/schema.ts` → output presente
+- `grep "export const settings = pgTable" src/db/schema.ts` → output presente
+- `grep -n "client_id" src/db/schema.ts` → solo righe in clients table e projects table (client_id come FK verso clients)
+- `grep "export async function getAllProjectsWithPayments\|export async function getProjectFullDetail\|export async function getClientWithProjects" src/lib/admin-queries.ts` → 3 match
+- `npx drizzle-kit push` ha completato con exit code 0
+- `npx tsc --noEmit` → no errori
+- `npm run build` → no errori
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/04-progetti-multi-project/04-02-PLAN.md b/.planning/phases/04-progetti-multi-project/04-02-PLAN.md
new file mode 100644
index 0000000..e09c1d2
--- /dev/null
+++ b/.planning/phases/04-progetti-multi-project/04-02-PLAN.md
@@ -0,0 +1,607 @@
+---
+phase: 04-progetti-multi-project
+plan: "02"
+type: execute
+wave: 2
+depends_on:
+ - 04-01-PLAN.md
+files_modified:
+ - src/components/admin/NavBar.tsx
+ - src/components/admin/ProjectRow.tsx
+ - src/app/admin/projects/page.tsx
+ - src/app/admin/projects/new/page.tsx
+ - src/app/admin/projects/project-actions.ts
+ - src/app/admin/clients/[id]/page.tsx
+autonomous: true
+requirements:
+ - PROJ-01
+ - PROJ-03
+
+must_haves:
+ truths:
+ - "La navbar admin mostra i link Progetti e Impostazioni oltre a Clienti e Catalogo"
+ - "La pagina /admin/projects elenca tutti i progetti con colonne Nome (+ cliente), Valore, Acconto, Saldo, Timer, €/h"
+ - "Il bottone '+ Nuovo Progetto' in /admin/projects apre un form che chiede nome e selezione cliente"
+ - "La pagina /admin/clients/[id] mostra cards dei progetti del cliente con bottone '+ Nuovo Progetto'"
+ - "Cliccando una card progetto si naviga a /admin/projects/[id]"
+ - "createProject e archiveProject sono server actions funzionanti"
+ artifacts:
+ - path: "src/components/admin/NavBar.tsx"
+ provides: "NavBar con link Progetti e Impostazioni"
+ contains: "href=\"/admin/projects\""
+ - path: "src/components/admin/ProjectRow.tsx"
+ provides: "Riga progetto per la lista /admin/projects"
+ contains: "ProjectWithPayments"
+ - path: "src/app/admin/projects/page.tsx"
+ provides: "Pagina lista tutti i progetti"
+ contains: "getAllProjectsWithPayments"
+ - path: "src/app/admin/projects/new/page.tsx"
+ provides: "Form creazione progetto con selezione cliente"
+ contains: "createProject"
+ - path: "src/app/admin/projects/project-actions.ts"
+ provides: "Server actions: createProject, archiveProject, updateProjectAcceptedTotal"
+ contains: "export async function createProject"
+ - path: "src/app/admin/clients/[id]/page.tsx"
+ provides: "Pagina cliente modificata per mostrare project cards"
+ contains: "getClientWithProjects"
+ key_links:
+ - from: "src/app/admin/projects/page.tsx"
+ to: "src/lib/admin-queries.ts"
+ via: "getAllProjectsWithPayments()"
+ pattern: "getAllProjectsWithPayments"
+ - from: "src/app/admin/clients/[id]/page.tsx"
+ to: "src/lib/admin-queries.ts"
+ via: "getClientWithProjects(id)"
+ pattern: "getClientWithProjects"
+ - from: "src/components/admin/ProjectRow.tsx"
+ to: "src/app/admin/timer-actions.ts"
+ via: "TimerCell with project_id"
+ pattern: "TimerCell"
+---
+
+
+Admin projects list e client detail rewrite. Consegna la prima slice verticale visibile: l'admin può vedere tutti i progetti in /admin/projects, creare nuovi progetti da /admin/clients/[id] o dal form globale, e navigare ai workspace progetto.
+
+Purpose: Rende operativa la struttura multi-project nell'area admin senza ancora richiedere il workspace completo del progetto (quello viene in 04-03). Dopo questo piano l'admin può creare e navigare progetti.
+
+Output: NavBar aggiornata, /admin/projects funzionale, /admin/clients/[id] mostra project cards.
+
+
+
+@/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
+
+
+
+
+Da src/lib/admin-queries.ts (creato in 04-01):
+```typescript
+export type ProjectWithPayments = {
+ id: string;
+ name: string;
+ client: { id: string; name: string; slug: string | null };
+ accepted_total: string;
+ archived: boolean;
+ created_at: Date;
+ payments: Array<{ id: string; label: string; status: string; amount: string }>;
+ activeTimerEntryId: string | null;
+ activeTimerStartedAt: Date | null;
+ totalTrackedSeconds: number;
+};
+
+export async function getAllProjectsWithPayments(includeArchived?: boolean): Promise;
+export async function getClientWithProjects(clientId: string): Promise;
+
+export type ClientWithProjects = Client & {
+ projects: Array<{ id: string; name: string; accepted_total: string; archived: boolean; created_at: Date }>;
+};
+```
+
+Da src/app/admin/timer-actions.ts (da aggiornare in 04-03, ma TimerCell già usato):
+```typescript
+// TimerCell props (da src/components/admin/TimerCell.tsx):
+// clientId: string ← NOTA: questo è un nome legacy, in ProjectRow passiamo project.id
+// activeEntryId: string | null
+// activeStartedAt: Date | null
+// totalTrackedSeconds: number
+```
+
+Pattern ClientRow (da clonare per ProjectRow):
+- src/components/admin/ClientRow.tsx — usa statusConfig, Badge, TimerCell, Link
+- Colonne ClientRow: nome, token/link, LTV (accepted_total), acconto badge, saldo badge, timer
+- Colonne ProjectRow (D-14): Nome+Cliente, Valore, Acconto, Saldo, Timer, €/h
+
+€/h in lista = accepted_total ÷ (totalTrackedSeconds / 3600). Se ore = 0, mostrare "—".
+
+Pattern admin page (da src/app/admin/page.tsx):
+- export const revalidate = 0
+- Server component asincrono, chiama query, passa a Row component
+
+
+
+
+
+
+ Task 1: NavBar + ProjectRow + server actions (createProject, archiveProject, updateProjectAcceptedTotal)
+
+ src/components/admin/NavBar.tsx
+ src/components/admin/ProjectRow.tsx
+ src/app/admin/projects/project-actions.ts
+
+
+
+ - src/components/admin/NavBar.tsx — leggere struttura attuale (link presenti, stili, imports)
+ - src/components/admin/ClientRow.tsx — leggere interamente: questo è il template ESATTO per ProjectRow
+ - src/components/admin/TimerCell.tsx — leggere per capire la prop interface (clientId, activeEntryId, activeStartedAt, totalTrackedSeconds)
+ - src/app/admin/clients/[id]/quote-actions.ts — pattern server action (requireAdmin, revalidatePath)
+
+
+
+**A. Aggiornare src/components/admin/NavBar.tsx**
+
+Aggiungere i link "Progetti" e "Impostazioni" al NavBar esistente. Leggere il file per trovare dove sono i link esistenti (Clienti, Statistiche, Catalogo) e aggiungere nell'ordine:
+- Clienti (/admin)
+- Progetti (/admin/projects) ← NUOVO
+- Statistiche (/admin/analytics)
+- Catalogo (/admin/catalog)
+- Impostazioni (/admin/impostazioni) ← NUOVO
+
+Ogni link usa il pattern esistente: ``.
+
+**B. Creare src/components/admin/ProjectRow.tsx**
+
+Clonare ClientRow.tsx sostituendo:
+- `ClientWithPayments` → `ProjectWithPayments` (import da @/lib/admin-queries)
+- Colonna nome: `project.name` in bold, `project.client.name` in testo secondario xs
+- Rimuovere colonna token/link cliente (non si mostra il link pubblico nella lista progetti)
+- Colonna valore: `€{parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`
+- Colonna Acconto: badge per `project.payments.find(p => p.label.toLowerCase().includes("acconto"))`
+- Colonna Saldo: badge per `project.payments.find(p => p.label.toLowerCase().includes("saldo"))`
+- Colonna Timer: ``
+- Colonna €/h: calcolo inline — `const hours = project.totalTrackedSeconds / 3600; const eurPerHour = hours > 0 ? parseFloat(project.accepted_total) / hours : null;` — mostrare `€{eurPerHour.toFixed(2)}/h` oppure `—` se null
+
+Link cliccabile sul nome: ``.
+
+Usare gli stessi statusConfig di ClientRow per i badge pagamento.
+
+**C. Creare src/app/admin/projects/project-actions.ts**
+
+```typescript
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { requireAdmin } from "@/lib/auth"; // stesso pattern delle altre actions
+import { db } from "@/db";
+import { projects, clients } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { nanoid } from "nanoid";
+
+export async function createProject(fd: FormData): Promise<{ projectId: string }> {
+ await requireAdmin();
+ const name = String(fd.get("name") ?? "").trim();
+ const clientId = String(fd.get("client_id") ?? "").trim();
+
+ if (!name) throw new Error("Nome progetto obbligatorio");
+ if (!clientId) throw new Error("Cliente obbligatorio");
+
+ // Verify client exists
+ const clientRows = await db
+ .select({ id: clients.id })
+ .from(clients)
+ .where(eq(clients.id, clientId))
+ .limit(1);
+ if (clientRows.length === 0) throw new Error("Cliente non trovato");
+
+ const id = nanoid();
+ await db.insert(projects).values({ id, client_id: clientId, name });
+
+ revalidatePath("/admin/projects");
+ revalidatePath(`/admin/clients/${clientId}`);
+ return { projectId: id };
+}
+
+export async function archiveProject(projectId: string): Promise {
+ await requireAdmin();
+ await db.update(projects).set({ archived: true }).where(eq(projects.id, projectId));
+ revalidatePath("/admin/projects");
+}
+
+export async function unarchiveProject(projectId: string): Promise {
+ await requireAdmin();
+ await db.update(projects).set({ archived: false }).where(eq(projects.id, projectId));
+ revalidatePath("/admin/projects");
+}
+
+export async function updateProjectAcceptedTotal(projectId: string, acceptedTotal: string): Promise {
+ await requireAdmin();
+ await db.update(projects).set({ accepted_total: acceptedTotal }).where(eq(projects.id, projectId));
+ revalidatePath(`/admin/projects/${projectId}`);
+}
+```
+
+NOTA: Verificare il path di `requireAdmin` leggendo un altro actions file (es. quote-actions.ts) — usare lo stesso import esatto.
+
+
+
+ npx tsc --noEmit 2>&1 | head -20
+
+
+
+ - src/components/admin/NavBar.tsx contains `href="/admin/projects"` (grep)
+ - src/components/admin/NavBar.tsx contains `href="/admin/impostazioni"` (grep)
+ - src/components/admin/ProjectRow.tsx exists e contains `ProjectWithPayments` (grep)
+ - src/components/admin/ProjectRow.tsx contains `totalTrackedSeconds / 3600` (formula €/h) (grep)
+ - src/app/admin/projects/project-actions.ts exports `createProject`, `archiveProject`, `unarchiveProject`, `updateProjectAcceptedTotal` (grep: `grep "export async function" src/app/admin/projects/project-actions.ts`)
+ - TypeScript compila senza errori
+
+
+ NavBar aggiornata, ProjectRow pronto, server actions create
+
+
+
+ Task 2: /admin/projects list page + /admin/projects/new form + /admin/clients/[id] project cards
+
+ src/app/admin/projects/page.tsx
+ src/app/admin/projects/new/page.tsx
+ src/app/admin/clients/[id]/page.tsx
+
+
+
+ - src/app/admin/page.tsx — template esatto per la struttura della lista (revalidate, table, map su rows)
+ - src/app/admin/clients/[id]/page.tsx — leggere INTERO FILE: va riscritto per mostrare project cards invece del workspace tab
+ - src/app/admin/catalog/page.tsx — pattern admin page con form inline (per /admin/projects/new)
+ - src/app/admin/clients/[id]/quote-actions.ts — per capire come il form usa server actions con redirect
+
+
+
+**A. Creare src/app/admin/projects/page.tsx**
+
+```typescript
+import { getAllProjectsWithPayments } from "@/lib/admin-queries";
+import { ProjectRow } from "@/components/admin/ProjectRow";
+import Link from "next/link";
+
+export const revalidate = 0;
+
+export default async function ProjectsPage() {
+ const projects = await getAllProjectsWithPayments();
+
+ return (
+
+
+
Progetti
+
+ + Nuovo Progetto
+
+
+
+ {projects.length === 0 ? (
+
+
Nessun progetto ancora. Creane uno dal dettaglio di un cliente.
+
+ ) : (
+
+
+
+
+
Progetto
+
Valore
+
Acconto
+
Saldo
+
Timer
+
€/h
+
+
+
+ {projects.map((project) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+```
+
+**B. Creare src/app/admin/projects/new/page.tsx**
+
+Form che permette di creare un progetto scegliendo il cliente da una select. Il form si sottomette con createProject e redirige al progetto appena creato.
+
+```typescript
+import { getAllClientsWithPayments } from "@/lib/admin-queries";
+import { createProject } from "@/app/admin/projects/project-actions";
+import { redirect } from "next/navigation";
+
+export const revalidate = 0;
+
+export default async function NewProjectPage() {
+ const clients = await getAllClientsWithPayments();
+ const activeClients = clients.filter((c) => !c.archived);
+
+ async function handleCreate(fd: FormData) {
+ "use server";
+ const result = await createProject(fd);
+ redirect(`/admin/projects/${result.projectId}`);
+ }
+
+ return (
+
+
+
Nuovo Progetto
+
Crea un nuovo progetto per un cliente esistente.
+
+
+
+
+
+
+ );
+}
+```
+
+**C. Riscrivere src/app/admin/clients/[id]/page.tsx**
+
+Questo file va RISCRITTO per mostrare project cards invece del workspace tab. Leggere il file corrente per capire gli import e adattarli.
+
+Il nuovo file deve:
+1. Chiamare `getClientWithProjects(id)` invece di `getClientFullDetail(id)`
+2. Mostrare le cards dei progetti con link a /admin/projects/[id]
+3. Mostrare un bottone "+ Nuovo Progetto" che naviga a /admin/projects/new?client_id=[id]
+4. Mantenere i link di edit e archivio cliente (ClientActions component se esiste, altrimenti link semplici)
+
+```typescript
+import { notFound } from "next/navigation";
+import { getClientWithProjects } from "@/lib/admin-queries";
+import Link from "next/link";
+
+export const revalidate = 0;
+
+export default async function ClientDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const data = await getClientWithProjects(id);
+ if (!data) notFound();
+
+ const { projects, ...client } = data;
+ const activeProjects = projects.filter((p) => !p.archived);
+ const archivedProjects = projects.filter((p) => p.archived);
+
+ return (
+
+ );
+}
+```
+
+NOTA CRITICA: I tab components (PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab) potrebbero avere prop `clientId` che originariamente si riferivano al client.id. In questo contesto, passiamo il project.id come `clientId` — i tab usano quel valore per le loro server actions (addPhase, addPayment, ecc.). Le server actions di fase/pagamento/documento potrebbero ancora cercare client_id nel DB. VERIFICARE leggendo ogni actions file:
+
+- Se le actions usano ancora `client_id` nel DB, bisogna aggiornare le actions dei tab per usare `project_id`. Questo è parte dello stesso task.
+- Leggere src/app/admin/clients/[id]/phase-actions.ts (o simile) e src/app/admin/clients/[id]/payment-actions.ts per capire se fanno insert con client_id.
+- Aggiornare TUTTI i file di actions che fanno insert/update con client_id su tabelle che ora usano project_id.
+
+Specificamente, cercare tutti i file di actions:
+```bash
+grep -r "client_id" src/app/admin/clients/[id]/ --include="*actions*"
+```
+Per ogni occorrenza che fa insert su phases, payments, documents, notes, quote_items: cambiare il campo da client_id a project_id e aggiornare i revalidatePath da /admin/clients/[id] a /admin/projects/[id].
+
+**B. Creare src/app/admin/impostazioni/page.tsx**
+
+```typescript
+import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
+import { revalidatePath } from "next/cache";
+
+export const revalidate = 0;
+
+export default async function ImpostazioniPage() {
+ const targetRate = await getTargetHourlyRate();
+
+ async function handleSave(fd: FormData) {
+ "use server";
+ const newRate = fd.get("target_hourly_rate");
+ if (!newRate || isNaN(parseFloat(String(newRate)))) return;
+ await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, String(parseFloat(String(newRate)).toFixed(2)));
+ revalidatePath("/admin/impostazioni");
+ }
+
+ return (
+
+
Impostazioni
+
+
+
Analytics Profittabilità
+
+
+
+
+ );
+}
+```
+
+
+
+ npm run build 2>&1 | tail -30
+
+
+
+ - src/app/admin/projects/[id]/page.tsx exists e contains `getProjectFullDetail` (grep)
+ - src/app/admin/projects/[id]/page.tsx contains `TimerTab` import e usage (grep)
+ - src/app/admin/projects/[id]/page.tsx contains `getTargetHourlyRate` (grep)
+ - src/app/admin/impostazioni/page.tsx exists e contains `SETTINGS_KEYS.TARGET_HOURLY_RATE` (grep)
+ - src/app/admin/impostazioni/page.tsx contains `updateSetting` (grep)
+ - Tutte le actions di fase/pagamento/documento/note/quote che facevano insert con client_id sono state aggiornate a project_id (grep: `grep -r "client_id" src/app/admin/clients/\[id\]/ --include="*actions*"` non deve avere insert su tabelle migrate)
+ - `npm run build` completa senza errori TypeScript
+ - Navigando /admin/projects/[id] (con un progetto esistente) la pagina carica senza 500 errors
+ - Il tab Timer mostra TimerCell e ProfitabilityCard renderizzati
+
+
+ /admin/projects/[id] workspace completo con timer e analytics; /admin/impostazioni funzionale
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin session → timer-actions | startTimer e stopTimer non hanno requireAdmin perché chiamati da TimerCell lato client; il guard è il middleware Auth.js su /admin/* che blocca accesso non autenticato |
+| Admin session → impostazioni | handleSave inline server action in pagina /admin/impostazioni — il guard Auth.js su /admin/* blocca utenti non autenticati |
+| project workspace → quote_items | QuoteTab viene passato quoteItems da getProjectFullDetail — non accessibile via client API (D-02 / CLAUDE.md constraint) |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-09 | Information Disclosure | getProjectFullDetail — quoteItems | mitigate | quoteItems inclusi solo nella risposta admin (questo workspace); la funzione client-view (Wave 3, 04-04) non deve includere quote_items — invariante CLAUDE.md |
+| T-04-10 | Tampering | timer-actions.ts — startTimer | accept | Auth.js middleware su /admin/* impedisce accesso anonimo; timer actions non espongono dati sensibili, solo time tracking |
+| T-04-11 | Information Disclosure | ProfitabilityCard — accepted_total visibile | accept | accepted_total è il totale accettato dal cliente (non il dettaglio dei singoli servizi) — corretto mostrarlo all'admin nel workspace progetto |
+| T-04-12 | Tampering | updateSetting — target_hourly_rate | accept | Setting è solo un numero (tariffa oraria); nessun rischio sicurezza; Auth.js middleware blocca accesso non autenticato a /admin/impostazioni |
+| T-04-13 | Tampering | phase-actions / payment-actions migrazione project_id | mitigate | Dopo aggiornamento actions: insert usa project_id con FK constraint → DB rifiuta project_id non validi con constraint violation |
+
+
+
+```bash
+# 1. Timer uses project_id
+grep "project_id: projectId" src/app/admin/timer-actions.ts
+
+# 2. No client_id insert in timer
+grep -v "project_id" src/app/admin/timer-actions.ts | grep "client_id"
+
+# 3. Analytics card exists
+grep "totalTrackedSeconds / 3600" src/components/admin/ProfitabilityCard.tsx
+
+# 4. TimerTab imports ProfitabilityCard
+grep "ProfitabilityCard" src/components/admin/tabs/TimerTab.tsx
+
+# 5. Project workspace uses new query
+grep "getProjectFullDetail" src/app/admin/projects/\[id\]/page.tsx
+
+# 6. Settings key constant used
+grep "SETTINGS_KEYS" src/app/admin/impostazioni/page.tsx
+
+# 7. Build
+npm run build
+```
+
+
+
+- /admin/projects/[id] carica senza errori e mostra tutti i tab (Fasi, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer)
+- Il tab Timer mostra TimerCell (play/stop) e ProfitabilityCard (con ore, €/h reale, costo ideale, delta)
+- /admin/impostazioni carica e mostra il form con il valore corrente della tariffa (default 50.00 se non impostata)
+- Salvando un nuovo valore in /admin/impostazioni il valore viene persistito e la pagina mostra il nuovo valore
+- `npm run build` passa senza errori
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/04-progetti-multi-project/04-04-PLAN.md b/.planning/phases/04-progetti-multi-project/04-04-PLAN.md
new file mode 100644
index 0000000..e23bdb9
--- /dev/null
+++ b/.planning/phases/04-progetti-multi-project/04-04-PLAN.md
@@ -0,0 +1,830 @@
+---
+phase: 04-progetti-multi-project
+plan: "04"
+type: execute
+wave: 3
+depends_on:
+ - 04-02-PLAN.md
+ - 04-03-PLAN.md
+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 (
+
+ );
+}
+```
+
+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)
+
+
+
\ No newline at end of file