---
phase: 19-reference-item-model-tags-schema
plan: 02
type: execute
wave: 2
depends_on: ["19-01"]
files_modified:
  - src/server/services/item.service.ts
  - src/server/services/thread.service.ts
  - src/server/routes/items.ts
  - tests/services/item.service.test.ts
  - tests/services/thread.service.test.ts
autonomous: true
requirements:
  - CATFLOW-03
  - CATFLOW-04
  - CATFLOW-05
  - CATFLOW-06

must_haves:
  truths:
    - "getAllItems and getItemById return merged data for reference items (global name/weight/price when globalItemId is set)"
    - "Creating an item with globalItemId stores a reference item with personal fields only"
    - "Duplicating a reference item preserves the globalItemId link"
    - "Thread candidates with globalItemId display global item data merged in"
    - "Resolving a thread with a catalog-linked candidate creates a reference item (globalItemId set, no data copy)"
    - "Resolving a thread with a standalone candidate still does full data copy"
    - "Link/unlink endpoints removed from items route"
  artifacts:
    - path: "src/server/services/item.service.ts"
      provides: "COALESCE merge for reference items in getAllItems, getItemById, createItem, duplicateItem"
      contains: "COALESCE"
    - path: "src/server/services/thread.service.ts"
      provides: "globalItemId on candidates, branched resolution logic"
      contains: "globalItemId"
    - path: "src/server/routes/items.ts"
      provides: "Cleaned route file without link/unlink endpoints"
    - path: "tests/services/item.service.test.ts"
      provides: "Tests for reference item creation and merged data retrieval"
      contains: "reference item"
    - path: "tests/services/thread.service.test.ts"
      provides: "Tests for catalog-linked candidate resolution"
      contains: "globalItemId"
  key_links:
    - from: "src/server/services/item.service.ts"
      to: "src/db/schema.ts (globalItems)"
      via: "LEFT JOIN + COALESCE"
      pattern: "leftJoin.*globalItems"
    - from: "src/server/services/thread.service.ts"
      to: "src/db/schema.ts (items)"
      via: "conditional insert based on candidate.globalItemId"
      pattern: "candidate\\.globalItemId"
---

<objective>
Implement the COALESCE merge pattern in item service for reference items, update thread service for catalog-linked candidates and branched resolution, remove link/unlink endpoints from items route.

Purpose: Core business logic for reference items and catalog-linked thread resolution. This is where the reference model comes alive.
Output: Updated item service with merge queries, thread service with branched resolution, cleaned items route, comprehensive tests.
</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/19-reference-item-model-tags-schema/19-CONTEXT.md
@.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md
@.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md

<interfaces>
<!-- Schema exports after Plan 01 -->
From src/db/schema.ts (post Plan 01):
```typescript
export const items = pgTable("items", {
  // ... existing fields ...
  globalItemId: integer("global_item_id").references(() => globalItems.id),
  purchasePriceCents: integer("purchase_price_cents"),
  // ...
});

export const threadCandidates = pgTable("thread_candidates", {
  // ... existing fields ...
  globalItemId: integer("global_item_id").references(() => globalItems.id),
  // ...
});

export const globalItems = pgTable("global_items", {
  id: serial("id").primaryKey(),
  brand: text("brand").notNull(),
  model: text("model").notNull(),
  category: text("category"),
  weightGrams: doublePrecision("weight_grams"),
  priceCents: integer("price_cents"),
  imageUrl: text("image_url"),
  description: text("description"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const tags = pgTable("tags", {
  id: serial("id").primaryKey(),
  name: text("name").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

export const globalItemTags = pgTable("global_item_tags", {
  globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
  tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
}, (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })]);
```

From src/shared/schemas.ts (post Plan 01):
```typescript
export const createItemSchema = z.object({
  // ... existing + globalItemId, purchasePriceCents
});
export const createCandidateSchema = z.object({
  // ... existing + globalItemId
});
// linkItemSchema REMOVED
```

Current item.service.ts functions:
```typescript
export async function getAllItems(db: Db, userId: number)
export async function getItemById(db: Db, userId: number, id: number)
export async function createItem(db: Db, userId: number, data: ...)
export async function updateItem(db: Db, userId: number, id: number, data: ...)
export async function duplicateItem(db: Db, userId: number, id: number)
export async function deleteItem(db: Db, userId: number, id: number)
```

