Merge branch 'worktree-agent-a5710ab6' into Develop

# Conflicts:
#	.planning/STATE.md
This commit is contained in:
2026-04-05 20:51:51 +02:00
8 changed files with 609 additions and 74 deletions

View File

@@ -184,10 +184,10 @@ Plans:
2. Global items can have multiple tags, searchable via API 2. Global items can have multiple tags, searchable via API
3. Thread candidates can link to a global item via globalItemId 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 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: Plans:
- [ ] 19-01-PLAN.md — Schema, migration, Zod schemas, types, seed script - [x] 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-02-PLAN.md — Item service COALESCE merge, thread resolution, route cleanup
- [ ] 19-03-PLAN.md — Global item tag filtering, secondary service merge propagation - [ ] 19-03-PLAN.md — Global item tag filtering, secondary service merge propagation
### Phase 20: FAB & Full-Screen Catalog Search ### Phase 20: FAB & Full-Screen Catalog Search
@@ -247,7 +247,7 @@ Plans:
| 16. Multi-User Data Model | v2.0 | 0/? | Not started | - | | 16. Multi-User Data Model | v2.0 | 0/? | Not started | - |
| 17. Object Storage | 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 | | 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 | - | | 20. FAB & Full-Screen Catalog Search | v2.0 | 0/? | Not started | - |
| 21. Add-from-Catalog & Thread Integration | 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 | - | | 22. Manual Entry Fallback | v2.0 | 0/? | Not started | - |

View File

