From d210cf6202dc0665402802dd2552b320c1ab66fd Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 21 May 2026 11:02:56 +0200 Subject: [PATCH] =?UTF-8?q?docs(04):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=204=20plans=20in=202=20waves=20for=20multi-project=20architect?= =?UTF-8?q?ure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 34 +- .../04-progetti-multi-project/04-01-PLAN.md | 761 ++++++++++++++++ .../04-progetti-multi-project/04-02-PLAN.md | 607 +++++++++++++ .../04-progetti-multi-project/04-03-PLAN.md | 672 ++++++++++++++ .../04-progetti-multi-project/04-04-PLAN.md | 830 ++++++++++++++++++ 5 files changed, 2898 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/04-progetti-multi-project/04-01-PLAN.md create mode 100644 .planning/phases/04-progetti-multi-project/04-02-PLAN.md create mode 100644 .planning/phases/04-progetti-multi-project/04-03-PLAN.md create mode 100644 .planning/phases/04-progetti-multi-project/04-04-PLAN.md 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 + + + +After completion, create `.planning/phases/04-progetti-multi-project/04-01-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`. + +Key items to document: +- Exact schema changes made (nuovi campi, nuove tabelle, FK migrate) +- drizzle-kit push output (conferma che le tabelle sono state create) +- Eventuali TypeScript errors risolti e come +- Nuove funzioni query esportate con le loro signature + \ 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.

