From d1ffd79bbb683d7a25b55db4b8828dc8f26703a1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:34:54 +0200 Subject: [PATCH] feat(19-02): add COALESCE merge for reference items in item service - getAllItems and getItemById LEFT JOIN globalItems with COALESCE for name, weight, price, image - createItem accepts globalItemId and purchasePriceCents, stores brand+model as fallback name - duplicateItem preserves globalItemId and purchasePriceCents - updateItem type includes globalItemId and purchasePriceCents - 10 new tests for reference item creation and merged data retrieval --- src/server/services/item.service.ts | 76 ++++++++-- tests/services/item.service.test.ts | 211 ++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 11 deletions(-) diff --git a/src/server/services/item.service.ts b/src/server/services/item.service.ts index 0ef3c3b..c0919d2 100644 --- a/src/server/services/item.service.ts +++ b/src/server/services/item.service.ts @@ -1,6 +1,6 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import type { db as prodDb } from "../../db/index.ts"; -import { categories, items } from "../../db/schema.ts"; +import { categories, globalItems, items } from "../../db/schema.ts"; import type { CreateItem } from "../../shared/types.ts"; type Db = typeof prodDb; @@ -9,15 +9,32 @@ export async function getAllItems(db: Db, userId: number) { return db .select({ id: items.id, - name: items.name, - weightGrams: items.weightGrams, - priceCents: items.priceCents, + name: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL + THEN ${globalItems.brand} || ' ' || ${globalItems.model} + ELSE ${items.name} + END, + ${items.name} + )`.as("name"), + weightGrams: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END, + ${items.weightGrams} + )`.as("weight_grams"), + priceCents: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END, + ${items.priceCents} + )`.as("price_cents"), + purchasePriceCents: items.purchasePriceCents, quantity: items.quantity, categoryId: items.categoryId, notes: items.notes, productUrl: items.productUrl, - imageFilename: items.imageFilename, + imageFilename: sql`COALESCE( + ${items.imageFilename}, + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END + )`.as("image_filename"), imageSourceUrl: items.imageSourceUrl, + globalItemId: items.globalItemId, createdAt: items.createdAt, updatedAt: items.updatedAt, categoryName: categories.name, @@ -25,6 +42,7 @@ export async function getAllItems(db: Db, userId: number) { }) .from(items) .innerJoin(categories, eq(items.categoryId, categories.id)) + .leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) .where(eq(items.userId, userId)); } @@ -32,18 +50,36 @@ export async function getItemById(db: Db, userId: number, id: number) { const [row] = await db .select({ id: items.id, - name: items.name, - weightGrams: items.weightGrams, - priceCents: items.priceCents, + name: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL + THEN ${globalItems.brand} || ' ' || ${globalItems.model} + ELSE ${items.name} + END, + ${items.name} + )`.as("name"), + weightGrams: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END, + ${items.weightGrams} + )`.as("weight_grams"), + priceCents: sql`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END, + ${items.priceCents} + )`.as("price_cents"), + purchasePriceCents: items.purchasePriceCents, categoryId: items.categoryId, notes: items.notes, productUrl: items.productUrl, - imageFilename: items.imageFilename, + imageFilename: sql`COALESCE( + ${items.imageFilename}, + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END + )`.as("image_filename"), imageSourceUrl: items.imageSourceUrl, + globalItemId: items.globalItemId, createdAt: items.createdAt, updatedAt: items.updatedAt, }) .from(items) + .leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) .where(and(eq(items.id, id), eq(items.userId, userId))); return row ?? null; @@ -58,10 +94,22 @@ export async function createItem( imageFilename?: string; }, ) { + // For reference items, look up global item for fallback name (items.name is NOT NULL) + let name = data.name; + if (data.globalItemId) { + const [gi] = await db + .select({ brand: globalItems.brand, model: globalItems.model }) + .from(globalItems) + .where(eq(globalItems.id, data.globalItemId)); + if (gi) { + name = `${gi.brand} ${gi.model}`; + } + } + const [row] = await db .insert(items) .values({ - name: data.name, + name, weightGrams: data.weightGrams ?? null, priceCents: data.priceCents ?? null, quantity: data.quantity ?? 1, @@ -71,6 +119,8 @@ export async function createItem( productUrl: data.productUrl ?? null, imageFilename: data.imageFilename ?? null, imageSourceUrl: data.imageSourceUrl ?? null, + globalItemId: data.globalItemId ?? null, + purchasePriceCents: data.purchasePriceCents ?? null, }) .returning(); @@ -91,6 +141,8 @@ export async function updateItem( productUrl: string; imageFilename: string; imageSourceUrl: string; + globalItemId: number; + purchasePriceCents: number; }>, ) { // Check if item exists and belongs to user @@ -131,6 +183,8 @@ export async function duplicateItem(db: Db, userId: number, id: number) { imageFilename: source.imageFilename, imageSourceUrl: source.imageSourceUrl, quantity: source.quantity, + globalItemId: source.globalItemId, + purchasePriceCents: source.purchasePriceCents, }) .returning(); diff --git a/tests/services/item.service.test.ts b/tests/services/item.service.test.ts index 8e21cc3..cf317cd 100644 --- a/tests/services/item.service.test.ts +++ b/tests/services/item.service.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "bun:test"; +import { globalItems } from "../../src/db/schema.ts"; import { createItem, deleteItem, @@ -168,6 +169,216 @@ describe("Item Service", () => { }); }); + describe("reference items (globalItemId)", () => { + async function insertGlobalItem( + testDb: any, + data: { + brand: string; + model: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + }, + ) { + const [row] = await testDb + .insert(globalItems) + .values(data) + .returning(); + return row; + } + + it("createItem with globalItemId creates a reference item with globalItemId set", async () => { + const gi = await insertGlobalItem(db, { + brand: "Big Agnes", + model: "Copper Spur HV UL2", + weightGrams: 1270, + priceCents: 44995, + }); + + const item = await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + expect(item).toBeDefined(); + expect(item?.globalItemId).toBe(gi.id); + }); + + it("createItem with globalItemId stores brand+model as fallback name", async () => { + const gi = await insertGlobalItem(db, { + brand: "MSR", + model: "Hubba Hubba", + weightGrams: 1540, + }); + + const item = await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + expect(item?.name).toBe("MSR Hubba Hubba"); + }); + + it("getAllItems returns merged name from global item for reference items", async () => { + const gi = await insertGlobalItem(db, { + brand: "Nemo", + model: "Tensor", + weightGrams: 425, + priceCents: 17995, + }); + + await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const all = await getAllItems(db, userId); + expect(all).toHaveLength(1); + expect(all[0].name).toBe("Nemo Tensor"); + }); + + it("getAllItems returns merged weightGrams from global item for reference items", async () => { + const gi = await insertGlobalItem(db, { + brand: "Nemo", + model: "Tensor", + weightGrams: 425, + priceCents: 17995, + }); + + await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const all = await getAllItems(db, userId); + expect(all[0].weightGrams).toBe(425); + }); + + it("getAllItems returns merged priceCents from global item for reference items", async () => { + const gi = await insertGlobalItem(db, { + brand: "Nemo", + model: "Tensor", + weightGrams: 425, + priceCents: 17995, + }); + + await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const all = await getAllItems(db, userId); + expect(all[0].priceCents).toBe(17995); + }); + + it("getAllItems returns item's own imageFilename when set, ignoring global imageUrl", async () => { + const gi = await insertGlobalItem(db, { + brand: "Nemo", + model: "Tensor", + imageUrl: "https://example.com/global.jpg", + }); + + await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + imageFilename: "local.jpg", + }); + + const all = await getAllItems(db, userId); + expect(all[0].imageFilename).toBe("local.jpg"); + }); + + it("getAllItems falls back to global imageUrl when item has no imageFilename", async () => { + const gi = await insertGlobalItem(db, { + brand: "Nemo", + model: "Tensor", + imageUrl: "https://example.com/global.jpg", + }); + + await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const all = await getAllItems(db, userId); + expect(all[0].imageFilename).toBe("https://example.com/global.jpg"); + }); + + it("getAllItems returns standalone item data unchanged", async () => { + await createItem(db, userId, { + name: "My Manual Tent", + weightGrams: 1500, + priceCents: 25000, + categoryId: 1, + }); + + const all = await getAllItems(db, userId); + expect(all).toHaveLength(1); + expect(all[0].name).toBe("My Manual Tent"); + expect(all[0].weightGrams).toBe(1500); + expect(all[0].priceCents).toBe(25000); + expect(all[0].globalItemId).toBeNull(); + }); + + it("getItemById returns merged data for a reference item", async () => { + const gi = await insertGlobalItem(db, { + brand: "Thermarest", + model: "NeoAir XLite", + weightGrams: 340, + priceCents: 20995, + }); + + const created = await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const item = await getItemById(db, userId, created?.id); + expect(item).toBeDefined(); + expect(item?.name).toBe("Thermarest NeoAir XLite"); + expect(item?.weightGrams).toBe(340); + expect(item?.priceCents).toBe(20995); + expect(item?.globalItemId).toBe(gi.id); + }); + + it("duplicateItem on a reference item preserves globalItemId", async () => { + const gi = await insertGlobalItem(db, { + brand: "MSR", + model: "PocketRocket 2", + weightGrams: 73, + priceCents: 4995, + }); + + const original = await createItem(db, userId, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const copy = await duplicateItem(db, userId, original?.id); + expect(copy).toBeDefined(); + expect(copy?.globalItemId).toBe(gi.id); + }); + + it("createItem with purchasePriceCents stores the value", async () => { + const item = await createItem(db, userId, { + name: "Discounted Tent", + categoryId: 1, + purchasePriceCents: 29999, + }); + + expect(item?.purchasePriceCents).toBe(29999); + }); + }); + describe("cross-user isolation", () => { it("user cannot see other user's items", async () => { const userId2 = await createSecondTestUser(db);