@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v1.3 milestone: v1.3
milestone_name: Research & Decision Tools milestone_name: Research & Decision Tools
status: executing status: executing
stopped_at: Phase 19 context gathered stopped_at: Completed 19-02-PLAN.md
last_updated: "2026-04-05T18:22:49.069Z" last_updated: "2026-04-05T18:51:11.895Z"
last_activity: 2026-04-05 -- Phase 19 execution started last_activity: 2026-04-05
progress: progress:
total_phases: 13 total_phases: 13
completed_phases: 11 completed_phases: 11
total_plans: 36 total_plans: 36
completed_plans: 31 completed_plans: 33
percent: 0 percent: 3
--- ---
# Project State # Project State
@@ -21,16 +21,16 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-03) See: .planning/PROJECT.md (updated 2026-04-03)
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing. **Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
**Current focus:** Phase 19 — reference-item-model-tags-schema **Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration)
## Current Position ## Current Position
Phase: 19 (reference-item-model-tags-schema) — EXECUTING Phase: 19 of 19 (Reference Item Model & Tags Schema)
Plan: 1 of 3 Plan: 2 of 3
Status: Executing Phase 19 Status: Ready to execute
Last activity: 2026-04-05 -- Phase 19 execution started Last activity: 2026-04-05
Progress: [----------] 0% (v2.0 milestone) Progress: [#---------] 3% (v2.0 milestone)
## Performance Metrics ## Performance Metrics
@@ -55,6 +55,10 @@ Key decisions made during v2.0 planning:
- Separate globalItems table — not a flag on user items table - Separate globalItems table — not a flag on user items table
- Single-user SQLite mode diverges at v2.0 boundary - Single-user SQLite mode diverges at v2.0 boundary
- [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension - [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension
- [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 ### Pending Todos
@@ -67,6 +71,6 @@ None active.
## Session Continuity ## Session Continuity
Last session: 2026-04-05T18:04:19.310Z Last session: 2026-04-05T18:51:11.893Z
Stopped at: Phase 19 context gathered Stopped at: Completed 19-02-PLAN.md
Resume file: .planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md Resume file: None

View File

@@ -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*

View File

@@ -1,16 +1,8 @@
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";
import { import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
createItemSchema,
linkItemSchema,
updateItemSchema,
} from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts"; import { parseId } from "../lib/params.ts";
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts"; import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
import {
linkItemToGlobal,
unlinkItemFromGlobal,
} from "../services/global-item.service.ts";
import { import {
createItem, createItem,
deleteItem, deleteItem,
@@ -122,32 +114,5 @@ app.delete("/:id", async (c) => {
return c.json({ success: true }); 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 }; export { app as itemRoutes };

View File

@@ -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 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"; import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
@@ -9,15 +9,32 @@ export async function getAllItems(db: Db, userId: number) {
return db return db
.select({ .select({
id: items.id, id: items.id,
name: items.name, name: sql<string>`COALESCE(
weightGrams: items.weightGrams, CASE WHEN ${items.globalItemId} IS NOT NULL
priceCents: items.priceCents, THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
)`.as("name"),
weightGrams: sql<number | null>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${items.weightGrams}
)`.as("weight_grams"),
priceCents: sql<number | null>`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, quantity: items.quantity,
categoryId: items.categoryId, categoryId: items.categoryId,
notes: items.notes, notes: items.notes,
productUrl: items.productUrl, productUrl: items.productUrl,
imageFilename: items.imageFilename, imageFilename: sql<string | null>`COALESCE(
${items.imageFilename},
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
)`.as("image_filename"),
imageSourceUrl: items.imageSourceUrl, imageSourceUrl: items.imageSourceUrl,
globalItemId: items.globalItemId,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
categoryName: categories.name, categoryName: categories.name,
@@ -25,6 +42,7 @@ export async function getAllItems(db: Db, userId: number) {
}) })
.from(items) .from(items)
.innerJoin(categories, eq(items.categoryId, categories.id)) .innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(eq(items.userId, userId)); .where(eq(items.userId, userId));
} }
@@ -32,18 +50,36 @@ export async function getItemById(db: Db, userId: number, id: number) {
const [row] = await db const [row] = await db
.select({ .select({
id: items.id, id: items.id,
name: items.name, name: sql<string>`COALESCE(
weightGrams: items.weightGrams, CASE WHEN ${items.globalItemId} IS NOT NULL
priceCents: items.priceCents, THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
)`.as("name"),
weightGrams: sql<number | null>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${items.weightGrams}
)`.as("weight_grams"),
priceCents: sql<number | null>`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, categoryId: items.categoryId,
notes: items.notes, notes: items.notes,
productUrl: items.productUrl, productUrl: items.productUrl,
imageFilename: items.imageFilename, imageFilename: sql<string | null>`COALESCE(
${items.imageFilename},
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
)`.as("image_filename"),
imageSourceUrl: items.imageSourceUrl, imageSourceUrl: items.imageSourceUrl,
globalItemId: items.globalItemId,
createdAt: items.createdAt, createdAt: items.createdAt,
updatedAt: items.updatedAt, updatedAt: items.updatedAt,
}) })
.from(items) .from(items)
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.where(and(eq(items.id, id), eq(items.userId, userId))); .where(and(eq(items.id, id), eq(items.userId, userId)));
return row ?? null; return row ?? null;
@@ -58,10 +94,22 @@ export async function createItem(
imageFilename?: string; 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 const [row] = await db
.insert(items) .insert(items)
.values({ .values({
name: data.name, name,
weightGrams: data.weightGrams ?? null, weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null, priceCents: data.priceCents ?? null,
quantity: data.quantity ?? 1, quantity: data.quantity ?? 1,
@@ -71,6 +119,8 @@ export async function createItem(
productUrl: data.productUrl ?? null, productUrl: data.productUrl ?? null,
imageFilename: data.imageFilename ?? null, imageFilename: data.imageFilename ?? null,
imageSourceUrl: data.imageSourceUrl ?? null, imageSourceUrl: data.imageSourceUrl ?? null,
globalItemId: data.globalItemId ?? null,
purchasePriceCents: data.purchasePriceCents ?? null,
}) })
.returning(); .returning();
@@ -91,6 +141,8 @@ export async function updateItem(
productUrl: string; productUrl: string;
imageFilename: string; imageFilename: string;
imageSourceUrl: string; imageSourceUrl: string;
globalItemId: number;
purchasePriceCents: number;
}>, }>,
) { ) {
// Check if item exists and belongs to user // 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, imageFilename: source.imageFilename,
imageSourceUrl: source.imageSourceUrl, imageSourceUrl: source.imageSourceUrl,
quantity: source.quantity, quantity: source.quantity,
globalItemId: source.globalItemId,
purchasePriceCents: source.purchasePriceCents,
}) })
.returning(); .returning();

View File

@@ -2,6 +2,7 @@ import { and, asc, desc, eq, max, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts"; import type { db as prodDb } from "../../db/index.ts";
import { import {
categories, categories,
globalItems,
items, items,
threadCandidates, threadCandidates,
threads, threads,
@@ -79,17 +80,33 @@ export async function getThreadWithCandidates(
.select({ .select({
id: threadCandidates.id, id: threadCandidates.id,
threadId: threadCandidates.threadId, threadId: threadCandidates.threadId,
name: threadCandidates.name, name: sql<string>`COALESCE(
weightGrams: threadCandidates.weightGrams, CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
priceCents: threadCandidates.priceCents, THEN ${globalItems.brand} || ' ' || ${globalItems.model}
ELSE ${threadCandidates.name}
END,
${threadCandidates.name}
)`.as("name"),
weightGrams: sql<number | null>`COALESCE(
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
${threadCandidates.weightGrams}
)`.as("weight_grams"),
priceCents: sql<number | null>`COALESCE(
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
${threadCandidates.priceCents}
)`.as("price_cents"),
categoryId: threadCandidates.categoryId, categoryId: threadCandidates.categoryId,
notes: threadCandidates.notes, notes: threadCandidates.notes,
productUrl: threadCandidates.productUrl, productUrl: threadCandidates.productUrl,
imageFilename: threadCandidates.imageFilename, imageFilename: sql<string | null>`COALESCE(
${threadCandidates.imageFilename},
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
)`.as("image_filename"),
imageSourceUrl: threadCandidates.imageSourceUrl, imageSourceUrl: threadCandidates.imageSourceUrl,
status: threadCandidates.status, status: threadCandidates.status,
pros: threadCandidates.pros, pros: threadCandidates.pros,
cons: threadCandidates.cons, cons: threadCandidates.cons,
globalItemId: threadCandidates.globalItemId,
createdAt: threadCandidates.createdAt, createdAt: threadCandidates.createdAt,
updatedAt: threadCandidates.updatedAt, updatedAt: threadCandidates.updatedAt,
categoryName: categories.name, categoryName: categories.name,
@@ -97,6 +114,7 @@ export async function getThreadWithCandidates(
}) })
.from(threadCandidates) .from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
.where(eq(threadCandidates.threadId, threadId)) .where(eq(threadCandidates.threadId, threadId))
.orderBy(asc(threadCandidates.sortOrder)); .orderBy(asc(threadCandidates.sortOrder));
@@ -190,6 +208,7 @@ export async function createCandidate(
pros: data.pros ?? null, pros: data.pros ?? null,
cons: data.cons ?? null, cons: data.cons ?? null,
sortOrder: nextSortOrder, sortOrder: nextSortOrder,
globalItemId: data.globalItemId ?? null,
}) })
.returning(); .returning();
@@ -332,10 +351,30 @@ export async function resolveThread(
? candidate.categoryId ? candidate.categoryId
: await getOrCreateUncategorized(tx as unknown as Db, userId); : await getOrCreateUncategorized(tx as unknown as Db, userId);
// 4. Create collection item from candidate data — with userId // 4. Create collection item — branched on catalog link
const [newItem] = await tx let insertValues: Record<string, unknown>;
.insert(items) if (candidate.globalItemId) {
.values({ // 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, name: candidate.name,
weightGrams: candidate.weightGrams, weightGrams: candidate.weightGrams,
priceCents: candidate.priceCents, priceCents: candidate.priceCents,
@@ -346,7 +385,11 @@ export async function resolveThread(
imageFilename: candidate.imageFilename, imageFilename: candidate.imageFilename,
imageSourceUrl: candidate.imageSourceUrl, imageSourceUrl: candidate.imageSourceUrl,
quantity: 1, quantity: 1,
}) };
}
const [newItem] = await tx
.insert(items)
.values(insertValues as any)
.returning(); .returning();
// 5. Archive the thread // 5. Archive the thread

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { globalItems } from "../../src/db/schema.ts";
import { import {
createItem, createItem,
deleteItem, 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", () => { describe("cross-user isolation", () => {
it("user cannot see other user's items", async () => { it("user cannot see other user's items", async () => {
const userId2 = await createSecondTestUser(db); const userId2 = await createSecondTestUser(db);

View File

@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { globalItems, items } from "../../src/db/schema.ts";
import { eq } from "drizzle-orm";
import { import {
createCandidate, createCandidate,
createThread, 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", () => { describe("cross-user isolation", () => {
it("user cannot see other user's threads", async () => { it("user cannot see other user's threads", async () => {
const userId2 = await createSecondTestUser(db); const userId2 = await createSecondTestUser(db);