+
+ ) : ( +
+ + + + + + + + + + + + + {projects.map((project) => ( + + ))} + +
ProgettoValoreAccontoSaldoTimer€/h
+
+ )} +
+ ); +} +``` + +**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.

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + + Annulla + +
+
+
+
+ ); +} +``` + +**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 ( +
+
+ + ← Clienti + +
+ +
+
+

{client.name}

+

{client.brand_name}

+
+
+ + + Nuovo Progetto + + + Modifica Cliente + +
+
+ + {activeProjects.length === 0 && ( +
+

Nessun progetto ancora per questo cliente.

+ + + Crea il primo progetto + +
+ )} + + {activeProjects.length > 0 && ( +
+ {activeProjects.map((project) => ( + +

{project.name}

+

+ {project.accepted_total && parseFloat(project.accepted_total) > 0 + ? `€${parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}` + : "Preventivo non impostato"} +

+ + ))} +
+ )} + + {archivedProjects.length > 0 && ( +
+

+ Archiviati ({archivedProjects.length}) +

+
+ {archivedProjects.map((project) => ( + +

{project.name}

+

Archiviato

+ + ))} +
+
+ )} +
+ ); +} +``` + +NOTA: Se il file corrente ha altri import (ClientActions, tabs, ecc.) che non servono più, rimuoverli per evitare TS errors. + +**D. Aggiornare /admin/projects/new per gestire il query param client_id** + +Il link "+ Nuovo Progetto" da /admin/clients/[id] passa `?client_id=[id]`. Aggiornare la NewProjectPage per pre-selezionare il cliente se il param è presente: + +```typescript +// Aggiungere searchParams alle props: +export default async function NewProjectPage({ + searchParams, +}: { + searchParams: Promise<{ client_id?: string }>; +}) { + const { client_id } = await searchParams; + // ... + // Nella select, aggiungere defaultValue o usare selected su ogni option: + // +} +``` +
+ + + npm run build 2>&1 | tail -20 + + + + - src/app/admin/projects/page.tsx exists e contains `getAllProjectsWithPayments` (grep) + - src/app/admin/projects/page.tsx contains `ProjectRow` (grep) + - src/app/admin/projects/new/page.tsx exists e contains `createProject` (grep) + - src/app/admin/clients/[id]/page.tsx contains `getClientWithProjects` (grep) + - src/app/admin/clients/[id]/page.tsx contains `href={\`/admin/projects/${` (grep — link alle cards progetto) + - src/app/admin/clients/[id]/page.tsx does NOT contain `getClientFullDetail` (grep — vecchia funzione rimossa) + - `npm run build` completa senza errori TypeScript + - Accedendo a /admin/projects (dopo `npm run dev`) la pagina carica senza 500 error + + + /admin/projects funzionale con lista e form creazione; /admin/clients/[id] mostra project cards +
+ +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Admin browser → Server Actions | createProject, archiveProject, updateProjectAcceptedTotal chiamati da form con requireAdmin() | +| Admin → /admin/projects/[id] | Link navigazione — il workspace progetto (04-03) avrà il suo guard; questo piano non espone dati sensibili | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-05 | Elevation of Privilege | createProject server action | mitigate | `requireAdmin()` all'inizio di ogni server action — verifica sessione Auth.js prima di qualsiasi DB write | +| T-04-06 | Tampering | archiveProject / updateProjectAcceptedTotal | mitigate | `requireAdmin()` guarda entrambe le actions; projectId viene da path param (non da query string non validata) | +| T-04-07 | Information Disclosure | /admin/clients/[id] project cards | accept | Dati mostrati sono solo nome progetto e accepted_total — nessun dato sensibile (quote_items mai esposti) | +| T-04-08 | Tampering | createProject con client_id da form | mitigate | Action verifica che il client_id esista nel DB prima di inserire — previene inserimento di progetti orfani su client_id inventato | + + + +```bash +# 1. NavBar has new links +grep "admin/projects\|admin/impostazioni" src/components/admin/NavBar.tsx + +# 2. ProjectRow exists and has formula +grep "totalTrackedSeconds / 3600" src/components/admin/ProjectRow.tsx + +# 3. Server actions have requireAdmin +grep "requireAdmin" src/app/admin/projects/project-actions.ts + +# 4. Client detail uses new query +grep "getClientWithProjects" src/app/admin/clients/\[id\]/page.tsx + +# 5. Build clean +npm run build +``` + + + +- /admin/projects mostra tabella vuota (o con dati se il seed ha creato progetti) senza errori +- /admin/projects/new mostra form con select clienti +- /admin/clients/[id] mostra grid cards progetti con bottone "+ Nuovo Progetto" +- Cliccando una card naviga a /admin/projects/[id] (che mostra 404 finché 04-03 non crea la pagina) +- `npm run build` passa senza errori TypeScript + + + +After completion, create `.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`. + +Key items to document: +- Nuovi file creati e loro funzione +- Come viene passato il client_id pre-selezionato nel form nuovo progetto +- Eventuali componenti legacy rimossi da clients/[id]/page.tsx + \ No newline at end of file diff --git a/.planning/phases/04-progetti-multi-project/04-03-PLAN.md b/.planning/phases/04-progetti-multi-project/04-03-PLAN.md new file mode 100644 index 0000000..e40da3a --- /dev/null +++ b/.planning/phases/04-progetti-multi-project/04-03-PLAN.md @@ -0,0 +1,672 @@ +--- +phase: 04-progetti-multi-project +plan: "03" +type: execute +wave: 2 +depends_on: + - 04-01-PLAN.md +files_modified: + - src/app/admin/projects/[id]/page.tsx + - src/app/admin/timer-actions.ts + - src/components/admin/tabs/TimerTab.tsx + - src/components/admin/ProfitabilityCard.tsx + - src/app/admin/impostazioni/page.tsx +autonomous: true +requirements: + - PROJ-01 + - PROJ-03 + - PROJ-05 + +must_haves: + truths: + - "La pagina /admin/projects/[id] mostra il workspace con tabs Fasi, Pagamenti, Documenti, Commenti, Preventivo, Timer" + - "Il tab Timer mostra il totale ore lavorate e un bottone play/stop funzionante" + - "Il tab Timer mostra la ProfitabilityCard con €/h reale, costo ideale, delta guadagno/perdita" + - "timer-actions.ts usa project_id invece di client_id per startTimer e stopTimer" + - "La pagina /admin/impostazioni esiste e permette di impostare target_hourly_rate" + artifacts: + - path: "src/app/admin/projects/[id]/page.tsx" + provides: "Workspace progetto con tabs" + contains: "getProjectFullDetail" + - path: "src/app/admin/timer-actions.ts" + provides: "Timer actions con project_id" + contains: "project_id: projectId" + - path: "src/components/admin/tabs/TimerTab.tsx" + provides: "Tab timer con TimerCell + ProfitabilityCard" + contains: "ProfitabilityCard" + - path: "src/components/admin/ProfitabilityCard.tsx" + provides: "Card analytics profittabilità" + contains: "totalTrackedSeconds / 3600" + - path: "src/app/admin/impostazioni/page.tsx" + provides: "Pagina impostazioni admin con target hourly rate" + contains: "target_hourly_rate" + key_links: + - from: "src/app/admin/projects/[id]/page.tsx" + to: "src/lib/admin-queries.ts" + via: "getProjectFullDetail(id)" + pattern: "getProjectFullDetail" + - from: "src/components/admin/tabs/TimerTab.tsx" + to: "src/components/admin/ProfitabilityCard.tsx" + via: "ProfitabilityCard component" + pattern: "ProfitabilityCard" + - from: "src/app/admin/impostazioni/page.tsx" + to: "src/lib/settings.ts" + via: "updateSetting / getTargetHourlyRate" + pattern: "getTargetHourlyRate\|updateSetting" +--- + + +Admin project workspace (/admin/projects/[id]) e analytics profittabilità. Clona il workspace di /admin/clients/[id] adattandolo al livello progetto, refactora il timer per usare project_id, crea il TimerTab con ProfitabilityCard, e aggiunge /admin/impostazioni per il target_hourly_rate. + +Può girare in PARALLELO con 04-02 perché non tocca nessuno degli stessi file. + +Purpose: Consegna il workspace completo per progetto (PROJ-01 e PROJ-03) e le analytics profittabilità (PROJ-05). Dopo questo piano l'admin ha un workspace funzionale per ogni progetto incluso il timer e le analytics. + +Output: /admin/projects/[id] funzionale, timer migrato a project_id, analytics card, settings page. + + + +@/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: +```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; +``` + +Da src/lib/settings.ts: +```typescript +export const SETTINGS_KEYS: { TARGET_HOURLY_RATE: "target_hourly_rate" }; +export async function getSetting(key: string): Promise; +export async function updateSetting(key: string, value: string): Promise; +export async function getTargetHourlyRate(): Promise; +``` + +Template da replicare per workspace (da src/app/admin/clients/[id]/page.tsx — da leggere in read_first): +- Tabs: PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab +- PhasesViewToggle per toggle kanban/list +- ClientActions (ora diventano ProjectActions) + +Nota: TimerCell usa il prop `clientId` per la compatibilità con il nome storico — in realtà passiamo il projectId. Il componente TimerCell chiama startTimer(clientId) e stopTimer(entryId) dalle timer-actions. + + + + + + + Task 1: Refactoring timer-actions.ts (client_id → project_id) + ProfitabilityCard + TimerTab + + src/app/admin/timer-actions.ts + src/components/admin/ProfitabilityCard.tsx + src/components/admin/tabs/TimerTab.tsx + + + + - src/app/admin/timer-actions.ts — leggere interamente: startTimer, stopTimer, le loro dipendenze da client_id nel DB + - src/components/admin/TimerCell.tsx — leggere le props interface e come chiama startTimer/stopTimer + - src/components/admin/tabs/QuoteTab.tsx — pattern "use client" + server action + useTransition per TimerTab + - src/app/admin/clients/[id]/page.tsx — vedere come TimerCell è attualmente passato (per capire dove compare il timer e cosa props riceve) + + + +**A. Aggiornare src/app/admin/timer-actions.ts** + +Riscrivere startTimer per usare project_id. Leggere prima l'intero file corrente. + +Il cambiamento principale: +1. Parametro `clientId: string` → `projectId: string` +2. `db.insert(time_entries).values({ id, client_id: clientId })` → `db.insert(time_entries).values({ id, project_id: projectId })` +3. La query "stop any running session" rimane GLOBALE (non per progetto) — D-15: solo un timer attivo alla volta +4. Aggiornare `revalidatePath` per includere `/admin/projects` + +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { time_entries } from "@/db/schema"; +import { eq, isNull, and } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export async function startTimer(projectId: string): Promise<{ entryId: string }> { + // Stop ALL currently running sessions (global: only one timer active at a time — D-15) + const running = await db + .select({ id: time_entries.id, started_at: time_entries.started_at }) + .from(time_entries) + .where(isNull(time_entries.ended_at)); + + for (const r of running) { + const now = new Date(); + const secs = Math.round((now.getTime() - new Date(r.started_at).getTime()) / 1000); + await db + .update(time_entries) + .set({ ended_at: now, duration_seconds: secs }) + .where(eq(time_entries.id, r.id)); + } + + // Create new entry scoped to PROJECT (not client) — D-19 + const id = nanoid(); + await db.insert(time_entries).values({ id, project_id: projectId }); + revalidatePath("/admin/projects"); + revalidatePath("/admin"); + return { entryId: id }; +} + +export async function stopTimer(entryId: string): Promise { + const rows = await db + .select({ started_at: time_entries.started_at }) + .from(time_entries) + .where(eq(time_entries.id, entryId)) + .limit(1); + + if (!rows[0]) return; + + const now = new Date(); + const secs = Math.round((now.getTime() - new Date(rows[0].started_at).getTime()) / 1000); + await db + .update(time_entries) + .set({ ended_at: now, duration_seconds: secs }) + .where(eq(time_entries.id, entryId)); + + revalidatePath("/admin/projects"); + revalidatePath("/admin"); +} +``` + +**B. Creare src/components/admin/ProfitabilityCard.tsx** + +Implementare il componente analytics (D-20). Calcolo: +- ore = totalTrackedSeconds / 3600 +- €/h reale = accepted_total ÷ ore (se ore > 0, altrimenti mostrare "—") +- costo ideale = targetHourlyRate × ore +- delta = accepted_total - costo_ideale (positivo = guadagno, negativo = perdita) + +```typescript +// src/components/admin/ProfitabilityCard.tsx +// NO "use client" — questo è un componente server-renderable (solo display, no interactivity) + +type ProfitabilityCardProps = { + acceptedTotal: string; // e.g., "1500.00" + totalTrackedSeconds: number; + targetHourlyRate: number; // e.g., 50 +}; + +export function ProfitabilityCard({ + acceptedTotal, + totalTrackedSeconds, + targetHourlyRate, +}: ProfitabilityCardProps) { + const hours = totalTrackedSeconds / 3600; + const accepted = parseFloat(acceptedTotal || "0"); + const realHourlyRate = hours > 0 ? accepted / hours : null; + const idealCost = targetHourlyRate * hours; + const delta = accepted - idealCost; + const deltaIsProfit = delta >= 0; + + return ( +
+

