From eabfca475c1474bec5256cddd8804b3f7f0642fe Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Apr 2026 21:28:46 +0200 Subject: [PATCH] docs(37): write wave plan files for admin global item management Plans 37-01 (server: services + admin-items routes) and 37-02 (client: hooks, list page, edit page, sidebar) with full acceptance criteria and read_first blocks per phase context, research, and UI-SPEC artifacts. Co-Authored-By: Claude Sonnet 4.6 --- .../37-01-PLAN.md | 712 +++++++++++ .../37-02-PLAN.md | 1053 +++++++++++++++++ 2 files changed, 1765 insertions(+) create mode 100644 .planning/phases/37-admin-global-item-management/37-01-PLAN.md create mode 100644 .planning/phases/37-admin-global-item-management/37-02-PLAN.md diff --git a/.planning/phases/37-admin-global-item-management/37-01-PLAN.md b/.planning/phases/37-admin-global-item-management/37-01-PLAN.md new file mode 100644 index 0000000..5bbce86 --- /dev/null +++ b/.planning/phases/37-admin-global-item-management/37-01-PLAN.md @@ -0,0 +1,712 @@ +--- +phase: 37 +plan: "01" +title: "Server — Admin Global Item Services & Routes" +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/server/services/global-item.service.ts + - src/server/routes/admin-items.ts + - src/server/routes/admin.ts + - tests/services/global-item.service.test.ts +autonomous: true +requirements: + - ADMN-02 + - ADMN-03 + - ADMN-04 +--- + +# Plan 37-01: Server — Admin Global Item Services & Routes + +## Objective + +Add three new service functions to `global-item.service.ts` (`listGlobalItemsForAdmin`, `updateGlobalItemById`, `deleteGlobalItem`), create the `src/server/routes/admin-items.ts` router with four admin endpoints, and mount it in `admin.ts`. Extend the test file with unit tests for all three new service functions. + +--- + + + + +Add listGlobalItemsForAdmin service function +execute + + +- `src/server/services/global-item.service.ts` — read entire file; understand existing imports (`SQL`, `and`, `count`, `eq`, `ilike`, `or`, `sql` from drizzle-orm), table imports (`globalItems`, `globalItemTags`, `items`, `manufacturers`, `tags`), `Db`/`TxDb` types, and the existing `searchGlobalItems` query structure +- `src/db/schema.ts` — verify column names on `globalItems`, `manufacturers`, `items`, `globalItemTags`, `tags` + + + +Add the following function to `src/server/services/global-item.service.ts`, immediately after the `searchGlobalItems` export: + +```typescript +export async function listGlobalItemsForAdmin( + db: Db, + opts: { + query?: string; + tagNames?: string[]; + offset?: number; + limit?: number; + } = {}, +) { + const { query, tagNames, offset = 0, limit = 50 } = opts; + const conditions: SQL[] = []; + + if (query) { + const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escaped}%`; + conditions.push( + or( + ilike(manufacturers.name, 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} + )`, + ); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // 1. Total count + const [{ total }] = await db + .select({ total: count() }) + .from(globalItems) + .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) + .where(whereClause); + + // 2. Paginated items + const pageItems = await db + .select({ + id: globalItems.id, + manufacturerId: globalItems.manufacturerId, + brand: manufacturers.name, + model: globalItems.model, + category: globalItems.category, + weightGrams: globalItems.weightGrams, + priceCents: globalItems.priceCents, + imageUrl: globalItems.imageUrl, + description: globalItems.description, + sourceUrl: globalItems.sourceUrl, + imageCredit: globalItems.imageCredit, + imageSourceUrl: globalItems.imageSourceUrl, + dominantColor: globalItems.dominantColor, + cropZoom: globalItems.cropZoom, + cropX: globalItems.cropX, + cropY: globalItems.cropY, + createdAt: globalItems.createdAt, + }) + .from(globalItems) + .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) + .where(whereClause) + .orderBy(manufacturers.name, globalItems.model) + .limit(limit) + .offset(offset); + + if (pageItems.length === 0) { + return { items: [], total: total ?? 0, hasMore: false, nextOffset: offset }; + } + + const ids = pageItems.map((i) => i.id); + + // 3. Batch fetch tags for this page + const tagRows = await db + .select({ + globalItemId: globalItemTags.globalItemId, + name: tags.name, + }) + .from(globalItemTags) + .innerJoin(tags, eq(tags.id, globalItemTags.tagId)) + .where(sql`${globalItemTags.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`); + + const tagsByItemId = new Map(); + for (const row of tagRows) { + const list = tagsByItemId.get(row.globalItemId) ?? []; + list.push(row.name); + tagsByItemId.set(row.globalItemId, list); + } + + // 4. Batch fetch owner counts for this page + const ownerRows = await db + .select({ + globalItemId: items.globalItemId, + ownerCount: count(), + }) + .from(items) + .where(sql`${items.globalItemId} IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`) + .groupBy(items.globalItemId); + + const ownerCountById = new Map(); + for (const row of ownerRows) { + if (row.globalItemId != null) { + ownerCountById.set(row.globalItemId, row.ownerCount); + } + } + + const enriched = pageItems.map((item) => ({ + ...item, + tags: tagsByItemId.get(item.id) ?? [], + ownerCount: ownerCountById.get(item.id) ?? 0, + })); + + const nextOffset = offset + limit; + return { + items: enriched, + total: total ?? 0, + hasMore: nextOffset < (total ?? 0), + nextOffset, + }; +} +``` + + + +- `src/server/services/global-item.service.ts` contains `export async function listGlobalItemsForAdmin(` +- Function signature includes `opts: { query?: string; tagNames?: string[]; offset?: number; limit?: number; }` +- Return type includes `items`, `total`, `hasMore`, `nextOffset` fields (readable in file) +- Tags are batch-fetched using a single IN query (file contains `tagsByItemId`) +- Owner counts are batch-fetched using a single IN query (file contains `ownerCountById`) +- `bun run build` exits 0 after this task + + + + +Add updateGlobalItemById service function +execute + + +- `src/server/services/global-item.service.ts` — read current state after T1; understand `syncGlobalItemTags` private function (lines ~126-144 of original), `TxDb` type, transaction pattern from `upsertGlobalItem` +- `src/db/schema.ts` — confirm `globalItems` column names: `manufacturerId`, `model`, `category`, `weightGrams`, `priceCents`, `imageUrl`, `description`, `sourceUrl`, `imageCredit`, `imageSourceUrl` + + + +Add the following function to `src/server/services/global-item.service.ts`, after the `listGlobalItemsForAdmin` export and before `getGlobalItemWithOwnerCount`: + +```typescript +export async function updateGlobalItemById( + db: Db, + id: number, + data: { + manufacturerId?: number; + model?: string; + category?: string | null; + weightGrams?: number | null; + priceCents?: number | null; + imageUrl?: string | null; + description?: string | null; + sourceUrl?: string | null; + imageCredit?: string | null; + imageSourceUrl?: string | null; + tags?: string[]; + }, +) { + return await db.transaction(async (tx) => { + const { tags: tagNames, ...fields } = data; + + // Build partial update — only set provided fields + const updateSet: Record = {}; + if (fields.manufacturerId !== undefined) updateSet.manufacturerId = fields.manufacturerId; + if (fields.model !== undefined) updateSet.model = fields.model; + if ("category" in fields) updateSet.category = fields.category ?? null; + if ("weightGrams" in fields) updateSet.weightGrams = fields.weightGrams ?? null; + if ("priceCents" in fields) updateSet.priceCents = fields.priceCents ?? null; + if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null; + if ("description" in fields) updateSet.description = fields.description ?? null; + if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null; + if ("imageCredit" in fields) updateSet.imageCredit = fields.imageCredit ?? null; + if ("imageSourceUrl" in fields) updateSet.imageSourceUrl = fields.imageSourceUrl ?? null; + + let item: typeof globalItems.$inferSelect | undefined; + if (Object.keys(updateSet).length > 0) { + const [updated] = await tx + .update(globalItems) + .set(updateSet) + .where(eq(globalItems.id, id)) + .returning(); + item = updated; + } else { + const [existing] = await tx + .select() + .from(globalItems) + .where(eq(globalItems.id, id)); + item = existing; + } + + if (!item) return null; + + if (tagNames !== undefined) { + await syncGlobalItemTags(tx, id, tagNames); + } + + return item; + }); +} +``` + + + +- `src/server/services/global-item.service.ts` contains `export async function updateGlobalItemById(` +- Function accepts `id: number` and partial `data` object with all optional fields +- Function uses a transaction and calls `syncGlobalItemTags` when `tags` is provided +- Function returns `null` if no item with `id` exists (readable in file) +- `bun run build` exits 0 after this task + + + + +Add deleteGlobalItem service function +execute + + +- `src/server/services/global-item.service.ts` — read current state after T1+T2; understand imports (need `items`, `globalItemTags`, `globalItems` from schema; `eq` from drizzle-orm) +- `src/db/schema.ts` — confirm `items.globalItemId` is nullable (no `onDelete: cascade`) and `globalItemTags.globalItemId` has no cascade; deletion order: NULL items FK → delete globalItemTags → delete globalItems + + + +Add the following function to `src/server/services/global-item.service.ts`, after `updateGlobalItemById`: + +```typescript +export async function deleteGlobalItem(db: Db, id: number) { + return await db.transaction(async (tx) => { + // 1. Verify item exists + const [existing] = await tx + .select({ id: globalItems.id }) + .from(globalItems) + .where(eq(globalItems.id, id)); + + if (!existing) return false; + + // 2. Nullify user item links (FK: items.globalItemId → globalItems.id, no cascade) + await tx + .update(items) + .set({ globalItemId: null }) + .where(eq(items.globalItemId, id)); + + // 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade) + await tx + .delete(globalItemTags) + .where(eq(globalItemTags.globalItemId, id)); + + // 4. Delete the global item + await tx + .delete(globalItems) + .where(eq(globalItems.id, id)); + + return true; + }); +} +``` + + + +- `src/server/services/global-item.service.ts` contains `export async function deleteGlobalItem(` +- Function executes in a transaction (file contains `db.transaction` wrapping the delete sequence) +- Function nullifies `items.globalItemId` before deleting (file contains `update(items).set({ globalItemId: null })`) +- Function deletes `globalItemTags` rows before deleting the global item (file contains `delete(globalItemTags)` before `delete(globalItems)`) +- Function returns `false` when item not found, `true` on success +- `bun run build` exits 0 after this task + + + + +Create admin-items route file +execute + + +- `src/server/routes/global-items.ts` — read as pattern reference: Hono Env type, `parseId` import, `zValidator` usage, route structure +- `src/server/routes/admin.ts` — read current state; understand how to mount sub-router (`app.route`) +- `src/server/middleware/auth.ts` — confirm `requireAdmin` is already exported (it is) +- `src/server/lib/params.ts` — confirm `parseId` export signature +- `src/shared/schemas.ts` — check if an admin update schema exists; will need to create inline Zod schema for the PUT body + + + +Create `src/server/routes/admin-items.ts` with the following content: + +```typescript +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import { parseId } from "../lib/params.ts"; +import { + deleteGlobalItem, + getGlobalItemWithOwnerCount, + listGlobalItemsForAdmin, + updateGlobalItemById, +} from "../services/global-item.service.ts"; + +type Env = { Variables: { db?: any; userId?: number } }; + +const app = new Hono(); + +const updateGlobalItemAdminSchema = z.object({ + manufacturerId: z.number().int().positive().optional(), + model: z.string().min(1).optional(), + category: z.string().nullable().optional(), + weightGrams: z.number().positive().nullable().optional(), + priceCents: z.number().int().nonnegative().nullable().optional(), + imageUrl: z.string().url().nullable().optional(), + description: z.string().nullable().optional(), + sourceUrl: z.string().url().nullable().optional(), + imageCredit: z.string().nullable().optional(), + imageSourceUrl: z.string().url().nullable().optional(), + tags: z.array(z.string().min(1)).optional(), +}); + +// GET /api/admin/items — paginated list with search + tag filter +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 offset = Number(c.req.query("offset") ?? "0"); + const limit = Number(c.req.query("limit") ?? "50"); + + const result = await listGlobalItemsForAdmin(db, { + query: q || undefined, + tagNames, + offset: isNaN(offset) ? 0 : offset, + limit: isNaN(limit) || limit > 100 ? 50 : limit, + }); + + return c.json(result); +}); + +// GET /api/admin/items/:id — single item with ownerCount +app.get("/:id", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + const item = await getGlobalItemWithOwnerCount(db, id); + if (!item) return c.json({ error: "Global item not found" }, 404); + return c.json(item); +}); + +// PUT /api/admin/items/:id — update item fields +app.put( + "/:id", + zValidator("json", updateGlobalItemAdminSchema), + async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + const data = c.req.valid("json"); + const item = await updateGlobalItemById(db, id, data); + if (!item) return c.json({ error: "Global item not found" }, 404); + return c.json(item); + }, +); + +// DELETE /api/admin/items/:id — delete item with FK cleanup +app.delete("/:id", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid item ID" }, 400); + const deleted = await deleteGlobalItem(db, id); + if (!deleted) return c.json({ error: "Global item not found" }, 404); + return c.json({ success: true }); +}); + +export { app as adminItemRoutes }; +``` + + + +- File `src/server/routes/admin-items.ts` exists +- File exports `adminItemRoutes` +- File contains `app.get("/",` handler that calls `listGlobalItemsForAdmin` +- File contains `app.get("/:id",` handler that calls `getGlobalItemWithOwnerCount` +- File contains `app.put("/:id",` handler with `zValidator` and `updateGlobalItemById` +- File contains `app.delete("/:id",` handler that calls `deleteGlobalItem` +- `bun run build` exits 0 after this task + + + + +Mount admin-items router in admin.ts +execute + + +- `src/server/routes/admin.ts` — read entire file (it is short — 17 lines); understand current structure (`app.use("/*", requireAuth, requireAdmin)` applied globally to the router) + + + +Edit `src/server/routes/admin.ts` to add the import and mount the admin items sub-router: + +```typescript +import { Hono } from "hono"; +import { requireAdmin, requireAuth } from "../middleware/auth.ts"; +import { adminItemRoutes } from "./admin-items.ts"; + +type Env = { Variables: { db?: any; userId?: number } }; + +const app = new Hono(); + +// All /api/admin/* routes require authentication + admin role +app.use("/*", requireAuth, requireAdmin); + +// Health check / ping for admin access verification +app.get("/", async (c) => { + return c.json({ ok: true }); +}); + +// Admin item management +app.route("/items", adminItemRoutes); + +export { app as adminRoutes }; +``` + + + +- `src/server/routes/admin.ts` contains `import { adminItemRoutes } from "./admin-items.ts"` +- `src/server/routes/admin.ts` contains `app.route("/items", adminItemRoutes)` +- `app.use("/*", requireAuth, requireAdmin)` remains on line before any routes (auth still applies to all sub-routes) +- `bun run build` exits 0 after this task + + + + +Add unit tests for new service functions +execute + + +- `tests/services/global-item.service.test.ts` — read the entire file; understand existing helpers (`insertManufacturer`, `insertGlobalItem`, `insertItem`, `insertTag`, `tagGlobalItem`), test db setup (`createTestDb`), and existing describe blocks +- `tests/helpers/db.ts` — confirm `createTestDb()` API and that it uses Drizzle migrations with SQLite in-memory + + + +Append the following `describe` blocks to the end of `tests/services/global-item.service.test.ts`, importing the three new service functions: + +First, add to the import statement at the top: +```typescript +import { + bulkUpsertGlobalItems, + deleteGlobalItem, + getGlobalItemWithOwnerCount, + listGlobalItemsForAdmin, + searchGlobalItems, + updateGlobalItemById, + upsertGlobalItem, +} from "../../src/server/services/global-item.service.ts"; +``` + +Then append at the end of the file: + +```typescript +describe("listGlobalItemsForAdmin", () => { + let db: TestDb["db"]; + + beforeEach(async () => { + ({ db } = await createTestDb()); + }); + + it("returns empty result when no items exist", async () => { + const result = await listGlobalItemsForAdmin(db); + expect(result.items).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.hasMore).toBe(false); + }); + + it("returns paginated items with total count", async () => { + const mfr = await insertManufacturer(db); + await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Alpha" }); + await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Beta" }); + await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Gamma" }); + + const result = await listGlobalItemsForAdmin(db, { limit: 2, offset: 0 }); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(true); + expect(result.nextOffset).toBe(2); + }); + + it("filters by query string (brand/model)", async () => { + const mfr = await insertManufacturer(db, "Salsa", "salsa"); + await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Woodsmoke 700" }); + const mfr2 = await insertManufacturer(db, "Apidura", "apidura"); + await insertGlobalItem(db, { manufacturerId: mfr2.id, model: "Racing Saddle Bag" }); + + const result = await listGlobalItemsForAdmin(db, { query: "salsa" }); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.model).toBe("Woodsmoke 700"); + }); + + it("includes tags and ownerCount per item", async () => { + const mfr = await insertManufacturer(db); + const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Test Item" }); + const tag = await insertTag(db, "bikepacking"); + await tagGlobalItem(db, globalItem.id, tag.id!); + + // Insert a user and item linking to the global item + const [user] = await db + .insert(schema.users) + .values({ logtoSub: "test-sub" }) + .returning(); + await insertItem(db, "My Test Item", user!.id, { globalItemId: globalItem.id }); + + const result = await listGlobalItemsForAdmin(db); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.tags).toContain("bikepacking"); + expect(result.items[0]!.ownerCount).toBe(1); + }); +}); + +describe("updateGlobalItemById", () => { + let db: TestDb["db"]; + + beforeEach(async () => { + ({ db } = await createTestDb()); + }); + + it("returns null for non-existent item", async () => { + const result = await updateGlobalItemById(db, 99999, { model: "Ghost" }); + expect(result).toBeNull(); + }); + + it("updates model field by id", async () => { + const mfr = await insertManufacturer(db); + const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Original" }); + + await updateGlobalItemById(db, globalItem.id, { model: "Updated" }); + + const updated = await getGlobalItemWithOwnerCount(db, globalItem.id); + expect(updated?.model).toBe("Updated"); + }); + + it("syncs tags when tags array provided", async () => { + const mfr = await insertManufacturer(db); + const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Item" }); + + await updateGlobalItemById(db, globalItem.id, { tags: ["cycling", "gravel"] }); + + const result = await listGlobalItemsForAdmin(db); + const found = result.items.find((i) => i.id === globalItem.id); + expect(found?.tags).toContain("cycling"); + expect(found?.tags).toContain("gravel"); + }); +}); + +describe("deleteGlobalItem", () => { + let db: TestDb["db"]; + + beforeEach(async () => { + ({ db } = await createTestDb()); + }); + + it("returns false for non-existent item", async () => { + const result = await deleteGlobalItem(db, 99999); + expect(result).toBe(false); + }); + + it("deletes item and returns true", async () => { + const mfr = await insertManufacturer(db); + const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "To Delete" }); + + const result = await deleteGlobalItem(db, globalItem.id); + expect(result).toBe(true); + + const found = await getGlobalItemWithOwnerCount(db, globalItem.id); + expect(found).toBeNull(); + }); + + it("nullifies items.globalItemId before deleting", async () => { + const mfr = await insertManufacturer(db); + const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Owned Item" }); + const [user] = await db + .insert(schema.users) + .values({ logtoSub: "delete-test-sub" }) + .returning(); + const userItem = await insertItem(db, "User Item", user!.id, { globalItemId: globalItem.id }); + + await deleteGlobalItem(db, globalItem.id); + + const [afterDelete] = await db + .select({ globalItemId: items.globalItemId }) + .from(items) + .where(eq(items.id, userItem!.id)); + expect(afterDelete?.globalItemId).toBeNull(); + }); + + it("removes globalItemTags before deleting", async () => { + const mfr = await insertManufacturer(db); + const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Delete" }); + const tag = await insertTag(db, "delete-tag"); + await tagGlobalItem(db, globalItem.id, tag.id!); + + await deleteGlobalItem(db, globalItem.id); + + const remainingTags = await db + .select() + .from(globalItemTags) + .where(eq(globalItemTags.globalItemId, globalItem.id)); + expect(remainingTags).toHaveLength(0); + }); +}); +``` + + + +- `tests/services/global-item.service.test.ts` imports `deleteGlobalItem`, `listGlobalItemsForAdmin`, `updateGlobalItemById` +- File contains `describe("listGlobalItemsForAdmin",` +- File contains `describe("updateGlobalItemById",` +- File contains `describe("deleteGlobalItem",` +- `bun test tests/services/global-item.service.test.ts` exits 0 with all new tests passing +- `bun run build` exits 0 after this task + + + + + +--- + + + +## Wave 1 Verification + +After all tasks in this plan complete: + +1. **Build check:** `bun run build` exits 0 +2. **Service tests:** `bun test tests/services/global-item.service.test.ts` exits 0 with all tests (including new ones) passing +3. **File existence:** `ls src/server/routes/admin-items.ts` exists +4. **Route mount:** `grep "adminItemRoutes" src/server/routes/admin.ts` shows the import and `app.route` call +5. **Service exports:** `grep "export async function" src/server/services/global-item.service.ts` shows `listGlobalItemsForAdmin`, `updateGlobalItemById`, `deleteGlobalItem` + + + + +- [ ] `listGlobalItemsForAdmin` service: returns paginated items with tags and ownerCount via batched queries +- [ ] `updateGlobalItemById` service: updates by ID in a transaction, syncs tags when provided +- [ ] `deleteGlobalItem` service: nullifies FK refs and removes tag associations before deleting, returns false for missing items +- [ ] `src/server/routes/admin-items.ts` created with GET /, GET /:id, PUT /:id, DELETE /:id +- [ ] Admin items router mounted at `/items` in `admin.ts` (resolves to `/api/admin/items`) +- [ ] All new service functions have unit tests that pass +- [ ] `bun run build` exits 0 +- [ ] `bun test tests/services/global-item.service.test.ts` exits 0 +- [ ] Requirements ADMN-02, ADMN-03, ADMN-04 are served by these endpoints + + + +- Admin can browse global catalog items via `GET /api/admin/items` (paginated, searchable) — ADMN-02 +- Admin can edit a global catalog item via `PUT /api/admin/items/:id` — ADMN-03 +- Admin can delete a global catalog item via `DELETE /api/admin/items/:id` — ADMN-04 +- Delete does not leave orphan FK violations (nullifies items.globalItemId first) +- All endpoints are protected by `requireAuth + requireAdmin` middleware (inherited from admin.ts router) + diff --git a/.planning/phases/37-admin-global-item-management/37-02-PLAN.md b/.planning/phases/37-admin-global-item-management/37-02-PLAN.md new file mode 100644 index 0000000..508a730 --- /dev/null +++ b/.planning/phases/37-admin-global-item-management/37-02-PLAN.md @@ -0,0 +1,1053 @@ +--- +phase: 37 +plan: "02" +title: "Client — Admin Items List, Edit Page & Sidebar" +type: execute +wave: 2 +depends_on: + - "37-01" +files_modified: + - src/client/hooks/useAdminGlobalItems.ts + - src/client/routes/admin.tsx + - src/client/routes/admin/items.tsx + - src/client/routes/admin/items.$itemId.tsx +autonomous: true +requirements: + - ADMN-02 + - ADMN-03 + - ADMN-04 +--- + +# Plan 37-02: Client — Admin Items List, Edit Page & Sidebar + +## Objective + +Build the client side of the admin global item management feature: a `useAdminGlobalItems` hooks file, the `/admin/items` list page with infinite scroll, the `/admin/items/$itemId` edit page with all fields and impact-aware delete confirmation, and activate the Items sidebar link in the admin shell. All styling matches the UI-SPEC (Tailwind classes specified explicitly). + +--- + + + + +Create useAdminGlobalItems hooks file +execute + + +- `src/client/hooks/useGlobalItems.ts` — read as pattern reference: `useQuery`/`useMutation` pattern, `apiGet`/`apiDelete`/`apiPut` usage, `ApiError` import, queryClient invalidation +- `src/client/lib/api.ts` — confirm `apiGet`, `apiPut`, `apiDelete` signatures + + + +Create `src/client/hooks/useAdminGlobalItems.ts` with the following content: + +```typescript +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { ApiError, apiDelete, apiGet, apiPut } from "../lib/api"; + +// ── Types ────────────────────────────────────────────────────────── + +export interface AdminGlobalItem { + id: number; + manufacturerId: number; + brand: string; + model: string; + category: string | null; + weightGrams: number | null; + priceCents: number | null; + imageUrl: string | null; + description: string | null; + sourceUrl: string | null; + imageCredit: string | null; + imageSourceUrl: string | null; + dominantColor: string | null; + cropZoom: number | null; + cropX: number | null; + cropY: number | null; + createdAt: string; + tags: string[]; + ownerCount: number; +} + +export interface AdminGlobalItemPage { + items: AdminGlobalItem[]; + total: number; + hasMore: boolean; + nextOffset: number; +} + +export interface AdminGlobalItemDetail extends Omit { + ownerCount: number; +} + +export interface UpdateGlobalItemPayload { + manufacturerId?: number; + model?: string; + category?: string | null; + weightGrams?: number | null; + priceCents?: number | null; + imageUrl?: string | null; + description?: string | null; + sourceUrl?: string | null; + imageCredit?: string | null; + imageSourceUrl?: string | null; + tags?: string[]; +} + +// ── Hooks ────────────────────────────────────────────────────────── + +export function useAdminGlobalItems(query?: string, tagNames?: string[]) { + const params = new URLSearchParams(); + if (query) params.set("q", query); + if (tagNames && tagNames.length > 0) params.set("tags", tagNames.join(",")); + params.set("limit", "50"); + const qs = params.toString(); + + return useInfiniteQuery({ + queryKey: ["admin-global-items", query ?? "", tagNames ?? []], + queryFn: ({ pageParam = 0 }) => + apiGet( + `/api/admin/items?offset=${pageParam}&${qs}`, + ), + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.nextOffset : undefined, + initialPageParam: 0, + }); +} + +export function useAdminGlobalItem(id: number | null) { + return useQuery({ + queryKey: ["admin-global-item", id], + queryFn: () => + apiGet(`/api/admin/items/${id}`), + enabled: id != null, + retry: (count, error) => + error instanceof ApiError && error.status === 404 ? false : count < 3, + }); +} + +export function useUpdateAdminGlobalItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + data, + }: { + id: number; + data: UpdateGlobalItemPayload; + }) => apiPut(`/api/admin/items/${id}`, data), + onSuccess: (_result, { id }) => { + queryClient.invalidateQueries({ queryKey: ["admin-global-items"] }); + queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] }); + }, + }); +} + +export function useDeleteAdminGlobalItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/admin/items/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-global-items"] }); + }, + }); +} +``` + + + +- File `src/client/hooks/useAdminGlobalItems.ts` exists +- File exports `useAdminGlobalItems` (uses `useInfiniteQuery` with `initialPageParam: 0`) +- File exports `useAdminGlobalItem` (uses `useQuery`, enabled only when id is not null) +- File exports `useUpdateAdminGlobalItem` (uses `useMutation` with `apiPut`) +- File exports `useDeleteAdminGlobalItem` (uses `useMutation` with `apiDelete`) +- `bun run build` exits 0 after this task + + + + +Activate Items sidebar link in admin.tsx +execute + + +- `src/client/routes/admin.tsx` — read entire file; identify the disabled Items `
` block (lines ~32-40) that must be replaced with an active ``; understand existing imports (`createFileRoute`, `Outlet`, `useNavigate`, `useEffect`, `useAuth`, `LucideIcon`) + + + +Edit `src/client/routes/admin.tsx`: + +1. Add `Link` to the `@tanstack/react-router` import line: +```typescript +import { Link, createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; +``` + +2. Replace the disabled Items `
` block: +```tsx +{/* Items — disabled (phase 37) */} +
+ + Items + + Soon + +
+``` + +With: +```tsx +{/* Items — active (phase 37) */} + + + Items + +``` + + + +- `src/client/routes/admin.tsx` imports `Link` from `@tanstack/react-router` +- File contains ``) +- File contains `activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}` +- File does NOT contain `cursor-not-allowed` on the Items entry +- File does NOT contain the "Soon" badge span for Items +- `bun run build` exits 0 after this task + + + + +Create admin items list route (/admin/items) +execute + + +- `src/client/routes/admin/index.tsx` — read as reference for the file-based route pattern in the admin directory; confirm `createFileRoute` usage +- `src/client/hooks/useAdminGlobalItems.ts` — the hooks file created in T1 (AdminGlobalItem type, useAdminGlobalItems, hook return shape) +- `src/client/lib/iconData.ts` — confirm `LucideIcon` export +- `src/client/hooks/useFormatters.ts` — confirm `useFormatters()` hook and its `formatWeight`/`formatPrice` methods +- `src/client/routes/admin.tsx` — confirm the `
` wrapper uses `bg-gray-50 p-6`; the list page renders inside `` + + + +Create `src/client/routes/admin/items.tsx` with the following content: + +```tsx +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useAdminGlobalItems } from "../../hooks/useAdminGlobalItems"; +import { useFormatters } from "../../hooks/useFormatters"; +import { useTags } from "../../hooks/useTags"; + +export const Route = createFileRoute("/admin/items")({ + component: AdminItemsPage, +}); + +function AdminItemsPage() { + const navigate = useNavigate(); + const { weight: formatWeight, price: formatPrice } = useFormatters(); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const sentinelRef = useRef(null); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useAdminGlobalItems( + debouncedQuery || undefined, + selectedTags.length > 0 ? selectedTags : undefined, + ); + + const { data: allTags } = useTags(); + + // Infinite scroll sentinel + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const allItems = data?.pages.flatMap((p) => p.items) ?? []; + const total = data?.pages[0]?.total ?? 0; + + function toggleTag(name: string) { + setSelectedTags((prev) => + prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name], + ); + } + + return ( +
+ {/* Header */} +
+
+

Catalog Items

+ {!isLoading && ( +

+ {total.toLocaleString()} items +

+ )} +
+ setSearchQuery(e.target.value)} + className="w-64 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300" + /> +
+ + {/* Tag filters */} + {allTags && allTags.length > 0 && ( +
+ {allTags.map((tag) => ( + + ))} +
+ )} + + {/* Error state */} + {isError && ( +
+ Failed to load catalog items. Please try again. +
+ )} + + {/* Table */} + {!isError && ( +
+ + + + + + + + + + + + + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + : allItems.map((item) => ( + + navigate({ to: "/admin/items/$itemId", params: { itemId: String(item.id) } }) + } + > + + + + + + + + ))} + +
+ Brand / Model + + Category + + Weight + + Price + + Tags + + Owners +
+
+
+ {item.brand} + {item.model} + + {item.category ?? } + + {item.weightGrams != null + ? formatWeight(item.weightGrams) + : } + + {item.priceCents != null + ? formatPrice(item.priceCents) + : } + + {item.tags.length === 0 ? ( + + ) : item.tags.length <= 2 ? ( +
+ {item.tags.map((t) => ( + + {t} + + ))} +
+ ) : ( + + +{item.tags.length} + + )} +
+ + {item.ownerCount} + +
+ + {/* Empty state (after load, no items) */} + {!isLoading && allItems.length === 0 && !isError && ( +
+

No items found

+

+ Try a different search or clear your filters. +

+
+ )} + + {/* Infinite scroll sentinel */} +
+ + {/* Loading more */} + {isFetchingNextPage && ( +
Loading...
+ )} + + {/* All loaded message */} + {!isLoading && !hasNextPage && allItems.length > 0 && ( +
+ All {total.toLocaleString()} items loaded +
+ )} +
+ )} +
+ ); +} +``` + +Note: `useTags` hook — check if it exists in `src/client/hooks/useTags.ts`. If not, replace `useTags()` with a direct `useQuery` call to `/api/tags`. + + + +- File `src/client/routes/admin/items.tsx` exists +- File exports route via `createFileRoute("/admin/items")(` +- File imports and calls `useAdminGlobalItems` with `useInfiniteQuery` (via the hook) +- File contains `sentinelRef` and `IntersectionObserver` for infinite scroll +- File contains the data table with columns: Brand/Model, Category, Weight, Price, Tags, Owners +- File contains skeleton loading rows (`animate-pulse`) +- File contains empty state with "No items found" text +- Row click calls `navigate` to `/admin/items/$itemId` +- `bun run build` exits 0 after this task (routeTree.gen.ts auto-updated by Vite) + + + + +Create admin item edit route (/admin/items/$itemId) +execute + + +- `src/client/hooks/useAdminGlobalItems.ts` — hooks file from T1: `useAdminGlobalItem`, `useUpdateAdminGlobalItem`, `useDeleteAdminGlobalItem`, `UpdateGlobalItemPayload`, `AdminGlobalItemDetail` types +- `src/client/routes/admin/items.tsx` — the list route just created (T3) to understand navigation patterns +- `src/server/routes/manufacturers.ts` — `GET /api/manufacturers` returns `{ id, name, slug }[]`; client needs to fetch this list for the brand dropdown +- `src/client/lib/api.ts` — `apiGet` for fetching manufacturers inline + + + +Create `src/client/routes/admin/items.$itemId.tsx` with the following content: + +```tsx +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { + useAdminGlobalItem, + useDeleteAdminGlobalItem, + useUpdateAdminGlobalItem, +} from "../../hooks/useAdminGlobalItems"; +import { apiGet } from "../../lib/api"; + +export const Route = createFileRoute("/admin/items/$itemId")({ + component: AdminItemEditPage, +}); + +interface Manufacturer { + id: number; + name: string; + slug: string; +} + +// ── Tag chip input ───────────────────────────────────────────────── + +function TagInput({ + value, + onChange, +}: { + value: string[]; + onChange: (tags: string[]) => void; +}) { + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(null); + + function addTag(raw: string) { + const tag = raw.trim().toLowerCase().replace(/\s+/g, "-"); + if (tag && !value.includes(tag)) { + onChange([...value, tag]); + } + setInputValue(""); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === "Backspace" && inputValue === "" && value.length > 0) { + onChange(value.slice(0, -1)); + } + } + + function removeTag(tag: string) { + onChange(value.filter((t) => t !== tag)); + } + + return ( +
inputRef.current?.focus()} + > + {value.map((tag) => ( + + {tag} + + + ))} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { if (inputValue) addTag(inputValue); }} + placeholder={value.length === 0 ? "Add tags..." : ""} + className="outline-none bg-transparent text-sm flex-1 min-w-[100px]" + /> +
+ ); +} + +// ── Main edit page ───────────────────────────────────────────────── + +function AdminItemEditPage() { + const { itemId } = Route.useParams(); + const id = Number(itemId); + const navigate = useNavigate(); + + const { data: item, isLoading, isError } = useAdminGlobalItem(isNaN(id) ? null : id); + const updateMutation = useUpdateAdminGlobalItem(); + const deleteMutation = useDeleteAdminGlobalItem(); + + const [manufacturers, setManufacturers] = useState([]); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Form state + const [form, setForm] = useState({ + manufacturerId: 0, + model: "", + category: "", + weightGrams: "", + priceCents: "", + imageUrl: "", + description: "", + sourceUrl: "", + imageCredit: "", + imageSourceUrl: "", + tags: [] as string[], + }); + + // Populate form when item loads + useEffect(() => { + if (item) { + setForm({ + manufacturerId: item.manufacturerId, + model: item.model, + category: item.category ?? "", + weightGrams: item.weightGrams != null ? String(item.weightGrams) : "", + priceCents: item.priceCents != null ? String(item.priceCents / 100) : "", + imageUrl: item.imageUrl ?? "", + description: item.description ?? "", + sourceUrl: item.sourceUrl ?? "", + imageCredit: item.imageCredit ?? "", + imageSourceUrl: item.imageSourceUrl ?? "", + tags: [], + }); + } + }, [item]); + + // Fetch manufacturers for dropdown + useEffect(() => { + apiGet("/api/manufacturers").then(setManufacturers).catch(() => {}); + }, []); + + function handleChange( + field: keyof typeof form, + value: string | number | string[], + ) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null; + const priceCents = + form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null; + + await updateMutation.mutateAsync({ + id, + data: { + manufacturerId: form.manufacturerId || undefined, + model: form.model || undefined, + category: form.category || null, + weightGrams: weightGrams, + priceCents: priceCents, + imageUrl: form.imageUrl || null, + description: form.description || null, + sourceUrl: form.sourceUrl || null, + imageCredit: form.imageCredit || null, + imageSourceUrl: form.imageSourceUrl || null, + tags: form.tags, + }, + }); + } + + async function handleDelete() { + await deleteMutation.mutateAsync(id); + navigate({ to: "/admin/items" }); + } + + const inputClass = + "w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300"; + const labelClass = "block text-sm font-medium text-gray-700 mb-1"; + const sectionClass = "border-t border-gray-100 pt-6 mt-6"; + + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (isError || !item) { + return ( +
+

