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

must_haves:
  truths:
    - "Global item search supports tag filtering with AND logic"
    - "Global item owner count uses items.globalItemId instead of itemGlobalLinks"
    - "Setup totals correctly include global item weight/price for reference items"
    - "Public setup items display merged data for reference items"
    - "Category totals include global item weight/price for reference items"
    - "CSV export shows merged data for reference items"
    - "GET /api/global-items?tags=x,y returns items matching ALL specified tags"
  artifacts:
    - path: "src/server/services/global-item.service.ts"
      provides: "Tag-filtered search with AND logic, owner count via items.globalItemId"
      contains: "tagNames"
    - path: "src/server/services/setup.service.ts"
      provides: "COALESCE merge in setup item queries and totals subqueries"
      contains: "global_items"
    - path: "src/server/services/totals.service.ts"
      provides: "COALESCE merge in category and global totals"
      contains: "global_items"
    - path: "src/server/services/profile.service.ts"
      provides: "COALESCE merge in public setup queries"
      contains: "global_items"
    - path: "src/server/services/csv.service.ts"
      provides: "COALESCE merge in CSV export query"
      contains: "global_items"
    - path: "src/server/routes/global-items.ts"
      provides: "Tag query param parsing and async handler fixes"
      contains: "tags"
    - path: "tests/services/global-item.service.test.ts"
      provides: "Tests for tag filtering and owner count"
      contains: "tag"
  key_links:
    - from: "src/server/services/setup.service.ts"
      to: "src/db/schema.ts (globalItems)"
      via: "LEFT JOIN in subqueries"
      pattern: "global_items"
    - from: "src/server/services/global-item.service.ts"
      to: "src/db/schema.ts (tags, globalItemTags)"
      via: "subquery with GROUP BY HAVING"
      pattern: "HAVING COUNT"
    - from: "src/server/routes/global-items.ts"
      to: "src/server/services/global-item.service.ts"
      via: "tag param parsing and forwarding"
      pattern: "tags.*split"
---

<objective>
Update global-item service for tag filtering and owner count, update all secondary services (setup, totals, profile, CSV) to merge global item data for reference items, update global-items route for tag query params.

