From bdcdd26f17583c7471a659a8947da5d04206bfda Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 06:14:05 +0200 Subject: [PATCH] docs(07): create phase plan for Unified Service Catalog Two-plan, two-wave migration: schema + backfill (07-01) then admin catalog CRUD rewire (07-02), per CAT-U-01/CAT-U-02 and the expand-contract pattern from PITFALLS_V2 Pitfall 1. --- .planning/ROADMAP.md | 9 +- .../07-unified-service-catalog/07-01-PLAN.md | 484 ++++++++++++++++++ .../07-unified-service-catalog/07-02-PLAN.md | 452 ++++++++++++++++ 3 files changed, 942 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/07-unified-service-catalog/07-01-PLAN.md create mode 100644 .planning/phases/07-unified-service-catalog/07-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f19c197..793a3e2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -158,9 +158,12 @@ Decimal phases appear between their surrounding integers in numeric order. 2. Migrazione zero data loss: campi audit (`migrated_from`, `migrated_id`) abilitano rollback sicuro 3. `/admin/catalog` list/add/edit/soft-delete funzionante con nuova tabella 4. Query vecchie servizi falliscono esplicitamente (no silent fallback) -**Plans**: 1 (schema migration + admin page) +**Plans**: 2 plans +**Plan list**: + - [ ] 07-01-PLAN.md — Schema: services table (audit trail) + backfill + zero-data-loss validation + - [ ] 07-02-PLAN.md — Admin catalog CRUD rewired to services table + e2e verification **Durata**: 3–4 giorni -**Status**: Pending planning +**Status**: Planning complete **Risk**: SQL deduplication logic; validate on staging before prod ### Phase 8: Offer Phases & Quote Templates @@ -251,7 +254,7 @@ Phases 7 → 8 → 9 → 10 → 11 planned for sequential execution. Research co | Phase | Plans | Status | Estimated Duration | Blocking? | |-------|-------|--------|-------------------|-----------| -| 7. Unified Service Catalog | 1 | Pending planning | 3–4 giorni | Foundation | +| 7. Unified Service Catalog | 2 | Planning complete | 3–4 giorni | Foundation | | 8. Offer Phases & Quote Templates | 2 | Pending planning | 2–3 giorni | Foundation | | 9. Quote Builder & Public Routes | 3 | Pending planning | 4–5 giorni | RHF compat test | | 10. CRM Pipeline & Activity Logging | 3 | Pending planning | 3–4 giorni | No | diff --git a/.planning/phases/07-unified-service-catalog/07-01-PLAN.md b/.planning/phases/07-unified-service-catalog/07-01-PLAN.md new file mode 100644 index 0000000..fd97d92 --- /dev/null +++ b/.planning/phases/07-unified-service-catalog/07-01-PLAN.md @@ -0,0 +1,484 @@ +--- +phase: "07-unified-service-catalog" +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - scripts/migrate-services.ts + - scripts/validate-services-migration.ts +autonomous: true +requirements: + - CAT-U-01 + +must_haves: + truths: + - "A single `services` table exists in Postgres with columns: id, name, description, unit_price, category, active, migrated_from, migrated_id, created_at" + - "Every active row from service_catalog (21 rows) and offer_services (35 rows) has a corresponding row in services with migrated_from + migrated_id set" + - "Name collisions between the two source tables are resolved by suffixing — no two services rows silently overwrite each other" + - "service_catalog and offer_services tables are NOT dropped or truncated — old data remains queryable for rollback" + - "A validation script proves zero data loss: row counts match (21 + 35 = 56 services rows minimum) and no orphaned references exist" + artifacts: + - path: "src/db/schema.ts" + provides: "New `services` pgTable definition + relations + Service/NewService types" + contains: "export const services = pgTable(\"services\"" + - path: "scripts/migrate-services.ts" + provides: "Idempotent backfill script: service_catalog + offer_services -> services with dedup" + contains: "migrated_from" + - path: "scripts/validate-services-migration.ts" + provides: "Post-migration validation: row count checks, orphan checks, name collision report" + contains: "SELECT COUNT" + key_links: + - from: "scripts/migrate-services.ts" + to: "services.migrated_from / services.migrated_id" + via: "INSERT ... migrated_from='service_catalog'|'offer_services', migrated_id=" + pattern: "migrated_from" + - from: "src/db/schema.ts services table" + to: "Postgres production DB (Coolify)" + via: "drizzle-kit push (additive only — no drop statements)" + pattern: "export const services = pgTable" +--- + + +Create the unified `services` table (per ARCHITECTURE.md "NEW: services Table") as an ADDITIVE schema change, then backfill it from both `service_catalog` (21 rows, operational pricing used by quote_items) and `offer_services` (35 rows, marketing pricing used by offer_micro_services) using the expand-phase pattern from PITFALLS_V2.md Pitfall 1. + +Purpose: Establish the single source of truth for service pricing without touching `service_catalog`, `offer_services`, `quote_items`, or `offer_micro_services` — those keep working unchanged. This is the "Expand" half of expand-contract; Phase 8 will build `offer_phase_services` against the new `services` table and progressively retire the old junction tables. Per Data Safety (LOCKED): no migration in this plan drops or truncates `clients`, `projects`, `payments`, `phases`, `service_catalog`, or `offer_services`. + +Output: `services` table live in production Postgres, fully backfilled with audit trail (`migrated_from`, `migrated_id`), validated against zero data loss with a checksum/row-count script. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/research/ARCHITECTURE.md +@.planning/research/PITFALLS_V2.md + + + + +From src/db/schema.ts (service_catalog — 21 rows, used by quote_items.service_id): +```typescript +export const service_catalog = pgTable("service_catalog", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + description: text("description"), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + active: boolean("active").notNull().default(true), +}); +``` + +From src/db/schema.ts (offer_services — 35 rows, used by offer_micro_services junction): +```typescript +export const offer_services = pgTable("offer_services", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + price: numeric("price", { precision: 10, scale: 2 }).notNull(), + transformation_description: text("transformation_description"), + active: boolean("active").notNull().default(true), +}); +``` + +From src/db/index.ts (db client — used identically in scripts/seed.ts): +```typescript +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +const client = postgres(process.env.DATABASE_URL ?? "postgres://build-placeholder"); +export const db = drizzle(client); +``` + +Target shape for the new table (from ARCHITECTURE.md "NEW: services Table"): +```typescript +export const services = pgTable("services", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + description: text("description"), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + category: text("category"), + active: boolean("active").notNull().default(true), + migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null + migrated_id: text("migrated_id"), // original row id from source table + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + + + + + + + Task 1: Add `services` table to schema.ts and push additive migration to Postgres + + src/db/schema.ts + + + In `src/db/schema.ts`, add the new `services` pgTable definition immediately after the `service_catalog` table definition (around line 173), per ARCHITECTURE.md "NEW: services Table (Unified Catalog)": + + ```typescript + // ============ SERVICES (UNIFIED CATALOG — replaces service_catalog + offer_services) ============ + // migrated_from/migrated_id provide audit trail for safe rollback during the + // expand-contract migration (Phase 7 expand; Phase 8 begins contract on offer side). + export const services = pgTable("services", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + name: text("name").notNull(), + description: text("description"), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + category: text("category"), + active: boolean("active").notNull().default(true), + migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null (new rows after Phase 7) + migrated_id: text("migrated_id"), // original id from source table, null for new rows + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }); + + export const servicesRelations = relations(services, (_) => ({ + // No FK relations yet in Phase 7 — quote_items and offer_micro_services + // continue to reference service_catalog/offer_services until Phase 8. + })); + ``` + + Add TypeScript types in the "TYPESCRIPT TYPES" section at the bottom of the file, near `ServiceCatalog`: + ```typescript + export type Service = typeof services.$inferSelect; + export type NewService = typeof services.$inferInsert; + ``` + + Do NOT modify `service_catalog`, `offer_services`, `quote_items`, `offer_micro_services`, or any other existing table. Do NOT add foreign keys from `services` to anything yet — this is a standalone additive table. + + Push the schema change to production Postgres on Coolify using the same SSH-based `drizzle-kit push` pattern used in Phase 5 (commit d670d49 / 1b0b2ea). Run: + ```bash + npx drizzle-kit push + ``` + Confirm the prompt shows ONLY a new table creation (`services`) with no ALTER/DROP statements on existing tables. If drizzle-kit proposes anything beyond `CREATE TABLE services` (e.g., proposes dropping or renaming an unrelated column), STOP and report — do not proceed with an unexpected diff. + + + grep -c "export const services = pgTable(\"services\"" src/db/schema.ts | grep -q "^1$" && echo "services table defined" + grep -q "export type Service = typeof services" src/db/schema.ts && echo "Service type exported" + grep -q "migrated_from: text(\"migrated_from\")" src/db/schema.ts && echo "audit trail columns present" + npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" + + + - `services` table defined in src/db/schema.ts with all columns from ARCHITECTURE.md spec including migrated_from/migrated_id audit columns + - `Service` and `NewService` types exported + - `service_catalog`, `offer_services`, `quote_items`, `offer_micro_services` definitions unchanged (verify with `git diff src/db/schema.ts` shows only additions) + - drizzle-kit push applied to production DB — `services` table exists in Postgres, confirmed via psql `\d services` or equivalent + - npm run build passes + + + + + Task 2: Write and run the backfill script (service_catalog + offer_services -> services) with dedup + + scripts/migrate-services.ts + + + - Migrating an empty `services` table: inserts 21 rows from service_catalog (migrated_from='service_catalog') + 35 rows from offer_services (migrated_from='offer_services') = 56 total rows + - Name collision (same `name` exists in both source tables): the offer_services row is inserted with `name` suffixed as `" (Offer)"` so both rows survive distinctly — per PITFALLS_V2.md Pitfall 1 prevention #2 + - Re-running the script on an already-migrated `services` table is a no-op (idempotent — skips rows where migrated_from+migrated_id pair already exists) + - service_catalog.description maps to services.description; offer_services.transformation_description maps to services.description (offer side has no separate description field) + - service_catalog.unit_price maps to services.unit_price; offer_services.price maps to services.unit_price + - active flag is copied verbatim from each source row + + + Create `scripts/migrate-services.ts` following the pattern of `scripts/seed.ts` (imports `db` from `@/db`, imports tables from `@/db/schema`, runs as a standalone script via `npx tsx scripts/migrate-services.ts`). + + Implementation outline: + + ```typescript + import { db } from "@/db"; + import { service_catalog, offer_services, services } from "@/db/schema"; + import { eq, and } from "drizzle-orm"; + + async function migrate() { + console.log("Starting services unification migration...\n"); + + // 1. Backfill from service_catalog (operational pricing, used by quote_items) + const catalogRows = await db.select().from(service_catalog); + let catalogInserted = 0; + let catalogSkipped = 0; + + for (const row of catalogRows) { + const existing = await db + .select() + .from(services) + .where(and(eq(services.migrated_from, "service_catalog"), eq(services.migrated_id, row.id))) + .limit(1); + + if (existing.length > 0) { + catalogSkipped++; + continue; + } + + await db.insert(services).values({ + name: row.name, + description: row.description, + unit_price: row.unit_price, + category: "catalog", + active: row.active, + migrated_from: "service_catalog", + migrated_id: row.id, + }); + catalogInserted++; + } + console.log(`service_catalog: ${catalogInserted} inserted, ${catalogSkipped} skipped (already migrated)`); + + // 2. Backfill from offer_services (marketing pricing, used by offer_micro_services) + const offerRows = await db.select().from(offer_services); + let offerInserted = 0; + let offerSkipped = 0; + let renamed = 0; + + for (const row of offerRows) { + const existing = await db + .select() + .from(services) + .where(and(eq(services.migrated_from, "offer_services"), eq(services.migrated_id, row.id))) + .limit(1); + + if (existing.length > 0) { + offerSkipped++; + continue; + } + + // Name collision check against ALL services rows inserted so far (both sources) + const collision = await db + .select() + .from(services) + .where(eq(services.name, row.name)) + .limit(1); + + const finalName = collision.length > 0 ? `${row.name} (Offer)` : row.name; + if (collision.length > 0) renamed++; + + await db.insert(services).values({ + name: finalName, + description: row.transformation_description, + unit_price: row.price, + category: "offer", + active: row.active, + migrated_from: "offer_services", + migrated_id: row.id, + }); + offerInserted++; + } + console.log(`offer_services: ${offerInserted} inserted, ${offerSkipped} skipped (already migrated), ${renamed} renamed for collision`); + + console.log("\nMigration complete. Run scripts/validate-services-migration.ts next."); + process.exit(0); + } + + migrate().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); + }); + ``` + + Run the script against production: + ```bash + npx tsx scripts/migrate-services.ts + ``` + + Report the printed counts (inserted/skipped/renamed for each source table). + + + test -f scripts/migrate-services.ts && grep -q "migrated_from" scripts/migrate-services.ts && echo "migration script exists" + grep -q "(Offer)" scripts/migrate-services.ts && echo "collision suffix logic present" + grep -q "and(eq(services.migrated_from" scripts/migrate-services.ts && echo "idempotency check present" + + + - scripts/migrate-services.ts exists, follows seed.ts conventions (imports db from @/db) + - Script executed successfully against production DB + - services table contains >= 56 rows (21 from service_catalog + 35 from offer_services), each with migrated_from + migrated_id set + - Any name collisions resolved via "(Offer)" suffix — verified by printed `renamed` count in script output + - Re-running the script is a no-op (idempotent skip path exercised) + + + + + Task 3: Write and run the validation script proving zero data loss + + scripts/validate-services-migration.ts + + + Create `scripts/validate-services-migration.ts`, following the same standalone-script pattern. It must run these checks and print PASS/FAIL for each: + + ```typescript + import { db } from "@/db"; + import { service_catalog, offer_services, services, quote_items, offer_micro_services } from "@/db/schema"; + import { sql, eq } from "drizzle-orm"; + + async function validate() { + let failures = 0; + + // Check 1: row counts match + const [catalogCount] = await db.select({ n: sql`count(*)::int` }).from(service_catalog); + const [offerCount] = await db.select({ n: sql`count(*)::int` }).from(offer_services); + const [migratedCatalog] = await db + .select({ n: sql`count(*)::int` }) + .from(services) + .where(eq(services.migrated_from, "service_catalog")); + const [migratedOffer] = await db + .select({ n: sql`count(*)::int` }) + .from(services) + .where(eq(services.migrated_from, "offer_services")); + + console.log(`service_catalog rows: ${catalogCount.n} | migrated: ${migratedCatalog.n}`); + console.log(`offer_services rows: ${offerCount.n} | migrated: ${migratedOffer.n}`); + + if (catalogCount.n !== migratedCatalog.n) { + console.log("FAIL: service_catalog row count does not match migrated count"); + failures++; + } else { + console.log("PASS: service_catalog fully migrated"); + } + + if (offerCount.n !== migratedOffer.n) { + console.log("FAIL: offer_services row count does not match migrated count"); + failures++; + } else { + console.log("PASS: offer_services fully migrated"); + } + + // Check 2: no orphaned migrated_id (every migrated_id from service_catalog still exists in service_catalog) + const orphanedCatalog = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM services s + WHERE s.migrated_from = 'service_catalog' + AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = s.migrated_id) + `); + const orphanedCatalogCount = (orphanedCatalog as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedCatalogCount > 0) { + console.log(`FAIL: ${orphanedCatalogCount} services rows reference missing service_catalog ids`); + failures++; + } else { + console.log("PASS: no orphaned service_catalog references"); + } + + const orphanedOffer = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM services s + WHERE s.migrated_from = 'offer_services' + AND NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = s.migrated_id) + `); + const orphanedOfferCount = (orphanedOffer as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedOfferCount > 0) { + console.log(`FAIL: ${orphanedOfferCount} services rows reference missing offer_services ids`); + failures++; + } else { + console.log("PASS: no orphaned offer_services references"); + } + + // Check 3: existing quote_items.service_id still resolve in service_catalog (untouched FK — must remain valid) + const orphanedQuoteItems = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM quote_items qi + WHERE qi.service_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = qi.service_id) + `); + const orphanedQuoteItemsCount = (orphanedQuoteItems as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedQuoteItemsCount > 0) { + console.log(`FAIL: ${orphanedQuoteItemsCount} quote_items reference missing service_catalog ids (pre-existing FK broken!)`); + failures++; + } else { + console.log("PASS: quote_items.service_id -> service_catalog FK intact (unchanged by this migration)"); + } + + // Check 4: existing offer_micro_services.service_id still resolve in offer_services (untouched FK — must remain valid) + const orphanedMicroServices = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM offer_micro_services oms + WHERE NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = oms.service_id) + `); + const orphanedMicroServicesCount = (orphanedMicroServices as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedMicroServicesCount > 0) { + console.log(`FAIL: ${orphanedMicroServicesCount} offer_micro_services reference missing offer_services ids (pre-existing FK broken!)`); + failures++; + } else { + console.log("PASS: offer_micro_services.service_id -> offer_services FK intact (unchanged by this migration)"); + } + + // Check 5: name collision report (informational) + const collisions = await db.execute(sql` + SELECT name, COUNT(*)::int AS n FROM services GROUP BY name HAVING COUNT(*) > 1 + `); + const collisionRows = collisions as unknown as Array<{ name: string; n: number }>; + if (collisionRows.length > 0) { + console.log(`WARNING: ${collisionRows.length} duplicate names remain in services (review):`, collisionRows); + } else { + console.log("PASS: no duplicate names in services"); + } + + console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`); + process.exit(failures === 0 ? 0 : 1); + } + + validate().catch((err) => { + console.error("Validation failed:", err); + process.exit(1); + }); + ``` + + Run: + ```bash + npx tsx scripts/validate-services-migration.ts + ``` + + All checks must print PASS. If any check prints FAIL, STOP — do not proceed to Plan 07-02 until resolved (do not drop/modify any table to "fix" a failure; investigate the migrate-services.ts logic instead since source tables must remain untouched). + + + test -f scripts/validate-services-migration.ts && echo "validation script exists" + npx tsx scripts/validate-services-migration.ts 2>&1 | grep -q "ALL CHECKS PASSED" && echo "validation passed" + + + - scripts/validate-services-migration.ts exists and runs against production DB + - All checks print PASS: row counts match, no orphaned migrated_id references, existing quote_items/offer_micro_services FKs remain intact (untouched by this migration) + - Any name collisions are reported and resolved via "(Offer)" suffix (Check 5 shows zero unresolved duplicates) + - Script exits 0 + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Migration script -> Production Postgres | Script runs with full DB credentials (DATABASE_URL); writes new rows only, must never DELETE/UPDATE/DROP on service_catalog or offer_services | +| drizzle-kit push -> Production schema | Schema diff applied directly to production (no staging DB available); diff must be additive-only | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07-01 | Tampering | drizzle-kit push proposes unintended ALTER/DROP on existing tables | mitigate | Task 1 explicitly requires reviewing the push diff before confirming; abort if anything beyond `CREATE TABLE services` appears | +| T-07-02 | Repudiation | Migration cannot be traced back to source rows after running | mitigate | `migrated_from` + `migrated_id` columns on every backfilled row provide full audit trail for rollback | +| T-07-03 | Tampering | Re-running migrate-services.ts creates duplicate rows on retry | mitigate | Idempotency check via `migrated_from` + `migrated_id` lookup before each insert (Task 2 behavior spec) | +| T-07-04 | Information Disclosure | DATABASE_URL exposed in script output/logs | accept | Scripts use `process.env.DATABASE_URL` via existing `@/db` client; never logged; same pattern as scripts/seed.ts already in repo | +| T-07-05 | Denial of Service | Backfill script run against production during business hours locks tables | accept | 56 rows total — INSERT-only operations on a new empty table; no lock contention with existing read paths (service_catalog/offer_services untouched) | + + + +After plan execution: +1. `npm run build` — no TypeScript errors +2. `npx drizzle-kit push` diff (re-run, should show "No changes detected" — confirms schema already applied) +3. `npx tsx scripts/validate-services-migration.ts` — prints "ALL CHECKS PASSED" +4. Manually inspect production DB: `SELECT migrated_from, COUNT(*) FROM services GROUP BY migrated_from` returns `service_catalog: 21, offer_services: 35` (or current row counts if they've changed since planning) +5. Confirm `service_catalog` and `offer_services` tables still exist with original row counts (no rows deleted) + + + +- `services` table exists in production Postgres with the full column set from ARCHITECTURE.md +- 100% of service_catalog and offer_services rows have a corresponding services row with migrated_from/migrated_id set +- Zero orphaned references; existing quote_items and offer_micro_services FKs remain valid (untouched) +- Name collisions resolved via deterministic suffixing, no silent overwrites +- service_catalog and offer_services tables remain in place, unmodified — rollback is possible by simply not using the new table +- npm run build passes + + + +After completion, create `.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md` + diff --git a/.planning/phases/07-unified-service-catalog/07-02-PLAN.md b/.planning/phases/07-unified-service-catalog/07-02-PLAN.md new file mode 100644 index 0000000..ecf89b1 --- /dev/null +++ b/.planning/phases/07-unified-service-catalog/07-02-PLAN.md @@ -0,0 +1,452 @@ +--- +phase: "07-unified-service-catalog" +plan: 02 +type: execute +wave: 2 +depends_on: ["07-01"] +files_modified: + - src/lib/admin-queries.ts + - src/app/admin/catalog/actions.ts + - src/app/admin/catalog/page.tsx + - src/components/admin/catalog/ServiceTable.tsx + - src/components/admin/catalog/ServiceForm.tsx + - src/components/admin/tabs/QuoteTab.tsx +autonomous: true +requirements: + - CAT-U-02 + +must_haves: + truths: + - "/admin/catalog lists services from the unified `services` table, not from `service_catalog`" + - "Admin can add a new service — it is inserted into `services` with migrated_from=null, migrated_id=null (new row, not migrated)" + - "Admin can edit a service's name, description, unit_price, category" + - "Admin can soft-delete (toggle active=false) a service" + - "Quote builder (QuoteTab) continues to read activeServices for the project workspace, now sourced from `services` instead of `service_catalog`" + - "No remaining application code path reads/writes `service_catalog` for the /admin/catalog page or quote builder service picker — old table is now dead code for these surfaces (still present in DB for audit/rollback)" + artifacts: + - path: "src/lib/admin-queries.ts" + provides: "getAllServices() and activeServices query updated to select from `services` table" + contains: "from(services)" + - path: "src/app/admin/catalog/actions.ts" + provides: "createService/updateService/toggleServiceActive operating on `services` table, with category field" + contains: "import { services } from \"@/db/schema\"" + - path: "src/components/admin/catalog/ServiceTable.tsx" + provides: "ServiceTable typed against `Service` (not `ServiceCatalog`), renders category column" + contains: "Service[]" + key_links: + - from: "src/app/admin/catalog/page.tsx" + to: "src/lib/admin-queries.ts getAllServices()" + via: "await getAllServices()" + pattern: "getAllServices" + - from: "src/lib/admin-queries.ts getAllServices()" + to: "services table" + via: "db.select().from(services)" + pattern: "from\\(services\\)" + - from: "src/components/admin/tabs/QuoteTab.tsx" + to: "src/lib/admin-queries.ts activeServices" + via: "ClientFullDetail.activeServices: Service[]" + pattern: "activeServices" +--- + + +Rewire `/admin/catalog` (CAT-U-02) — the admin-facing service catalog CRUD used by the quote builder — from the legacy `service_catalog` table to the new unified `services` table created in Plan 07-01. New services created from this point forward are written to `services` with `migrated_from=null`. + +Purpose: Complete the "Expand" half of the catalog unification for the surface that matters most operationally (the admin catalog page + quote builder service picker). This satisfies ROADMAP success criteria #3 ("`/admin/catalog` list/add/edit/soft-delete funzionante con nuova tabella") and #4 ("Query vecchie servizi falliscono esplicitamente") for this code path — `service_catalog` is no longer read or written by any application code for these surfaces, but the table itself remains in Postgres (untouched, per Data Safety LOCKED) holding historical data for `quote_items.service_id` FK integrity and rollback. + +Output: `/admin/catalog` and the project workspace QuoteTab both operate on `services`; `service_catalog` becomes a read-only historical table referenced only by the existing `quote_items.service_id` FK and the historical quote-items label join (unchanged). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/07-unified-service-catalog/07-01-PLAN.md + + + +```typescript +export const services = pgTable("services", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + description: text("description"), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + category: text("category"), + active: boolean("active").notNull().default(true), + migrated_from: text("migrated_from"), + migrated_id: text("migrated_id"), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export type Service = typeof services.$inferSelect; +export type NewService = typeof services.$inferInsert; +``` + + +```typescript +"use server"; + +import { db } from "@/db"; +import { service_catalog } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const serviceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + description: z.string().optional(), + unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), +}); + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} + +export async function createService(formData: FormData) { + await requireAdmin(); + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(service_catalog).values({ + name: parsed.data.name, + description: parsed.data.description ?? null, + unit_price: parsed.data.unit_price.toFixed(2), + }); + revalidatePath("/admin/catalog"); +} + +export async function updateService(serviceId: string, formData: FormData) { + // ... same pattern with .update(service_catalog).set({...}).where(eq(service_catalog.id, serviceId)) +} + +export async function toggleServiceActive(serviceId: string, active: boolean) { + // ... same pattern with .update(service_catalog).set({ active }).where(eq(service_catalog.id, serviceId)) +} +``` + + +```typescript +import { service_catalog, ... } from "@/db/schema"; +import type { ServiceCatalog, ... } from "@/db/schema"; + +export async function getAllServices(): Promise { + return db.select().from(service_catalog).orderBy(asc(service_catalog.name)); +} + +// Inside getClientFullDetail(): +const activeServiceRows = await db + .select() + .from(service_catalog) + .where(eq(service_catalog.active, true)) + .orderBy(asc(service_catalog.name)); + +export type ClientFullDetail = { + // ... + activeServices: ServiceCatalog[]; +}; +``` + +A second, near-identical block exists later in the same file (a different view's data assembly) — +search for the second occurrence of `activeServices: ServiceCatalog[]` and its matching +`db.select().from(service_catalog).where(eq(service_catalog.active, true))` query. + + +```typescript +import type { ServiceCatalog } from "@/db/schema"; +function ServiceRow({ service }: { service: ServiceCatalog }) { ... } +export function ServiceTable({ services }: { services: ServiceCatalog[] }) { ... } +``` + + +```typescript +import type { ServiceCatalog } from "@/db/schema"; +// ... +activeServices: ServiceCatalog[]; +``` + +IMPORTANT: `quote_items.service_id` FK still references `service_catalog.id` (unchanged — Plan 07-01 +left this untouched). This means the `getClientFullDetail()` quote-items label join +(`COALESCE(service_catalog.name, quote_items.custom_label)`) must KEEP joining against +`service_catalog` for EXISTING `quote_items` rows — those rows have `service_id` values that are +`service_catalog.id` values, not `services.id` values. Only the `activeServices` list (used for the +"add new quote item" picker in QuoteTab) and the `/admin/catalog` CRUD page move to `services`. Do +not change the `quote_items` join in `getClientFullDetail()`. + + + + + + + Task 1: Update query layer — getAllServices() and activeServices now read from `services` + + src/lib/admin-queries.ts + + + In `src/lib/admin-queries.ts`: + + 1. Add `services` and `Service` to the existing imports from `@/db/schema` (keep `service_catalog` and `ServiceCatalog` imports — they are still needed for the `quote_items` label join, which must remain unchanged per the interfaces note above). + + 2. Update `getAllServices()`: + ```typescript + export async function getAllServices(): Promise { + return db.select().from(services).orderBy(asc(services.name)); + } + ``` + + 3. Inside `getClientFullDetail()`, change the `activeServiceRows` query (used to populate `ClientFullDetail.activeServices`) from `service_catalog` to `services`: + ```typescript + const activeServiceRows = await db + .select() + .from(services) + .where(eq(services.active, true)) + .orderBy(asc(services.name)); + ``` + + 4. Update the `ClientFullDetail` type's `activeServices` field from `ServiceCatalog[]` to `Service[]`. + + 5. Find the SECOND occurrence of this same pattern further down the file (a different view's data assembly — search for `activeServices: ServiceCatalog[]` and the matching `db.select().from(service_catalog).where(eq(service_catalog.active, true))` block around line 530). Apply the same `service_catalog` -> `services` change there too, including its type annotation. + + 6. Do NOT change the `quote_items` queries that join `service_catalog` for `QuoteItemWithLabel.label` (the `COALESCE(service_catalog.name, quote_items.custom_label)` joins around lines 323 and 519) — these resolve historical `quote_items.service_id` values which still point at `service_catalog.id`. Leave `service_catalog` and `ServiceCatalog` imports in place for this purpose. + + + grep -q "export async function getAllServices(): Promise<Service\[\]>" src/lib/admin-queries.ts && echo "getAllServices returns Service[]" + test "$(grep -c 'from(services)' src/lib/admin-queries.ts)" -ge 2 && echo "activeServices queries use services table" + grep -q 'COALESCE(\${service_catalog.name}' src/lib/admin-queries.ts && echo "quote_items label join still uses service_catalog (correct -- historical FK)" + npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" + + + - getAllServices() selects from `services`, returns `Service[]` + - Both `activeServices` queries (in getClientFullDetail and the second view) select from `services` where active=true, typed as `Service[]` + - quote_items label join (COALESCE service_catalog.name / custom_label) unchanged — still joins service_catalog + - npm run build passes + + + + + Task 2: Rewire /admin/catalog actions + components to `services` table with category field + + src/app/admin/catalog/actions.ts + src/components/admin/catalog/ServiceTable.tsx + src/components/admin/catalog/ServiceForm.tsx + src/components/admin/tabs/QuoteTab.tsx + + + **src/app/admin/catalog/actions.ts** — replace all `service_catalog` references with `services`: + + ```typescript + "use server"; + + import { db } from "@/db"; + import { services } from "@/db/schema"; + import { revalidatePath } from "next/cache"; + import { eq } from "drizzle-orm"; + import { z } from "zod"; + import { getServerSession } from "next-auth"; + import { authOptions } from "@/lib/auth"; + + const serviceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + description: z.string().optional(), + unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), + category: z.string().optional(), + }); + + async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); + } + + export async function createService(formData: FormData) { + await requireAdmin(); + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + category: formData.get("category") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + // New rows created from /admin/catalog are NOT migrated — migrated_from/migrated_id stay null + await db.insert(services).values({ + name: parsed.data.name, + description: parsed.data.description ?? null, + unit_price: parsed.data.unit_price.toFixed(2), + category: parsed.data.category || null, + }); + revalidatePath("/admin/catalog"); + } + + export async function updateService(serviceId: string, formData: FormData) { + await requireAdmin(); + const parsed = serviceSchema.safeParse({ + name: formData.get("name"), + description: formData.get("description") ?? "", + unit_price: formData.get("unit_price"), + category: formData.get("category") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db + .update(services) + .set({ + name: parsed.data.name, + description: parsed.data.description ?? null, + unit_price: parsed.data.unit_price.toFixed(2), + category: parsed.data.category || null, + }) + .where(eq(services.id, serviceId)); + revalidatePath("/admin/catalog"); + } + + export async function toggleServiceActive(serviceId: string, active: boolean) { + await requireAdmin(); + await db.update(services).set({ active }).where(eq(services.id, serviceId)); + revalidatePath("/admin/catalog"); + } + ``` + + **src/components/admin/catalog/ServiceTable.tsx** — change the type import and prop type from `ServiceCatalog` to `Service`: + - Replace `import type { ServiceCatalog } from "@/db/schema";` with `import type { Service } from "@/db/schema";` + - Replace `function ServiceRow({ service }: { service: ServiceCatalog })` with `function ServiceRow({ service }: { service: Service })` + - Replace `export function ServiceTable({ services }: { services: ServiceCatalog[] })` with `export function ServiceTable({ services }: { services: Service[] })` + - Add a "Categoria" column to the table: render `service.category ?? "—"` in a new cell alongside name, description, unit_price, active — follow the existing table markup pattern in the file for cell styling (same className conventions as the description cell). + + **src/components/admin/catalog/ServiceForm.tsx** — add a category input field: + - Add a `category` text Input + Label, following the same pattern as the existing `description` field (optional, placed after description in the form layout) + - The form already calls `createService(fd)` with FormData — no action signature change needed since `createService` reads `formData.get("category")` + + **src/components/admin/tabs/QuoteTab.tsx** — update the type import: + - Replace `import type { ServiceCatalog } from "@/db/schema";` with `import type { Service } from "@/db/schema";` + - Replace `activeServices: ServiceCatalog[];` with `activeServices: Service[];` + - No other logic changes — QuoteTab only reads `id`, `name`, `unit_price` from each service object, all present on `Service`. + + + grep -q 'import { services } from "@/db/schema"' src/app/admin/catalog/actions.ts && echo "actions.ts imports services" + grep -q "service_catalog" src/app/admin/catalog/actions.ts && echo "STALE REFERENCE FOUND" || echo "no service_catalog references in actions.ts" + grep -q "import type { Service } from \"@/db/schema\"" src/components/admin/catalog/ServiceTable.tsx && echo "ServiceTable typed against Service" + grep -q "category" src/components/admin/catalog/ServiceForm.tsx && echo "ServiceForm has category field" + grep -q "activeServices: Service\[\]" src/components/admin/tabs/QuoteTab.tsx && echo "QuoteTab typed against Service[]" + npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" + + + - src/app/admin/catalog/actions.ts: createService/updateService/toggleServiceActive operate on `services` table; createService accepts optional `category`; zero references to `service_catalog` + - ServiceTable.tsx and ServiceForm.tsx typed against `Service`, render/edit `category` field + - QuoteTab.tsx typed against `Service[]` for activeServices + - npm run build passes + + + + + Task 3: End-to-end verification — catalog CRUD on services table + quote builder regression check + + scripts/validate-services-migration.ts + + + This task closes out Phase 7 with a manual + scripted verification pass. No new files beyond an + extension to the existing validation script. + + 1. Re-run the validation script from Plan 07-01 to confirm the migration is still intact after + the schema/code changes in Tasks 1-2: + ```bash + npx tsx scripts/validate-services-migration.ts + ``` + Must still print "ALL CHECKS PASSED". + + 2. Append ONE additional check to `scripts/validate-services-migration.ts`: confirm `services` + contains at least one row with `migrated_from IS NULL` IF any new service was created via + `/admin/catalog` during manual testing (this check is informational/best-effort — print a + count, do not fail the script if zero, since manual testing may not have created a row yet): + ```typescript + // Check 6: informational — count of net-new (non-migrated) services + const [netNew] = await db + .select({ n: sql`count(*)::int` }) + .from(services) + .where(sql`migrated_from IS NULL`); + console.log(`INFO: ${netNew.n} net-new services created post-migration (migrated_from IS NULL)`); + ``` + + 3. Manual verification steps (perform via `npm run dev` against production DB or a local tunnel, + whichever this project's existing dev workflow uses): + - Visit `/admin/catalog` — confirm the list shows >= 56 services (the migrated rows from Plan + 07-01), with name/description/unit_price/category/active columns rendered correctly + - Add a new service via the form (e.g., name="Test Service Phase 7", unit_price=100, + category="test") — confirm it appears in the list immediately + - Edit the new service's price to 150 — confirm the table reflects the change + - Toggle the new service's active flag off — confirm it shows as inactive (per existing + ServiceTable UI convention for inactive rows) + - Open an existing client's project workspace QuoteTab (`/admin/clients/[id]` -> a project + with a quote) — confirm the service picker dropdown is populated from `services` (lists + active services including the new "Test Service Phase 7" before it was deactivated, and the + 56 migrated services) + - Confirm any EXISTING quote_items on that project still display their original labels + correctly (proves the untouched `service_catalog` join for historical `quote_items.label` + still works) + + 4. Re-run `npx tsx scripts/validate-services-migration.ts` one final time — must print "ALL + CHECKS PASSED" plus the new informational line from step 2. + + + npx tsx scripts/validate-services-migration.ts 2>&1 | grep -q "ALL CHECKS PASSED" && echo "validation still passes after CRUD rewire" + grep -q "net-new services created post-migration" scripts/validate-services-migration.ts && echo "informational check 6 added" + npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" + + + - scripts/validate-services-migration.ts still passes all checks after the CRUD rewire, plus prints the new informational net-new count + - /admin/catalog list/add/edit/soft-delete all confirmed working against `services` table (manual verification) + - QuoteTab service picker confirmed populated from `services` + - Existing quote_items on at least one project still resolve their labels correctly via the unchanged service_catalog join (proves backward compatibility) + - npm run build passes + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Admin (authenticated) -> /admin/catalog actions | createService/updateService/toggleServiceActive require an active Auth.js session (requireAdmin()) | +| /admin/catalog -> services table | New/edited rows written with migrated_from=null — must not collide with migrated rows' migrated_id semantics | +| QuoteTab -> services (activeServices) | Quote builder service picker now reads `services`; must not silently include inactive or stale rows | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07-06 | Tampering | createService/updateService/toggleServiceActive on `services` | mitigate | requireAdmin() unchanged from Phase 3 pattern — session check before any write, identical to existing service_catalog actions | +| T-07-07 | Repudiation | New services created post-migration are indistinguishable from migrated ones in audit | mitigate | migrated_from/migrated_id remain NULL for net-new rows — Task 3 informational check makes this auditable | +| T-07-08 | Information Disclosure | quote_items.unit_price snapshots could be confused with live `services.unit_price` if join changed | mitigate | Task 1 explicitly preserves the existing service_catalog join for QuoteItemWithLabel — unit_price snapshots in quote_items remain immutable and unaffected by services table edits | +| T-07-09 | Tampering | Editing a migrated service's unit_price in `services` retroactively changes price shown for NEW quote items only | accept | This is the intended behavior of a "single source of truth" catalog (CAT-U-01/02 goal) — historical quote_items snapshots are unaffected (see T-07-08); admin is the sole user and aware of this | + + + +After plan execution: +1. `npm run build` — no TypeScript errors +2. `npx tsx scripts/validate-services-migration.ts` — "ALL CHECKS PASSED" + net-new informational line +3. Visit `/admin/catalog` — list renders from `services` (>= 56 rows + any net-new), category column visible +4. Add/edit/toggle a test service — persists correctly, migrated_from stays null for the new row +5. Open a project workspace QuoteTab — service picker populated from `services`; existing quote_items labels still resolve via untouched service_catalog join +6. `grep -rn "service_catalog" src/app/admin/catalog/ src/components/admin/catalog/ src/components/admin/tabs/QuoteTab.tsx` returns no matches (confirms CAT-U-02 surfaces fully migrated off the old table) + + + +- `/admin/catalog` list/add/edit/soft-delete fully functional against `services` table (ROADMAP success criterion #3) +- New services created via /admin/catalog have migrated_from=null, migrated_id=null +- QuoteTab service picker reads from `services` via ClientFullDetail.activeServices: Service[] +- service_catalog is no longer referenced by /admin/catalog or QuoteTab code paths — only by the unchanged historical quote_items label join (ROADMAP success criterion #4: "Query vecchie servizi falliscono esplicitamente" satisfied for these surfaces) +- service_catalog and offer_services tables remain in Postgres, unmodified, for FK integrity and rollback +- npm run build passes + + + +After completion, create `.planning/phases/07-unified-service-catalog/07-02-SUMMARY.md` +