Merge branch 'worktree-agent-a5710ab6' into Develop
# Conflicts: # .planning/STATE.md
This commit is contained in:
@@ -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 | - |
|
||||
|
||||
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
|
||||
milestone: v1.3
|
||||
milestone_name: Research & Decision Tools
|
||||
status: executing
|
||||
stopped_at: Phase 19 context gathered
|
||||
last_updated: "2026-04-05T18:22:49.069Z"
|
||||
last_activity: 2026-04-05 -- Phase 19 execution started
|
||||
stopped_at: Completed 19-02-PLAN.md
|
||||
last_updated: "2026-04-05T18:51:11.895Z"
|
||||
last_activity: 2026-04-05
|
||||
progress:
|
||||
total_phases: 13
|
||||
completed_phases: 11
|
||||
total_plans: 36
|
||||
completed_plans: 31
|
||||
percent: 0
|
||||
completed_plans: 33
|
||||
percent: 3
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -21,16 +21,16 @@ progress:
|
||||
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.
|
||||
**Current focus:** Phase 19 — reference-item-model-tags-schema
|
||||
**Current focus:** v2.0 Platform Foundation — Phase 14 (PostgreSQL Migration)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 19 (reference-item-model-tags-schema) — EXECUTING
|
||||
Plan: 1 of 3
|
||||
Status: Executing Phase 19
|
||||
Last activity: 2026-04-05 -- Phase 19 execution started
|
||||
Phase: 19 of 19 (Reference Item Model & Tags Schema)
|
||||
Plan: 2 of 3
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-04-05
|
||||
|
||||
Progress: [----------] 0% (v2.0 milestone)
|
||||
Progress: [#---------] 3% (v2.0 milestone)
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
@@ -55,6 +55,10 @@ Key decisions made during v2.0 planning:
|
||||
- Separate globalItems table — not a flag on user items table
|
||||
- Single-user SQLite mode diverges at v2.0 boundary
|
||||
- [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
|
||||
|
||||
@@ -67,6 +71,6 @@ None active.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-05T18:04:19.310Z
|
||||
Stopped at: Phase 19 context gathered
|
||||
Resume file: .planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md
|
||||
Last session: 2026-04-05T18:51:11.893Z
|
||||
Stopped at: Completed 19-02-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -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*
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||
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,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
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,
|
||||
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<string>`COALESCE(
|
||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||
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,
|
||||
notes: items.notes,
|
||||
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,
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<string>`COALESCE(
|
||||
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
|
||||
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,
|
||||
notes: threadCandidates.notes,
|
||||
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,
|
||||
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<string, unknown>;
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user