Purpose: Ensure reference items display correct merged data everywhere in the application -- not just in the item service, but in setups, totals, profiles, and CSV export. Add tag filtering for catalog discovery.
Output: All services correctly merge global item data, tag search works via API, 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 + globalItemId, purchasePriceCents
});
export const globalItems = pgTable("global_items", {
  id, brand, model, category, weightGrams, priceCents, imageUrl, description, createdAt
});
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] })]);
// itemGlobalLinks REMOVED
```

Current setup.service.ts key queries:
```typescript
// getAllSetups - has raw SQL subqueries for totalWeight and totalCost using items.weight_grams and items.price_cents
// getSetupWithItems - selects items fields directly via innerJoin on items
```

Current totals.service.ts:
```typescript
// getCategoryTotals - SUM(items.weightGrams * items.quantity)
// getGlobalTotals - SUM(items.weightGrams * items.quantity)
```

Current profile.service.ts:
```typescript
// getPublicProfile - raw SQL subqueries for totalWeight and totalCost
// getPublicSetupWithItems - selects items fields directly
```

Current csv.service.ts:
```typescript
// exportItemsCsv - selects items.name, items.weightGrams, items.priceCents directly
```

Current global-item.service.ts:
```typescript
export async function searchGlobalItems(db: Db = prodDb, query?: string)
export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number)
export async function linkItemToGlobal(db: Db = prodDb, itemId: number, globalItemId: number) // REMOVE
export async function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) // REMOVE
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Global item service tag filtering + owner count migration + tests</name>
  <files>src/server/services/global-item.service.ts, src/server/routes/global-items.ts, tests/services/global-item.service.test.ts</files>
  <read_first>
    - src/server/services/global-item.service.ts
    - src/server/routes/global-items.ts
    - tests/services/global-item.service.test.ts
    - tests/routes/global-items.test.ts
    - src/db/schema.ts
    - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (Pattern 2: Tag Filtering, Owner Count Migration)
  </read_first>
  <behavior>
    - Test: searchGlobalItems with no tags returns all items (existing behavior)
    - Test: searchGlobalItems with tags=["ultralight"] returns only items tagged "ultralight"
    - Test: searchGlobalItems with tags=["ultralight","bikepacking"] returns only items tagged with BOTH (AND logic)
    - Test: searchGlobalItems with query + tags combines text search and tag filtering
    - Test: getGlobalItemWithOwnerCount returns count based on items.globalItemId (not itemGlobalLinks)
    - Test: getGlobalItemWithOwnerCount returns 0 when no items reference the global item
  </behavior>
  <action>
    **Rewrite `src/server/services/global-item.service.ts`:**

    1. Replace imports: remove `itemGlobalLinks`, add `globalItemTags, items, tags` from schema.
    2. Add `ilike` import from drizzle-orm (replacing `like` -- per Pitfall 6, PostgreSQL LIKE is case-sensitive).
    3. Remove `linkItemToGlobal` and `unlinkItemFromGlobal` functions entirely.

    4. Update `searchGlobalItems` to accept `tagNames` parameter and use `ilike` instead of `like`:
    ```typescript
    export async function searchGlobalItems(
      db: Db = prodDb,
      query?: string,
      tagNames?: string[],
    ) {
      const conditions: SQL[] = [];

      if (query) {
        const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
        const pattern = `%${escaped}%`;
        conditions.push(
          or(ilike(globalItems.brand, pattern), ilike(globalItems.model, pattern))!
        );
      }

      if (tagNames && tagNames.length > 0) {
        conditions.push(
          sql`${globalItems.id} IN (
            SELECT ${globalItemTags.globalItemId}
            FROM ${globalItemTags}
            JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId}
            WHERE ${tags.name} IN (${sql.join(tagNames.map(t => sql`${t}`), sql`, `)})
            GROUP BY ${globalItemTags.globalItemId}
            HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}
          )`
        );
      }

      if (conditions.length === 0) {
        return db.select().from(globalItems);
      }

      return db.select().from(globalItems).where(and(...conditions));
    }
    ```

    5. Update `getGlobalItemWithOwnerCount` to count via `items.globalItemId` instead of `itemGlobalLinks`:
    ```typescript
    export async function getGlobalItemWithOwnerCount(
      db: Db = prodDb,
      id: number,
    ) {
      const [item] = await db
        .select()
        .from(globalItems)
        .where(eq(globalItems.id, id));

      if (!item) return null;

      const [result] = await db
        .select({ ownerCount: count() })
        .from(items)
        .where(eq(items.globalItemId, id));

      return { ...item, ownerCount: result?.ownerCount ?? 0 };
    }
    ```

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

    1. Make both route handlers async (currently missing `await` on service calls).
    2. Parse `tags` query param and split into array:
    ```typescript
    app.get("/", async (c) => {
      const db = c.get("db");
      const q = c.req.query("q");
      const tagsParam = c.req.query("tags");
      const tagNames = tagsParam ? tagsParam.split(",").map(t => t.trim()).filter(Boolean) : undefined;
      const items = await searchGlobalItems(db, q || undefined, tagNames);
      return c.json(items);
    });

    app.get("/:id", async (c) => {
      const db = c.get("db");
      const id = parseId(c.req.param("id"));
      if (!id) return c.json({ error: "Invalid global item ID" }, 400);
      const item = await getGlobalItemWithOwnerCount(db, id);
      if (!item) return c.json({ error: "Global item not found" }, 404);
      return c.json(item);
    });
    ```

    **Rewrite `tests/services/global-item.service.test.ts`:**

    The existing tests use sync SQLite patterns (`.get()`, `.all()`, `.run()`). Rewrite entirely using async PGlite pattern from `createTestDb()`. Add test helpers:
    ```typescript
    async function insertGlobalItem(db, data) { ... }
    async function insertTag(db, name) { ... }
    async function tagGlobalItem(db, globalItemId, tagId) { ... }
    ```

    Test cases: search without tags, search with single tag, search with multiple tags (AND logic), search with query + tags, owner count via items.globalItemId.
  </action>
  <verify>
    <automated>bun test tests/services/global-item.service.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - src/server/services/global-item.service.ts `searchGlobalItems` has `tagNames?: string[]` parameter
    - src/server/services/global-item.service.ts contains `ilike` (not `like`) for text search
    - src/server/services/global-item.service.ts contains `HAVING COUNT(DISTINCT` for tag AND logic
    - src/server/services/global-item.service.ts `getGlobalItemWithOwnerCount` queries `items` table with `eq(items.globalItemId, id)` (not itemGlobalLinks)
    - src/server/services/global-item.service.ts does NOT contain `linkItemToGlobal` or `unlinkItemFromGlobal`
    - src/server/services/global-item.service.ts does NOT import `itemGlobalLinks`
    - src/server/routes/global-items.ts contains `c.req.query("tags")`
    - src/server/routes/global-items.ts both handlers use `await`
    - tests/services/global-item.service.test.ts uses `await` pattern (not `.get()`, `.all()`, `.run()`)
    - tests/services/global-item.service.test.ts contains tests with "tag" in the name
    - `bun test tests/services/global-item.service.test.ts` exits 0
  </acceptance_criteria>
  <done>Global item search supports tag filtering with AND logic. Owner count uses direct FK. Link/unlink functions removed. Route handles tags query param. All tests pass with async PGlite.</done>
