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>
25 KiB
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 |
|
true |
|
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,sqlfrom drizzle-orm), table imports (globalItems,globalItemTags,items,manufacturers,tags),Db/TxDbtypes, and the existingsearchGlobalItemsquery structuresrc/db/schema.ts— verify column names onglobalItems,manufacturers,items,globalItemTags,tags</read_first>
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.tscontainsexport async function listGlobalItemsForAdmin(- Function signature includes
opts: { query?: string; tagNames?: string[]; offset?: number; limit?: number; } - Return type includes
items,total,hasMore,nextOffsetfields (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 buildexits 0 after this task </acceptance_criteria>
<read_first>
src/server/services/global-item.service.ts— read current state after T1; understandsyncGlobalItemTagsprivate function (lines ~126-144 of original),TxDbtype, transaction pattern fromupsertGlobalItemsrc/db/schema.ts— confirmglobalItemscolumn names:manufacturerId,model,category,weightGrams,priceCents,imageUrl,description,sourceUrl,imageCredit,imageSourceUrl</read_first>
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.tscontainsexport async function updateGlobalItemById(- Function accepts
id: numberand partialdataobject with all optional fields - Function uses a transaction and calls
syncGlobalItemTagswhentagsis provided - Function returns
nullif no item withidexists (readable in file) bun run buildexits 0 after this task </acceptance_criteria>
<read_first>
src/server/services/global-item.service.ts— read current state after T1+T2; understand imports (needitems,globalItemTags,globalItemsfrom schema;eqfrom drizzle-orm)src/db/schema.ts— confirmitems.globalItemIdis nullable (noonDelete: cascade) andglobalItemTags.globalItemIdhas no cascade; deletion order: NULL items FK → delete globalItemTags → delete globalItems </read_first>
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.tscontainsexport async function deleteGlobalItem(- Function executes in a transaction (file contains
db.transactionwrapping the delete sequence) - Function nullifies
items.globalItemIdbefore deleting (file containsupdate(items).set({ globalItemId: null })) - Function deletes
globalItemTagsrows before deleting the global item (file containsdelete(globalItemTags)beforedelete(globalItems)) - Function returns
falsewhen item not found,trueon success bun run buildexits 0 after this task </acceptance_criteria>
<read_first>
src/server/routes/global-items.ts— read as pattern reference: Hono Env type,parseIdimport,zValidatorusage, route structuresrc/server/routes/admin.ts— read current state; understand how to mount sub-router (app.route)src/server/middleware/auth.ts— confirmrequireAdminis already exported (it is)src/server/lib/params.ts— confirmparseIdexport signaturesrc/shared/schemas.ts— check if an admin update schema exists; will need to create inline Zod schema for the PUT body </read_first>
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.tsexists - File exports
adminItemRoutes - File contains
app.get("/",handler that callslistGlobalItemsForAdmin - File contains
app.get("/:id",handler that callsgetGlobalItemWithOwnerCount - File contains
app.put("/:id",handler withzValidatorandupdateGlobalItemById - File contains
app.delete("/:id",handler that callsdeleteGlobalItem bun run buildexits 0 after this task </acceptance_criteria>
<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>
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.tscontainsimport { adminItemRoutes } from "./admin-items.ts"src/server/routes/admin.tscontainsapp.route("/items", adminItemRoutes)app.use("/*", requireAuth, requireAdmin)remains on line before any routes (auth still applies to all sub-routes)bun run buildexits 0 after this task </acceptance_criteria>
<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 blockstests/helpers/db.ts— confirmcreateTestDb()API and that it uses Drizzle migrations with SQLite in-memory </read_first>
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.tsimportsdeleteGlobalItem,listGlobalItemsForAdmin,updateGlobalItemById- File contains
describe("listGlobalItemsForAdmin", - File contains
describe("updateGlobalItemById", - File contains
describe("deleteGlobalItem", bun test tests/services/global-item.service.test.tsexits 0 with all new tests passingbun run buildexits 0 after this task </acceptance_criteria>
Wave 1 Verification
After all tasks in this plan complete:
- Build check:
bun run buildexits 0 - Service tests:
bun test tests/services/global-item.service.test.tsexits 0 with all tests (including new ones) passing - File existence:
ls src/server/routes/admin-items.tsexists - Route mount:
grep "adminItemRoutes" src/server/routes/admin.tsshows the import andapp.routecall - Service exports:
grep "export async function" src/server/services/global-item.service.tsshowslistGlobalItemsForAdmin,updateGlobalItemById,deleteGlobalItem
<success_criteria>
listGlobalItemsForAdminservice: returns paginated items with tags and ownerCount via batched queriesupdateGlobalItemByIdservice: updates by ID in a transaction, syncs tags when provideddeleteGlobalItemservice: nullifies FK refs and removes tag associations before deleting, returns false for missing itemssrc/server/routes/admin-items.tscreated with GET /, GET /:id, PUT /:id, DELETE /:id- Admin items router mounted at
/itemsinadmin.ts(resolves to/api/admin/items) - All new service functions have unit tests that pass
bun run buildexits 0bun test tests/services/global-item.service.test.tsexits 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 + requireAdminmiddleware (inherited from admin.ts router) </must_haves>