Current thread.service.ts key functions:
```typescript
export async function getThreadWithCandidates(db: Db, userId: number, threadId: number)
export async function createCandidate(db: Db, userId: number, threadId: number, data: ...)
export async function resolveThread(db: Db, userId: number, threadId: number, candidateId: number)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Item service COALESCE merge + reference item creation + tests</name>
  <files>src/server/services/item.service.ts, tests/services/item.service.test.ts</files>
  <read_first>
    - src/server/services/item.service.ts
    - src/db/schema.ts
    - tests/services/item.service.test.ts
    - tests/helpers/db.ts
    - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (Pattern 1: COALESCE Merge section)
  </read_first>
  <behavior>
    - Test: createItem with globalItemId creates a reference item; returned row has globalItemId set
    - Test: createItem with globalItemId stores brand+model from global item as fallback name (per Pitfall 3)
    - Test: getAllItems returns merged name (brand + ' ' + model from globalItems) for reference items
    - Test: getAllItems returns merged weightGrams from globalItems for reference items
    - Test: getAllItems returns merged priceCents from globalItems for reference items
    - Test: getAllItems returns item's own imageFilename when set, falls back to globalItems.imageUrl
    - Test: getAllItems returns standalone item data unchanged (no globalItemId)
    - Test: getItemById returns merged data for a reference item
    - Test: getItemById returns globalItemId field in response
    - Test: duplicateItem on a reference item preserves globalItemId
    - Test: createItem with purchasePriceCents stores the value
  </behavior>
  <action>
    **Update `src/server/services/item.service.ts`:**

    1. Add imports: `import { globalItems } from "../../db/schema.ts"` and `import { sql } from "drizzle-orm"`.

    2. Rewrite `getAllItems` to LEFT JOIN globalItems and COALESCE fields (per D-06, D-07):
    ```typescript
    export async function getAllItems(db: Db, userId: number) {
      return db
        .select({
          id: items.id,
          name: sql<string>`COALESCE(
            CASE WHEN ${items.globalItemId} IS NOT NULL
              THEN ${globalItems.brand} || ' ' || ${globalItems.model}
              ELSE ${items.name}
            END,
            ${items.name}
          )`.as("name"),
          weightGrams: sql<number | null>`COALESCE(
            CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
            ${items.weightGrams}
          )`.as("weight_grams"),
          priceCents: sql<number | null>`COALESCE(
            CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
            ${items.priceCents}
          )`.as("price_cents"),
          purchasePriceCents: items.purchasePriceCents,
          quantity: items.quantity,
          categoryId: items.categoryId,
          notes: items.notes,
          productUrl: items.productUrl,
          imageFilename: sql<string | null>`COALESCE(
            ${items.imageFilename},
            CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
          )`.as("image_filename"),
          imageSourceUrl: items.imageSourceUrl,
          globalItemId: items.globalItemId,
          createdAt: items.createdAt,
          updatedAt: items.updatedAt,
          categoryName: categories.name,
          categoryIcon: categories.icon,
        })
        .from(items)
        .innerJoin(categories, eq(items.categoryId, categories.id))
        .leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
        .where(eq(items.userId, userId));
    }
    ```

    3. Rewrite `getItemById` with same COALESCE pattern (same select fields minus categoryName/categoryIcon, plus leftJoin on globalItems).

    4. Update `createItem` to accept `globalItemId` and `purchasePriceCents`:
    - When `data.globalItemId` is provided, look up the global item to get brand+model for the fallback `name` field (per Pitfall 3 -- items.name is NOT NULL).
    - Add `globalItemId: data.globalItemId ?? null` and `purchasePriceCents: data.purchasePriceCents ?? null` to the insert values.

    5. Update `duplicateItem` to copy `globalItemId` and `purchasePriceCents` from source.

    6. Update `updateItem` data type to include `globalItemId` and `purchasePriceCents` in the Partial type.

    **Write tests in `tests/services/item.service.test.ts`:**

    Add a test helper to insert a global item:
    ```typescript
    async function insertGlobalItem(db: Db, data: { brand: string; model: string; weightGrams?: number; priceCents?: number; imageUrl?: string }) {
      const [row] = await db.insert(globalItems).values(data).returning();
      return row;
    }
    ```

    Then add test cases for the behaviors listed above. Each test creates a global item, creates a reference item pointing to it, then verifies the merged data returned by getAllItems/getItemById.
  </action>
  <verify>
    <automated>bun test tests/services/item.service.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - src/server/services/item.service.ts `getAllItems` contains `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))`
    - src/server/services/item.service.ts `getAllItems` contains `COALESCE` for name, weightGrams, priceCents, imageFilename
    - src/server/services/item.service.ts `getAllItems` select includes `globalItemId: items.globalItemId`
    - src/server/services/item.service.ts `getAllItems` select includes `purchasePriceCents: items.purchasePriceCents`
    - src/server/services/item.service.ts `getItemById` contains `.leftJoin(globalItems`
    - src/server/services/item.service.ts `createItem` values include `globalItemId: data.globalItemId ?? null`
    - src/server/services/item.service.ts `duplicateItem` copies `globalItemId` from source
    - tests/services/item.service.test.ts contains at least 5 new tests with "reference item" or "globalItemId" in the name
    - `bun test tests/services/item.service.test.ts` exits 0
  </acceptance_criteria>
  <done>Item service returns merged data for reference items via COALESCE joins. createItem accepts globalItemId. duplicateItem preserves links. All tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Thread service candidate globalItemId + branched resolution + route cleanup + tests</name>
  <files>src/server/services/thread.service.ts, src/server/routes/items.ts, tests/services/thread.service.test.ts</files>
  <read_first>
    - src/server/services/thread.service.ts
    - src/server/routes/items.ts
    - tests/services/thread.service.test.ts
    - src/db/schema.ts
    - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (Pattern 3: Branched Thread Resolution)
  </read_first>
  <behavior>
    - Test: createCandidate with globalItemId stores the value on the candidate row
    - Test: getThreadWithCandidates returns globalItemId field on each candidate
    - Test: getThreadWithCandidates merges global item data (brand+model as name, weight, price) for candidates with globalItemId
    - Test: resolveThread with candidate having globalItemId creates a reference item (items.globalItemId = candidate.globalItemId, no weight/price copy)
    - Test: resolveThread with candidate without globalItemId creates a standalone item (full data copy, existing behavior)
    - Test: resolveThread reference item has brand+model as fallback name
  </behavior>
  <action>
    **Update `src/server/services/thread.service.ts`:**

    1. Add import for `globalItems` from schema.

    2. Update `createCandidate` to accept and store `globalItemId`:
    Add `globalItemId: data.globalItemId ?? null` to the insert values object (after `imageSourceUrl`).

    3. Update `getThreadWithCandidates` to LEFT JOIN globalItems and merge candidate data:
    - Add `.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))` after the innerJoin on categories.
    - Update select to use COALESCE for name, weightGrams, priceCents, imageFilename when candidate has globalItemId:
    ```typescript
    name: sql<string>`COALESCE(
      CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
        THEN ${globalItems.brand} || ' ' || ${globalItems.model}
        ELSE ${threadCandidates.name}
      END,
      ${threadCandidates.name}
    )`.as("name"),
    weightGrams: sql<number | null>`COALESCE(
      CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
      ${threadCandidates.weightGrams}
    )`.as("weight_grams"),
    priceCents: sql<number | null>`COALESCE(
      CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
      ${threadCandidates.priceCents}
    )`.as("price_cents"),
    imageFilename: sql<string | null>`COALESCE(
      ${threadCandidates.imageFilename},
      CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
    )`.as("image_filename"),
    ```
    - Add `globalItemId: threadCandidates.globalItemId` to the select.

    4. Update `resolveThread` step 4 (item creation) with branched logic (per D-12, D-13):
    ```typescript
    // 4. Create collection item — branched on catalog link
    let insertValues: any;
    if (candidate.globalItemId) {
      // Reference item — link to global, personal fields only
      // Look up global item for fallback name
      const [gi] = await tx.select().from(globalItems).where(eq(globalItems.id, candidate.globalItemId));
      const fallbackName = gi ? `${gi.brand} ${gi.model}` : candidate.name;
      insertValues = {
        name: fallbackName,
        globalItemId: candidate.globalItemId,
        categoryId: safeCategoryId,
        userId,
        notes: candidate.notes,
        imageFilename: candidate.imageFilename,
        imageSourceUrl: candidate.imageSourceUrl,
        quantity: 1,
      };
    } else {
      // Standalone item — full data copy (existing behavior)
      insertValues = {
        name: candidate.name,
        weightGrams: candidate.weightGrams,
        priceCents: candidate.priceCents,
        categoryId: safeCategoryId,
        userId,
        notes: candidate.notes,
        productUrl: candidate.productUrl,
        imageFilename: candidate.imageFilename,
        imageSourceUrl: candidate.imageSourceUrl,
        quantity: 1,
      };
    }
    const [newItem] = await tx.insert(items).values(insertValues).returning();
    ```

    **Update `src/server/routes/items.ts`:**

    1. Remove the `POST /:id/link` and `DELETE /:id/link` route handlers (lines 125-151).
    2. Remove imports of `linkItemSchema` from schemas.ts.
    3. Remove imports of `linkItemToGlobal` and `unlinkItemFromGlobal` from global-item.service.ts.

    **Write tests in `tests/services/thread.service.test.ts`:**

    Add tests for:
    - Creating a candidate with `globalItemId` and verifying it is stored
    - getThreadWithCandidates returns merged data for catalog-linked candidates
    - resolveThread with catalog-linked candidate creates reference item (globalItemId set, no weight/price on item row)
    - resolveThread with standalone candidate still copies all data (regression test)
  </action>
  <verify>
    <automated>bun test tests/services/thread.service.test.ts && grep -q "linkItemToGlobal" src/server/routes/items.ts; test $? -eq 1 && echo "PASS" || echo "FAIL"</automated>
  </verify>
  <acceptance_criteria>
    - src/server/services/thread.service.ts `createCandidate` values include `globalItemId: data.globalItemId ?? null`
    - src/server/services/thread.service.ts `getThreadWithCandidates` contains `.leftJoin(globalItems`
    - src/server/services/thread.service.ts `getThreadWithCandidates` select includes `globalItemId: threadCandidates.globalItemId`
    - src/server/services/thread.service.ts `resolveThread` contains `if (candidate.globalItemId)` branching logic
    - src/server/services/thread.service.ts `resolveThread` reference item branch sets `globalItemId: candidate.globalItemId`
    - src/server/routes/items.ts does NOT contain `linkItemToGlobal` or `unlinkItemFromGlobal`
    - src/server/routes/items.ts does NOT contain `linkItemSchema`
    - src/server/routes/items.ts does NOT contain `/:id/link`
    - tests/services/thread.service.test.ts contains tests with "globalItemId" or "reference" in the name
    - `bun test tests/services/thread.service.test.ts` exits 0
  </acceptance_criteria>
  <done>Thread candidates support globalItemId with merged display. Resolution branches correctly between reference and standalone items. Link/unlink endpoints removed from items route. All tests pass.</done>
</task>

</tasks>

<verification>
```bash
# Item service merge
grep "leftJoin.*globalItems" src/server/services/item.service.ts  # Should match
grep "COALESCE" src/server/services/item.service.ts  # Should match multiple times
grep "globalItemId" src/server/services/item.service.ts  # Should match in select + create

# Thread service
grep "leftJoin.*globalItems" src/server/services/thread.service.ts  # Should match
grep "candidate.globalItemId" src/server/services/thread.service.ts  # Should match in resolve

# Route cleanup
grep "link" src/server/routes/items.ts  # Should only match "linkItemToGlobal" is gone

# Tests pass
bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts
```
</verification>

<success_criteria>
- Reference items return merged data (global name/weight/price) transparently via COALESCE joins
- Standalone items continue to work identically to before
- Thread candidates with globalItemId display merged global item data
- Thread resolution creates reference items when candidate has globalItemId
- Thread resolution creates standalone items when candidate has no globalItemId
- Link/unlink endpoints fully removed from items route
- All item and thread service tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/19-reference-item-model-tags-schema/19-02-SUMMARY.md`
</output>
