10 Commits

Author SHA1 Message Date
6a1572a817 docs(phase-25): evolve PROJECT.md after phase completion
All checks were successful
CI / ci (push) Successful in 1m18s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
2026-04-10 11:13:50 +02:00
1789ee9093 docs(phase-25): complete phase execution 2026-04-10 11:13:18 +02:00
aeb3402576 docs(25-02): complete HTTP routes, MCP catalog tools, and attribution display plan 2026-04-10 11:08:16 +02:00
fc9a9134e8 chore(25-02): apply biome formatter to task 1 and 2 files 2026-04-10 11:06:11 +02:00
e4a65314bd feat(25-02): add attribution display on catalog detail page
- GlobalItem interface extended with sourceUrl, imageCredit, imageSourceUrl fields
- Attribution block below image: Photo credit and source link when present
- Product page link (sourceUrl) at bottom of detail page
- Image div mb-6 moved to attribution paragraph for consistent spacing
2026-04-10 11:05:52 +02:00
df6c75f164 feat(25-02): add MCP catalog tools upsert_catalog_item and bulk_upsert_catalog
- New catalog.ts with catalogToolDefinitions and registerCatalogTools
- upsert_catalog_item: single item upsert with full attribution fields (SEED-03)
- bulk_upsert_catalog: batch upsert up to 100 items with created/updated counts
- Registered in createMcpServer after image tools
- 6 new MCP catalog tool tests passing
2026-04-10 11:03:50 +02:00
6491615b1d feat(25-02): add POST single and bulk upsert routes for global items
- POST /api/global-items upserts single item via upsertGlobalItem service
- POST /api/global-items/bulk upserts up to 100 items via bulkUpsertGlobalItems service
- Zod validation via @hono/zod-validator with upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema
2026-04-10 11:02:49 +02:00
25f590247c test(25-02): add failing tests for POST single and bulk upsert routes 2026-04-10 11:02:28 +02:00
9dbf019466 docs(25-01): complete catalog enrichment data layer plan
- SUMMARY.md: attribution columns, upsert service, Zod schemas
- STATE.md: advance to plan 2, add decisions
- ROADMAP.md: update phase 25 progress
- REQUIREMENTS.md: mark CATL-01, CATL-02, CATL-05 complete
2026-04-10 10:59:58 +02:00
c8ebbf8139 feat(25-01): Zod schemas, upsert service functions, passing tests
- Add upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema to schemas.ts
- Add UpsertGlobalItemInput and BulkUpsertGlobalItemsInput types to types.ts
- Implement upsertGlobalItem with onConflictDoUpdate and tag sync
- Implement bulkUpsertGlobalItems processing array in single transaction
- Fix migration 0003 to only add new columns + unique constraint
- All 21 tests pass including 8 new upsert operation tests
2026-04-10 10:58:36 +02:00
20 changed files with 1144 additions and 56 deletions

View File

@@ -54,6 +54,10 @@ Help people make better gear decisions — discover what others use, compare rea
- ✓ Item and catalog detail pages replacing slide-out panels — v2.0 - ✓ Item and catalog detail pages replacing slide-out panels — v2.0
- ✓ Add-from-catalog flow for collection items and thread candidates — v2.0 - ✓ Add-from-catalog flow for collection items and thread candidates — v2.0
- ✓ Manual entry fallback with catalog submission prompt stub — v2.0 - ✓ Manual entry fallback with catalog submission prompt stub — v2.0
- ✓ Catalog attribution fields (sourceUrl, imageCredit, imageSourceUrl) on global items — v2.1
- ✓ Unique constraint on (brand, model) preventing catalog duplicates — v2.1
- ✓ Bulk import API with upsert semantics for catalog enrichment — v2.1
- ✓ MCP catalog tools (upsert_catalog_item, bulk_upsert_catalog) for agent seeding — v2.1
### Active ### Active
@@ -94,7 +98,7 @@ Tech stack: React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tail
Primary use case is bikepacking gear but data model is hobby-agnostic. Primary use case is bikepacking gear but data model is hobby-agnostic.
Auth: External OIDC via Logto (browser sessions) + API keys (programmatic) + MCP OAuth (Claude). Auth: External OIDC via Logto (browser sessions) + API keys (programmatic) + MCP OAuth (Claude).
Infrastructure: PostgreSQL, MinIO (S3-compatible image storage), Docker Compose for dev/prod. Infrastructure: PostgreSQL, MinIO (S3-compatible image storage), Docker Compose for dev/prod.
Features: MCP server (19 tools), global item catalog, user profiles, public setup sharing, catalog-driven gear flow, item/candidate detail pages, candidate ranking/comparison/impact preview. Features: MCP server (21 tools), global item catalog with attribution and bulk import, user profiles, public setup sharing, catalog-driven gear flow, item/candidate detail pages, candidate ranking/comparison/impact preview.
18+ test files (service-level, route-level integration, MCP). E2E tests pending rewrite for OIDC auth (backlog 999.1). 18+ test files (service-level, route-level integration, MCP). E2E tests pending rewrite for OIDC auth (backlog 999.1).
## Constraints ## Constraints
@@ -167,4 +171,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state 4. Update Context with current state
--- ---
*Last updated: 2026-04-10 after Phase 24 complete — public access & rate limiting live* *Last updated: 2026-04-10 after Phase 25 complete — catalog enrichment & agent tools*

View File

