Files
simone bdcdd26f17 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.
2026-06-11 06:14:05 +02:00

24 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
07-unified-service-catalog 02 execute 2
07-01
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
true
CAT-U-02
truths artifacts key_links
/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)
path provides contains
src/lib/admin-queries.ts getAllServices() and activeServices query updated to select from `services` table from(services)
path provides contains
src/app/admin/catalog/actions.ts createService/updateService/toggleServiceActive operating on `services` table, with category field import { services } from "@/db/schema"
path provides contains
src/components/admin/catalog/ServiceTable.tsx ServiceTable typed against `Service` (not `ServiceCatalog`), renders category column Service[]
from to via pattern
src/app/admin/catalog/page.tsx src/lib/admin-queries.ts getAllServices() await getAllServices() getAllServices
from to via pattern
src/lib/admin-queries.ts getAllServices() services table db.select().from(services) from(services)
from to via pattern
src/components/admin/tabs/QuoteTab.tsx src/lib/admin-queries.ts activeServices ClientFullDetail.activeServices: Service[] 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).

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

@.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;


<!-- Current src/app/admin/catalog/actions.ts — full file, REPLACE service_catalog references with services -->
```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))
}
import { service_catalog, ... } from "@/db/schema";
import type { ServiceCatalog, ... } from "@/db/schema";

export async function getAllServices(): Promise<ServiceCatalog[]> {
  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.

import type { ServiceCatalog } from "@/db/schema";
function ServiceRow({ service }: { service: ServiceCatalog }) { ... }
export function ServiceTable({ services }: { services: ServiceCatalog[] }) { ... }
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<Service[]> {
  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<number>`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

<threat_model>

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

<success_criteria>

  • /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 </success_criteria>
After completion, create `.planning/phases/07-unified-service-catalog/07-02-SUMMARY.md`