---
phase: 18-global-items-public-profiles
plan: 02
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
  - src/server/services/global-item.service.ts
  - src/server/routes/global-items.ts
  - src/db/seed-global-items.ts
  - src/db/seed.ts
  - src/server/index.ts
  - tests/services/global-item.service.test.ts
  - tests/routes/global-items.test.ts
autonomous: true
requirements: [GLOB-01, GLOB-02, GLOB-03, GLOB-04, GLOB-05]

must_haves:
  truths:
    - "GET /api/global-items returns the full global catalog without authentication"
    - "GET /api/global-items?q=revelate returns only items matching brand or model (case-insensitive)"
    - "GET /api/global-items/:id returns item details with an ownerCount field"
    - "POST /api/items/:id/link links a user item to a global item (requires auth)"
    - "DELETE /api/items/:id/link removes the link (requires auth)"
    - "Seed data imports on first run and is idempotent on subsequent runs"
  artifacts:
    - path: "src/server/services/global-item.service.ts"
      provides: "searchGlobalItems, getGlobalItemWithOwnerCount, linkItemToGlobal, unlinkItemFromGlobal"
      exports: ["searchGlobalItems", "getGlobalItemWithOwnerCount", "linkItemToGlobal", "unlinkItemFromGlobal"]
    - path: "src/server/routes/global-items.ts"
      provides: "GET /api/global-items, GET /api/global-items/:id"
      min_lines: 30
    - path: "src/db/seed-global-items.ts"
      provides: "seedGlobalItems function"
      exports: ["seedGlobalItems"]
    - path: "tests/services/global-item.service.test.ts"
      provides: "Service tests for GLOB-01 through GLOB-05"
      min_lines: 50
    - path: "tests/routes/global-items.test.ts"
      provides: "Route tests for global item endpoints"
      min_lines: 40
  key_links:
    - from: "src/server/routes/global-items.ts"
      to: "src/server/services/global-item.service.ts"
      via: "import and call service functions"
      pattern: "searchGlobalItems|getGlobalItemWithOwnerCount"
    - from: "src/server/index.ts"
      to: "src/server/routes/global-items.ts"
      via: "app.route registration"
      pattern: "app\\.route.*global-items"
    - from: "src/db/seed.ts"
      to: "src/db/seed-global-items.ts"
      via: "seedGlobalItems call in seedDefaults"
      pattern: "seedGlobalItems"
---

<objective>
Build the complete global item catalog backend: service layer with ILIKE search and owner count, route handlers for public GET + authenticated link/unlink, seed script integration, and auth middleware updates for public access.

Purpose: Delivers GLOB-01 through GLOB-05 server-side. Users can search gear, view details with owner counts, and link personal items to global entries.
Output: global-item.service.ts, global-items.ts routes, seed-global-items.ts, updated index.ts + seed.ts, service + route 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/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md

@src/db/schema.ts
@src/server/services/item.service.ts
@src/server/routes/items.ts
@src/server/index.ts
@src/server/middleware/auth.ts
@src/db/seed.ts
@tests/helpers/db.ts

<interfaces>
<!-- From Plan 01 outputs (schema.ts additions): -->
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 itemGlobalLinks = pgTable("item_global_links", {
  id: serial("id").primaryKey(),
  itemId: integer("item_id").notNull().references(() => items.id, { onDelete: "cascade" }).unique(),
  globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
});

<!-- From schemas.ts: -->
export const searchGlobalItemsSchema = z.object({ q: z.string().optional() });
export const linkItemSchema = z.object({ globalItemId: z.number().int().positive() });
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Global item service + seed script + tests</name>
  <files>src/server/services/global-item.service.ts, src/db/seed-global-items.ts, src/db/seed.ts, tests/services/global-item.service.test.ts</files>
  <read_first>src/server/services/item.service.ts, src/db/seed.ts, tests/helpers/db.ts, src/db/schema.ts, src/shared/schemas.ts</read_first>
  <behavior>
    - searchGlobalItems(db) returns all global items when no query provided
    - searchGlobalItems(db, "revelate") returns only items with "revelate" in brand or model (case-insensitive)
    - searchGlobalItems(db, "100%") does not match everything (wildcard chars escaped)
    - getGlobalItemWithOwnerCount(db, id) returns item with ownerCount: 0 when no links exist
    - getGlobalItemWithOwnerCount(db, id) returns ownerCount: 2 when 2 user items are linked
    - getGlobalItemWithOwnerCount(db, nonExistentId) returns null
    - linkItemToGlobal(db, itemId, globalItemId) creates link, returns link row
    - linkItemToGlobal(db, itemId, globalItemId) when already linked throws/returns error
    - unlinkItemFromGlobal(db, itemId) removes the link
    - seedGlobalItems(db) inserts seed data on first call, skips on second call (idempotent)
  </behavior>
  <action>
**global-item.service.ts**: Create at `src/server/services/global-item.service.ts`. Follow the existing service pattern (import db type from `../../db/index.ts`, use `type Db = typeof prodDb`).

Functions:
1. `searchGlobalItems(db: Db, query?: string)` — No userId param (per D-03, public data). Uses `ilike` from drizzle-orm on brand and model columns. Escape `%` and `_` in query before wrapping in `%..%` pattern. Return `db.select().from(globalItems)` with optional where clause using `or(ilike(brand, pattern), ilike(model, pattern))`.

