Files
Simone Cavalli a4942d7684 docs(03): plan Phase 3 — Service Catalog & Quote Builder (4 plans, 2 waves)
Wave 1: schema push (service_id nullable + custom_label).
Wave 2 (parallel): catalog CRUD page + quote builder tab.
Wave 3: E2E human verification checkpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:23:15 +02:00

216 lines
8.8 KiB
Markdown

---
phase: "03"
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
autonomous: true
requirements:
- CAT-01
- CAT-02
- ADMIN-03
must_haves:
truths:
- "quote_items.service_id is nullable in the database (free-form items can be inserted without a catalog reference)"
- "quote_items.custom_label column exists in the database (free-form label storage)"
- "TypeScript QuoteItem type reflects both changes (no compile errors when service_id is null or custom_label is set)"
- "drizzle-kit push completes without errors against the live Neon database"
artifacts:
- path: "src/db/schema.ts"
provides: "Updated quote_items table definition with nullable service_id and custom_label column"
contains: "custom_label: text(\"custom_label\")"
key_links:
- from: "src/db/schema.ts quote_items.service_id"
to: "Neon Postgres quote_items table"
via: "drizzle-kit push"
pattern: "service_id.*references.*service_catalog"
---
<objective>
Make the two schema changes required for free-form quote items — make `service_id` nullable and add `custom_label text` — then push the changes to the live Neon database.
Purpose: All subsequent plans (Wave 2) reference `custom_label` and insert rows with `service_id = null`. Without this push, the DB will reject those inserts with a column-not-found or NOT NULL constraint error.
Output: Updated `src/db/schema.ts` and a successful `drizzle-kit push` confirmation.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
<interfaces>
<!-- Current quote_items definition in src/db/schema.ts lines 159-172 -->
```typescript
export const quote_items = pgTable("quote_items", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
client_id: text("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
service_id: text("service_id")
.notNull() // <-- REMOVE .notNull()
.references(() => service_catalog.id, { onDelete: "restrict" }),
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
// custom_label missing — ADD after subtotal
});
```
<!-- After changes, the definition must be: -->
```typescript
export const quote_items = pgTable("quote_items", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
client_id: text("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
service_id: text("service_id")
.references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — no .notNull()
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
custom_label: text("custom_label"), // new field
});
```
<!-- quoteItemsRelations also references service_id — the relation stays but the field is now optional: -->
```typescript
export const quoteItemsRelations = relations(quote_items, ({ one }) => ({
client: one(clients, { fields: [quote_items.client_id], references: [clients.id] }),
service: one(service_catalog, {
fields: [quote_items.service_id],
references: [service_catalog.id],
}),
}));
```
<!-- The relation definition does NOT need to change — Drizzle handles nullable FK relations correctly. -->
<!-- Inferred TypeScript type after change (auto-generated by Drizzle): -->
```typescript
export type QuoteItem = typeof quote_items.$inferSelect;
// QuoteItem.service_id will be: string | null
// QuoteItem.custom_label will be: string | null
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Update quote_items schema — make service_id nullable and add custom_label</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (full file — understand current definition before editing)
</read_first>
<files>src/db/schema.ts</files>
<action>
Edit `src/db/schema.ts` — two targeted changes to the `quote_items` table definition (lines 159-172):
**Change 1 — Remove `.notNull()` from service_id (per D-03 from CONTEXT.md):**
Before:
```typescript
service_id: text("service_id")
.notNull()
.references(() => service_catalog.id, { onDelete: "restrict" }),
```
After:
```typescript
service_id: text("service_id")
.references(() => service_catalog.id, { onDelete: "restrict" }),
```
**Change 2 — Add custom_label field after the `subtotal` line:**
```typescript
custom_label: text("custom_label"),
```
No other changes to the file. The `quoteItemsRelations` block does NOT need to change.
After the edit, run `npx tsc --noEmit` to confirm zero TypeScript errors before pushing.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -v '^//' src/db/schema.ts | grep -c 'custom_label: text("custom_label")'</automated>
Expected: 1
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -A3 'service_id: text("service_id")' src/db/schema.ts | grep -c 'notNull'</automated>
Expected: 0 (notNull must be gone from service_id)
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
Expected: no output (zero errors)
</verify>
<done>
`src/db/schema.ts` compiles with zero TypeScript errors. `service_id` has no `.notNull()`. `custom_label: text("custom_label")` is present in the quote_items table definition.
</done>
</task>
<task type="auto">
<name>Task 2: [BLOCKING] Push schema changes to Neon database</name>
<read_first>
- /Users/simonecavalli/IAMCAVALLI/.env.local (verify DATABASE_URL is set before running push)
- /Users/simonecavalli/IAMCAVALLI/drizzle.config.ts (verify push config points to correct schema)
</read_first>
<files>— (no source files modified; runs drizzle-kit against live DB)</files>
<action>
Run drizzle-kit push with the .env.local DATABASE_URL loaded:
```bash
cd /Users/simonecavalli/IAMCAVALLI
set -a && source .env.local && set +a && npx drizzle-kit push
```
When prompted to confirm schema changes, accept all changes. The push will:
1. DROP NOT NULL constraint from `quote_items.service_id`
2. ADD COLUMN `custom_label text` to `quote_items`
If the push fails with "column already exists" for `custom_label`, the column was already added in a prior run — this is safe to ignore. Verify the column exists by checking the push output or running a quick query.
Do NOT skip this task. Wave 2 plans cannot execute correctly without the DB columns existing.
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && set -a && source .env.local && set +a && npx drizzle-kit push 2>&1 | tail -5</automated>
Expected: Output contains "No changes" or "Changes applied" — either confirms the schema is in sync.
</verify>
<done>
`drizzle-kit push` exits without error. The live Neon DB has `quote_items.service_id` as nullable and `quote_items.custom_label text` column present.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Schema file → Neon DB | drizzle-kit push executes DDL against the live database; misconfigured DATABASE_URL would push to wrong environment |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01-01 | Tampering | drizzle-kit push | mitigate | Always load DATABASE_URL from `.env.local` (not hardcoded); verify `.env.local` exists before running push |
| T-03-01-02 | Denial of Service | Neon DB DDL | accept | Schema changes are additive (ADD COLUMN, DROP NOT NULL) — no data loss risk; onDelete: "restrict" prevents orphaned quote_items |
</threat_model>
<verification>
After both tasks complete:
1. `grep 'custom_label' src/db/schema.ts` returns the field definition
2. `grep -A3 'service_id: text' src/db/schema.ts` shows no `.notNull()` on service_id
3. `npx tsc --noEmit` exits 0
4. `npx drizzle-kit push` reports "No changes" (schema is in sync with DB)
</verification>
<success_criteria>
- `src/db/schema.ts` has nullable `service_id` and `custom_label: text("custom_label")` in quote_items
- TypeScript compiles with zero errors
- drizzle-kit push confirms schema is synced to Neon DB
- Wave 2 plans can safely reference `custom_label` and insert rows with `service_id = null`
</success_criteria>
<output>
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md`
</output>