Profittabilità

+ +
+
+

Ore lavorate

+

{hours.toFixed(1)}h

+
+
+

Importo accettato

+

+ {accepted > 0 ? `€${accepted.toFixed(2)}` : "Non impostato"} +

+
+
+ +
+
+ €/h reale + + {realHourlyRate !== null ? `€${realHourlyRate.toFixed(2)}/h` : "—"} + +
+
+ €/h target + €{targetHourlyRate.toFixed(2)}/h +
+
+ Costo ideale ({hours.toFixed(1)}h × €{targetHourlyRate}/h) + €{idealCost.toFixed(2)} +
+
+ + {hours > 0 && accepted > 0 && ( +
+ Delta (guadagno/perdita) + + {deltaIsProfit ? "+" : ""}€{delta.toFixed(2)} + +
+ )} + + {hours === 0 && ( +

+ Avvia il timer per iniziare a tracciare le ore. +

+ )} +
+ ); +} +``` + +**C. Creare src/components/admin/tabs/TimerTab.tsx** + +Il TimerTab mostra il timer (TimerCell) e la ProfitabilityCard. È un Client Component perché TimerCell è "use client". + +```typescript +"use client"; + +import { TimerCell } from "@/components/admin/TimerCell"; +import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard"; + +type TimerTabProps = { + projectId: string; + acceptedTotal: string; + activeTimerEntryId: string | null; + activeTimerStartedAt: Date | null; + totalTrackedSeconds: number; + targetHourlyRate: number; +}; + +export function TimerTab({ + projectId, + acceptedTotal, + activeTimerEntryId, + activeTimerStartedAt, + totalTrackedSeconds, + targetHourlyRate, +}: TimerTabProps) { + return ( +
+
+

