From 323a80b450c96d55f709ba1dac6350642fa868ea Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:20:25 +0200 Subject: [PATCH] docs(19): create phase plan --- .planning/ROADMAP.md | 60 ++- .../19-01-PLAN.md | 343 +++++++++++++ .../19-02-PLAN.md | 413 ++++++++++++++++ .../19-03-PLAN.md | 453 ++++++++++++++++++ 4 files changed, 1268 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-02-PLAN.md create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 36cdadf..da6bd1f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -6,7 +6,7 @@ - ✅ **v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15) - ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16) - 🚧 **v1.3 Research & Decision Tools** — Phases 10-13 (in progress) -- 📋 **v2.0 Platform Foundation** — Phases 14-18 (planned) +- 📋 **v2.0 Platform Foundation** �� Phases 14-22 (planned) ## Phases @@ -55,6 +55,10 @@ - [ ] **Phase 16: Multi-User Data Model** — Add user ownership to all entities with cross-user data isolation - [ ] **Phase 17: Object Storage** — Move images from local filesystem to MinIO (S3-compatible) - [x] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing (completed 2026-04-05) +- [ ] **Phase 19: Reference Item Model & Tags Schema** — Collection items as references to global catalog, tag system for discovery +- [ ] **Phase 20: FAB & Full-Screen Catalog Search** — Global FAB with mini menu, full-screen catalog search with tag filtering +- [ ] **Phase 21: Add-from-Catalog & Thread Integration** — Add catalog items to collection and threads, resolution creates reference items +- [ ] **Phase 22: Manual Entry Fallback** — Manual add for items not in catalog, non-functional submission prompt ## Phase Details @@ -171,6 +175,56 @@ Plans: **Plans**: TBD **UI hint**: yes +### Phase 19: Reference Item Model & Tags Schema +**Goal**: Collection items can be references to global catalog entries, and global items support tags for discovery +**Depends on**: Phase 18 +**Requirements**: CATFLOW-03, CATFLOW-04, CATFLOW-05, CATFLOW-06, TAG-01, TAG-02 +**Success Criteria** (what must be TRUE): + 1. A collection item can reference a global item and displays merged data (global base + personal fields) + 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: +- [ ] 19-01-PLAN.md — Schema, migration, Zod schemas, types, seed script +- [ ] 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 +**Goal**: Users discover and add gear through a catalog-first search experience with tag filtering +**Depends on**: Phase 19 +**Requirements**: CATFLOW-01, CATFLOW-02 +**Success Criteria** (what must be TRUE): + 1. FAB visible on all pages with mini menu showing "Add to Collection" and "Start Thread" + 2. "New Setup" option appears in FAB on setups page only + 3. Full-screen catalog search overlay opens from either add option + 4. Search results display catalog items with name, weight, price, owner count + 5. Tag chips filter search results +**Plans**: TBD +**UI hint**: yes + +### Phase 21: Add-from-Catalog & Thread Integration +**Goal**: Users can add catalog items to their collection and to threads directly from search +**Depends on**: Phase 20 +**Requirements**: CATFLOW-03, CATFLOW-05, CATFLOW-06 +**Success Criteria** (what must be TRUE): + 1. User can add a catalog item to collection with one confirmation step (category picker + notes) + 2. User can add catalog items as thread candidates instantly from search + 3. Resolving a catalog-linked candidate creates a properly linked reference item in collection +**Plans**: TBD +**UI hint**: yes + +### Phase 22: Manual Entry Fallback +**Goal**: Users can still add items not found in the catalog via manual entry +**Depends on**: Phase 21 +**Requirements**: CATFLOW-07, CATFLOW-08 +**Success Criteria** (what must be TRUE): + 1. User can fall back to manual entry from catalog search via "Add Manually" link + 2. Manual entry saves a standalone collection item (no globalItemId) + 3. "Submit to catalog?" prompt appears after manual save but takes no backend action +**Plans**: TBD +**UI hint**: yes + ## Progress | Phase | Milestone | Plans Complete | Status | Completed | @@ -193,3 +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 | - | +| 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/phases/19-reference-item-model-tags-schema/19-01-PLAN.md b/.planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md new file mode 100644 index 0000000..f235f28 --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md @@ -0,0 +1,343 @@ +--- +phase: 19-reference-item-model-tags-schema +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - src/shared/schemas.ts + - src/shared/types.ts + - tests/helpers/db.ts + - src/db/seed-global-items.ts +autonomous: true +requirements: + - CATFLOW-03 + - TAG-01 + - TAG-02 + +must_haves: + truths: + - "items table has globalItemId nullable FK column and purchasePriceCents nullable integer column" + - "threadCandidates table has globalItemId nullable FK column" + - "tags table exists with id, name (unique), createdAt" + - "globalItemTags join table exists with composite PK on (globalItemId, tagId)" + - "itemGlobalLinks table no longer exists in schema" + - "Existing itemGlobalLinks data is migrated to items.globalItemId before table drop" + - "Zod schemas accept globalItemId and purchasePriceCents on items and candidates" + - "Seed script creates curated tag set for outdoor/adventure gear" + artifacts: + - path: "src/db/schema.ts" + provides: "Updated schema with items.globalItemId, items.purchasePriceCents, threadCandidates.globalItemId, tags, globalItemTags tables; no itemGlobalLinks" + contains: "globalItemId" + - path: "src/shared/schemas.ts" + provides: "Updated Zod schemas with globalItemId and purchasePriceCents fields, tags query param, removed linkItemSchema" + contains: "purchasePriceCents" + - path: "src/shared/types.ts" + provides: "Updated types removing ItemGlobalLink, adding Tag and GlobalItemTag" + contains: "Tag" + - path: "tests/helpers/db.ts" + provides: "Test helper compatible with new schema" + - path: "src/db/seed-global-items.ts" + provides: "Tag seeding alongside global items" + contains: "tags" + key_links: + - from: "src/db/schema.ts" + to: "drizzle-pg migration SQL" + via: "bun run db:generate" + pattern: "global_item_id" + - from: "src/shared/schemas.ts" + to: "src/shared/types.ts" + via: "Zod inference" + pattern: "globalItemId" +--- + + +Update the database schema, Zod validation schemas, TypeScript types, test helpers, and seed script to support the reference item model and tag system. + +Purpose: Establish the data foundation that all subsequent service and route changes depend on. This is the schema layer -- no business logic changes. +Output: Updated schema.ts, schemas.ts, types.ts, test helpers, seed script, and a Drizzle migration file. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md +@.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md + + + +From src/db/schema.ts: +```typescript +export const items = pgTable("items", { ... }); // Add globalItemId, purchasePriceCents +export const threadCandidates = pgTable("thread_candidates", { ... }); // Add globalItemId +export const globalItems = pgTable("global_items", { ... }); // Unchanged +export const itemGlobalLinks = pgTable("item_global_links", { ... }); // REMOVE entirely +``` + +From src/shared/schemas.ts: +```typescript +export const createItemSchema = z.object({ ... }); // Add globalItemId, purchasePriceCents +export const createCandidateSchema = z.object({ ... }); // Add globalItemId +export const searchGlobalItemsSchema = z.object({ q: z.string().optional() }); // Add tags +export const linkItemSchema = z.object({ ... }); // REMOVE +``` + +From src/shared/types.ts: +```typescript +export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect; // REMOVE +export type LinkItem = z.infer; // REMOVE +// ADD: Tag, GlobalItemTag types from new schema tables +``` + + + + + + + Task 1: Update schema.ts, generate migration with data migration step + src/db/schema.ts + + - src/db/schema.ts + - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (migration order section) + + + Modify `src/db/schema.ts` with the following changes: + + **Add to `items` table definition (after the `quantity` field):** + ```typescript + globalItemId: integer("global_item_id").references(() => globalItems.id), + purchasePriceCents: integer("purchase_price_cents"), + ``` + + **Add to `threadCandidates` table definition (after the `sortOrder` field):** + ```typescript + globalItemId: integer("global_item_id").references(() => globalItems.id), + ``` + + **Add new `tags` table after the `globalItems` table:** + ```typescript + export const tags = pgTable("tags", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }); + ``` + + **Add new `globalItemTags` join table after `tags`:** + ```typescript + export const globalItemTags = pgTable( + "global_item_tags", + { + globalItemId: integer("global_item_id") + .notNull() + .references(() => globalItems.id, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => tags.id, { onDelete: "cascade" }), + }, + (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })], + ); + ``` + + **Remove the entire `itemGlobalLinks` table definition** (lines 146-155 including the comment above it). + + After editing schema.ts, run `bun run db:generate` to produce a migration SQL file. + + Then manually edit the generated migration SQL file in `drizzle-pg/` to insert a data migration step. After the `ALTER TABLE "items" ADD COLUMN "global_item_id"` line and before the `DROP TABLE "item_global_links"` line, add: + ```sql + UPDATE "items" SET "global_item_id" = ( + SELECT "global_item_id" FROM "item_global_links" + WHERE "item_global_links"."item_id" = "items"."id" + ); + ``` + + This ensures existing link data is preserved before the old table is dropped (per D-19, D-20). + + + grep -c "globalItemId" src/db/schema.ts | grep -q "^[3-9]" && grep -c "tags" src/db/schema.ts | grep -q "^[2-9]" && ! grep -q "itemGlobalLinks" src/db/schema.ts && echo "PASS" || echo "FAIL" + + + - src/db/schema.ts contains `globalItemId: integer("global_item_id").references(() => globalItems.id)` in items table + - src/db/schema.ts contains `purchasePriceCents: integer("purchase_price_cents")` in items table + - src/db/schema.ts contains `globalItemId: integer("global_item_id").references(() => globalItems.id)` in threadCandidates table + - src/db/schema.ts contains `export const tags = pgTable("tags"` + - src/db/schema.ts contains `export const globalItemTags = pgTable("global_item_tags"` + - src/db/schema.ts does NOT contain `itemGlobalLinks` + - A new migration SQL file exists in drizzle-pg/ with `ALTER TABLE "items" ADD COLUMN "global_item_id"` + - Migration SQL file contains `UPDATE "items" SET "global_item_id"` BEFORE `DROP TABLE "item_global_links"` + + Schema has globalItemId on items and threadCandidates, purchasePriceCents on items, tags + globalItemTags tables, no itemGlobalLinks. Migration includes data migration step. + + + + Task 2: Update Zod schemas, types, test helpers, and seed script + src/shared/schemas.ts, src/shared/types.ts, tests/helpers/db.ts, src/db/seed-global-items.ts + + - src/shared/schemas.ts + - src/shared/types.ts + - tests/helpers/db.ts + - src/db/seed-global-items.ts + - src/db/schema.ts (after Task 1 changes) + + + **Update `src/shared/schemas.ts`:** + + 1. Add `globalItemId` and `purchasePriceCents` to `createItemSchema`: + ```typescript + export const createItemSchema = z.object({ + name: z.string().min(1, "Name is required"), + weightGrams: z.number().nonnegative().optional(), + priceCents: z.number().int().nonnegative().optional(), + categoryId: z.number().int().positive(), + notes: z.string().optional(), + productUrl: z.string().url().optional().or(z.literal("")), + imageFilename: z.string().optional(), + imageSourceUrl: z.string().url().optional().or(z.literal("")), + quantity: z.number().int().positive().optional(), + globalItemId: z.number().int().positive().optional(), + purchasePriceCents: z.number().int().nonnegative().optional(), + }); + ``` + + 2. Add `globalItemId` to `createCandidateSchema`: + ```typescript + export const createCandidateSchema = z.object({ + name: z.string().min(1, "Name is required"), + weightGrams: z.number().nonnegative().optional(), + priceCents: z.number().int().nonnegative().optional(), + categoryId: z.number().int().positive(), + notes: z.string().optional(), + productUrl: z.string().url().optional().or(z.literal("")), + imageFilename: z.string().optional(), + imageSourceUrl: z.string().url().optional().or(z.literal("")), + status: candidateStatusSchema.optional(), + pros: z.string().optional(), + cons: z.string().optional(), + globalItemId: z.number().int().positive().optional(), + }); + ``` + + 3. Update `searchGlobalItemsSchema` to accept tags: + ```typescript + export const searchGlobalItemsSchema = z.object({ + q: z.string().optional(), + tags: z.string().optional(), + }); + ``` + + 4. Remove `linkItemSchema` entirely (the `z.object({ globalItemId: ... })` definition). + + **Update `src/shared/types.ts`:** + + 1. Remove `itemGlobalLinks` from the import of `../db/schema.ts`. + 2. Remove `linkItemSchema` from the import of `./schemas.ts`. + 3. Remove `export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect;`. + 4. Remove `export type LinkItem = z.infer;`. + 5. Add imports for `tags` and `globalItemTags` from schema. + 6. Add: + ```typescript + export type Tag = typeof tags.$inferSelect; + export type GlobalItemTag = typeof globalItemTags.$inferSelect; + ``` + + **Update `tests/helpers/db.ts`:** + + No structural changes needed -- test helper uses Drizzle migrations which will automatically apply the new schema. Verify it still works by confirming `createTestDb()` applies migrations cleanly. + + **Update `src/db/seed-global-items.ts`:** + + 1. Convert from sync `.all()` / `.run()` patterns to async `await` pattern. + 2. Import `tags` from schema. + 3. Add a `seedTags` function that inserts curated tags (idempotent -- skip if any exist): + ```typescript + const SEED_TAGS = [ + "handlebar-bag", "framebag", "saddlebag", "top-tube-bag", + "stem-bag", "fork-bag", "hip-pack", "backpack", + "tent", "bivy", "tarp", "hammock", + "sleeping-bag", "sleeping-pad", "quilt", "pillow", + "stove", "cookware", "water-filter", "water-bottle", + "headlamp", "bike-light", + "ultralight", "waterproof", "budget", "premium", + "bikepacking", "hiking", "camping", "touring", + ]; + ``` + 4. Make `seedGlobalItems` async and call `seedTags` at the end. + 5. The `seedTags` function: + ```typescript + export async function seedTags(db: Db = prodDb) { + const existing = await db.select().from(tags).limit(1); + if (existing.length > 0) return; + + for (const name of SEED_TAGS) { + await db.insert(tags).values({ name }); + } + } + ``` + 6. Update `seedGlobalItems` to be async, replace `.all()` with `await`, replace `.run()` with `await`, and call `await seedTags(db)` at the end. + + + grep -q "globalItemId" src/shared/schemas.ts && grep -q "purchasePriceCents" src/shared/schemas.ts && ! grep -q "linkItemSchema" src/shared/schemas.ts && grep -q "Tag" src/shared/types.ts && ! grep -q "ItemGlobalLink" src/shared/types.ts && grep -q "SEED_TAGS" src/db/seed-global-items.ts && echo "PASS" || echo "FAIL" + + + - src/shared/schemas.ts `createItemSchema` contains `globalItemId: z.number().int().positive().optional()` + - src/shared/schemas.ts `createItemSchema` contains `purchasePriceCents: z.number().int().nonnegative().optional()` + - src/shared/schemas.ts `createCandidateSchema` contains `globalItemId: z.number().int().positive().optional()` + - src/shared/schemas.ts `searchGlobalItemsSchema` contains `tags: z.string().optional()` + - src/shared/schemas.ts does NOT contain `linkItemSchema` + - src/shared/types.ts contains `export type Tag = typeof tags.$inferSelect` + - src/shared/types.ts contains `export type GlobalItemTag = typeof globalItemTags.$inferSelect` + - src/shared/types.ts does NOT contain `ItemGlobalLink` + - src/shared/types.ts does NOT contain `linkItemSchema` + - src/db/seed-global-items.ts contains `SEED_TAGS` array with at least 25 tag names + - src/db/seed-global-items.ts contains `async function seedTags` + - src/db/seed-global-items.ts contains `async function seedGlobalItems` (converted from sync) + + Zod schemas accept new fields, old link schema removed, types updated, seed script creates tags, test helper works with new schema. + + + + + +```bash +# Schema has correct exports +grep -c "globalItemId" src/db/schema.ts # Should be >= 3 (items, threadCandidates, globalItemTags) +grep "itemGlobalLinks" src/db/schema.ts # Should return nothing + +# Zod schemas correct +grep "globalItemId" src/shared/schemas.ts # Should appear in createItemSchema and createCandidateSchema +grep "linkItemSchema" src/shared/schemas.ts # Should return nothing + +# Types correct +grep "Tag" src/shared/types.ts # Should show Tag and GlobalItemTag +grep "ItemGlobalLink" src/shared/types.ts # Should return nothing + +# Migration exists +ls drizzle-pg/*.sql | tail -1 # Should show new migration file +grep "global_item_id" drizzle-pg/*.sql # Should find ADD COLUMN and UPDATE statements + +# Seed script +grep "SEED_TAGS" src/db/seed-global-items.ts # Should exist +grep "async" src/db/seed-global-items.ts # Should show async functions +``` + + + +- Schema defines items.globalItemId, items.purchasePriceCents, threadCandidates.globalItemId, tags table, globalItemTags table +- itemGlobalLinks table completely removed from schema +- Drizzle migration generated with data migration step +- Zod schemas updated with new fields, old linkItemSchema removed +- Types updated with Tag and GlobalItemTag, old ItemGlobalLink removed +- Seed script creates 28+ curated tags for outdoor/adventure gear +- Test helper works with new schema (migrations apply cleanly) + + + +After completion, create `.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md` + diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-02-PLAN.md b/.planning/phases/19-reference-item-model-tags-schema/19-02-PLAN.md new file mode 100644 index 0000000..e16b693 --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-02-PLAN.md @@ -0,0 +1,413 @@ +--- +phase: 19-reference-item-model-tags-schema +plan: 02 +type: execute +wave: 2 +depends_on: ["19-01"] +files_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 +autonomous: true +requirements: + - CATFLOW-03 + - CATFLOW-04 + - CATFLOW-05 + - CATFLOW-06 + +must_haves: + truths: + - "getAllItems and getItemById return merged data for reference items (global name/weight/price when globalItemId is set)" + - "Creating an item with globalItemId stores a reference item with personal fields only" + - "Duplicating a reference item preserves the globalItemId link" + - "Thread candidates with globalItemId display global item data merged in" + - "Resolving a thread with a catalog-linked candidate creates a reference item (globalItemId set, no data copy)" + - "Resolving a thread with a standalone candidate still does full data copy" + - "Link/unlink endpoints removed from items route" + artifacts: + - path: "src/server/services/item.service.ts" + provides: "COALESCE merge for reference items in getAllItems, getItemById, createItem, duplicateItem" + contains: "COALESCE" + - path: "src/server/services/thread.service.ts" + provides: "globalItemId on candidates, branched resolution logic" + contains: "globalItemId" + - path: "src/server/routes/items.ts" + provides: "Cleaned route file without link/unlink endpoints" + - path: "tests/services/item.service.test.ts" + provides: "Tests for reference item creation and merged data retrieval" + contains: "reference item" + - path: "tests/services/thread.service.test.ts" + provides: "Tests for catalog-linked candidate resolution" + contains: "globalItemId" + key_links: + - from: "src/server/services/item.service.ts" + to: "src/db/schema.ts (globalItems)" + via: "LEFT JOIN + COALESCE" + pattern: "leftJoin.*globalItems" + - from: "src/server/services/thread.service.ts" + to: "src/db/schema.ts (items)" + via: "conditional insert based on candidate.globalItemId" + pattern: "candidate\\.globalItemId" +--- + + +Implement the COALESCE merge pattern in item service for reference items, update thread service for catalog-linked candidates and branched resolution, remove link/unlink endpoints from items route. + +Purpose: Core business logic for reference items and catalog-linked thread resolution. This is where the reference model comes alive. +Output: Updated item service with merge queries, thread service with branched resolution, cleaned items route, comprehensive tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md +@.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md +@.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md + + + +From src/db/schema.ts (post Plan 01): +```typescript +export const items = pgTable("items", { + // ... existing fields ... + globalItemId: integer("global_item_id").references(() => globalItems.id), + purchasePriceCents: integer("purchase_price_cents"), + // ... +}); + +export const threadCandidates = pgTable("thread_candidates", { + // ... existing fields ... + globalItemId: integer("global_item_id").references(() => globalItems.id), + // ... +}); + +export const globalItems = pgTable("global_items", { + id: serial("id").primaryKey(), + brand: text("brand").notNull(), + model: text("model").notNull(), + category: text("category"), + weightGrams: doublePrecision("weight_grams"), + priceCents: integer("price_cents"), + imageUrl: text("image_url"), + description: text("description"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const tags = pgTable("tags", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const globalItemTags = pgTable("global_item_tags", { + globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }), + tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }), +}, (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })]); +``` + +From src/shared/schemas.ts (post Plan 01): +```typescript +export const createItemSchema = z.object({ + // ... existing + globalItemId, purchasePriceCents +}); +export const createCandidateSchema = z.object({ + // ... existing + globalItemId +}); +// linkItemSchema REMOVED +``` + +Current item.service.ts functions: +```typescript +export async function getAllItems(db: Db, userId: number) +export async function getItemById(db: Db, userId: number, id: number) +export async function createItem(db: Db, userId: number, data: ...) +export async function updateItem(db: Db, userId: number, id: number, data: ...) +export async function duplicateItem(db: Db, userId: number, id: number) +export async function deleteItem(db: Db, userId: number, id: number) +``` + +Current thread.service.ts key functions: +```typescript +export async function getThreadWithCandidates(db: Db, userId: number, threadId: number) +export async function createCandidate(db: Db, userId: number, threadId: number, data: ...) +export async function resolveThread(db: Db, userId: number, threadId: number, candidateId: number) +``` + + + + + + + Task 1: Item service COALESCE merge + reference item creation + tests + src/server/services/item.service.ts, tests/services/item.service.test.ts + + - src/server/services/item.service.ts + - src/db/schema.ts + - tests/services/item.service.test.ts + - tests/helpers/db.ts + - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (Pattern 1: COALESCE Merge section) + + + - Test: createItem with globalItemId creates a reference item; returned row has globalItemId set + - Test: createItem with globalItemId stores brand+model from global item as fallback name (per Pitfall 3) + - Test: getAllItems returns merged name (brand + ' ' + model from globalItems) for reference items + - Test: getAllItems returns merged weightGrams from globalItems for reference items + - Test: getAllItems returns merged priceCents from globalItems for reference items + - Test: getAllItems returns item's own imageFilename when set, falls back to globalItems.imageUrl + - Test: getAllItems returns standalone item data unchanged (no globalItemId) + - Test: getItemById returns merged data for a reference item + - Test: getItemById returns globalItemId field in response + - Test: duplicateItem on a reference item preserves globalItemId + - Test: createItem with purchasePriceCents stores the value + + + **Update `src/server/services/item.service.ts`:** + + 1. Add imports: `import { globalItems } from "../../db/schema.ts"` and `import { sql } from "drizzle-orm"`. + + 2. Rewrite `getAllItems` to LEFT JOIN globalItems and COALESCE fields (per D-06, D-07): + ```typescript + export async function getAllItems(db: Db, userId: number) { + return db + .select({ + id: items.id, + 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: 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, + categoryIcon: categories.icon, + }) + .from(items) + .innerJoin(categories, eq(items.categoryId, categories.id)) + .leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) + .where(eq(items.userId, userId)); + } + ``` + + 3. Rewrite `getItemById` with same COALESCE pattern (same select fields minus categoryName/categoryIcon, plus leftJoin on globalItems). + + 4. Update `createItem` to accept `globalItemId` and `purchasePriceCents`: + - When `data.globalItemId` is provided, look up the global item to get brand+model for the fallback `name` field (per Pitfall 3 -- items.name is NOT NULL). + - Add `globalItemId: data.globalItemId ?? null` and `purchasePriceCents: data.purchasePriceCents ?? null` to the insert values. + + 5. Update `duplicateItem` to copy `globalItemId` and `purchasePriceCents` from source. + + 6. Update `updateItem` data type to include `globalItemId` and `purchasePriceCents` in the Partial type. + + **Write tests in `tests/services/item.service.test.ts`:** + + Add a test helper to insert a global item: + ```typescript + async function insertGlobalItem(db: Db, data: { brand: string; model: string; weightGrams?: number; priceCents?: number; imageUrl?: string }) { + const [row] = await db.insert(globalItems).values(data).returning(); + return row; + } + ``` + + Then add test cases for the behaviors listed above. Each test creates a global item, creates a reference item pointing to it, then verifies the merged data returned by getAllItems/getItemById. + + + bun test tests/services/item.service.test.ts + + + - src/server/services/item.service.ts `getAllItems` contains `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` + - src/server/services/item.service.ts `getAllItems` contains `COALESCE` for name, weightGrams, priceCents, imageFilename + - src/server/services/item.service.ts `getAllItems` select includes `globalItemId: items.globalItemId` + - src/server/services/item.service.ts `getAllItems` select includes `purchasePriceCents: items.purchasePriceCents` + - src/server/services/item.service.ts `getItemById` contains `.leftJoin(globalItems` + - src/server/services/item.service.ts `createItem` values include `globalItemId: data.globalItemId ?? null` + - src/server/services/item.service.ts `duplicateItem` copies `globalItemId` from source + - tests/services/item.service.test.ts contains at least 5 new tests with "reference item" or "globalItemId" in the name + - `bun test tests/services/item.service.test.ts` exits 0 + + Item service returns merged data for reference items via COALESCE joins. createItem accepts globalItemId. duplicateItem preserves links. All tests pass. + + + + Task 2: Thread service candidate globalItemId + branched resolution + route cleanup + tests + src/server/services/thread.service.ts, src/server/routes/items.ts, tests/services/thread.service.test.ts + + - src/server/services/thread.service.ts + - src/server/routes/items.ts + - tests/services/thread.service.test.ts + - src/db/schema.ts + - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (Pattern 3: Branched Thread Resolution) + + + - Test: createCandidate with globalItemId stores the value on the candidate row + - Test: getThreadWithCandidates returns globalItemId field on each candidate + - Test: getThreadWithCandidates merges global item data (brand+model as name, weight, price) for candidates with globalItemId + - Test: resolveThread with candidate having globalItemId creates a reference item (items.globalItemId = candidate.globalItemId, no weight/price copy) + - Test: resolveThread with candidate without globalItemId creates a standalone item (full data copy, existing behavior) + - Test: resolveThread reference item has brand+model as fallback name + + + **Update `src/server/services/thread.service.ts`:** + + 1. Add import for `globalItems` from schema. + + 2. Update `createCandidate` to accept and store `globalItemId`: + Add `globalItemId: data.globalItemId ?? null` to the insert values object (after `imageSourceUrl`). + + 3. Update `getThreadWithCandidates` to LEFT JOIN globalItems and merge candidate data: + - Add `.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))` after the innerJoin on categories. + - Update select to use COALESCE for name, weightGrams, priceCents, imageFilename when candidate has globalItemId: + ```typescript + 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"), + imageFilename: sql`COALESCE( + ${threadCandidates.imageFilename}, + CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END + )`.as("image_filename"), + ``` + - Add `globalItemId: threadCandidates.globalItemId` to the select. + + 4. Update `resolveThread` step 4 (item creation) with branched logic (per D-12, D-13): + ```typescript + // 4. Create collection item — branched on catalog link + let insertValues: any; + if (candidate.globalItemId) { + // Reference item — link to global, personal fields only + // Look up global item for fallback name + 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, + categoryId: safeCategoryId, + userId, + notes: candidate.notes, + productUrl: candidate.productUrl, + imageFilename: candidate.imageFilename, + imageSourceUrl: candidate.imageSourceUrl, + quantity: 1, + }; + } + const [newItem] = await tx.insert(items).values(insertValues).returning(); + ``` + + **Update `src/server/routes/items.ts`:** + + 1. Remove the `POST /:id/link` and `DELETE /:id/link` route handlers (lines 125-151). + 2. Remove imports of `linkItemSchema` from schemas.ts. + 3. Remove imports of `linkItemToGlobal` and `unlinkItemFromGlobal` from global-item.service.ts. + + **Write tests in `tests/services/thread.service.test.ts`:** + + Add tests for: + - Creating a candidate with `globalItemId` and verifying it is stored + - getThreadWithCandidates returns merged data for catalog-linked candidates + - resolveThread with catalog-linked candidate creates reference item (globalItemId set, no weight/price on item row) + - resolveThread with standalone candidate still copies all data (regression test) + + + bun test tests/services/thread.service.test.ts && grep -q "linkItemToGlobal" src/server/routes/items.ts; test $? -eq 1 && echo "PASS" || echo "FAIL" + + + - src/server/services/thread.service.ts `createCandidate` values include `globalItemId: data.globalItemId ?? null` + - src/server/services/thread.service.ts `getThreadWithCandidates` contains `.leftJoin(globalItems` + - src/server/services/thread.service.ts `getThreadWithCandidates` select includes `globalItemId: threadCandidates.globalItemId` + - src/server/services/thread.service.ts `resolveThread` contains `if (candidate.globalItemId)` branching logic + - src/server/services/thread.service.ts `resolveThread` reference item branch sets `globalItemId: candidate.globalItemId` + - src/server/routes/items.ts does NOT contain `linkItemToGlobal` or `unlinkItemFromGlobal` + - src/server/routes/items.ts does NOT contain `linkItemSchema` + - src/server/routes/items.ts does NOT contain `/:id/link` + - tests/services/thread.service.test.ts contains tests with "globalItemId" or "reference" in the name + - `bun test tests/services/thread.service.test.ts` exits 0 + + Thread candidates support globalItemId with merged display. Resolution branches correctly between reference and standalone items. Link/unlink endpoints removed from items route. All tests pass. + + + + + +```bash +# Item service merge +grep "leftJoin.*globalItems" src/server/services/item.service.ts # Should match +grep "COALESCE" src/server/services/item.service.ts # Should match multiple times +grep "globalItemId" src/server/services/item.service.ts # Should match in select + create + +# Thread service +grep "leftJoin.*globalItems" src/server/services/thread.service.ts # Should match +grep "candidate.globalItemId" src/server/services/thread.service.ts # Should match in resolve + +# Route cleanup +grep "link" src/server/routes/items.ts # Should only match "linkItemToGlobal" is gone + +# Tests pass +bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts +``` + + + +- Reference items return merged data (global name/weight/price) transparently via COALESCE joins +- Standalone items continue to work identically to before +- Thread candidates with globalItemId display merged global item data +- Thread resolution creates reference items when candidate has globalItemId +- Thread resolution creates standalone items when candidate has no globalItemId +- Link/unlink endpoints fully removed from items route +- All item and thread service tests pass + + + +After completion, create `.planning/phases/19-reference-item-model-tags-schema/19-02-SUMMARY.md` + diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-03-PLAN.md b/.planning/phases/19-reference-item-model-tags-schema/19-03-PLAN.md new file mode 100644 index 0000000..44a1998 --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-03-PLAN.md @@ -0,0 +1,453 @@ +--- +phase: 19-reference-item-model-tags-schema +plan: 03 +type: execute +wave: 2 +depends_on: ["19-01"] +files_modified: + - src/server/services/global-item.service.ts + - src/server/services/setup.service.ts + - src/server/services/totals.service.ts + - src/server/services/profile.service.ts + - src/server/services/csv.service.ts + - src/server/routes/global-items.ts + - tests/services/global-item.service.test.ts +autonomous: true +requirements: + - CATFLOW-04 + - TAG-01 + - TAG-02 + +must_haves: + truths: + - "Global item search supports tag filtering with AND logic" + - "Global item owner count uses items.globalItemId instead of itemGlobalLinks" + - "Setup totals correctly include global item weight/price for reference items" + - "Public setup items display merged data for reference items" + - "Category totals include global item weight/price for reference items" + - "CSV export shows merged data for reference items" + - "GET /api/global-items?tags=x,y returns items matching ALL specified tags" + artifacts: + - path: "src/server/services/global-item.service.ts" + provides: "Tag-filtered search with AND logic, owner count via items.globalItemId" + contains: "tagNames" + - path: "src/server/services/setup.service.ts" + provides: "COALESCE merge in setup item queries and totals subqueries" + contains: "global_items" + - path: "src/server/services/totals.service.ts" + provides: "COALESCE merge in category and global totals" + contains: "global_items" + - path: "src/server/services/profile.service.ts" + provides: "COALESCE merge in public setup queries" + contains: "global_items" + - path: "src/server/services/csv.service.ts" + provides: "COALESCE merge in CSV export query" + contains: "global_items" + - path: "src/server/routes/global-items.ts" + provides: "Tag query param parsing and async handler fixes" + contains: "tags" + - path: "tests/services/global-item.service.test.ts" + provides: "Tests for tag filtering and owner count" + contains: "tag" + key_links: + - from: "src/server/services/setup.service.ts" + to: "src/db/schema.ts (globalItems)" + via: "LEFT JOIN in subqueries" + pattern: "global_items" + - from: "src/server/services/global-item.service.ts" + to: "src/db/schema.ts (tags, globalItemTags)" + via: "subquery with GROUP BY HAVING" + pattern: "HAVING COUNT" + - from: "src/server/routes/global-items.ts" + to: "src/server/services/global-item.service.ts" + via: "tag param parsing and forwarding" + pattern: "tags.*split" +--- + + +Update global-item service for tag filtering and owner count, update all secondary services (setup, totals, profile, CSV) to merge global item data for reference items, update global-items route for tag query params. + +Purpose: Ensure reference items display correct merged data everywhere in the application -- not just in the item service, but in setups, totals, profiles, and CSV export. Add tag filtering for catalog discovery. +Output: All services correctly merge global item data, tag search works via API, comprehensive tests. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md +@.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md +@.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md + + + +From src/db/schema.ts (post Plan 01): +```typescript +export const items = pgTable("items", { + // ... existing + globalItemId, purchasePriceCents +}); +export const globalItems = pgTable("global_items", { + id, brand, model, category, weightGrams, priceCents, imageUrl, description, createdAt +}); +export const tags = pgTable("tags", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); +export const globalItemTags = pgTable("global_item_tags", { + globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }), + tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }), +}, (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })]); +// itemGlobalLinks REMOVED +``` + +Current setup.service.ts key queries: +```typescript +// getAllSetups - has raw SQL subqueries for totalWeight and totalCost using items.weight_grams and items.price_cents +// getSetupWithItems - selects items fields directly via innerJoin on items +``` + +Current totals.service.ts: +```typescript +// getCategoryTotals - SUM(items.weightGrams * items.quantity) +// getGlobalTotals - SUM(items.weightGrams * items.quantity) +``` + +Current profile.service.ts: +```typescript +// getPublicProfile - raw SQL subqueries for totalWeight and totalCost +// getPublicSetupWithItems - selects items fields directly +``` + +Current csv.service.ts: +```typescript +// exportItemsCsv - selects items.name, items.weightGrams, items.priceCents directly +``` + +Current global-item.service.ts: +```typescript +export async function searchGlobalItems(db: Db = prodDb, query?: string) +export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) +export async function linkItemToGlobal(db: Db = prodDb, itemId: number, globalItemId: number) // REMOVE +export async function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) // REMOVE +``` + + + + + + + Task 1: Global item service tag filtering + owner count migration + tests + src/server/services/global-item.service.ts, src/server/routes/global-items.ts, tests/services/global-item.service.test.ts + + - src/server/services/global-item.service.ts + - src/server/routes/global-items.ts + - tests/services/global-item.service.test.ts + - tests/routes/global-items.test.ts + - src/db/schema.ts + - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (Pattern 2: Tag Filtering, Owner Count Migration) + + + - Test: searchGlobalItems with no tags returns all items (existing behavior) + - Test: searchGlobalItems with tags=["ultralight"] returns only items tagged "ultralight" + - Test: searchGlobalItems with tags=["ultralight","bikepacking"] returns only items tagged with BOTH (AND logic) + - Test: searchGlobalItems with query + tags combines text search and tag filtering + - Test: getGlobalItemWithOwnerCount returns count based on items.globalItemId (not itemGlobalLinks) + - Test: getGlobalItemWithOwnerCount returns 0 when no items reference the global item + + + **Rewrite `src/server/services/global-item.service.ts`:** + + 1. Replace imports: remove `itemGlobalLinks`, add `globalItemTags, items, tags` from schema. + 2. Add `ilike` import from drizzle-orm (replacing `like` -- per Pitfall 6, PostgreSQL LIKE is case-sensitive). + 3. Remove `linkItemToGlobal` and `unlinkItemFromGlobal` functions entirely. + + 4. Update `searchGlobalItems` to accept `tagNames` parameter and use `ilike` instead of `like`: + ```typescript + export async function searchGlobalItems( + db: Db = prodDb, + query?: string, + tagNames?: string[], + ) { + const conditions: SQL[] = []; + + if (query) { + const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escaped}%`; + conditions.push( + or(ilike(globalItems.brand, pattern), ilike(globalItems.model, pattern))! + ); + } + + if (tagNames && tagNames.length > 0) { + conditions.push( + sql`${globalItems.id} IN ( + SELECT ${globalItemTags.globalItemId} + FROM ${globalItemTags} + JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId} + WHERE ${tags.name} IN (${sql.join(tagNames.map(t => sql`${t}`), sql`, `)}) + GROUP BY ${globalItemTags.globalItemId} + HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length} + )` + ); + } + + if (conditions.length === 0) { + return db.select().from(globalItems); + } + + return db.select().from(globalItems).where(and(...conditions)); + } + ``` + + 5. Update `getGlobalItemWithOwnerCount` to count via `items.globalItemId` instead of `itemGlobalLinks`: + ```typescript + export async function getGlobalItemWithOwnerCount( + db: Db = prodDb, + id: number, + ) { + const [item] = await db + .select() + .from(globalItems) + .where(eq(globalItems.id, id)); + + if (!item) return null; + + const [result] = await db + .select({ ownerCount: count() }) + .from(items) + .where(eq(items.globalItemId, id)); + + return { ...item, ownerCount: result?.ownerCount ?? 0 }; + } + ``` + + **Update `src/server/routes/global-items.ts`:** + + 1. Make both route handlers async (currently missing `await` on service calls). + 2. Parse `tags` query param and split into array: + ```typescript + app.get("/", async (c) => { + const db = c.get("db"); + const q = c.req.query("q"); + const tagsParam = c.req.query("tags"); + const tagNames = tagsParam ? tagsParam.split(",").map(t => t.trim()).filter(Boolean) : undefined; + const items = await searchGlobalItems(db, q || undefined, tagNames); + return c.json(items); + }); + + app.get("/:id", async (c) => { + const db = c.get("db"); + const id = parseId(c.req.param("id")); + if (!id) return c.json({ error: "Invalid global item ID" }, 400); + const item = await getGlobalItemWithOwnerCount(db, id); + if (!item) return c.json({ error: "Global item not found" }, 404); + return c.json(item); + }); + ``` + + **Rewrite `tests/services/global-item.service.test.ts`:** + + The existing tests use sync SQLite patterns (`.get()`, `.all()`, `.run()`). Rewrite entirely using async PGlite pattern from `createTestDb()`. Add test helpers: + ```typescript + async function insertGlobalItem(db, data) { ... } + async function insertTag(db, name) { ... } + async function tagGlobalItem(db, globalItemId, tagId) { ... } + ``` + + Test cases: search without tags, search with single tag, search with multiple tags (AND logic), search with query + tags, owner count via items.globalItemId. + + + bun test tests/services/global-item.service.test.ts + + + - src/server/services/global-item.service.ts `searchGlobalItems` has `tagNames?: string[]` parameter + - src/server/services/global-item.service.ts contains `ilike` (not `like`) for text search + - src/server/services/global-item.service.ts contains `HAVING COUNT(DISTINCT` for tag AND logic + - src/server/services/global-item.service.ts `getGlobalItemWithOwnerCount` queries `items` table with `eq(items.globalItemId, id)` (not itemGlobalLinks) + - src/server/services/global-item.service.ts does NOT contain `linkItemToGlobal` or `unlinkItemFromGlobal` + - src/server/services/global-item.service.ts does NOT import `itemGlobalLinks` + - src/server/routes/global-items.ts contains `c.req.query("tags")` + - src/server/routes/global-items.ts both handlers use `await` + - tests/services/global-item.service.test.ts uses `await` pattern (not `.get()`, `.all()`, `.run()`) + - tests/services/global-item.service.test.ts contains tests with "tag" in the name + - `bun test tests/services/global-item.service.test.ts` exits 0 + + Global item search supports tag filtering with AND logic. Owner count uses direct FK. Link/unlink functions removed. Route handles tags query param. All tests pass with async PGlite. + + + + Task 2: Secondary service COALESCE merge propagation (setup, totals, profile, CSV) + src/server/services/setup.service.ts, src/server/services/totals.service.ts, src/server/services/profile.service.ts, src/server/services/csv.service.ts + + - src/server/services/setup.service.ts + - src/server/services/totals.service.ts + - src/server/services/profile.service.ts + - src/server/services/csv.service.ts + - src/db/schema.ts + - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (All Query Locations table, Pitfall 4) + + + Update all secondary services that read item data to LEFT JOIN globalItems and COALESCE weight/price/name for reference items. This prevents Pitfall 1 (missed merge points) and Pitfall 4 (totals missing global data). + + **Update `src/server/services/setup.service.ts`:** + + 1. Add import: `import { globalItems } from "../../db/schema.ts"`. + + 2. In `getAllSetups`, update the totalWeight and totalCost raw SQL subqueries to join globalItems: + ```typescript + totalWeight: sql`COALESCE(( + SELECT SUM( + COALESCE( + CASE WHEN items.global_item_id IS NOT NULL THEN global_items.weight_grams ELSE NULL END, + items.weight_grams + ) * items.quantity + ) FROM setup_items + JOIN items ON items.id = setup_items.item_id + LEFT JOIN global_items ON global_items.id = items.global_item_id + WHERE setup_items.setup_id = setups.id + ), 0)`.as("total_weight"), + totalCost: sql`COALESCE(( + SELECT SUM( + COALESCE( + CASE WHEN items.global_item_id IS NOT NULL THEN global_items.price_cents ELSE NULL END, + items.price_cents + ) * items.quantity + ) FROM setup_items + JOIN items ON items.id = setup_items.item_id + LEFT JOIN global_items ON global_items.id = items.global_item_id + WHERE setup_items.setup_id = setups.id + ), 0)`.as("total_cost"), + ``` + + 3. In `getSetupWithItems`, update the item list query: + - Add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` after the categories join. + - Replace direct `items.name`, `items.weightGrams`, `items.priceCents`, `items.imageFilename` with COALESCE versions: + ```typescript + 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"), + imageFilename: sql`COALESCE( + ${items.imageFilename}, + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END + )`.as("image_filename"), + ``` + - Add `globalItemId: items.globalItemId` and `purchasePriceCents: items.purchasePriceCents` to the select. + + **Update `src/server/services/totals.service.ts`:** + + 1. Add import: `import { globalItems } from "../../db/schema.ts"`. + + 2. In `getCategoryTotals`, add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` and update SUM expressions: + ```typescript + totalWeight: sql`COALESCE(SUM( + COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END, + ${items.weightGrams} + ) * ${items.quantity} + ), 0)`, + totalCost: sql`COALESCE(SUM( + COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END, + ${items.priceCents} + ) * ${items.quantity} + ), 0)`, + ``` + + 3. In `getGlobalTotals`, same pattern -- add leftJoin on globalItems, COALESCE weight/price in SUM. + + **Update `src/server/services/profile.service.ts`:** + + 1. Add import: `import { globalItems } from "../../db/schema.ts"`. + + 2. In `getPublicProfile`, update the totalWeight and totalCost raw SQL subqueries to join global_items (same pattern as setup.service.ts `getAllSetups`). + + 3. In `getPublicSetupWithItems`, update the item list query: + - Add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` after the categories join. + - Replace item field selects with COALESCE versions (same as setup.service.ts `getSetupWithItems`). + - Add `globalItemId: items.globalItemId` to the select. + + **Update `src/server/services/csv.service.ts`:** + + 1. Add import: `import { globalItems } from "../../db/schema.ts"` and `import { sql } from "drizzle-orm"`. + + 2. In `exportItemsCsv`, update the query: + - Add `.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` after the categories join. + - Replace `items.name` with COALESCE name expression. + - Replace `items.weightGrams` with COALESCE weightGrams expression. + - Replace `items.priceCents` with COALESCE priceCents expression. + + No changes to `importItemsCsv` -- imports create standalone items (per research recommendation). + + + grep -l "global_items" src/server/services/setup.service.ts src/server/services/totals.service.ts src/server/services/profile.service.ts src/server/services/csv.service.ts | wc -l | grep -q "^4$" && echo "PASS" || echo "FAIL" + + + - src/server/services/setup.service.ts imports `globalItems` from schema + - src/server/services/setup.service.ts `getAllSetups` totalWeight/totalCost subqueries contain `LEFT JOIN global_items` + - src/server/services/setup.service.ts `getSetupWithItems` contains `.leftJoin(globalItems` + - src/server/services/setup.service.ts `getSetupWithItems` select includes `globalItemId: items.globalItemId` + - src/server/services/totals.service.ts imports `globalItems` from schema + - src/server/services/totals.service.ts `getCategoryTotals` contains `.leftJoin(globalItems` + - src/server/services/totals.service.ts `getGlobalTotals` contains `.leftJoin(globalItems` + - src/server/services/profile.service.ts imports `globalItems` from schema + - src/server/services/profile.service.ts `getPublicProfile` totalWeight/totalCost subqueries contain `LEFT JOIN global_items` + - src/server/services/profile.service.ts `getPublicSetupWithItems` contains `.leftJoin(globalItems` + - src/server/services/csv.service.ts imports `globalItems` from schema + - src/server/services/csv.service.ts `exportItemsCsv` contains `.leftJoin(globalItems` + - `bun test` exits 0 (full suite -- verifies no regressions) + + All 4 secondary services correctly merge global item data for reference items. Setup totals, category totals, global totals, public profile setup totals, and CSV export all use COALESCE joins. Full test suite passes. + + + + + +```bash +# All secondary services updated +for f in setup.service.ts totals.service.ts profile.service.ts csv.service.ts; do + grep -q "globalItems" "src/server/services/$f" && echo "$f: OK" || echo "$f: MISSING" +done + +# Global item service updated +grep -q "tagNames" src/server/services/global-item.service.ts && echo "Tag filtering: OK" +grep -q "ilike" src/server/services/global-item.service.ts && echo "Case-insensitive: OK" +grep -q "itemGlobalLinks" src/server/services/global-item.service.ts && echo "FAIL: still uses itemGlobalLinks" || echo "itemGlobalLinks removed: OK" + +# Route updated +grep -q "tags" src/server/routes/global-items.ts && echo "Route tags: OK" + +# Full test suite +bun test +``` + + + +- Global item search supports tag filtering with AND intersection logic +- Owner count correctly uses items.globalItemId instead of removed itemGlobalLinks +- All 4 secondary services (setup, totals, profile, CSV) merge global item data for reference items +- No service reads raw items.weightGrams/priceCents without COALESCE when globalItemId could be set +- GET /api/global-items accepts ?tags=x,y query parameter +- Full test suite passes with no regressions + + + +After completion, create `.planning/phases/19-reference-item-model-tags-schema/19-03-SUMMARY.md` +