From 8a5ee731d019e442b88f42da0fed6029897a9ef8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:49:56 +0200 Subject: [PATCH] feat(19-02): add catalog-linked candidates, branched resolution, remove link/unlink routes - getThreadWithCandidates LEFT JOINs globalItems with COALESCE for name, weight, price, image - createCandidate accepts and stores globalItemId - resolveThread branches: reference item (globalItemId set) vs standalone (full data copy) - Removed link/unlink endpoints from items route (replaced by direct globalItemId FK) - 6 new tests for catalog-linked candidates and branched resolution --- src/server/routes/items.ts | 37 +----- src/server/services/thread.service.ts | 61 ++++++++-- tests/services/thread.service.test.ts | 157 ++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 45 deletions(-) diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts index 5eb36e4..93247dd 100644 --- a/src/server/routes/items.ts +++ b/src/server/routes/items.ts @@ -1,16 +1,8 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; -import { - createItemSchema, - linkItemSchema, - updateItemSchema, -} from "../../shared/schemas.ts"; +import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts"; import { parseId } from "../lib/params.ts"; import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts"; -import { - linkItemToGlobal, - unlinkItemFromGlobal, -} from "../services/global-item.service.ts"; import { createItem, deleteItem, @@ -122,32 +114,5 @@ app.delete("/:id", async (c) => { return c.json({ success: true }); }); -app.post("/:id/link", zValidator("json", linkItemSchema), (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 = getItemById(db, id); - if (!item) return c.json({ error: "Item not found" }, 404); - - try { - const link = linkItemToGlobal(db, id, c.req.valid("json").globalItemId); - return c.json(link, 201); - } catch { - return c.json({ error: "Item already linked to a global item" }, 409); - } -}); - -app.delete("/:id/link", (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 = getItemById(db, id); - if (!item) return c.json({ error: "Item not found" }, 404); - - unlinkItemFromGlobal(db, id); - return c.json({ success: true }); -}); export { app as itemRoutes }; diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index 9aceabd..fcdfae4 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -2,6 +2,7 @@ import { and, asc, desc, eq, max, sql } from "drizzle-orm"; import type { db as prodDb } from "../../db/index.ts"; import { categories, + globalItems, items, threadCandidates, threads, @@ -79,17 +80,33 @@ export async function getThreadWithCandidates( .select({ id: threadCandidates.id, threadId: threadCandidates.threadId, - name: threadCandidates.name, - weightGrams: threadCandidates.weightGrams, - priceCents: threadCandidates.priceCents, + name: sql`COALESCE( + CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL + THEN ${globalItems.brand} || ' ' || ${globalItems.model} + ELSE ${threadCandidates.name} + END, + ${threadCandidates.name} + )`.as("name"), + weightGrams: sql`COALESCE( + CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END, + ${threadCandidates.weightGrams} + )`.as("weight_grams"), + priceCents: sql`COALESCE( + CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END, + ${threadCandidates.priceCents} + )`.as("price_cents"), categoryId: threadCandidates.categoryId, notes: threadCandidates.notes, productUrl: threadCandidates.productUrl, - imageFilename: threadCandidates.imageFilename, + imageFilename: sql`COALESCE( + ${threadCandidates.imageFilename}, + CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END + )`.as("image_filename"), imageSourceUrl: threadCandidates.imageSourceUrl, status: threadCandidates.status, pros: threadCandidates.pros, cons: threadCandidates.cons, + globalItemId: threadCandidates.globalItemId, createdAt: threadCandidates.createdAt, updatedAt: threadCandidates.updatedAt, categoryName: categories.name, @@ -97,6 +114,7 @@ export async function getThreadWithCandidates( }) .from(threadCandidates) .innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) + .leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id)) .where(eq(threadCandidates.threadId, threadId)) .orderBy(asc(threadCandidates.sortOrder)); @@ -190,6 +208,7 @@ export async function createCandidate( pros: data.pros ?? null, cons: data.cons ?? null, sortOrder: nextSortOrder, + globalItemId: data.globalItemId ?? null, }) .returning(); @@ -332,10 +351,30 @@ export async function resolveThread( ? candidate.categoryId : await getOrCreateUncategorized(tx as unknown as Db, userId); - // 4. Create collection item from candidate data — with userId - const [newItem] = await tx - .insert(items) - .values({ + // 4. Create collection item — branched on catalog link + let insertValues: Record; + if (candidate.globalItemId) { + // Reference item — link to global, personal fields only + const [gi] = await tx + .select() + .from(globalItems) + .where(eq(globalItems.id, candidate.globalItemId)); + const fallbackName = gi + ? `${gi.brand} ${gi.model}` + : candidate.name; + insertValues = { + name: fallbackName, + globalItemId: candidate.globalItemId, + categoryId: safeCategoryId, + userId, + notes: candidate.notes, + imageFilename: candidate.imageFilename, + imageSourceUrl: candidate.imageSourceUrl, + quantity: 1, + }; + } else { + // Standalone item — full data copy (existing behavior) + insertValues = { name: candidate.name, weightGrams: candidate.weightGrams, priceCents: candidate.priceCents, @@ -346,7 +385,11 @@ export async function resolveThread( imageFilename: candidate.imageFilename, imageSourceUrl: candidate.imageSourceUrl, quantity: 1, - }) + }; + } + const [newItem] = await tx + .insert(items) + .values(insertValues as any) .returning(); // 5. Archive the thread diff --git a/tests/services/thread.service.test.ts b/tests/services/thread.service.test.ts index ec455c0..ca590a4 100644 --- a/tests/services/thread.service.test.ts +++ b/tests/services/thread.service.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it } from "bun:test"; +import { globalItems, items } from "../../src/db/schema.ts"; +import { eq } from "drizzle-orm"; import { createCandidate, createThread, @@ -616,6 +618,161 @@ describe("Thread Service", () => { }); }); + describe("catalog-linked candidates (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("createCandidate with globalItemId stores the value on the candidate row", async () => { + const gi = await insertGlobalItem(db, { + brand: "Big Agnes", + model: "Copper Spur HV UL2", + weightGrams: 1270, + priceCents: 44995, + }); + const thread = await createThread(db, userId, { + name: "Tent Research", + categoryId: 1, + }); + const candidate = await createCandidate(db, userId, thread.id, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + expect(candidate).toBeDefined(); + expect(candidate.globalItemId).toBe(gi.id); + }); + + it("getThreadWithCandidates returns globalItemId field on each candidate", async () => { + const gi = await insertGlobalItem(db, { + brand: "MSR", + model: "Hubba Hubba", + weightGrams: 1540, + }); + const thread = await createThread(db, userId, { + name: "Tent Options", + categoryId: 1, + }); + await createCandidate(db, userId, thread.id, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const result = await getThreadWithCandidates(db, userId, thread.id); + expect(result?.candidates).toHaveLength(1); + expect(result?.candidates[0].globalItemId).toBe(gi.id); + }); + + it("getThreadWithCandidates merges global item data for candidates with globalItemId", async () => { + const gi = await insertGlobalItem(db, { + brand: "Nemo", + model: "Tensor", + weightGrams: 425, + priceCents: 17995, + }); + const thread = await createThread(db, userId, { + name: "Pad Research", + categoryId: 1, + }); + await createCandidate(db, userId, thread.id, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const result = await getThreadWithCandidates(db, userId, thread.id); + expect(result?.candidates[0].name).toBe("Nemo Tensor"); + expect(result?.candidates[0].weightGrams).toBe(425); + expect(result?.candidates[0].priceCents).toBe(17995); + }); + + it("resolveThread with candidate having globalItemId creates a reference item", async () => { + const gi = await insertGlobalItem(db, { + brand: "Thermarest", + model: "NeoAir XLite", + weightGrams: 340, + priceCents: 20995, + }); + const thread = await createThread(db, userId, { + name: "Pad Decision", + categoryId: 1, + }); + const candidate = await createCandidate(db, userId, thread.id, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + notes: "Great pad", + }); + + const result = await resolveThread(db, userId, thread.id, candidate.id); + expect(result.success).toBe(true); + expect(result.item).toBeDefined(); + expect(result.item?.globalItemId).toBe(gi.id); + expect(result.item?.name).toBe("Thermarest NeoAir XLite"); + // Reference item should NOT copy weight/price from candidate + expect(result.item?.weightGrams).toBeNull(); + expect(result.item?.priceCents).toBeNull(); + expect(result.item?.notes).toBe("Great pad"); + }); + + it("resolveThread with standalone candidate creates a full data copy item", async () => { + const thread = await createThread(db, userId, { + name: "Stove Decision", + categoryId: 1, + }); + const candidate = await createCandidate(db, userId, thread.id, { + name: "Jetboil Flash", + categoryId: 1, + weightGrams: 371, + priceCents: 10995, + notes: "Fast boil", + productUrl: "https://example.com/jetboil", + }); + + const result = await resolveThread(db, userId, thread.id, candidate.id); + expect(result.success).toBe(true); + expect(result.item?.globalItemId).toBeNull(); + expect(result.item?.name).toBe("Jetboil Flash"); + expect(result.item?.weightGrams).toBe(371); + expect(result.item?.priceCents).toBe(10995); + expect(result.item?.productUrl).toBe("https://example.com/jetboil"); + }); + + it("resolveThread reference item has brand+model as fallback name", async () => { + const gi = await insertGlobalItem(db, { + brand: "MSR", + model: "PocketRocket 2", + }); + const thread = await createThread(db, userId, { + name: "Stove Research", + categoryId: 1, + }); + const candidate = await createCandidate(db, userId, thread.id, { + name: "placeholder", + categoryId: 1, + globalItemId: gi.id, + }); + + const result = await resolveThread(db, userId, thread.id, candidate.id); + expect(result.item?.name).toBe("MSR PocketRocket 2"); + }); + }); + describe("cross-user isolation", () => { it("user cannot see other user's threads", async () => { const userId2 = await createSecondTestUser(db);