</task>

<task type="auto">
  <name>Task 2: Secondary service COALESCE merge propagation (setup, totals, profile, CSV)</name>
  <files>src/server/services/setup.service.ts, src/server/services/totals.service.ts, src/server/services/profile.service.ts, src/server/services/csv.service.ts</files>
  <read_first>
    - src/server/services/setup.service.ts
    - src/server/services/totals.service.ts
    - src/server/services/profile.service.ts
    - src/server/services/csv.service.ts
    - src/db/schema.ts
    - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (All Query Locations table, Pitfall 4)
  </read_first>
  <action>
    Update all secondary services that read item data to LEFT JOIN globalItems and COALESCE weight/price/name for reference items. This prevents Pitfall 1 (missed merge points) and Pitfall 4 (totals missing global data).

    **Update `src/server/services/setup.service.ts`:**

    1. Add import: `import { globalItems } from "../../db/schema.ts"`.

    2. In `getAllSetups`, update the totalWeight and totalCost raw SQL subqueries to join globalItems:
    ```typescript
    totalWeight: sql<number>`COALESCE((
      SELECT SUM(
        COALESCE(
          CASE WHEN items.global_item_id IS NOT NULL THEN global_items.weight_grams ELSE NULL END,
          items.weight_grams
        ) * items.quantity
      ) FROM setup_items
      JOIN items ON items.id = setup_items.item_id
      LEFT JOIN global_items ON global_items.id = items.global_item_id
      WHERE setup_items.setup_id = setups.id
    ), 0)`.as("total_weight"),
    totalCost: sql<number>`COALESCE((
      SELECT SUM(
        COALESCE(
          CASE WHEN items.global_item_id IS NOT NULL THEN global_items.price_cents ELSE NULL END,
          items.price_cents
        ) * items.quantity
      ) FROM setup_items
      JOIN items ON items.id = setup_items.item_id
      LEFT JOIN global_items ON global_items.id = items.global_item_id
      WHERE setup_items.setup_id = setups.id
    ), 0)`.as("total_cost"),
    ```

    3. In `getSetupWithItems`, update the item list query:
    - Add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` after the categories join.
    - Replace direct `items.name`, `items.weightGrams`, `items.priceCents`, `items.imageFilename` with COALESCE versions:
    ```typescript
    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"),
    imageFilename: sql<string | null>`COALESCE(
      ${items.imageFilename},
      CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
    )`.as("image_filename"),
    ```
    - Add `globalItemId: items.globalItemId` and `purchasePriceCents: items.purchasePriceCents` to the select.

    **Update `src/server/services/totals.service.ts`:**

    1. Add import: `import { globalItems } from "../../db/schema.ts"`.

    2. In `getCategoryTotals`, add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` and update SUM expressions:
    ```typescript
    totalWeight: sql<number>`COALESCE(SUM(
      COALESCE(
        CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
        ${items.weightGrams}
      ) * ${items.quantity}
    ), 0)`,
    totalCost: sql<number>`COALESCE(SUM(
      COALESCE(
        CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
        ${items.priceCents}
      ) * ${items.quantity}
    ), 0)`,
    ```

    3. In `getGlobalTotals`, same pattern -- add leftJoin on globalItems, COALESCE weight/price in SUM.

    **Update `src/server/services/profile.service.ts`:**

    1. Add import: `import { globalItems } from "../../db/schema.ts"`.

    2. In `getPublicProfile`, update the totalWeight and totalCost raw SQL subqueries to join global_items (same pattern as setup.service.ts `getAllSetups`).

    3. In `getPublicSetupWithItems`, update the item list query:
    - Add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` after the categories join.
    - Replace item field selects with COALESCE versions (same as setup.service.ts `getSetupWithItems`).
    - Add `globalItemId: items.globalItemId` to the select.

    **Update `src/server/services/csv.service.ts`:**

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

    2. In `exportItemsCsv`, update the query:
    - Add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` after the categories join.
    - Replace `items.name` with COALESCE name expression.
    - Replace `items.weightGrams` with COALESCE weightGrams expression.
    - Replace `items.priceCents` with COALESCE priceCents expression.

    No changes to `importItemsCsv` -- imports create standalone items (per research recommendation).
  </action>
  <verify>
    <automated>grep -l "global_items" src/server/services/setup.service.ts src/server/services/totals.service.ts src/server/services/profile.service.ts src/server/services/csv.service.ts | wc -l | grep -q "^4$" && echo "PASS" || echo "FAIL"</automated>
  </verify>
  <acceptance_criteria>
    - src/server/services/setup.service.ts imports `globalItems` from schema
    - src/server/services/setup.service.ts `getAllSetups` totalWeight/totalCost subqueries contain `LEFT JOIN global_items`
    - src/server/services/setup.service.ts `getSetupWithItems` contains `.leftJoin(globalItems`
    - src/server/services/setup.service.ts `getSetupWithItems` select includes `globalItemId: items.globalItemId`
    - src/server/services/totals.service.ts imports `globalItems` from schema
    - src/server/services/totals.service.ts `getCategoryTotals` contains `.leftJoin(globalItems`
    - src/server/services/totals.service.ts `getGlobalTotals` contains `.leftJoin(globalItems`
    - src/server/services/profile.service.ts imports `globalItems` from schema
    - src/server/services/profile.service.ts `getPublicProfile` totalWeight/totalCost subqueries contain `LEFT JOIN global_items`
    - src/server/services/profile.service.ts `getPublicSetupWithItems` contains `.leftJoin(globalItems`
    - src/server/services/csv.service.ts imports `globalItems` from schema
    - src/server/services/csv.service.ts `exportItemsCsv` contains `.leftJoin(globalItems`
    - `bun test` exits 0 (full suite -- verifies no regressions)
  </acceptance_criteria>
  <done>All 4 secondary services correctly merge global item data for reference items. Setup totals, category totals, global totals, public profile setup totals, and CSV export all use COALESCE joins. Full test suite passes.</done>
</task>

</tasks>

<verification>
```bash
# All secondary services updated
for f in setup.service.ts totals.service.ts profile.service.ts csv.service.ts; do
  grep -q "globalItems" "src/server/services/$f" && echo "$f: OK" || echo "$f: MISSING"
done

# Global item service updated
grep -q "tagNames" src/server/services/global-item.service.ts && echo "Tag filtering: OK"
grep -q "ilike" src/server/services/global-item.service.ts && echo "Case-insensitive: OK"
grep -q "itemGlobalLinks" src/server/services/global-item.service.ts && echo "FAIL: still uses itemGlobalLinks" || echo "itemGlobalLinks removed: OK"

# Route updated
grep -q "tags" src/server/routes/global-items.ts && echo "Route tags: OK"

# Full test suite
bun test
```
</verification>

<success_criteria>
- Global item search supports tag filtering with AND intersection logic
- Owner count correctly uses items.globalItemId instead of removed itemGlobalLinks
- All 4 secondary services (setup, totals, profile, CSV) merge global item data for reference items
- No service reads raw items.weightGrams/priceCents without COALESCE when globalItemId could be set
- GET /api/global-items accepts ?tags=x,y query parameter
- Full test suite passes with no regressions
</success_criteria>

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