diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts index dc8e97f..8a67e50 100644 --- a/src/server/services/global-item.service.ts +++ b/src/server/services/global-item.service.ts @@ -1,17 +1,20 @@ import type { SQL } from "drizzle-orm"; import { and, count, eq, ilike, or, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; -import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts"; +import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts"; type Db = typeof prodDb; type TxDb = Parameters[0]>[0]; -/** - * Search global items by brand or model and/or tag names. - * Text search uses ILIKE for case-insensitive matching (PostgreSQL). - * Tag filtering uses AND logic -- items must have ALL specified tags. - * Escapes % and _ wildcard characters in user input. - */ +async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise { + const [m] = await (db as Db) + .select({ id: manufacturers.id }) + .from(manufacturers) + .where(eq(manufacturers.slug, slug)); + if (!m) throw new Error(`Manufacturer not found: ${slug}`); + return m.id; +} + export async function searchGlobalItems( db: Db = prodDb, query?: string, @@ -23,7 +26,7 @@ export async function searchGlobalItems( const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); const pattern = `%${escaped}%`; conditions.push( - or(ilike(globalItems.brand, pattern), ilike(globalItems.model, pattern))!, + or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!, ); } @@ -43,24 +46,59 @@ export async function searchGlobalItems( ); } + const baseQuery = 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)); + if (conditions.length === 0) { - return db.select().from(globalItems); + return baseQuery; } - return db - .select() - .from(globalItems) - .where(and(...conditions)); + return baseQuery.where(and(...conditions)); } -/** - * Get a single global item by ID with the count of user items referencing it - * via items.globalItemId. - */ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { const [item] = await db - .select() + .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(eq(globalItems.id, id)); if (!item) return null; @@ -73,10 +111,6 @@ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { return { ...item, ownerCount: result?.ownerCount ?? 0 }; } -/** - * Sync tags for a global item: delete existing, re-insert provided tag names. - * Creates tags that don't exist yet (create-if-not-exists). - */ async function syncGlobalItemTags( tx: TxDb, globalItemId: number, @@ -97,15 +131,10 @@ async function syncGlobalItemTags( } } -/** - * Upsert a single global item by (brand, model). - * Creates if not exists, updates all non-key fields if exists. - * Tag sync: provided → sync; undefined → leave untouched; [] → clear all tags. - */ export async function upsertGlobalItem( db: Db, data: { - brand: string; + manufacturerSlug: string; model: string; category?: string; weightGrams?: number; @@ -118,23 +147,25 @@ export async function upsertGlobalItem( tags?: string[]; }, ) { + const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug); + return await db.transaction(async (tx) => { const [existing] = await tx .select({ id: globalItems.id }) .from(globalItems) .where( and( - eq(globalItems.brand, data.brand), + eq(globalItems.manufacturerId, manufacturerId), eq(globalItems.model, data.model), ), ); - const { tags: tagNames, ...itemData } = data; + const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data; const [item] = await tx .insert(globalItems) .values({ - brand: itemData.brand, + manufacturerId, model: itemData.model, category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, @@ -146,7 +177,7 @@ export async function upsertGlobalItem( imageSourceUrl: itemData.imageSourceUrl ?? null, }) .onConflictDoUpdate({ - target: [globalItems.brand, globalItems.model], + target: [globalItems.manufacturerId, globalItems.model], set: { category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, @@ -161,22 +192,17 @@ export async function upsertGlobalItem( .returning(); if (tagNames !== undefined) { - await syncGlobalItemTags(tx, item.id, tagNames); + await syncGlobalItemTags(tx, item!.id, tagNames); } - return { item, created: !existing }; + return { item: item!, created: !existing }; }); } -/** - * Bulk upsert global items in a single transaction. - * Returns { created, updated, items } with accurate counts. - * Rolls back entirely if any item fails. - */ export async function bulkUpsertGlobalItems( db: Db, itemsData: Array<{ - brand: string; + manufacturerSlug: string; model: string; category?: string; weightGrams?: number; @@ -195,22 +221,24 @@ export async function bulkUpsertGlobalItems( const resultItems = []; for (const data of itemsData) { + const manufacturerId = await resolveManufacturerId(tx as unknown as Db, data.manufacturerSlug); + const [existing] = await tx .select({ id: globalItems.id }) .from(globalItems) .where( and( - eq(globalItems.brand, data.brand), + eq(globalItems.manufacturerId, manufacturerId), eq(globalItems.model, data.model), ), ); - const { tags: tagNames, ...itemData } = data; + const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data; const [item] = await tx .insert(globalItems) .values({ - brand: itemData.brand, + manufacturerId, model: itemData.model, category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, @@ -222,7 +250,7 @@ export async function bulkUpsertGlobalItems( imageSourceUrl: itemData.imageSourceUrl ?? null, }) .onConflictDoUpdate({ - target: [globalItems.brand, globalItems.model], + target: [globalItems.manufacturerId, globalItems.model], set: { category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, @@ -237,7 +265,7 @@ export async function bulkUpsertGlobalItems( .returning(); if (tagNames !== undefined) { - await syncGlobalItemTags(tx, item.id, tagNames); + await syncGlobalItemTags(tx, item!.id, tagNames); } if (existing) { @@ -245,7 +273,7 @@ export async function bulkUpsertGlobalItems( } else { created++; } - resultItems.push(item); + resultItems.push(item!); } return { created, updated, items: resultItems }; diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts index 99093e7..cf0ba09 100644 --- a/tests/services/global-item.service.test.ts +++ b/tests/services/global-item.service.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; +import * as schema from "../../src/db/schema.ts"; import { globalItems, globalItemTags, @@ -17,10 +18,18 @@ import { createTestDb } from "../helpers/db.ts"; type TestDb = Awaited>; +async function insertManufacturer(db: TestDb["db"], name = "Apidura", slug = "apidura") { + const [row] = await db + .insert(schema.manufacturers) + .values({ name, slug, website: `https://${slug}.com` }) + .returning(); + return row!; +} + async function insertGlobalItem( db: TestDb["db"], data: { - brand: string; + manufacturerId: number; model: string; category?: string; weightGrams?: number; @@ -30,14 +39,14 @@ async function insertGlobalItem( const [row] = await db .insert(globalItems) .values({ - brand: data.brand, + manufacturerId: data.manufacturerId, model: data.model, category: data.category ?? null, weightGrams: data.weightGrams ?? null, priceCents: data.priceCents ?? null, }) .returning(); - return row; + return row!; } async function insertItem( @@ -78,28 +87,20 @@ describe("Global Item Service", () => { describe("searchGlobalItems", () => { it("returns all global items when no query provided", async () => { - await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const results = await searchGlobalItems(db); expect(results).toHaveLength(2); }); it("returns items matching brand (case-insensitive)", async () => { - await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const results = await searchGlobalItems(db, "revelate"); expect(results).toHaveLength(1); @@ -107,14 +108,10 @@ describe("Global Item Service", () => { }); it("returns items matching model (case-insensitive)", async () => { - await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const results = await searchGlobalItems(db, "HANDLEBAR"); expect(results).toHaveLength(1); @@ -122,42 +119,30 @@ describe("Global Item Service", () => { }); it("does not match everything with wildcard chars", async () => { - await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const results = await searchGlobalItems(db, "100%"); expect(results).toHaveLength(0); }); it("returns all items when no tags provided", async () => { - await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const results = await searchGlobalItems(db, undefined, undefined); expect(results).toHaveLength(2); }); it("filters by single tag", async () => { - const gi1 = await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - const _gi2 = await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const tag = await insertTag(db, "ultralight"); await tagGlobalItem(db, gi1.id, tag.id); @@ -168,14 +153,10 @@ describe("Global Item Service", () => { }); it("filters by multiple tags with AND logic", async () => { - const gi1 = await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - const gi2 = await insertGlobalItem(db, { - brand: "Apidura", - model: "Handlebar Pack", - }); + const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const m2 = await insertManufacturer(db, "Apidura", "apidura"); + const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" }); + const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" }); const tagUL = await insertTag(db, "ultralight"); const tagBP = await insertTag(db, "bikepacking"); @@ -194,14 +175,9 @@ describe("Global Item Service", () => { }); it("combines text search and tag filtering", async () => { - const gi1 = await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Terrapin System", - }); - const gi2 = await insertGlobalItem(db, { - brand: "Revelate Designs", - model: "Spinelock", - }); + const m = await insertManufacturer(db, "Revelate Designs", "revelate-designs"); + const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System" }); + const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock" }); const tag = await insertTag(db, "bikepacking"); await tagGlobalItem(db, gi1.id, tag.id); @@ -216,10 +192,8 @@ describe("Global Item Service", () => { describe("getGlobalItemWithOwnerCount", () => { it("returns item with ownerCount 0 when no items reference it", async () => { - const gi = await insertGlobalItem(db, { - brand: "MSR", - model: "PocketRocket 2", - }); + const m = await insertManufacturer(db, "MSR", "msr"); + const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" }); const result = await getGlobalItemWithOwnerCount(db, gi.id); expect(result).not.toBeNull(); @@ -228,10 +202,8 @@ describe("Global Item Service", () => { }); it("returns ownerCount matching number of items with globalItemId", async () => { - const gi = await insertGlobalItem(db, { - brand: "MSR", - model: "PocketRocket 2", - }); + const m = await insertManufacturer(db, "MSR", "msr"); + const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" }); await insertItem(db, "My Stove", userId, { globalItemId: gi.id }); await insertItem(db, "Another Stove", userId, { @@ -269,8 +241,9 @@ describe("Global Item Service", () => { describe("upsert operations", () => { it("upsertGlobalItem creates new item and returns { item, created: true }", async () => { + await insertManufacturer(db, "Revelate Designs", "revelate-designs"); const result = await upsertGlobalItem(db, { - brand: "Revelate Designs", + manufacturerSlug: "revelate-designs", model: "Terrapin System", category: "Bags", weightGrams: 210, @@ -278,19 +251,19 @@ describe("Global Item Service", () => { expect(result.created).toBe(true); expect(result.item.id).toBeDefined(); - expect(result.item.brand).toBe("Revelate Designs"); expect(result.item.model).toBe("Terrapin System"); }); - it("upsertGlobalItem updates existing item on (brand, model) conflict and returns { item, created: false }", async () => { + it("upsertGlobalItem updates existing item on (manufacturerId, model) conflict and returns { item, created: false }", async () => { + await insertManufacturer(db, "MSR", "msr"); await upsertGlobalItem(db, { - brand: "MSR", + manufacturerSlug: "msr", model: "PocketRocket 2", weightGrams: 83, }); const second = await upsertGlobalItem(db, { - brand: "MSR", + manufacturerSlug: "msr", model: "PocketRocket 2", weightGrams: 90, }); @@ -304,8 +277,9 @@ describe("Global Item Service", () => { }); it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => { + await insertManufacturer(db, "Apidura", "apidura"); const result = await upsertGlobalItem(db, { - brand: "Apidura", + manufacturerSlug: "apidura", model: "Handlebar Pack", sourceUrl: "https://apidura.com/shop/handlebar-pack/", imageCredit: "Apidura Ltd", @@ -322,8 +296,9 @@ describe("Global Item Service", () => { }); it("upsertGlobalItem with tags creates tags and links them", async () => { + await insertManufacturer(db, "Therm-a-Rest", "therm-a-rest"); const result = await upsertGlobalItem(db, { - brand: "Therm-a-Rest", + manufacturerSlug: "therm-a-rest", model: "NeoAir XLite", tags: ["sleeping-pad", "ultralight"], }); @@ -342,16 +317,17 @@ describe("Global Item Service", () => { }); it("upsertGlobalItem without tags leaves existing tags untouched", async () => { + await insertManufacturer(db, "Sea to Summit", "sea-to-summit"); // Create item with tags const first = await upsertGlobalItem(db, { - brand: "Sea to Summit", + manufacturerSlug: "sea-to-summit", model: "Spark III", tags: ["sleeping-bag"], }); // Upsert without tags await upsertGlobalItem(db, { - brand: "Sea to Summit", + manufacturerSlug: "sea-to-summit", model: "Spark III", weightGrams: 450, }); @@ -366,16 +342,17 @@ describe("Global Item Service", () => { }); it("upsertGlobalItem with empty tags array clears existing tags", async () => { + await insertManufacturer(db, "Big Agnes", "big-agnes"); // Create item with tags const first = await upsertGlobalItem(db, { - brand: "Big Agnes", + manufacturerSlug: "big-agnes", model: "Copper Spur HV UL2", tags: ["tent", "ultralight"], }); // Upsert with empty tags await upsertGlobalItem(db, { - brand: "Big Agnes", + manufacturerSlug: "big-agnes", model: "Copper Spur HV UL2", tags: [], }); @@ -390,10 +367,12 @@ describe("Global Item Service", () => { }); it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => { + await insertManufacturer(db, "Petzl", "petzl"); + await insertManufacturer(db, "Black Diamond", "black-diamond"); const result = await bulkUpsertGlobalItems(db, [ - { brand: "Petzl", model: "Actik Core", weightGrams: 87 }, - { brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, - { brand: "Black Diamond", model: "Spot 350", weightGrams: 90 }, + { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 }, + { manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, + { manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90 }, ]); expect(result.created).toBe(3); @@ -402,16 +381,18 @@ describe("Global Item Service", () => { }); it("bulkUpsertGlobalItems handles mix of new and existing items", async () => { + await insertManufacturer(db, "Petzl", "petzl"); + await insertManufacturer(db, "Black Diamond", "black-diamond"); // Pre-insert one item await upsertGlobalItem(db, { - brand: "Petzl", + manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87, }); const result = await bulkUpsertGlobalItems(db, [ - { brand: "Petzl", model: "Actik Core", weightGrams: 90 }, // existing - { brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, // new + { manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing + { manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, // new ]); expect(result.created).toBe(1);