Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 |
|
true |
|
|
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.mdTabelle 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_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)
// ============ 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;
<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;
}
<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> |
# 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 presentegrep "export const settings = pgTable" src/db/schema.ts→ output presentegrep -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 matchnpx drizzle-kit pushha completato con exit code 0npx tsc --noEmit→ no errorinpm run build→ no errori </success_criteria>
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