docs(04): create phase plan — 4 plans in 2 waves for multi-project architecture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Schema corrente — punto di partenza per la migrazione. Estratto da src/db/schema.ts. -->
|
||||
|
||||
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.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Schema migration — projects table, slug, settings, FK migration</name>
|
||||
<files>src/db/schema.ts</files>
|
||||
|
||||
<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>
|
||||
|
||||
<action>
|
||||
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;
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
|
||||
<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>
|
||||
|
||||
<done>schema.ts aggiornato con tutte le nuove tabelle e FK migrate, TypeScript pulito</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: [BLOCKING] drizzle-kit push — apply schema to Neon DB</name>
|
||||
<files>src/db/schema.ts</files>
|
||||
|
||||
<read_first>
|
||||
- drizzle.config.ts — verificare la configurazione del push (URL, schema path, out dir)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npx drizzle-kit push 2>&1; echo "Exit code: $?"</automated>
|
||||
</verify>
|
||||
|
||||
<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>
|
||||
|
||||
<done>Schema applicato al DB Neon live, tutte le nuove tabelle presenti</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Query layer — getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects + settings.ts</name>
|
||||
<files>
|
||||
src/lib/admin-queries.ts
|
||||
src/lib/settings.ts
|
||||
</files>
|
||||
|
||||
<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>
|
||||
|
||||
<action>
|
||||
**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<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:
|
||||
|
||||
```typescript
|
||||
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()`:
|
||||
```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<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)**
|
||||
|
||||
```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<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;
|
||||
}
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit 2>&1 | head -40</automated>
|
||||
</verify>
|
||||
|
||||
<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>
|
||||
|
||||
<done>Query layer aggiornato con tutte le funzioni project-scoped; settings.ts creato con SETTINGS_KEYS constant</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
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
|
||||
```
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
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
|
||||
</output>
|
||||
Reference in New Issue
Block a user