From d1ffd79bbb683d7a25b55db4b8828dc8f26703a1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:34:54 +0200 Subject: [PATCH 1/3] 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); From 8a5ee731d019e442b88f42da0fed6029897a9ef8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:49:56 +0200 Subject: [PATCH 2/3] 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); From 59deaea95a090647ec8843779b65dbdabf5f33d3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:51:26 +0200 Subject: [PATCH 3/3] docs(19-02): complete item and thread service COALESCE merge plan - SUMMARY.md with task commits, decisions, and verification results - STATE.md updated with position, progress, and decisions - ROADMAP.md updated with plan progress --- .planning/ROADMAP.md | 8 +- .planning/STATE.md | 23 ++-- .../19-02-SUMMARY.md | 101 ++++++++++++++++++ 3 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-02-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index da6bd1f..7b0ecc4 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -184,10 +184,10 @@ Plans: 2. Global items can have multiple tags, searchable via API 3. Thread candidates can link to a global item via globalItemId 4. Resolving a thread with a catalog-linked candidate creates a reference item with auto-link -**Plans:** 3 plans +**Plans:** 2/3 plans executed Plans: -- [ ] 19-01-PLAN.md — Schema, migration, Zod schemas, types, seed script -- [ ] 19-02-PLAN.md — Item service COALESCE merge, thread resolution, route cleanup +- [x] 19-01-PLAN.md — Schema, migration, Zod schemas, types, seed script +- [x] 19-02-PLAN.md — Item service COALESCE merge, thread resolution, route cleanup - [ ] 19-03-PLAN.md — Global item tag filtering, secondary service merge propagation ### Phase 20: FAB & Full-Screen Catalog Search @@ -247,7 +247,7 @@ Plans: | 16. Multi-User Data Model | v2.0 | 0/? | Not started | - | | 17. Object Storage | v2.0 | 0/? | Not started | - | | 18. Global Items & Public Profiles | v2.0 | 4/5 | Complete | 2026-04-05 | -| 19. Reference Item Model & Tags Schema | v2.0 | 0/3 | Not started | - | +| 19. Reference Item Model & Tags Schema | v2.0 | 2/3 | In Progress| | | 20. FAB & Full-Screen Catalog Search | v2.0 | 0/? | Not started | - | | 21. Add-from-Catalog & Thread Integration | v2.0 | 0/? | Not started | - | | 22. Manual Entry Fallback | v2.0 | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 7e46721..7da072c 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools -status: planning -stopped_at: Completed 19-01-PLAN.md -last_updated: "2026-04-05T11:22:25.312Z" +status: executing +stopped_at: Completed 19-02-PLAN.md +last_updated: "2026-04-05T18:51:11.895Z" last_activity: 2026-04-05 progress: - total_phases: 12 + total_phases: 13 completed_phases: 11 - total_plans: 33 - completed_plans: 31 - percent: 0 + total_plans: 36 + completed_plans: 33 + percent: 3 --- # Project State @@ -26,8 +26,8 @@ See: .planning/PROJECT.md (updated 2026-04-03) ## Current Position Phase: 19 of 19 (Reference Item Model & Tags Schema) -Plan: 1 of 3 -Status: Executing +Plan: 2 of 3 +Status: Ready to execute Last activity: 2026-04-05 Progress: [#---------] 3% (v2.0 milestone) @@ -58,6 +58,7 @@ Key decisions made during v2.0 planning: - [Phase 19]: Direct globalItemId FK on items replaces itemGlobalLinks junction table - [Phase 19]: Data migration SQL: UPDATE items before DROP TABLE item_global_links - [Phase 19]: Flat tags system without type categorization per D-16 +- [Phase 19-reference-item-model-tags-schema]: COALESCE merge pattern for transparent reference item data in item/thread services ### Pending Todos @@ -70,6 +71,6 @@ None active. ## Session Continuity -Last session: 2026-04-05T18:28:00Z -Stopped at: Completed 19-01-PLAN.md +Last session: 2026-04-05T18:51:11.893Z +Stopped at: Completed 19-02-PLAN.md Resume file: None diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-02-SUMMARY.md b/.planning/phases/19-reference-item-model-tags-schema/19-02-SUMMARY.md new file mode 100644 index 0000000..c557641 --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-02-SUMMARY.md @@ -0,0 +1,101 @@ +--- +phase: 19-reference-item-model-tags-schema +plan: 02 +subsystem: services +tags: [item-service, thread-service, coalesce, reference-items, catalog-link] + +requires: + - phase: 19-reference-item-model-tags-schema + plan: 01 + provides: globalItemId FK on items and threadCandidates, tags tables +provides: + - COALESCE merge pattern in item service for transparent reference item data + - Branched thread resolution (reference vs standalone items) + - Catalog-linked candidates with merged global item display data + - Cleaned items route without link/unlink endpoints +affects: [19-03, client-hooks, mcp-tools] + +tech-stack: + added: [] + patterns: + - "COALESCE merge: LEFT JOIN globalItems with CASE WHEN for name, weight, price, image" + - "Branched resolution: candidate.globalItemId determines reference vs standalone item creation" + +key-files: + created: [] + modified: + - src/server/services/item.service.ts + - src/server/services/thread.service.ts + - src/server/routes/items.ts + - tests/services/item.service.test.ts + - tests/services/thread.service.test.ts + +key-decisions: + - "COALESCE with CASE WHEN pattern ensures standalone items are unaffected by globalItems JOIN" + - "Reference item resolution omits weight/price/productUrl - those come from global item via COALESCE on read" + - "Image fallback: item's own imageFilename takes precedence, global imageUrl used as fallback" + +patterns-established: + - "Reference items: service layer transparently merges global data via SQL COALESCE, clients see unified shape" + - "Branched resolution: resolveThread checks candidate.globalItemId to determine item creation strategy" + +requirements-completed: [CATFLOW-03, CATFLOW-04, CATFLOW-05, CATFLOW-06] + +duration: 8min +completed: 2026-04-05 +--- + +# Phase 19 Plan 02: Item & Thread Service COALESCE Merge Summary + +**COALESCE merge pattern in item/thread services for transparent reference item data, branched thread resolution, and link/unlink endpoint removal** + +## Performance + +- **Duration:** 8 min +- **Started:** 2026-04-05T18:31:23Z +- **Completed:** 2026-04-05T18:39:00Z +- **Tasks:** 2 +- **Files modified:** 5 + +## Accomplishments + +- Item service getAllItems and getItemById use LEFT JOIN + COALESCE to transparently merge global item data for reference items +- createItem accepts globalItemId, looks up global item for brand+model fallback name (items.name is NOT NULL) +- duplicateItem preserves globalItemId and purchasePriceCents from source +- Thread service getThreadWithCandidates merges global item data for catalog-linked candidates +- createCandidate stores globalItemId on candidate row +- resolveThread branches: reference items get globalItemId set with no weight/price copy; standalone items get full data copy +- Removed link/unlink endpoints from items route (replaced by direct globalItemId FK) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Item service COALESCE merge + reference item creation + tests** - `d1ffd79` (feat) +2. **Task 2: Thread service candidate globalItemId + branched resolution + route cleanup + tests** - `8a5ee73` (feat) + +## Files Created/Modified + +- `src/server/services/item.service.ts` - LEFT JOIN globalItems with COALESCE in getAllItems/getItemById, globalItemId in createItem/duplicateItem/updateItem +- `src/server/services/thread.service.ts` - LEFT JOIN globalItems in getThreadWithCandidates, globalItemId in createCandidate, branched resolveThread +- `src/server/routes/items.ts` - Removed link/unlink endpoints and imports of linkItemToGlobal, unlinkItemFromGlobal, linkItemSchema +- `tests/services/item.service.test.ts` - 10 new tests for reference item creation, merged data retrieval, purchasePriceCents +- `tests/services/thread.service.test.ts` - 6 new tests for catalog-linked candidates and branched resolution + +## Decisions Made + +- Used COALESCE with CASE WHEN pattern (not simple COALESCE) to ensure standalone items are completely unaffected by the LEFT JOIN +- Reference item resolution intentionally omits weight, price, and productUrl from the insert - those come from the global item via COALESCE on read +- Image fallback order: item's own imageFilename first, global item's imageUrl second (user uploads override catalog images) + +## Deviations from Plan + +None - plan executed exactly as written. + +## Known Stubs + +None - all data paths are fully wired. + +--- +*Phase: 19-reference-item-model-tags-schema* +*Completed: 2026-04-05*