Timer

+ +
+ + +
+ ); +} +``` + +NOTA: TimerCell usa il prop `clientId` ma nel contesto progetto gli passiamo `projectId`. Questo è intentionale per mantenere la compatibilità con TimerCell senza modificarlo. TimerCell chiamerà `startTimer(projectId)` — che ora è il parametro corretto. + +Verificare che TimerCell importi da `@/app/admin/timer-actions` e non da un path relativo. Se usa path relativo, assicurarsi che la risoluzione sia corretta. +
+ + + npx tsc --noEmit 2>&1 | head -20 + + + + - src/app/admin/timer-actions.ts contains `project_id: projectId` nella insert (grep: `grep "project_id: projectId" src/app/admin/timer-actions.ts`) + - src/app/admin/timer-actions.ts does NOT contain `client_id:` nella insert (grep: vecchio pattern rimosso) + - src/components/admin/ProfitabilityCard.tsx exists e contains `totalTrackedSeconds / 3600` (grep) + - src/components/admin/tabs/TimerTab.tsx exists e contains `ProfitabilityCard` (grep) + - src/components/admin/tabs/TimerTab.tsx contains `clientId={projectId}` (passa project id a TimerCell) (grep) + - TypeScript compila senza errori + + + Timer migrato a project_id, ProfitabilityCard e TimerTab creati +
+ + + Task 2: /admin/projects/[id] workspace + /admin/impostazioni settings page + + src/app/admin/projects/[id]/page.tsx + src/app/admin/impostazioni/page.tsx + + + + - src/app/admin/clients/[id]/page.tsx — LEGGERE INTERAMENTE: questo è il template esatto che cloniamo per projects/[id]; capire tutti i component imports, i pattern params, la struttura Tabs + - src/lib/settings.ts — import getTargetHourlyRate, updateSetting, SETTINGS_KEYS + - src/components/admin/tabs/TimerTab.tsx — props interface appena creato in Task 1 + - src/app/admin/catalog/page.tsx — pattern per la settings page (form con server action inline) + + + +**A. Creare src/app/admin/projects/[id]/page.tsx** + +Clonare src/app/admin/clients/[id]/page.tsx sostituendo: +- `getClientFullDetail(id)` → `getProjectFullDetail(id)` (import da @/lib/admin-queries) +- Le props dei tab components: sostituire clientId con projectId dove necessario +- Aggiungere il tab Timer (nuovo) usando TimerTab +- Header: mostrare nome progetto + "← Progetti" come breadcrumb, sottotitolo = nome cliente + +```typescript +import { notFound } from "next/navigation"; +import { getProjectFullDetail } from "@/lib/admin-queries"; +import { getTargetHourlyRate } from "@/lib/settings"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; +import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab"; +import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; +import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; +import { QuoteTab } from "@/components/admin/tabs/QuoteTab"; +import { NotesTab } from "@/components/admin/tabs/NotesTab"; // se esiste +import { TimerTab } from "@/components/admin/tabs/TimerTab"; +import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; +import Link from "next/link"; + +export const revalidate = 0; + +export default async function ProjectDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const [detail, targetHourlyRate] = await Promise.all([ + getProjectFullDetail(id), + getTargetHourlyRate(), + ]); + + if (!detail) notFound(); + + const { + project, + phases, + payments, + documents, + notes, + comments, + quoteItems, + activeServices, + activeTimerEntryId, + activeTimerStartedAt, + totalTrackedSeconds, + } = detail; + + return ( +
+
+ + ← Progetti + +
+ +
+
+