Failed to load item. Please try again.

+
+ ); + } + + const ownerText = + item.ownerCount === 0 + ? "Not in any collection" + : item.ownerCount === 1 + ? "1 user in collection" + : `${item.ownerCount} users in collection`; + + return ( +
+ {/* Back link */} + + + {/* Page heading */} +
+

+ {item.brand} {item.model} +

+

{ownerText}

+
+ +
+ {/* Image section */} +
+ {item.imageUrl && ( + {`${item.brand} + )} + + handleChange("imageUrl", e.target.value)} + className={inputClass} + placeholder="https://..." + /> +
+ + {/* Brand + Model */} +
+
+
+ + +
+
+ + handleChange("model", e.target.value)} + className={inputClass} + placeholder="e.g. Woodsmoke 700" + /> +
+
+
+ + handleChange("category", e.target.value)} + className={inputClass} + placeholder="e.g. Bikepacking Bags" + /> +
+
+ + {/* Weight + Price */} +
+
+
+ + handleChange("weightGrams", e.target.value)} + className={inputClass} + placeholder="e.g. 450" + min="0" + step="1" + /> +
+
+ + handleChange("priceCents", e.target.value)} + className={inputClass} + placeholder="e.g. 129.99" + min="0" + step="0.01" + /> +
+
+
+ + {/* Tags + Description + Source */} +
+
+ + handleChange("tags", tags)} + /> +
+
+ +