Files
GearBox/.planning/phases/37-admin-global-item-management/37-01-PLAN.md
Jean-Luc Makiola eabfca475c 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 <noreply@anthropic.com>
2026-04-19 21:28:46 +02:00

25 KiB

phase, plan, title, type, wave, depends_on, files_modified, autonomous, requirements
phase plan title type wave depends_on files_modified autonomous requirements
37 01 Server — Admin Global Item Services & Routes execute 1
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
true
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.


execute

<read_first>

  • 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 </read_first>
Add the following function to `src/server/services/global-item.service.ts`, immediately after the `searchGlobalItems` export:
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<number, string[]>();
  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<number, number>();
  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,
  };
}

<acceptance_criteria>

  • 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 </acceptance_criteria>
execute

<read_first>

  • 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 </read_first>
Add the following function to `src/server/services/global-item.service.ts`, after the `listGlobalItemsForAdmin` export and before `getGlobalItemWithOwnerCount`:
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<string, unknown> = {};
    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;
  });
}

<acceptance_criteria>

  • 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 </acceptance_criteria>
execute

<read_first>

  • 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 </read_first>
Add the following function to `src/server/services/global-item.service.ts`, after `updateGlobalItemById`:
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;
  });
}

<acceptance_criteria>

  • 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 </acceptance_criteria>
execute

<read_first>

  • 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 </read_first>
Create `src/server/routes/admin-items.ts` with the following content:
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<Env>();

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 };

<acceptance_criteria>

  • 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 </acceptance_criteria>
execute

<read_first>

  • 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) </read_first>
Edit `src/server/routes/admin.ts` to add the import and mount the admin items sub-router:
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<Env>();

// 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 };

<acceptance_criteria>

  • 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 </acceptance_criteria>
execute

<read_first>

  • 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 </read_first>
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:

import {
  bulkUpsertGlobalItems,
  deleteGlobalItem,
  getGlobalItemWithOwnerCount,
  listGlobalItemsForAdmin,
  searchGlobalItems,
  updateGlobalItemById,
  upsertGlobalItem,
} from "../../src/server/services/global-item.service.ts";

Then append at the end of the file:

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

<acceptance_criteria>

  • 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 </acceptance_criteria>

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

<success_criteria>

  • 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 </success_criteria>

<must_haves>

  • 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) </must_haves>