{project.name}

+

+ + {project.client.name} + +

+
+
+ + + + Fasi & Task + Pagamenti + Documenti + Note + Commenti + Preventivo + Timer + + + + } + phases={phases} + clientId={id} + /> + + + + + + + + + + + {/* Render NotesTab solo se il component esiste — altrimenti inline */} + +
+ {notes.length === 0 && ( +

Nessuna nota ancora.

+ )} + {notes.map((note) => ( +
+

{note.body}

+

+ {new Date(note.created_at).toLocaleDateString("it-IT")} +

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

+ +
+
+ +

+ Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto. +

+
+ + + /h +
+
+ + +
+
+
+ ); +} +``` +
+ + + 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 + + + +After completion, create `.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`. + +Key items to document: +- Quali actions file sono stati aggiornati da client_id a project_id (lista esaustiva) +- Come TimerCell è stato adattato per usare project_id (prop naming) +- Se NotesTab esiste come component o se le note sono state implementate inline +- Valore default inizializzato per target_hourly_rate + \ 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 ( +
+
+

{client.name}

+

Nessun progetto disponibile al momento.

+
+
+ ); + } + + if (projects.length === 1) { + // D-09: 1 project → direct view without selector + const view = await getProjectView(projects[0].id); + if (!view) notFound(); + + return ; + } + + // D-10: 2+ projects → tabs with brand names + // Fetch all project views in parallel + const projectViews = await Promise.all( + projects.map(async (p) => ({ + project: p, + view: await getProjectView(p.id), + })) + ); + + return ( +
+
+
+

{client.name}

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

Progetto non disponibile.

+ )} +
+ ))} +
+
+
+ ); +} +``` + +Per `ClientDashboardView`: leggere il file attuale di /c/[token]/page.tsx per capire come è strutturata la dashboard corrente. Il componente `ClientDashboardView` è probabilmente già esistente o il rendering è inline. Adattare seguendo ESATTAMENTE la struttura attuale: +- Se il file corrente ha un componente separato (es. ClientDashboard o simile) → riutilizzarlo, passando `view` invece di `clientView` +- Se il rendering è inline → estrarlo in una funzione helper `ClientDashboardView` nello stesso file +- I dati che `ClientDashboardView` riceve vengono ora da `ProjectView` invece di `ClientView` — adattare le prop references + +CRITICO: verificare che `ClientDashboardView` NON abbia accesso a quote_items — deve usare solo i dati di `ProjectView` (phases, payments con solo status, documents, notes, comments). + +Il campo `accepted_total` da mostrare viene da `view.project.accepted_total` (non dal client-level). + +**B. Aggiornare src/app/admin/clients/[id]/edit/page.tsx** + +Aggiungere il campo slug con: +1. Input field con label "Slug personalizzato" +2. Validazione Zod: `slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal(""))` — stringa vuota = nessuno slug +3. Preview del link risultante: `/{slug || client.token}` +4. Testo help: "Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri." + +Leggere il file per trovare il form attuale e aggiungere il campo slug nel form esistente. L'action di salvataggio deve aggiornare `clients.slug` oltre ai campi esistenti. + +Schema Zod da aggiungere/aggiornare per il campo slug: +```typescript +const updateClientSchema = z.object({ + // ... existing fields ... + slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal("")).transform(v => v === "" ? null : v), +}); +``` + +Nel form HTML: +```html +
+ +

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

+ + {/* Link preview */} +

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

+
+``` + +Nella server action che salva, aggiungere l'update di `clients.slug`: +```typescript +// Se slug è stringa vuota, settarlo a null (rimuove lo slug) +await db.update(clients).set({ + // ...existing fields... + slug: parsed.slug ?? null, +}).where(eq(clients.id, clientId)); +``` + +Aggiungere anche gestione errore per unique constraint violation (se lo slug è già usato da un altro cliente), mostrando un messaggio user-friendly. +
+ + + npm run build 2>&1 | tail -20 + + + + - src/app/c/[token]/page.tsx contains `getClientWithProjectsByToken` (grep) + - src/app/c/[token]/page.tsx contains `projects.length === 1` (grep — single project direct view logic) + - src/app/c/[token]/page.tsx contains `Tabs` import (grep — multi-project tabs) + - src/app/c/[token]/page.tsx does NOT contain `quote_items` anywhere (grep) + - src/app/admin/clients/[id]/edit/page.tsx contains `slug` input field (grep: `grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx`) + - src/app/admin/clients/[id]/edit/page.tsx contains `/^[a-z0-9-]{3,50}$/` validation pattern (grep) + - `npm run build` completa senza errori TypeScript + + + Dashboard cliente funziona con singolo progetto (vista diretta) e multi-progetto (tabs); slug impostabile dall'admin +
+ + + + Funzionalità complete di Phase 04: + 1. Schema multi-project con FK migrate (04-01) + 2. Admin projects list + create + client detail con project cards (04-02) + 3. Admin project workspace con timer project-scoped e analytics profittabilità (04-03) + 4. Slug resolution middleware + dashboard cliente multi-project + slug edit (questo piano) + + + + Eseguire `npm run dev` e verificare manualmente: + + **Test 1 — Admin projects list (/admin/projects)** + - Aprire /admin/projects + - Verificare che la pagina carichi senza errori + - Verificare colonne: Progetto (con nome cliente sotto), Valore, Acconto, Saldo, Timer, €/h + + **Test 2 — Creazione progetto** + - Aprire /admin e cliccare su un cliente + - Verificare che /admin/clients/[id] mostri project cards (non più il workspace tab) + - Cliccare "+ Nuovo Progetto" e creare un progetto + - Verificare che il redirect vada a /admin/projects/[id] + + **Test 3 — Workspace progetto (/admin/projects/[id])** + - Aprire /admin/projects/[id] per il progetto appena creato + - Verificare tutti i tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer + - Nel tab Timer: verificare play/stop funziona, ProfitabilityCard mostra ore lavorate, €/h, costo ideale, delta + + **Test 4 — Impostazioni (/admin/impostazioni)** + - Aprire /admin/impostazioni + - Verificare form con campo tariffa oraria target (default 50.00) + - Cambiare il valore, salvare, ricaricare — verificare che il nuovo valore sia persistito + - Aprire /admin/projects/[id] → tab Timer → verificare che la tariffa target aggiornata appaia nella ProfitabilityCard + + **Test 5 — Slug cliente** + - Aprire /admin/clients/[id]/edit per un cliente + - Impostare slug "mario-rossi" (o simile) + - Salvare e verificare che non ci siano errori + - Aprire /c/mario-rossi → verificare che carichi la dashboard del cliente corretto + + **Test 6 — Fallback token** + - Con lo stesso cliente che ha lo slug impostato, aprire /c/[token-originale] + - Verificare che carichi correttamente (fallback token deve funzionare) + + **Test 7 — Dashboard multi-progetto** + - Per il cliente di test, creare un secondo progetto + - Aprire /c/[token-o-slug] del cliente + - Verificare che appaiano le tabs con i nomi dei due progetti + - Cliccare tra i tabs e verificare che i dati siano scoped al progetto corretto + + **Test 8 — Dashboard singolo progetto** + - Per un cliente con 1 solo progetto, aprire /c/[token] + - Verificare che NON appaiano tabs — la dashboard si apre direttamente sul progetto + + + + Digitare "approvato" se tutti i test passano, oppure descrivere gli errori trovati per correzione. + + + +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Public internet → /c/[slug-or-token] | Chiunque con il link accede alla dashboard; il middleware valida prima slug poi token — accesso bloccato se entrambi falliscono | +| Client dashboard → DB | getProjectView NON espone quote_items né payment amounts — invarianti CLAUDE.md + DASH-07 | +| Admin edit → clients.slug | Il campo slug è validato con regex e aggiornato solo in sessione admin autenticata | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-14 | Information Disclosure | getProjectView — payments | mitigate | SELECT include solo id, label, status — amount escluso esplicitamente. Commento nel codice documenta il motivo (DASH-07 + CLAUDE.md). grep di test in acceptance criteria verifica l'assenza di amount | +| T-04-15 | Information Disclosure | getProjectView — quote_items | mitigate | quote_items NON importato in client-view.ts. Acceptance criteria include grep check `grep "quote_items" src/lib/client-view.ts` → deve essere assente | +| T-04-16 | Tampering | clients.slug — unique constraint | mitigate | DB unique constraint su clients.slug previene slug duplicati; server action cattura unique violation e mostra errore user-friendly | +| T-04-17 | Spoofing | Slug collisione con token esistente | accept | Slug regex [a-z0-9-]{3,50} non può collidere con nanoid tokens (che usano anche maiuscole e caratteri speciali); middleware prova prima slug poi token nell'ordine corretto (D-06) | +| T-04-18 | Information Disclosure | Dashboard multi-project tabs — dati cross-project | mitigate | Ogni getProjectView(projectId) è scoped con WHERE eq(phases.project_id, projectId) — un cliente non può vedere dati di un altro cliente perché l'accesso è gate-kept dal client.id risolto dal token | + + + +```bash +# 1. Slug API route exists +ls src/app/api/internal/validate-slug/route.ts + +# 2. Middleware has slug-first +grep -n "validate-slug\|validate-token" src/proxy.ts + +# 3. client-view.ts has new functions +grep "export async function" src/lib/client-view.ts + +# 4. client-view.ts security invariants +grep "quote_items" src/lib/client-view.ts # must be empty +grep "amount" src/lib/client-view.ts # must not appear in payments select + +# 5. Dashboard has tabs logic +grep "projects.length === 1" src/app/c/\[token\]/page.tsx + +# 6. Edit page has slug field +grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx + +# 7. Build clean +npm run build +``` + + + +- /c/[slug] risolve correttamente alla dashboard del cliente → stesso comportamento di /c/[token] +- /c/[token] continua a funzionare come fallback per i link esistenti +- Dashboard con 1 progetto → nessun selettore/tabs, vista diretta +- Dashboard con 2+ progetti → shadcn Tabs con nomi brand, switch funziona +- /admin/impostazioni persiste il target_hourly_rate e la ProfitabilityCard nel workspace progetto lo usa +- `npm run build` → 0 errori TypeScript +- `grep "quote_items" src/lib/client-view.ts` → nessun output (security invariant verificato) + + + +After completion, create `.planning/phases/04-progetti-multi-project/04-04-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`. + +Key items to document: +- Come è stata implementata la logica single/multi-project nella dashboard +- Come la edit page gestisce slug vuoto → null (rimozione slug) +- Eventuali adattamenti al componente ClientDashboardView per lavorare con ProjectView invece di ClientView +- Conferma dei security invariants (no quote_items, no payment amounts in client-view.ts) + \ No newline at end of file