diff --git a/src/server/routes/admin-items.ts b/src/server/routes/admin-items.ts new file mode 100644 index 0000000..0ddfd50 --- /dev/null +++ b/src/server/routes/admin-items.ts @@ -0,0 +1,89 @@ +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 }; diff --git a/src/server/routes/admin.ts b/src/server/routes/admin.ts index 20885e5..ef1986e 100644 --- a/src/server/routes/admin.ts +++ b/src/server/routes/admin.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { requireAdmin, requireAuth } from "../middleware/auth.ts"; +import { adminItemRoutes } from "./admin-items.ts"; type Env = { Variables: { db?: any; userId?: number } }; @@ -13,4 +14,7 @@ app.get("/", async (c) => { return c.json({ ok: true }); }); +// Admin item management +app.route("/items", adminItemRoutes); + export { app as adminRoutes }; diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts index a9ad530..fbfff83 100644 --- a/src/server/services/global-item.service.ts +++ b/src/server/services/global-item.service.ts @@ -88,6 +88,226 @@ export async function searchGlobalItems( return baseQuery.where(and(...conditions)); } +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, + }; +} + +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; + }); +} + +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; + }); +} + export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { const [item] = await db .select({ diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts index 4334884..4b573cb 100644 --- a/tests/services/global-item.service.test.ts +++ b/tests/services/global-item.service.test.ts @@ -10,8 +10,11 @@ import { import { seedGlobalItems } from "../../src/db/seed-global-items.ts"; import { bulkUpsertGlobalItems, + deleteGlobalItem, getGlobalItemWithOwnerCount, + listGlobalItemsForAdmin, searchGlobalItems, + updateGlobalItemById, upsertGlobalItem, } from "../../src/server/services/global-item.service.ts"; import { createTestDb } from "../helpers/db.ts"; @@ -503,3 +506,153 @@ describe("Global Item Service", () => { }); }); }); + +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); + }); +});