@@ -25,17 +25,17 @@ Requirements for Public Discovery milestone. Each maps to roadmap phases.
### Catalog Enrichment ### Catalog Enrichment
- [ ] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl) - [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
- [ ] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates - [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
- [ ] **CATL-03**: Catalog detail pages display image attribution with credit and source link - [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
- [ ] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request - [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
- [ ] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail) - [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
### Agent Seeding Tools ### Agent Seeding Tools
- [ ] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped) - [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
- [ ] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population - [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
- [ ] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters - [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
### Infrastructure ### Infrastructure
@@ -122,14 +122,14 @@ Which phases cover which requirements. Updated during roadmap creation.
| PUBL-04 | Phase 24 | Complete | | PUBL-04 | Phase 24 | Complete |
| PUBL-05 | Phase 24 | Complete | | PUBL-05 | Phase 24 | Complete |
| INFR-01 | Phase 24 | Complete | | INFR-01 | Phase 24 | Complete |
| CATL-01 | Phase 25 | Pending | | CATL-01 | Phase 25 | Complete |
| CATL-02 | Phase 25 | Pending | | CATL-02 | Phase 25 | Complete |
| CATL-03 | Phase 25 | Pending | | CATL-03 | Phase 25 | Complete |
| CATL-04 | Phase 25 | Pending | | CATL-04 | Phase 25 | Complete |
| CATL-05 | Phase 25 | Pending | | CATL-05 | Phase 25 | Complete |
| SEED-01 | Phase 25 | Pending | | SEED-01 | Phase 25 | Complete |
| SEED-02 | Phase 25 | Pending | | SEED-02 | Phase 25 | Complete |
| SEED-03 | Phase 25 | Pending | | SEED-03 | Phase 25 | Complete |
| DISC-01 | Phase 26 | Pending | | DISC-01 | Phase 26 | Pending |
| DISC-02 | Phase 26 | Pending | | DISC-02 | Phase 26 | Pending |
| DISC-03 | Phase 26 | Pending | | DISC-03 | Phase 26 | Pending |

View File

@@ -69,7 +69,7 @@
**Milestone Goal:** Transform GearBox from a login-first tool into a public-first discovery platform with always-on catalog search and a browsable feed of community content. **Milestone Goal:** Transform GearBox from a login-first tool into a public-first discovery platform with always-on catalog search and a browsable feed of community content.
- [x] **Phase 24: Public Access & Infrastructure** - Remove the login wall from read-only routes and add rate limiting to public endpoints (completed 2026-04-10) - [x] **Phase 24: Public Access & Infrastructure** - Remove the login wall from read-only routes and add rate limiting to public endpoints (completed 2026-04-10)
- [ ] **Phase 25: Catalog Enrichment & Agent Tools** - Add attribution fields to global items, bulk import API, and MCP tools for agent-powered seeding - [x] **Phase 25: Catalog Enrichment & Agent Tools** - Add attribution fields to global items, bulk import API, and MCP tools for agent-powered seeding (completed 2026-04-10)
- [ ] **Phase 26: Discovery Landing Page** - Replace the dashboard with a public-first landing page featuring catalog search and community feed - [ ] **Phase 26: Discovery Landing Page** - Replace the dashboard with a public-first landing page featuring catalog search and community feed
## Phase Details ## Phase Details
@@ -105,7 +105,7 @@ Plans:
**Plans**: 2 plans **Plans**: 2 plans
Plans: Plans:
- [ ] 25-01-PLAN.md — Schema migration (attribution columns + unique constraint) and upsert service layer - [x] 25-01-PLAN.md — Schema migration (attribution columns + unique constraint) and upsert service layer
- [ ] 25-02-PLAN.md — HTTP upsert routes, MCP catalog tools, and client attribution display - [ ] 25-02-PLAN.md — HTTP upsert routes, MCP catalog tools, and client attribution display
### Phase 26: Discovery Landing Page ### Phase 26: Discovery Landing Page
@@ -149,7 +149,7 @@ Plans:
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 | | 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 | | 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
| 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 | | 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 |
| 25. Catalog Enrichment & Agent Tools | v2.1 | 0/2 | Not started | - | | 25. Catalog Enrichment & Agent Tools | v2.1 | 1/2 | Complete | 2026-04-10 |
| 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - | | 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - |
## Backlog ## Backlog

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v2.1 milestone: v2.1
milestone_name: Public Discovery milestone_name: Public Discovery
status: verifying status: verifying
stopped_at: Phase 25 context gathered stopped_at: Completed 25-02-PLAN.md
last_updated: "2026-04-10T08:33:11.968Z" last_updated: "2026-04-10T09:13:14.646Z"
last_activity: 2026-04-10 last_activity: 2026-04-10
progress: progress:
total_phases: 6 total_phases: 6
completed_phases: 1 completed_phases: 2
total_plans: 2 total_plans: 4
completed_plans: 2 completed_plans: 4
percent: 0 percent: 0
--- ---
@@ -21,7 +21,7 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-09) See: .planning/PROJECT.md (updated 2026-04-09)
**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 24public-access-infrastructure **Current focus:** Phase 25catalog-enrichment-agent-tools
## Current Position ## Current Position
@@ -64,6 +64,10 @@ v2.1 decisions:
- [Phase 24-public-access-infrastructure]: Browse tier 120/min, detail tier 60/min — same limits for auth and anon users - [Phase 24-public-access-infrastructure]: Browse tier 120/min, detail tier 60/min — same limits for auth and anon users
- [Phase 24]: Both auth prompt CTAs go to /login — Logto handles sign-in and sign-up at the same OIDC endpoint - [Phase 24]: Both auth prompt CTAs go to /login — Logto handles sign-in and sign-up at the same OIDC endpoint
- [Phase 24]: Soft navigate() replaces hard window.location.href for private route redirect — defers until auth resolves - [Phase 24]: Soft navigate() replaces hard window.location.href for private route redirect — defers until auth resolves
- [Phase 25-catalog-enrichment-agent-tools]: Three-way tag sync: undefined=leave untouched, []=clear all, [names]=replace — enables selective tag updates from catalog agents
- [Phase 25-catalog-enrichment-agent-tools]: unique(brand, model) constraint on globalItems: enables safe ON CONFLICT DO UPDATE for catalog enrichment agents
- [Phase 25-catalog-enrichment-agent-tools]: Catalog MCP tools use registerCatalogTools(db) without userId — shared catalog needs no user scoping
- [Phase 25-catalog-enrichment-agent-tools]: Attribution spacing: image div removes mb-6, attribution paragraph takes mb-6, fallback div ensures consistent spacing
### Pending Todos ### Pending Todos
@@ -75,6 +79,6 @@ None.
## Session Continuity ## Session Continuity
Last session: 2026-04-10T08:33:11.966Z Last session: 2026-04-10T09:07:33.636Z
Stopped at: Phase 25 context gathered Stopped at: Completed 25-02-PLAN.md
Resume file: .planning/phases/25-catalog-enrichment-agent-tools/25-CONTEXT.md Resume file: None

View File

@@ -0,0 +1,132 @@
---
phase: 25-catalog-enrichment-agent-tools
plan: 01
subsystem: database
tags: [drizzle, postgres, zod, catalog, upsert, attribution]
# Dependency graph
requires: []
provides:
- globalItems table with sourceUrl, imageCredit, imageSourceUrl attribution columns
- unique constraint on (brand, model) in globalItems table
- migration 0003_loving_serpent_society.sql
- upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema Zod schemas
- UpsertGlobalItemInput and BulkUpsertGlobalItemsInput TypeScript types
- upsertGlobalItem service function with tag sync
- bulkUpsertGlobalItems service function with transaction atomicity
affects:
- 25-02 (HTTP routes and MCP tools will call these service functions)
# Tech tracking
tech-stack:
added: []
patterns:
- onConflictDoUpdate with multi-column target for brand+model upsert
- syncGlobalItemTags helper using delete-then-insert in transaction
- tag create-if-not-exists via onConflictDoUpdate on tags.name
key-files:
created:
- drizzle-pg/0003_loving_serpent_society.sql
modified:
- src/db/schema.ts
- src/shared/schemas.ts
- src/shared/types.ts
- src/server/services/global-item.service.ts
- tests/services/global-item.service.test.ts
key-decisions:
- "Unique constraint on (brand, model): enables safe ON CONFLICT DO UPDATE for catalog enrichment"
- "Tags sync: undefined=leave untouched, []=clear all, [names]=replace — three-way tag handling"
- "Migration 0003 fixed: drizzle-kit generated spurious duplicate DDL; trimmed to only new changes"
patterns-established:
- "upsertGlobalItem pattern: check existence before upsert to track created vs updated"
- "syncGlobalItemTags: delete existing links, then create-if-not-exists tags and insert links"
requirements-completed: [CATL-01, CATL-02, CATL-05]
# Metrics
duration: 3min
completed: 2026-04-10
---
# Phase 25 Plan 01: Catalog Enrichment Data Layer Summary
**globalItems attribution columns (sourceUrl, imageCredit, imageSourceUrl) with unique(brand, model) constraint, upsertGlobalItem/bulkUpsertGlobalItems service functions, and Zod schemas — 21 tests passing**
## Performance
- **Duration:** ~3 min
- **Started:** 2026-04-10T08:55:26Z
- **Completed:** 2026-04-10T08:58:39Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- Added three attribution columns to globalItems table with unique(brand, model) constraint and generated migration
- Implemented upsertGlobalItem with onConflictDoUpdate, three-way tag sync, and created/updated tracking
- Implemented bulkUpsertGlobalItems processing arrays in a single atomic transaction
- Defined upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema Zod validation schemas
- All 21 tests pass (13 pre-existing + 8 new upsert operation tests)
## Task Commits
Each task was committed atomically:
1. **Task 1: Schema migration — attribution columns + unique constraint** - `39ef9cc` (feat)
2. **Task 2: TDD RED — failing tests** - `9093a2c` (test)
3. **Task 2: TDD GREEN — Zod schemas, service functions, tests passing** - `c8ebbf8` (feat)
_Note: TDD tasks have multiple commits (test RED → feat GREEN)_
## Files Created/Modified
- `src/db/schema.ts` - Added sourceUrl, imageCredit, imageSourceUrl columns and unique().on(brand, model) constraint to globalItems
- `drizzle-pg/0003_loving_serpent_society.sql` - Migration adding 3 columns + unique constraint
- `src/shared/schemas.ts` - Added upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema
- `src/shared/types.ts` - Added UpsertGlobalItemInput and BulkUpsertGlobalItemsInput types
- `src/server/services/global-item.service.ts` - Added upsertGlobalItem, bulkUpsertGlobalItems, syncGlobalItemTags
- `tests/services/global-item.service.test.ts` - Added 8 upsert operation tests
## Decisions Made
- **Three-way tag handling**: `undefined` leaves existing tags untouched, `[]` clears all tags, `[names]` replaces tags. This allows callers to selectively update tags without clobbering existing data.
- **Unique constraint on (brand, model)**: Required for ON CONFLICT DO UPDATE semantics. Without it, duplicate inserts would fail rather than update.
- **Created/updated tracking via pre-check**: The service checks for existing row before upsert to accurately report created vs updated counts, since ON CONFLICT DO UPDATE doesn't distinguish via returning rows alone.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed drizzle-kit generated spurious duplicate DDL in migration 0003**
- **Found during:** Task 1 (schema migration)
- **Issue:** drizzle-kit generated a migration that re-created global_item_tags, re-added FKs on items and thread_candidates, and re-added oauth_codes.user_id — all already present in migration 0002. PGlite tests failed with "relation already exists".
- **Fix:** Trimmed migration 0003 to only include the three new ALTER TABLE ADD COLUMN statements and the unique constraint.
- **Files modified:** drizzle-pg/0003_loving_serpent_society.sql
- **Verification:** 21 tests pass including all new upsert tests
- **Committed in:** c8ebbf8 (Task 2 feat commit)
---
**Total deviations:** 1 auto-fixed (Rule 1 - bug in generated migration)
**Impact on plan:** Fix was necessary for test correctness. No scope creep.
## Issues Encountered
- drizzle-kit migration generation included duplicate DDL from prior migrations — likely a state tracking issue in the drizzle-kit snapshots. Fixed by manually editing the migration to contain only the new changes.
## User Setup Required
None - no external service configuration required. Production database push (`bun run db:push`) will apply the migration when the database is available.
## Next Phase Readiness
- Data layer complete: globalItems has attribution columns, unique constraint, and upsert service functions
- Plan 02 (HTTP routes + MCP tools) can now import upsertGlobalItem and bulkUpsertGlobalItems directly
- No blockers
---
*Phase: 25-catalog-enrichment-agent-tools*
*Completed: 2026-04-10*

View File

@@ -0,0 +1,130 @@
---
phase: 25-catalog-enrichment-agent-tools
plan: 02
subsystem: api,mcp,client
tags: [hono, zod, mcp, catalog, upsert, attribution, react]
# Dependency graph
requires:
- 25-01 (upsertGlobalItem, bulkUpsertGlobalItems, Zod schemas)
provides:
- POST /api/global-items endpoint (single upsert)
- POST /api/global-items/bulk endpoint (batch upsert, max 100)
- upsert_catalog_item MCP tool with attribution fields
- bulk_upsert_catalog MCP tool with batch processing
- Catalog detail page attribution display (imageCredit, imageSourceUrl, sourceUrl)
affects:
- MCP agents can now seed the global catalog via upsert_catalog_item and bulk_upsert_catalog
- Catalog detail page now shows image credit and source link when present
# Tech tracking
tech-stack:
added: []
patterns:
- zValidator middleware pattern for Hono routes (upsertGlobalItemSchema, bulkUpsertGlobalItemsSchema)
- registerCatalogTools(db) factory pattern — no userId needed for shared catalog
- Attribution display: conditional inline text below image with credit + source link
key-files:
created:
- src/server/mcp/tools/catalog.ts
modified:
- src/server/routes/global-items.ts
- src/server/mcp/index.ts
- src/client/hooks/useGlobalItems.ts
- src/client/routes/global-items/$globalItemId.tsx
- tests/routes/global-items.test.ts
- tests/mcp/tools.test.ts
key-decisions:
- "Catalog MCP tools use registerCatalogTools(db) without userId — shared catalog needs no user scoping"
- "Attribution spacing: image div removes mb-6, attribution paragraph adds mb-6 so spacing is consistent whether or not attribution exists"
- "Bulk route handler uses zValidator middleware which returns 400 on any validation failure before DB access"
requirements-completed: [CATL-03, CATL-04, SEED-01, SEED-02, SEED-03]
# Metrics
duration: 5min
completed: 2026-04-10
---
# Phase 25 Plan 02: HTTP Routes, MCP Catalog Tools, and Attribution Display Summary
**POST /api/global-items, POST /api/global-items/bulk, upsert_catalog_item and bulk_upsert_catalog MCP tools, and catalog detail page attribution display — 61 tests passing, lint clean, build succeeds**
## Performance
- **Duration:** ~5 min
- **Started:** 2026-04-10T09:01:57Z
- **Completed:** 2026-04-10T09:06:28Z
- **Tasks:** 3
- **Files modified:** 7
## Accomplishments
- Added POST /api/global-items with Zod validation via zValidator — returns { item, created }
- Added POST /api/global-items/bulk with up to 100 items in atomic transaction — returns { created, updated, items }
- Created src/server/mcp/tools/catalog.ts with catalogToolDefinitions and registerCatalogTools factory
- Registered catalog tools in createMcpServer after image tools (no userId needed — catalog is global)
- Extended GlobalItem interface with sourceUrl, imageCredit, imageSourceUrl fields
- Added attribution display on catalog detail page: Photo credit + source link inline below image
- Added product page link (sourceUrl) at bottom of detail page
- All 61 affected tests pass (16 route tests + 24 MCP tool tests + 21 service tests)
## Task Commits
1. **Task 1 TDD RED — failing route tests** - `25f5902` (test)
2. **Task 1 TDD GREEN — POST routes implementation** - `6491615` (feat)
3. **Task 2 — MCP catalog tools + registration + tests** - `df6c75f` (feat)
4. **Task 3 — Client attribution display + GlobalItem interface** - `e4a6531` (feat)
5. **Biome formatting cleanup** - `fc9a913` (chore)
## Files Created/Modified
- `src/server/routes/global-items.ts` — Added app.post("/") and app.post("/bulk") with Zod validation
- `src/server/mcp/tools/catalog.ts` — New file: catalogToolDefinitions, registerCatalogTools with attribution fields
- `src/server/mcp/index.ts` — Registered catalog tools in createMcpServer
- `src/client/hooks/useGlobalItems.ts` — GlobalItem interface extended with sourceUrl, imageCredit, imageSourceUrl
- `src/client/routes/global-items/$globalItemId.tsx` — Attribution block below image, product page link
- `tests/routes/global-items.test.ts` — 9 new tests for POST single and bulk routes
- `tests/mcp/tools.test.ts` — 6 new tests for catalog MCP tools
## Decisions Made
- **Catalog tools without userId**: `registerCatalogTools(db)` matches the `registerImageTools()` pattern — shared global catalog has no user scoping concern.
- **Attribution spacing**: The image `div` drops `mb-6` and the attribution `<p>` takes `mb-6`. A fallback `<div className="mb-6" />` ensures consistent header spacing when no attribution is present.
- **Validation-first bulk rejection**: `zValidator` middleware rejects the entire bulk request before any DB call if any item fails validation. This matches the plan requirement for batch-wide rejection on validation failure.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Merged Plan 01 changes into worktree**
- **Found during:** Task 1 setup
- **Issue:** The worktree branch `worktree-agent-a9d30e61` was created from an older commit and did not have Plan 01's service functions, schema changes, or migration. Attempting to import `upsertGlobalItem` would have failed.
- **Fix:** Ran `git merge feature/catalog-enrichment-upsert --no-verify` to fast-forward the worktree to include all Plan 01 commits.
- **Impact:** Required merge before starting any task, but was non-destructive (fast-forward).
- **Commit:** Resolved by merge (no separate commit — fast-forward)
**2. [Rule 3 - Blocking] Biome formatter required re-formatting multiple files**
- **Found during:** Task 3 lint verification
- **Issue:** Initial implementations of catalog.ts, global-items.ts (routes), and tests had lines exceeding biome's print width, causing `bun run lint` to fail.
- **Fix:** Ran `bunx @biomejs/biome format --write` on affected files. Committed formatting changes as separate chore commit.
- **Commit:** `fc9a913`
---
**Total deviations:** 2 auto-fixed (Rule 3 - blocking issues)
**Impact on plan:** Both fixes were necessary for correctness and lint compliance. No scope creep.
## Known Stubs
None — all attribution fields are wired end-to-end from the database through the API to the UI.
## User Setup Required
None — no new external services required. The MCP tools are available immediately after restart with an authenticated session.
---
*Phase: 25-catalog-enrichment-agent-tools*
*Completed: 2026-04-10*

View File

@@ -0,0 +1,158 @@
---
phase: 25-catalog-enrichment-agent-tools
verified: 2026-04-10T09:30:00Z
status: passed
score: 11/11 must-haves verified
re_verification: false
human_verification:
- test: "Open a catalog item with imageCredit and imageSourceUrl set in the database"
expected: "'Photo: <credit>' text appears below the product image, with a 'Source' link that opens the original image URL"
why_human: "Visual rendering and link behavior cannot be verified without a running browser"
- test: "Open a catalog item with sourceUrl set"
expected: "'View product page →' link appears below the description and opens the product page"
why_human: "Visual layout and link behavior cannot be verified without a running browser"
---
# Phase 25: Catalog Enrichment Agent Tools — Verification Report
**Phase Goal:** Global items carry attribution metadata and can be bulk-populated by an MCP agent swarm
**Verified:** 2026-04-10T09:30:00Z
**Status:** PASSED
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths (Plan 01)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | upsertGlobalItem called with sourceUrl, imageCredit, imageSourceUrl returns them in the result | VERIFIED | `tests/services/global-item.service.test.ts` line 306: passes all 3 attribution fields and asserts each is returned; test passes |
| 2 | Two upserts with the same (brand, model) return the same item id and created: false on the second call | VERIFIED | `tests/services/global-item.service.test.ts` line 285: verifies `created: false` on second upsert; test passes |
| 3 | Inserting a duplicate (brand, model) updates the existing row instead of failing | VERIFIED | `onConflictDoUpdate` with `target: [globalItems.brand, globalItems.model]` in service; migration adds unique constraint |
| 4 | bulkUpsertGlobalItems returns accurate created vs updated counts matching input mix | VERIFIED | `tests/services/global-item.service.test.ts` tests bulk with mix of new and existing; 21 tests pass |
| 5 | Tags are synced (create-if-not-exists) when provided, left untouched when omitted | VERIFIED | Three-way tag logic (undefined/[]/[names]) confirmed in service and tested at lines 324, 344, 368 |
### Observable Truths (Plan 02)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 6 | POST /api/global-items upserts a single catalog item and returns the item with id | VERIFIED | Route implemented with `zValidator`; `tests/routes/global-items.test.ts` 16 tests pass |
| 7 | POST /api/global-items/bulk upserts up to 100 items in a single transaction and returns created/updated counts | VERIFIED | Route implemented; test covers count accuracy and max-100 enforcement |
| 8 | POST /api/global-items/bulk rejects the entire batch if any item fails validation | VERIFIED | `zValidator` middleware rejects before DB; test confirms 400 with invalid item in array |
| 9 | MCP tool upsert_catalog_item writes a global item with attribution fields | VERIFIED | `catalog.ts` implements handler calling `upsertGlobalItem`; SEED-03 test at line 296 passes all 3 attribution fields and asserts result; 24 MCP tests pass |
| 10 | MCP tool bulk_upsert_catalog batch-writes global items via the bulk service | VERIFIED | `catalog.ts` implements handler calling `bulkUpsertGlobalItems`; tests at lines 318 and 336 pass |
| 11 | Catalog detail page shows image credit and source link below the image when present | VERIFIED (code) | `$globalItemId.tsx` lines 87103: conditional attribution block with `Photo:` credit and `Source` link; client type extended with all 3 fields. Human visual check needed |
**Score:** 11/11 truths verified (1 with additional human visual check recommended)
---
## Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/db/schema.ts` | globalItems table with attribution columns and unique constraint | VERIFIED | Lines 147152: sourceUrl, imageCredit, imageSourceUrl columns + `unique().on(table.brand, table.model)` |
| `drizzle-pg/0003_loving_serpent_society.sql` | Migration adding 3 columns + unique constraint | VERIFIED | 4-line migration: 3 ALTER TABLE ADD COLUMN + unique constraint |
| `src/shared/schemas.ts` | Zod schemas for upsert and bulk upsert | VERIFIED | `upsertGlobalItemSchema` at line 106, `bulkUpsertGlobalItemsSchema` at line 120 with `.max(100)` |
| `src/shared/types.ts` | UpsertGlobalItemInput and BulkUpsertGlobalItemsInput types | VERIFIED | Lines 5556 export both types inferred from Zod schemas |
| `src/server/services/global-item.service.ts` | upsertGlobalItem and bulkUpsertGlobalItems functions | VERIFIED | Both exported at lines 105 and 176; full implementation, no stubs |
| `tests/services/global-item.service.test.ts` | Tests for upsert, duplicate handling, bulk, tags | VERIFIED | 8 new tests in `describe("upsert operations")`; 21 total pass |
| `src/server/routes/global-items.ts` | POST / and POST /bulk route handlers | VERIFIED | Lines 4360: both routes with zValidator |
| `src/server/mcp/tools/catalog.ts` | catalogToolDefinitions and registerCatalogTools | VERIFIED | File exists; both exports present; attribution fields in inputSchema |
| `src/server/mcp/index.ts` | Catalog tool registration in createMcpServer | VERIFIED | Lines 1012: imports; lines 6267: registration loop |
| `src/client/hooks/useGlobalItems.ts` | GlobalItem interface with attribution fields | VERIFIED | Lines 1315: sourceUrl, imageCredit, imageSourceUrl as `string \| null` |
| `src/client/routes/global-items/$globalItemId.tsx` | Attribution display below image | VERIFIED | Lines 87104: attribution block + fallback spacer div |
| `tests/routes/global-items.test.ts` | Tests for POST single and bulk endpoints | VERIFIED | 9 new tests for POST; 16 total pass |
| `tests/mcp/tools.test.ts` | Tests for catalog MCP tools | VERIFIED | 6 new catalog tool tests; 24 total pass |
---
## Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `src/server/routes/global-items.ts` | `src/server/services/global-item.service.ts` | import + call upsertGlobalItem / bulkUpsertGlobalItems | WIRED | Lines 813: both service functions imported; lines 46, 57: called in handlers |
| `src/server/mcp/tools/catalog.ts` | `src/server/services/global-item.service.ts` | import + call upsertGlobalItem / bulkUpsertGlobalItems | WIRED | Lines 36: both imported; lines 96, 122: called in tool handlers |
| `src/server/mcp/index.ts` | `src/server/mcp/tools/catalog.ts` | import catalogToolDefinitions + registerCatalogTools | WIRED | Lines 1012: both imported; lines 6367: registerCatalogTools(db) called, loop registers all tools |
| `src/client/routes/global-items/$globalItemId.tsx` | `src/client/hooks/useGlobalItems.ts` | useGlobalItem hook, GlobalItem interface | WIRED | Line 4: useGlobalItem imported; lines 88, 90, 91, 193: item.imageCredit, item.imageSourceUrl, item.sourceUrl all referenced in JSX |
---
## Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `$globalItemId.tsx` | `item.imageCredit`, `item.imageSourceUrl`, `item.sourceUrl` | `useGlobalItem``GET /api/global-items/:id``getGlobalItemWithOwnerCount``db.select().from(globalItems)` | Yes — `select()` without column restriction returns all columns including attribution fields | FLOWING |
| `tests/services/global-item.service.test.ts` | `result.item.sourceUrl`, `result.item.imageCredit`, `result.item.imageSourceUrl` | `upsertGlobalItem``tx.insert(...).returning()` | Yes — `returning()` returns full inserted/updated row | FLOWING |
---
## Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Service tests pass | `bun test tests/services/global-item.service.test.ts` | 21 pass, 0 fail | PASS |
| Route tests pass | `bun test tests/routes/global-items.test.ts` | 16 pass, 0 fail | PASS |
| MCP tool tests pass | `bun test tests/mcp/tools.test.ts` | 24 pass, 0 fail | PASS |
| Source lint clean | `bun run lint` (src/ and tests/ only) | No errors in src/ or tests/ | PASS |
| Build succeeds | Not run (no build output to check) | N/A | SKIP — build output not verified |
Note: Full `bun test` suite shows 15 failures and 7 errors, but all failures are in `tests/services/storage.service.test.ts` — a pre-existing mock/dynamic-import issue from phase 17 (commit `be1197f`, April 7) that predates phase 25. No phase 25 file is responsible. The `.obsidian/` lint errors are from Obsidian vault JSON files outside the source tree.
---
## Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| CATL-01 | 25-01 | Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl) | SATISFIED | sourceUrl, imageCredit, imageSourceUrl columns added to schema; `brand` column serves as manufacturer per plan D-02 |
| CATL-02 | 25-01 | Global items have a unique constraint on (brand, model) preventing duplicates | SATISFIED | `unique().on(table.brand, table.model)` in schema; migration 0003 applied |
| CATL-03 | 25-02 | Catalog detail pages display image attribution with credit and source link | SATISFIED (visual check needed) | Attribution block in `$globalItemId.tsx` lines 87103; human visual test required |
| CATL-04 | 25-02 | Bulk import API endpoint accepts multiple catalog items in one request | SATISFIED | `POST /api/global-items/bulk` implemented and tested |
| CATL-05 | 25-01 | Bulk import uses upsert semantics (ON CONFLICT update, not fail) | SATISFIED | `onConflictDoUpdate` in both `upsertGlobalItem` and `bulkUpsertGlobalItems` |
| SEED-01 | 25-02 | MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped) | SATISFIED | `upsert_catalog_item` in `catalogToolDefinitions`; registered without userId |
| SEED-02 | 25-02 | MCP server has a `bulk_upsert_catalog` tool for batch catalog population | SATISFIED | `bulk_upsert_catalog` in `catalogToolDefinitions`; registered and tested |
| SEED-03 | 25-02 | Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters | SATISFIED | `sourceUrl`, `imageCredit`, `imageSourceUrl` in `catalogItemInputSchema`; test at line 296 explicitly passes and asserts all 3 |
All 8 required requirement IDs are satisfied. No orphaned requirements found for phase 25.
---
## Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| None | — | — | — | — |
No TODO, FIXME, placeholder, empty return, or hardcoded-empty-data patterns found in phase 25 files.
---
## Human Verification Required
### 1. Image Attribution Display
**Test:** Create a global item via `POST /api/global-items` with `imageCredit: "Test Photographer"` and `imageSourceUrl: "https://example.com/image"`. Open the catalog detail page for that item.
**Expected:** Below the product image, "Photo: Test Photographer · Source" appears in small gray text, with "Source" as a clickable link opening `https://example.com/image`.
**Why human:** Visual layout, conditional rendering, and link behavior require a running browser.
### 2. Product Page Link Display
**Test:** Create a global item with `sourceUrl: "https://example.com/product"`. Open its detail page.
**Expected:** "View product page →" link appears below the description section and opens the correct URL.
**Why human:** Visual layout and link behavior require a running browser.
---
## Summary
Phase 25 achieves its goal. Global items now carry attribution metadata (`sourceUrl`, `imageCredit`, `imageSourceUrl`) stored in the database with a unique constraint on `(brand, model)`. An MCP agent swarm can populate the catalog in bulk via `upsert_catalog_item` and `bulk_upsert_catalog` tools, both wired to the service layer through direct imports. The HTTP surface is also available via `POST /api/global-items` and `POST /api/global-items/bulk` with Zod validation. The client detail page renders attribution inline below the product image.
All 8 requirement IDs (CATL-01 through CATL-05, SEED-01 through SEED-03) are satisfied with direct code evidence. All phase-specific tests (61 across 3 test files) pass. Pre-existing storage.service test failures and .obsidian lint issues are not introduced by this phase.
Two items are flagged for human visual verification: attribution rendering and product page link on the catalog detail page.
---
_Verified: 2026-04-10T09:30:00Z_
_Verifier: Claude (gsd-verifier)_

View File

@@ -1,16 +1,4 @@
CREATE TABLE "global_item_tags" (
"global_item_id" integer NOT NULL,
"tag_id" integer NOT NULL,
CONSTRAINT "global_item_tags_global_item_id_tag_id_pk" PRIMARY KEY("global_item_id","tag_id")
);
--> statement-breakpoint
ALTER TABLE "global_items" ADD COLUMN "source_url" text;--> statement-breakpoint ALTER TABLE "global_items" ADD COLUMN "source_url" text;--> statement-breakpoint
ALTER TABLE "global_items" ADD COLUMN "image_credit" text;--> statement-breakpoint ALTER TABLE "global_items" ADD COLUMN "image_credit" text;--> statement-breakpoint
ALTER TABLE "global_items" ADD COLUMN "image_source_url" text;--> statement-breakpoint ALTER TABLE "global_items" ADD COLUMN "image_source_url" text;--> statement-breakpoint
ALTER TABLE "oauth_codes" ADD COLUMN "user_id" integer NOT NULL;--> statement-breakpoint
ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "items" ADD CONSTRAINT "items_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oauth_codes" ADD CONSTRAINT "oauth_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model"); ALTER TABLE "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model");

View File

@@ -10,6 +10,9 @@ interface GlobalItem {
priceCents: number | null; priceCents: number | null;
imageUrl: string | null; imageUrl: string | null;
description: string | null; description: string | null;
sourceUrl: string | null;
imageCredit: string | null;
imageSourceUrl: string | null;
createdAt: string; createdAt: string;
} }

View File

@@ -67,15 +67,15 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({ const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
id: '/', id: '/threads/$threadId/',
path: '/', path: '/threads/$threadId/',
getParentRoute: () => ThreadsThreadIdRoute, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ThreadsThreadIdCandidatesCandidateIdRoute = const ThreadsThreadIdCandidatesCandidateIdRoute =
ThreadsThreadIdCandidatesCandidateIdRouteImport.update({ ThreadsThreadIdCandidatesCandidateIdRouteImport.update({
id: '/candidates/$candidateId', id: '/threads/$threadId/candidates/$candidateId',
path: '/candidates/$candidateId', path: '/threads/$threadId/candidates/$candidateId',
getParentRoute: () => ThreadsThreadIdRoute, getParentRoute: () => rootRouteImport,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
@@ -170,6 +170,8 @@ export interface RootRouteChildren {
UsersUserIdRoute: typeof UsersUserIdRoute UsersUserIdRoute: typeof UsersUserIdRoute
CollectionIndexRoute: typeof CollectionIndexRoute CollectionIndexRoute: typeof CollectionIndexRoute
GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute
ThreadsThreadIdIndexRoute: typeof ThreadsThreadIdIndexRoute
ThreadsThreadIdCandidatesCandidateIdRoute: typeof ThreadsThreadIdCandidatesCandidateIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -239,17 +241,17 @@ declare module '@tanstack/react-router' {
} }
'/threads/$threadId/': { '/threads/$threadId/': {
id: '/threads/$threadId/' id: '/threads/$threadId/'
path: '/' path: '/threads/$threadId'
fullPath: '/threads/$threadId/' fullPath: '/threads/$threadId/'
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
parentRoute: typeof ThreadsThreadIdRoute parentRoute: typeof rootRouteImport
} }
'/threads/$threadId/candidates/$candidateId': { '/threads/$threadId/candidates/$candidateId': {
id: '/threads/$threadId/candidates/$candidateId' id: '/threads/$threadId/candidates/$candidateId'
path: '/candidates/$candidateId' path: '/threads/$threadId/candidates/$candidateId'
fullPath: '/threads/$threadId/candidates/$candidateId' fullPath: '/threads/$threadId/candidates/$candidateId'
preLoaderRoute: typeof ThreadsThreadIdCandidatesCandidateIdRouteImport preLoaderRoute: typeof ThreadsThreadIdCandidatesCandidateIdRouteImport
parentRoute: typeof ThreadsThreadIdRoute parentRoute: typeof rootRouteImport
} }
} }
} }
@@ -264,6 +266,9 @@ const rootRouteChildren: RootRouteChildren = {
UsersUserIdRoute: UsersUserIdRoute, UsersUserIdRoute: UsersUserIdRoute,
CollectionIndexRoute: CollectionIndexRoute, CollectionIndexRoute: CollectionIndexRoute,
GlobalItemsIndexRoute: GlobalItemsIndexRoute, GlobalItemsIndexRoute: GlobalItemsIndexRoute,
ThreadsThreadIdIndexRoute: ThreadsThreadIdIndexRoute,
ThreadsThreadIdCandidatesCandidateIdRoute:
ThreadsThreadIdCandidatesCandidateIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -62,7 +62,7 @@ function GlobalItemDetail() {
</div> </div>
{/* Image */} {/* Image */}
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6"> <div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden">
{item.imageUrl ? ( {item.imageUrl ? (
<img <img
src={item.imageUrl} src={item.imageUrl}
@@ -84,6 +84,25 @@ function GlobalItemDetail() {
)} )}
</div> </div>
{/* Attribution */}
{(item.imageCredit || item.imageSourceUrl) && (
<p className="text-xs text-gray-400 mt-1 mb-6">
{item.imageCredit && <span>Photo: {item.imageCredit}</span>}
{item.imageCredit && item.imageSourceUrl && <span> · </span>}
{item.imageSourceUrl && (
<a
href={item.imageSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-gray-600 transition-colors"
>
Source
</a>
)}
</p>
)}
{!(item.imageCredit || item.imageSourceUrl) && <div className="mb-6" />}
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1"> <p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">
@@ -169,6 +188,20 @@ function GlobalItemDetail() {
<p className="text-gray-600 leading-relaxed">{item.description}</p> <p className="text-gray-600 leading-relaxed">{item.description}</p>
</div> </div>
)} )}
{/* Product page link */}
{item.sourceUrl && (
<div className="mt-4">
<a
href={item.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:text-blue-600 underline transition-colors"
>
View product page &rarr;
</a>
</div>
)}
</div> </div>
); );
} }

View File

@@ -6,6 +6,10 @@ import { db as prodDb } from "../../db/index.ts";
import { verifyApiKey } from "../services/auth.service.ts"; import { verifyApiKey } from "../services/auth.service.ts";
import { verifyAccessToken } from "../services/oauth.service.ts"; import { verifyAccessToken } from "../services/oauth.service.ts";
import { getCollectionSummary } from "./resources/collection.ts"; import { getCollectionSummary } from "./resources/collection.ts";
import {
catalogToolDefinitions,
registerCatalogTools,
} from "./tools/catalog.ts";
import { import {
categoryToolDefinitions, categoryToolDefinitions,
registerCategoryTools, registerCategoryTools,
@@ -55,6 +59,13 @@ function createMcpServer(db: Db, userId: number): McpServer {
server.tool(def.name, def.description, def.inputSchema, handler); server.tool(def.name, def.description, def.inputSchema, handler);
} }
// Register catalog tools (no userId needed — catalog is global)
const catalogHandlers = registerCatalogTools(db);
for (const def of catalogToolDefinitions) {
const handler = catalogHandlers[def.name as keyof typeof catalogHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register collection summary resource // Register collection summary resource
server.resource( server.resource(
"collection-summary", "collection-summary",

View File

@@ -0,0 +1,134 @@
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
bulkUpsertGlobalItems,
upsertGlobalItem,
} from "../../services/global-item.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
const catalogItemInputSchema = {
brand: z.string().describe("Brand or manufacturer name"),
model: z
.string()
.describe("Model name — combined with brand forms the unique identifier"),
category: z
.string()
.optional()
.describe("Category name (e.g., 'Bags', 'Lights')"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z
.number()
.optional()
.describe("MSRP price in cents (e.g., 9999 = $99.99)"),
imageUrl: z.string().optional().describe("URL to the product image"),
description: z.string().optional().describe("Product description"),
sourceUrl: z
.string()
.optional()
.describe("URL to the product page on manufacturer/retailer site"),
imageCredit: z
.string()
.optional()
.describe("Image credit — photographer or source name"),
imageSourceUrl: z
.string()
.optional()
.describe("Original URL where the image was sourced from"),
tags: z
.array(z.string())
.optional()
.describe("Tags for categorization (created automatically if new)"),
};
export const catalogToolDefinitions = [
{
name: "upsert_catalog_item",
description:
"Add or update a single item in the global catalog. If an item with the same brand and model already exists, it will be updated. Includes attribution fields for image credit and source tracking. Requires authentication.",
inputSchema: catalogItemInputSchema,
},
{
name: "bulk_upsert_catalog",
description:
"Add or update multiple items in the global catalog in a single batch (max 100). All items are processed in one transaction — if any item fails, the entire batch is rolled back. Each item is upserted on (brand, model) uniqueness.",
inputSchema: {
items: z
.array(z.object(catalogItemInputSchema))
.max(100)
.describe("Array of catalog items to upsert (max 100 per batch)"),
},
},
];
// Catalog tools operate on shared catalog — no userId needed for data scoping
// db is passed for database access
export function registerCatalogTools(db: Db) {
return {
upsert_catalog_item: async (args: {
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
}): Promise<ToolResult> => {
try {
const result = await upsertGlobalItem(db, args);
return textResult({
...result.item,
created: result.created,
});
} catch (err) {
return errorResult((err as Error).message);
}
},
bulk_upsert_catalog: async (args: {
items: Array<{
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
}>;
}): Promise<ToolResult> => {
try {
const result = await bulkUpsertGlobalItems(db, args.items);
return textResult({
created: result.created,
updated: result.updated,
totalProcessed: result.items.length,
items: result.items,
});
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}

View File

@@ -1,8 +1,15 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";
import {
bulkUpsertGlobalItemsSchema,
upsertGlobalItemSchema,
} from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts"; import { parseId } from "../lib/params.ts";
import { import {
bulkUpsertGlobalItems,
getGlobalItemWithOwnerCount, getGlobalItemWithOwnerCount,
searchGlobalItems, searchGlobalItems,
upsertGlobalItem,
} from "../services/global-item.service.ts"; } from "../services/global-item.service.ts";
type Env = { Variables: { db?: any } }; type Env = { Variables: { db?: any } };
@@ -32,4 +39,24 @@ app.get("/:id", async (c) => {
return c.json(item); return c.json(item);
}); });
// Single item upsert — per D-10
app.post("/", zValidator("json", upsertGlobalItemSchema), async (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const result = await upsertGlobalItem(db, data);
return c.json(result);
});
// Bulk upsert — per D-06, D-07, D-08
app.post(
"/bulk",
zValidator("json", bulkUpsertGlobalItemsSchema),
async (c) => {
const db = c.get("db");
const { items } = c.req.valid("json");
const result = await bulkUpsertGlobalItems(db, items);
return c.json(result);
},
);
export { app as globalItemRoutes }; export { app as globalItemRoutes };

View File

@@ -4,6 +4,7 @@ import { db as prodDb } from "../../db/index.ts";
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts"; import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** /**
* Search global items by brand or model and/or tag names. * Search global items by brand or model and/or tag names.
@@ -71,3 +72,182 @@ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
return { ...item, ownerCount: result?.ownerCount ?? 0 }; return { ...item, ownerCount: result?.ownerCount ?? 0 };
} }
/**
* Sync tags for a global item: delete existing, re-insert provided tag names.
* Creates tags that don't exist yet (create-if-not-exists).
*/
async function syncGlobalItemTags(
tx: TxDb,
globalItemId: number,
tagNames: string[],
) {
await tx
.delete(globalItemTags)
.where(eq(globalItemTags.globalItemId, globalItemId));
for (const name of tagNames) {
const [tag] = await tx
.insert(tags)
.values({ name })
.onConflictDoUpdate({ target: tags.name, set: { name } })
.returning({ id: tags.id });
await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id });
}
}
/**
* Upsert a single global item by (brand, model).
* Creates if not exists, updates all non-key fields if exists.
* Tag sync: provided → sync; undefined → leave untouched; [] → clear all tags.
*/
export async function upsertGlobalItem(
db: Db,
data: {
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
},
) {
return await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(
and(
eq(globalItems.brand, data.brand),
eq(globalItems.model, data.model),
),
);
const { tags: tagNames, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
model: itemData.model,
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
})
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
set: {
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
},
})
.returning();
if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames);
}
return { item, created: !existing };
});
}
/**
* Bulk upsert global items in a single transaction.
* Returns { created, updated, items } with accurate counts.
* Rolls back entirely if any item fails.
*/
export async function bulkUpsertGlobalItems(
db: Db,
itemsData: Array<{
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
}>,
) {
return await db.transaction(async (tx) => {
let created = 0;
let updated = 0;
const resultItems = [];
for (const data of itemsData) {
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(
and(
eq(globalItems.brand, data.brand),
eq(globalItems.model, data.model),
),
);
const { tags: tagNames, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
model: itemData.model,
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
})
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
set: {
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
},
})
.returning();
if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames);
}
if (existing) {
updated++;
} else {
created++;
}
resultItems.push(item);
}
return { created, updated, items: resultItems };
});
}

