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 paginationgetGlobalItemWithOwnerCount— single item with owner countupsertGlobalItem— 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:
- Null out
items.globalItemIdbefore deleting the global item (SET globalItemId = null WHERE globalItemId = id), OR - 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:
UPDATE items SET global_item_id = null WHERE global_item_id = id— nullify user item linksDELETE FROM global_item_tags WHERE global_item_id = id— remove tag associationsDELETE 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 filterGET /api/admin/items/:id— single item with owner countPUT /api/admin/items/:id— update item fields (wrapupsertGlobalItem)DELETE /api/admin/items/:id— delete item (newdeleteGlobalItemservice)
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)tagNamesfilterlimitandoffset- 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
idand partial update fields - Updates
globalItemsrecord 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>:
<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 inputweightGrams— number inputpriceCents— number input (display as price, store as cents)imageUrl— text input (URL) or ImageUpload component for S3imageCredit— text inputimageSourceUrl— text input (URL)description— textareasourceUrl— 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:
- Comma-separated text input (simplest)
- 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:
- Fetch tags in a separate query per item (N+1, bad)
- 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:
deleteGlobalItem(db, id)service with correct FK cleanup orderlistGlobalItemsForAdmin(db, opts)service with pagination + batched tags + owner countsupdateGlobalItemById(db, id, data)service for direct-by-id updates- New admin item sub-router at
src/server/routes/admin-items.ts - Two new client routes:
admin/items.tsxandadmin/items.$itemId.tsx useAdminGlobalItemsinfinite query hook +useAdminGlobalItem+ update/delete mutations- Sidebar "Items" link activation in
admin.tsx
No schema changes. No migration required. All within the existing PostgreSQL + Drizzle + React Query + TanStack Router stack.