2. `getGlobalItemWithOwnerCount(db: Db, id: number)` — Select from globalItems where id matches. Then count from itemGlobalLinks where globalItemId matches. Return `{ ...item, ownerCount }` or null.

3. `linkItemToGlobal(db: Db, itemId: number, globalItemId: number)` — Insert into itemGlobalLinks. Let unique constraint on itemId handle duplicates (catch and return 409-style error).

4. `unlinkItemFromGlobal(db: Db, itemId: number)` — Delete from itemGlobalLinks where itemId matches. Return deleted count.

**seed-global-items.ts**: Create at `src/db/seed-global-items.ts`.
- `export async function seedGlobalItems(db: Db)` — Check if any rows exist in globalItems table. If yes, return early. If no, import from `./global-items-seed.json` and insert all rows.

**seed.ts**: Add `seedGlobalItems(prodDb)` call to the existing `seedDefaults()` function (after existing seeds).

**Tests**: Write tests FIRST (TDD). Use `createTestDb()` from test helper. Insert test global items directly in test setup. For owner count tests, create test user items and link them.
  </action>
  <verify>
    <automated>bun test tests/services/global-item.service.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "searchGlobalItems" src/server/services/global-item.service.ts
    - grep -q "getGlobalItemWithOwnerCount" src/server/services/global-item.service.ts
    - grep -q "linkItemToGlobal" src/server/services/global-item.service.ts
    - grep -q "unlinkItemFromGlobal" src/server/services/global-item.service.ts
    - grep -q "seedGlobalItems" src/db/seed-global-items.ts
    - grep -q "seedGlobalItems" src/db/seed.ts
    - grep -q "ilike" src/server/services/global-item.service.ts
    - test -f tests/services/global-item.service.test.ts
  </acceptance_criteria>
  <done>All 4 service functions pass tests. Seed script is idempotent. ILIKE search works case-insensitively with wildcard escaping.</done>
</task>

<task type="auto">
  <name>Task 2: Global item routes + auth middleware update + route tests</name>
  <files>src/server/routes/global-items.ts, src/server/routes/items.ts, src/server/index.ts, tests/routes/global-items.test.ts</files>
  <read_first>src/server/routes/items.ts, src/server/index.ts, src/server/middleware/auth.ts, tests/routes/setups.test.ts</read_first>
  <action>
**global-items.ts route**: Create at `src/server/routes/global-items.ts`. Follow existing route pattern (Hono app with Env type).

1. `GET /` (maps to `/api/global-items`) — per D-16. Read `q` from query string. Call `searchGlobalItems(db, q)`. Return JSON array. No auth needed.

2. `GET /:id` (maps to `/api/global-items/:id`) — per D-17. Parse id with `parseId`. Call `getGlobalItemWithOwnerCount(db, id)`. Return 404 if null, otherwise JSON with ownerCount.

**items.ts route updates** — per D-18, D-19. Add two new endpoints to existing item routes:

3. `POST /:id/link` — Validate body with `linkItemSchema` via zValidator. Get userId from context. Verify the item belongs to the user (call getItemById first). Call `linkItemToGlobal(db, itemId, globalItemId)`. Return 201 on success, 409 if already linked, 404 if item not found.

4. `DELETE /:id/link` — Get userId. Verify item ownership. Call `unlinkItemFromGlobal(db, itemId)`. Return 200.

**index.ts updates**:
1. Import `globalItemRoutes` from routes/global-items.ts
2. Register: `app.route("/api/global-items", globalItemRoutes)` — place after existing route registrations.
3. Update auth middleware skip: Add `if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next();` before the `requireAuth` call, per Research Pattern 3 recommendation.

**Route tests**: Follow existing route test pattern (from tests/routes/setups.test.ts). Create test Hono app with db middleware + auth middleware. Test:
- GET /api/global-items returns 200 without auth
- GET /api/global-items?q=tent filters results
- GET /api/global-items/:id returns item with ownerCount
- GET /api/global-items/999 returns 404
- POST /api/items/:id/link returns 201
- POST /api/items/:id/link duplicate returns 409
- DELETE /api/items/:id/link returns 200
  </action>
  <verify>
    <automated>bun test tests/routes/global-items.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "global-items" src/server/index.ts
    - grep -q "globalItemRoutes\|globalItems" src/server/routes/global-items.ts
    - grep -q "link" src/server/routes/items.ts
    - grep -q "api/global-items" src/server/index.ts
    - test -f tests/routes/global-items.test.ts
  </acceptance_criteria>
  <done>Global item endpoints work: search returns filtered results, detail includes ownerCount, link/unlink modify junction table. Auth middleware allows unauthenticated GET access to /api/global-items. All route tests pass.</done>
</task>

</tasks>

<verification>
- `bun test tests/services/global-item.service.test.ts` — all service tests pass
- `bun test tests/routes/global-items.test.ts` — all route tests pass
- `bun test` — full suite passes (no regressions)
</verification>

<success_criteria>
Global item catalog is fully functional server-side. Search, detail with owner count, link/unlink all work. Seed data imports idempotently. Public GET endpoints work without auth. All tests pass.
</success_criteria>

<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-02-SUMMARY.md`
</output>