View File

@@ -102,6 +102,25 @@ export const searchGlobalItemsSchema = z.object({
tags: z.string().optional(), tags: z.string().optional(),
}); });
// Catalog upsert schemas
export const upsertGlobalItemSchema = z.object({
brand: z.string().min(1, "Brand is required"),
model: z.string().min(1, "Model is required"),
category: z.string().optional(),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
imageUrl: z.string().url().optional().or(z.literal("")),
description: z.string().optional(),
sourceUrl: z.string().url().optional().or(z.literal("")),
imageCredit: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
tags: z.array(z.string().min(1).max(100)).max(20).optional(),
});
export const bulkUpsertGlobalItemsSchema = z.object({
items: z.array(upsertGlobalItemSchema).min(1).max(100),
});
// Profile schemas // Profile schemas
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
displayName: z.string().max(100).optional(), displayName: z.string().max(100).optional(),

View File

@@ -11,6 +11,7 @@ import type {
threads, threads,
} from "../db/schema.ts"; } from "../db/schema.ts";
import type { import type {
bulkUpsertGlobalItemsSchema,
createCandidateSchema, createCandidateSchema,
createCategorySchema, createCategorySchema,
createItemSchema, createItemSchema,
@@ -27,6 +28,7 @@ import type {
updateProfileSchema, updateProfileSchema,
updateSetupSchema, updateSetupSchema,
updateThreadSchema, updateThreadSchema,
upsertGlobalItemSchema,
} from "./schemas.ts"; } from "./schemas.ts";
// Types inferred from Zod schemas // Types inferred from Zod schemas
@@ -50,6 +52,10 @@ export type UpdateClassification = z.infer<typeof updateClassificationSchema>;
// Global item types // Global item types
export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>; export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>;
export type UpdateProfile = z.infer<typeof updateProfileSchema>; export type UpdateProfile = z.infer<typeof updateProfileSchema>;
export type UpsertGlobalItemInput = z.infer<typeof upsertGlobalItemSchema>;
export type BulkUpsertGlobalItemsInput = z.infer<
typeof bulkUpsertGlobalItemsSchema
>;
// Types inferred from Drizzle schema // Types inferred from Drizzle schema
export type Item = typeof items.$inferSelect; export type Item = typeof items.$inferSelect;

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts"; import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts";
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts"; import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
import { registerItemTools } from "../../src/server/mcp/tools/items.ts"; import { registerItemTools } from "../../src/server/mcp/tools/items.ts";
import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts"; import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
@@ -252,6 +253,122 @@ describe("MCP Collection Summary Resource", () => {
}); });
}); });
describe("MCP Catalog Tools", () => {
test("upsert_catalog_item creates a new global item with created=true", async () => {
const { db } = await createTestDb();
const tools = registerCatalogTools(db);
const result = await tools.upsert_catalog_item({
brand: "Revelate Designs",
model: "Terrapin System",
weightGrams: 235,
priceCents: 16500,
});
const data = parseResult(result);
expect(data.brand).toBe("Revelate Designs");
expect(data.model).toBe("Terrapin System");
expect(data.created).toBe(true);
expect(data.id).toBeDefined();
});
test("upsert_catalog_item updates existing item on brand+model match", async () => {
const { db } = await createTestDb();
const tools = registerCatalogTools(db);
// Create initial item
await tools.upsert_catalog_item({
brand: "Apidura",
model: "Handlebar Pack",
});
// Update it
const result = await tools.upsert_catalog_item({
brand: "Apidura",
model: "Handlebar Pack",
description: "Updated description",
weightGrams: 120,
});
const data = parseResult(result);
expect(data.created).toBe(false);
expect(data.description).toBe("Updated description");
expect(data.weightGrams).toBe(120);
});
test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => {
const { db } = await createTestDb();
const tools = registerCatalogTools(db);
const result = await tools.upsert_catalog_item({
brand: "MSR",
model: "PocketRocket 2",
sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2",
imageCredit: "MSR Photography",
imageSourceUrl:
"https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg",
});
const data = parseResult(result);
expect(data.sourceUrl).toBe(
"https://www.cascadedesigns.com/msr/pocket-rocket-2",
);
expect(data.imageCredit).toBe("MSR Photography");
expect(data.imageSourceUrl).toBe(
"https://cdn.cascadedesigns.com/images/pocket-rocket-2.jpg",
);
});
test("bulk_upsert_catalog processes array and returns created/updated counts", async () => {
const { db } = await createTestDb();
const tools = registerCatalogTools(db);
const result = await tools.bulk_upsert_catalog({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
{ brand: "MSR", model: "PocketRocket 2" },
],
});
const data = parseResult(result);
expect(data.created).toBe(3);
expect(data.updated).toBe(0);
expect(data.totalProcessed).toBe(3);
expect(data.items).toHaveLength(3);
});
test("bulk_upsert_catalog returns totalProcessed matching input length", async () => {
const { db } = await createTestDb();
const tools = registerCatalogTools(db);
// Pre-create one item
await tools.upsert_catalog_item({
brand: "Revelate Designs",
model: "Terrapin System",
});
const result = await tools.bulk_upsert_catalog({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
],
});
const data = parseResult(result);
expect(data.totalProcessed).toBe(2);
expect(data.created).toBe(1);
expect(data.updated).toBe(1);
});
test("catalog tool definitions include attribution fields in inputSchema", () => {
const {
catalogToolDefinitions,
} = require("../../src/server/mcp/tools/catalog.ts");
const upsertDef = catalogToolDefinitions.find(
(d: { name: string }) => d.name === "upsert_catalog_item",
);
expect(upsertDef).toBeDefined();
expect(upsertDef.inputSchema.sourceUrl).toBeDefined();
expect(upsertDef.inputSchema.imageCredit).toBeDefined();
expect(upsertDef.inputSchema.imageSourceUrl).toBeDefined();
});
});
describe("MCP Cross-User Isolation", () => { describe("MCP Cross-User Isolation", () => {
test("user 2 cannot see user 1's items via MCP tools", async () => { test("user 2 cannot see user 1's items via MCP tools", async () => {
const { db, userId } = await createTestDb(); const { db, userId } = await createTestDb();

View File

@@ -111,6 +111,139 @@ describe("Global Item Routes", () => {
}); });
}); });
describe("POST /api/global-items", () => {
it("returns 200 with item and created=true on new item", async () => {
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand: "Revelate Designs",
model: "Terrapin System",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.item.brand).toBe("Revelate Designs");
expect(body.item.model).toBe("Terrapin System");
expect(body.created).toBe(true);
});
it("returns 200 with created=false when upserting existing item", async () => {
await insertGlobalItem(db, "Revelate Designs", "Terrapin System");
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand: "Revelate Designs",
model: "Terrapin System",
description: "Updated description",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.created).toBe(false);
expect(body.item.description).toBe("Updated description");
});
it("returns 400 when brand is missing", async () => {
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "Terrapin System" }),
});
expect(res.status).toBe(400);
});
it("returns 400 when model is missing", async () => {
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand: "Revelate Designs" }),
});
expect(res.status).toBe(400);
});
});
describe("POST /api/global-items/bulk", () => {
it("returns 200 with created/updated counts", async () => {
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
],
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.created).toBe(2);
expect(body.updated).toBe(0);
expect(body.items).toHaveLength(2);
});
it("returns correct counts for mix of new and existing items", async () => {
await insertGlobalItem(db, "Revelate Designs", "Terrapin System");
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
],
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.created).toBe(1);
expect(body.updated).toBe(1);
});
it("returns 400 when items array is empty", async () => {
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [] }),
});
expect(res.status).toBe(400);
});
it("returns 400 when items array exceeds 100", async () => {
const items = Array.from({ length: 101 }, (_, i) => ({
brand: `Brand${i}`,
model: `Model${i}`,
}));
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
expect(res.status).toBe(400);
});
it("returns 400 for invalid item in array (missing brand)", async () => {
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ model: "Invalid Item without brand" },
],
}),
});
expect(res.status).toBe(400);
});
});
describe("GET /api/global-items/:id", () => { describe("GET /api/global-items/:id", () => {
it("returns item with ownerCount", async () => { it("returns item with ownerCount", async () => {
const gi = await insertGlobalItem(db, "MSR", "PocketRocket 2"); const gi = await insertGlobalItem(db, "MSR", "PocketRocket 2");

View File

@@ -312,9 +312,13 @@ describe("Global Item Service", () => {
imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg", imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg",
}); });
expect(result.item.sourceUrl).toBe("https://apidura.com/shop/handlebar-pack/"); expect(result.item.sourceUrl).toBe(
"https://apidura.com/shop/handlebar-pack/",
);
expect(result.item.imageCredit).toBe("Apidura Ltd"); expect(result.item.imageCredit).toBe("Apidura Ltd");
expect(result.item.imageSourceUrl).toBe("https://apidura.com/images/handlebar-pack.jpg"); expect(result.item.imageSourceUrl).toBe(
"https://apidura.com/images/handlebar-pack.jpg",
);
}); });
it("upsertGlobalItem with tags creates tags and links them", async () => { it("upsertGlobalItem with tags creates tags and links them", async () => {