# Phase 37: Admin — Global Item Management — Research

**Phase:** 37 — Admin — Global Item Management
**Researched:** 2026-04-19
**Requirements:** ADMN-02, ADMN-03, ADMN-04

---

## Summary

This phase extends the admin shell built in Phase 36 with two new pages: a paginated list of all global catalog items (`/admin/items`) and a full-page edit form for each item (`/admin/items/$itemId`). The server side needs a `deleteGlobalItem` service function and admin-specific routes under `/api/admin/items`. The client side needs two new TanStack Router routes, a `useAdminGlobalItems` infinite query hook, and a `useAdminGlobalItem` + delete/update hooks. All primary patterns are already established in the codebase.

---

## 1. Server-Side: What Needs to Be Added

### 1.1 Missing: `deleteGlobalItem` in global-item.service.ts

`src/server/services/global-item.service.ts` currently exports:
- `searchGlobalItems` — basic search with tag filter, no pagination
- `getGlobalItemWithOwnerCount` — single item with owner count
- `upsertGlobalItem` — create/update by (manufacturerId, model)
- `bulkUpsertGlobalItems` — bulk version

**Missing:** `deleteGlobalItem(db, id)` — DELETE from `globalItems` WHERE id. This is simple; no cascades defined on `globalItems` for `items.globalItemId` (it's a nullable reference, no `onDelete: cascade`). Deleting a global item will NOT cascade-delete user items — user items will have `globalItemId = null` after deletion (the FK is nullable). Verify FK behavior before writing the delete service.

Checking schema.ts: `items.globalItemId` is `integer("global_item_id").references(() => globalItems.id)` — no `onDelete` clause, which in PostgreSQL defaults to `RESTRICT` (not CASCADE). **This means deleting a global item that has owners will FAIL with a FK constraint violation.** The plan must handle this: either:
1. Null out `items.globalItemId` before deleting the global item (SET globalItemId = null WHERE globalItemId = id), OR
2. Block delete if ownerCount > 0, with the impact-aware confirmation dialog already planned in CONTEXT.md

Decision (from CONTEXT.md D-07): the confirmation dialog shows the ownerCount. But the FK constraint will still block deletion even after user confirmation. The service must null the FK references before deleting the global item. Pattern: in a transaction, `UPDATE items SET global_item_id = null WHERE global_item_id = $id`, then `DELETE FROM global_items WHERE id = $id`.

Also need to clean up `globalItemTags` — but those have `onDelete: cascade` from the schema? Let me verify: `globalItemTags.globalItemId` references `globalItems.id`. The schema does not show an explicit `onDelete` clause, so it also defaults to RESTRICT. The delete must explicitly delete `globalItemTags` rows before deleting the global item.

Correct delete order in a transaction:
1. `UPDATE items SET global_item_id = null WHERE global_item_id = id` — nullify user item links
2. `DELETE FROM global_item_tags WHERE global_item_id = id` — remove tag associations
3. `DELETE FROM global_items WHERE id = id` — safe to delete now

### 1.2 Admin Item Routes: `/api/admin/items`

Current `src/server/routes/admin.ts` only has `GET /` returning `{ ok: true }`. Must be extended (or a separate sub-router mounted at `/api/admin/items`).

Pattern from codebase: create a new `src/server/routes/admin-items.ts` router and mount it in `admin.ts` using `app.route("/items", adminItemRoutes)`. This keeps admin.ts clean and follows the same pattern as `global-items.ts`.

Required endpoints:
- `GET /api/admin/items` — paginated list (cursor-based or offset), search, tag filter
- `GET /api/admin/items/:id` — single item with owner count
- `PUT /api/admin/items/:id` — update item fields (wrap `upsertGlobalItem`)
- `DELETE /api/admin/items/:id` — delete item (new `deleteGlobalItem` service)

**Pagination approach for admin list:** The existing `searchGlobalItems` returns ALL items matching the query — no pagination. For the admin list with infinite scroll (D-03), the server must support cursor or offset pagination. Options:
- **Offset/limit** — simple, works for admin use case (items don't change frequently). `GET /api/admin/items?offset=0&limit=50`
- **Cursor-based** — already used in discovery feed (`src/server/services/discovery.service.ts`). More robust but more complex.

Recommendation: **offset/limit** for admin. Simpler to implement. Admin is a low-traffic, single-user feature. The discovery service cursor pattern adds complexity not needed here.

The existing `searchGlobalItems` must be extended or a new `listGlobalItemsForAdmin` function created to support:
- `query` (text search across brand + model)
- `tagNames` filter
- `limit` and `offset`
- Total count for display ("1,247 items")

### 1.3 Admin PUT: Updating Global Items

`upsertGlobalItem` takes `manufacturerSlug` and does an upsert by (manufacturerId, model). For admin edit, the admin changes individual fields (not always brand/model). Using the existing `upsertGlobalItem` is viable — admin sends the full item data including manufacturerSlug.

However, the admin edit needs to update by `id` not by (brand, model). A new `updateGlobalItemById` service function is cleaner:
- Takes `id` and partial update fields
- Updates `globalItems` record directly by ID
- Syncs tags via `syncGlobalItemTags` (already private in service file — needs to be called or made into a shared helper)

Alternatively: reuse `upsertGlobalItem` since it handles the update path if (manufacturerId, model) already exists. The admin form sends manufacturerSlug + model which are guaranteed unique, so the upsert will always hit the update path for existing items.

**Decision:** Add `updateGlobalItemById(db, id, data)` to the service — cleaner than relying on upsert conflict resolution for admin edits. Takes `{ manufacturerId?, model?, category?, weightGrams?, priceCents?, imageUrl?, description?, sourceUrl?, imageCredit?, imageSourceUrl?, tags? }`. This is more direct and avoids the manufacturerSlug → manufacturerId resolution dance.

### 1.4 Tags for Filter

The list has tag filtering (ADMN-02). Need `GET /api/tags` (or existing tag endpoint) to populate the tag filter dropdown. Verify: `src/server/routes/tags.ts` exists and exposes `GET /api/tags`. The `useTags` hook likely fetches from this endpoint.

---

## 2. Client-Side: Routes and Hooks

### 2.1 New TanStack Router Routes

Per CONTEXT.md decisions D-04 and the existing TanStack Router file-based routing pattern:
- `src/client/routes/admin/items.tsx` — admin item list (`/admin/items`)
- `src/client/routes/admin/items.$itemId.tsx` — admin item edit page (`/admin/items/$itemId`)

Note: TanStack Router uses `$` prefix for dynamic segments in filenames. Verify: existing route `src/client/routes/global-items/$globalItemId.tsx` uses `$` prefix. Same pattern applies here.

### 2.2 Admin Shell Sidebar: Enable Items Link

`src/client/routes/admin.tsx` currently renders "Items" as a disabled `<div>` with cursor-not-allowed. Phase 37 replaces this with an active `<Link to="/admin/items">` with appropriate active styling. The "Tags" entry stays disabled.

Active link pattern — use `useRouterState` or TanStack Router's `<Link activeProps>`:
```tsx
<Link
  to="/admin/items"
  className={({ isActive }) =>
    `flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
      isActive
        ? "bg-gray-100 text-gray-900 font-medium"
        : "text-gray-600 hover:bg-gray-50"
    }`
  }
>
```

TanStack Router `<Link>` supports `activeProps` for active state styling.

### 2.3 Infinite Scroll Pattern

CONTEXT.md D-03 specifies server-side infinite scroll with IntersectionObserver. The `useInfiniteQuery` from React Query is the right hook. Pattern from existing hooks:

The discovery hooks use simple `useQuery` (not infinite). For true infinite scroll, use `useInfiniteQuery`:
```typescript
export function useAdminGlobalItems(query?: string, tags?: string[]) {
  return useInfiniteQuery({
    queryKey: ["admin-global-items", query ?? "", tags ?? []],
    queryFn: ({ pageParam = 0 }) =>
      apiGet<AdminGlobalItemPage>(`/api/admin/items?offset=${pageParam}&limit=50${...}`),
    getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
    initialPageParam: 0,
  });
}
```

IntersectionObserver sentinel div at bottom of table triggers `fetchNextPage()`.

### 2.4 Tags Column in Table

CONTEXT.md leaves the exact tag display to Claude's discretion. Given the table density requirement (D-01), tags should display as a count badge when > 2 tags, full chips when <= 2. This keeps the table dense while showing the data.

### 2.5 Edit Form Fields

CONTEXT.md D-03/D-04 specifies the edit page is a full-page form. Fields to edit (per ADMN-03):
- `brand` (manufacturer) — Dropdown of existing manufacturers (D-06)
- `model` — text input
- `weightGrams` — number input
- `priceCents` — number input (display as price, store as cents)
- `imageUrl` — text input (URL) or ImageUpload component for S3
- `imageCredit` — text input
- `imageSourceUrl` — text input (URL)
- `description` — textarea
- `sourceUrl` — text input (URL)
- `tags` — multi-select or chip input

**Manufacturer dropdown:** Needs `GET /api/manufacturers` (already exists). The `listManufacturers` service returns `{ id, name, slug, ... }`. The dropdown should show `name` but track `slug` for the API call. OR if using `updateGlobalItemById`, pass `manufacturerId` directly (integer).

**Tags input:** No existing multi-select tag component. Options:
1. Comma-separated text input (simplest)
2. Chip-style with add/remove (better UX)

Recommendation: Use a simple tag input that renders chips. The CatalogSearchOverlay already has tag chips pattern. Implement inline — a text input that adds tags on Enter/comma, chips that remove on click. ~30 lines of code.

### 2.6 Delete Flow

Delete is on the edit page only (D-05). Pattern: button at bottom triggers an inline modal/dialog. Can reuse the ConfirmDialog pattern from `src/client/components/ConfirmDialog.tsx` but it is hardwired to item deletion from UIStore. For admin use, create an inline confirmation state (local `useState`):

```tsx
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
```

Show the impact-aware message: "Delete {brand} {model}? {N} users have this in their collection. This cannot be undone."

After confirmation, call `DELETE /api/admin/items/:id` and navigate back to `/admin/items`.

---

## 3. Missing Service: `listGlobalItemsForAdmin`

The existing `searchGlobalItems` does not support pagination. Need a new service function that returns paginated results with total count:

```typescript
export async function listGlobalItemsForAdmin(
  db: Db,
  opts: {
    query?: string;
    tagNames?: string[];
    offset?: number;
    limit?: number;
  }
) {
  // Returns { items: [...], total: number, hasMore: boolean, nextOffset: number }
}
```

This function also needs to return tags for each item (for the tags column). Two options:
1. Fetch tags in a separate query per item (N+1, bad)
2. Fetch tags for all returned items in a single query after the main query

Option 2 is better: after fetching the page of global items, fetch all `globalItemTags JOIN tags WHERE globalItemId IN (ids)` in one query, then map back to items.

Also need **owner count** per item in the list (for display and delete confirmation). Same approach: after fetching items page, count items per globalItemId in one query: `SELECT global_item_id, COUNT(*) FROM items WHERE global_item_id IN (ids) GROUP BY global_item_id`.

The list query will need:
- Main paginated query with offset/limit
- Count query for total
- Tags batch query
- Owner count batch query

This is 4 queries per page load — acceptable for an admin use case. Performance is not critical for a single-admin tool.

---

## 4. Validation Architecture

Key validations for this phase:

| Check | Command/Method | Expected |
|-------|---------------|----------|
| TypeScript compiles | `bun run build` | Exit 0 |
| Admin items list loads | `GET /api/admin/items` with admin token | Returns `{ items, total, hasMore }` |
| Admin item detail loads | `GET /api/admin/items/1` with admin token | Returns item with ownerCount |
| Admin item update | `PUT /api/admin/items/1` with admin token | Returns updated item |
| Admin item delete | `DELETE /api/admin/items/1` with admin token | Returns `{ success: true }` |
| Delete blocked for non-admin | `DELETE /api/admin/items/1` without auth | Returns 401 |
| Infinite scroll activates | Scroll to bottom of list | Next page fetches |
| Edit page navigates back | Click "← Items" | Returns to /admin/items |
| Delete confirmation shows ownerCount | Open delete dialog | Shows correct count |
| routeTree.gen.ts updated | Check file | Contains /admin/items and /admin/items/$itemId routes |

---

## 5. Existing Patterns to Reuse (Summary)

| Pattern | Location | Use For |
|---------|----------|---------|
| Hono route handler | `src/server/routes/global-items.ts` | Admin items routes |
| `requireAuth` + `requireAdmin` chaining | `src/server/routes/admin.ts` | Admin items route middleware |
| `useInfiniteQuery` | React Query docs (not yet used in codebase) | Admin items infinite scroll |
| `useQuery` with enabled flag | `src/client/hooks/useGlobalItems.ts` | Admin item detail hook |
| `apiGet`, `apiPut`, `apiDelete` | `src/client/lib/api.ts` | API calls |
| `LucideIcon` | `src/client/lib/iconData.ts` | Icons in admin UI |
| `useFormatters()` | `src/client/hooks/useFormatters.ts` | Weight/price display in table |
| `GearImage` | `src/client/components/GearImage.tsx` | Thumbnail in table |
| Local confirm state (useState) | Various components | Admin delete confirmation |
| `Link to="/admin/items"` with activeProps | TanStack Router | Sidebar nav active state |
| `IntersectionObserver` sentinel | (not yet in codebase, standard pattern) | Infinite scroll trigger |

---

## RESEARCH COMPLETE

Phase 37 is well-scoped and low-risk. The main new work:
1. `deleteGlobalItem(db, id)` service with correct FK cleanup order
2. `listGlobalItemsForAdmin(db, opts)` service with pagination + batched tags + owner counts
3. `updateGlobalItemById(db, id, data)` service for direct-by-id updates
4. New admin item sub-router at `src/server/routes/admin-items.ts`
5. Two new client routes: `admin/items.tsx` and `admin/items.$itemId.tsx`
6. `useAdminGlobalItems` infinite query hook + `useAdminGlobalItem` + update/delete mutations
7. Sidebar "Items" link activation in `admin.tsx`

No schema changes. No migration required. All within the existing PostgreSQL + Drizzle + React Query + TanStack Router stack.
