Files
GearBox/.planning/phases/37-admin-global-item-management/37-RESEARCH.md

14 KiB

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.

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

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

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

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:

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.