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