Files
clienthub/.planning/phases/04-progetti-multi-project/04-01-PLAN.md
T

27 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-progetti-multi-project 01 execute 1
src/db/schema.ts
src/lib/admin-queries.ts
src/lib/settings.ts
true
PROJ-01
PROJ-03
PROJ-05
truths artifacts key_links
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
path provides contains
src/db/schema.ts Schema completo con projects table, slug su clients, settings table, FK migrated export const projects = pgTable
path provides exports
src/lib/admin-queries.ts Query layer per progetti
getAllProjectsWithPayments
getProjectFullDetail
getClientWithProjects
path provides exports
src/lib/settings.ts Utility per settings key-value
getSetting
updateSetting
getTargetHourlyRate
from to via pattern
src/lib/admin-queries.ts src/db/schema.ts import projects, settings from @/db/schema from.*@/db/schema
from to via pattern
src/lib/settings.ts src/db/schema.ts import settings from @/db/schema 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.

<execution_context> @/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/.claude/get-shit-done/templates/summary.md </execution_context>

@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md @/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.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

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

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)

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

  client_id: text("client_id")
    .notNull()
    .references(() => clients.id, { onDelete: "cascade" }),

con:

  project_id: text("project_id")
    .notNull()
    .references(() => projects.id, { onDelete: "cascade" }),

4. Migrare FK in payments (D-02) — stesso pattern: sostituire client_idproject_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_idproject_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)

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

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:

export const clientsRelations = relations(clients, ({ many }) => ({
  projects: many(projects),
}));

Aggiornare phasesRelations — cambiare client in project:

export const phasesRelations = relations(phases, ({ one, many }) => ({
  project: one(projects, { fields: [phases.project_id], references: [projects.id] }),
  tasks: many(tasks),
}));

Aggiornare paymentsRelations:

export const paymentsRelations = relations(payments, ({ one }) => ({
  project: one(projects, { fields: [payments.project_id], references: [projects.id] }),
}));

Aggiornare documentsRelations:

export const documentsRelations = relations(documents, ({ one }) => ({
  project: one(projects, { fields: [documents.project_id], references: [projects.id] }),
}));

Aggiornare notesRelations:

export const notesRelations = relations(notes, ({ one }) => ({
  project: one(projects, { fields: [notes.project_id], references: [projects.id] }),
}));

Aggiornare quoteItemsRelations:

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:

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:

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

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

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

<read_first> - drizzle.config.ts — verificare la configurazione del push (URL, schema path, out dir) </read_first>

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.

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: $?"

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

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

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

**A. Aggiornare src/lib/admin-queries.ts**

Aggiungere in cima agli import projects e settings dalla schema:

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:

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:

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<ProjectWithPayments[]> {
  // 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<string>`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:

export type ProjectFullDetail = {
  project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } };
  phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
  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<ProjectFullDetail | null> {
  // 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<string>`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<string>`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():

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

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<ClientWithProjects | null> {
  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)

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<string | null> {
  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<void> {
  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<number> {
  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

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

Query layer aggiornato con tutte le funzioni project-scoped; settings.ts creato con SETTINGS_KEYS constant

<threat_model>

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
</threat_model>
Dopo il completamento di tutti e 3 i task:
# 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

<success_criteria>

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