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