---
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"
---

<objective>
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).
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-unified-service-catalog/07-01-PLAN.md

<interfaces>
<!-- services table + types created in Plan 07-01 (src/db/schema.ts) -->
```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))
}
```

<!-- Current src/lib/admin-queries.ts relevant excerpts -->
```typescript
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.

<!-- Current src/components/admin/catalog/ServiceTable.tsx — typed against ServiceCatalog -->
```typescript
import type { ServiceCatalog } from "@/db/schema";
function ServiceRow({ service }: { service: ServiceCatalog }) { ... }
export function ServiceTable({ services }: { services: ServiceCatalog[] }) { ... }
```

<!-- src/components/admin/tabs/QuoteTab.tsx — consumes activeServices -->
```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()`.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 1: Update query layer — getAllServices() and activeServices now read from `services`</name>
  <files>
    src/lib/admin-queries.ts
  </files>
  <action>
    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.
  </action>
  <verify>
    <automated>grep -q "export async function getAllServices(): Promise&lt;Service\[\]&gt;" src/lib/admin-queries.ts && echo "getAllServices returns Service[]"</automated>
    <automated>test "$(grep -c 'from(services)' src/lib/admin-queries.ts)" -ge 2 && echo "activeServices queries use services table"</automated>
    <automated>grep -q 'COALESCE(\${service_catalog.name}' src/lib/admin-queries.ts && echo "quote_items label join still uses service_catalog (correct -- historical FK)"</automated>
    <automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
  </verify>
  <done>
    - 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
  </done>
</task>

<task type="auto" tdd="false">
  <name>Task 2: Rewire /admin/catalog actions + components to `services` table with category field</name>
  <files>
    src/app/admin/catalog/actions.ts
    src/components/admin/catalog/ServiceTable.tsx
    src/components/admin/catalog/ServiceForm.tsx
    src/components/admin/tabs/QuoteTab.tsx
  </files>
  <action>
    **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`.
  </action>
  <verify>
    <automated>grep -q 'import { services } from "@/db/schema"' src/app/admin/catalog/actions.ts && echo "actions.ts imports services"</automated>
    <automated>grep -q "service_catalog" src/app/admin/catalog/actions.ts && echo "STALE REFERENCE FOUND" || echo "no service_catalog references in actions.ts"</automated>
    <automated>grep -q "import type { Service } from \"@/db/schema\"" src/components/admin/catalog/ServiceTable.tsx && echo "ServiceTable typed against Service"</automated>
    <automated>grep -q "category" src/components/admin/catalog/ServiceForm.tsx && echo "ServiceForm has category field"</automated>
    <automated>grep -q "activeServices: Service\[\]" src/components/admin/tabs/QuoteTab.tsx && echo "QuoteTab typed against Service[]"</automated>
    <automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
  </verify>
  <done>
    - 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
  </done>
</task>

<task type="auto">
  <name>Task 3: End-to-end verification — catalog CRUD on services table + quote builder regression check</name>
  <files>
    scripts/validate-services-migration.ts
  </files>
  <action>
    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.
  </action>
  <verify>
    <automated>npx tsx scripts/validate-services-migration.ts 2>&1 | grep -q "ALL CHECKS PASSED" && echo "validation still passes after CRUD rewire"</automated>
    <automated>grep -q "net-new services created post-migration" scripts/validate-services-migration.ts && echo "informational check 6 added"</automated>
    <automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
  </verify>
  <done>
    - 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
  </done>
</task>

</tasks>

<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>

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

<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>

<output>
After completion, create `.planning/phases/07-unified-service-catalog/07-02-SUMMARY.md`
</output>
