Compare commits
10 Commits
9093a2c8f6
...
6a1572a817
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a1572a817 | |||
| 1789ee9093 | |||
| aeb3402576 | |||
| fc9a9134e8 | |||
| e4a65314bd | |||
| df6c75f164 | |||
| 6491615b1d | |||
| 25f590247c | |||
| 9dbf019466 | |||
| c8ebbf8139 |
@@ -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
|
||||
- ✓ Add-from-catalog flow for collection items and thread candidates — 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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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).
|
||||
|
||||
## Constraints
|
||||
@@ -167,4 +171,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
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*
|
||||
|
||||
@@ -25,17 +25,17 @@ Requirements for Public Discovery milestone. Each maps to roadmap phases.
|
||||
|
||||
### Catalog Enrichment
|
||||
|
||||
- [ ] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
||||
- [ ] **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
|
||||
- [ ] **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-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
||||
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
|
||||
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
|
||||
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
|
||||
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
|
||||
|
||||
### Agent Seeding Tools
|
||||
|
||||
- [ ] **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
|
||||
- [ ] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
||||
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
|
||||
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
|
||||
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
||||
|
||||
### Infrastructure
|
||||
|
||||
@@ -122,14 +122,14 @@ Which phases cover which requirements. Updated during roadmap creation.
|
||||
| PUBL-04 | Phase 24 | Complete |
|
||||
| PUBL-05 | Phase 24 | Complete |
|
||||
| INFR-01 | Phase 24 | Complete |
|
||||
| CATL-01 | Phase 25 | Pending |
|
||||
| CATL-02 | Phase 25 | Pending |
|
||||
| CATL-03 | Phase 25 | Pending |
|
||||
| CATL-04 | Phase 25 | Pending |
|
||||
| CATL-05 | Phase 25 | Pending |
|
||||
| SEED-01 | Phase 25 | Pending |
|
||||
| SEED-02 | Phase 25 | Pending |
|
||||
| SEED-03 | Phase 25 | Pending |
|
||||
| CATL-01 | Phase 25 | Complete |
|
||||
| CATL-02 | Phase 25 | Complete |
|
||||
| CATL-03 | Phase 25 | Complete |
|
||||
| CATL-04 | Phase 25 | Complete |
|
||||
| CATL-05 | Phase 25 | Complete |
|
||||
| SEED-01 | Phase 25 | Complete |
|
||||
| SEED-02 | Phase 25 | Complete |
|
||||
| SEED-03 | Phase 25 | Complete |
|
||||
| DISC-01 | Phase 26 | Pending |
|
||||
| DISC-02 | Phase 26 | Pending |
|
||||
| DISC-03 | Phase 26 | Pending |
|
||||
|
||||
@@ -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.
|
||||
|
||||
- [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 Details
|
||||
@@ -105,7 +105,7 @@ Plans:
|
||||
**Plans**: 2 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
|
||||
|
||||
### Phase 26: Discovery Landing Page
|
||||
@@ -149,7 +149,7 @@ Plans:
|
||||
| 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 |
|
||||
| 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 | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v2.1
|
||||
milestone_name: Public Discovery
|
||||
status: verifying
|
||||
stopped_at: Phase 25 context gathered
|
||||
last_updated: "2026-04-10T08:33:11.968Z"
|
||||
stopped_at: Completed 25-02-PLAN.md
|
||||
last_updated: "2026-04-10T09:13:14.646Z"
|
||||
last_activity: 2026-04-10
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 1
|
||||
total_plans: 2
|
||||
completed_plans: 2
|
||||
completed_phases: 2
|
||||
total_plans: 4
|
||||
completed_plans: 4
|
||||
percent: 0
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@ progress:
|
||||
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.
|
||||
**Current focus:** Phase 24 — public-access-infrastructure
|
||||
**Current focus:** Phase 25 — catalog-enrichment-agent-tools
|
||||
|
||||
## 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]: 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 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
|
||||
|
||||
@@ -75,6 +79,6 @@ None.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-10T08:33:11.966Z
|
||||
Stopped at: Phase 25 context gathered
|
||||
Resume file: .planning/phases/25-catalog-enrichment-agent-tools/25-CONTEXT.md
|
||||
Last session: 2026-04-10T09:07:33.636Z
|
||||
Stopped at: Completed 25-02-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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 87–103: 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 147–152: 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 55–56 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 43–60: 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 10–12: imports; lines 62–67: registration loop |
|
||||
| `src/client/hooks/useGlobalItems.ts` | GlobalItem interface with attribution fields | VERIFIED | Lines 13–15: sourceUrl, imageCredit, imageSourceUrl as `string \| null` |
|
||||
| `src/client/routes/global-items/$globalItemId.tsx` | Attribution display below image | VERIFIED | Lines 87–104: 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 8–13: 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 3–6: 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 10–12: both imported; lines 63–67: 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 87–103; 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)_
|
||||
@@ -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 "image_credit" 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");
|
||||
|
||||
@@ -10,6 +10,9 @@ interface GlobalItem {
|
||||
priceCents: number | null;
|
||||
imageUrl: string | null;
|
||||
description: string | null;
|
||||
sourceUrl: string | null;
|
||||
imageCredit: string | null;
|
||||
imageSourceUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,15 +67,15 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => ThreadsThreadIdRoute,
|
||||
id: '/threads/$threadId/',
|
||||
path: '/threads/$threadId/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ThreadsThreadIdCandidatesCandidateIdRoute =
|
||||
ThreadsThreadIdCandidatesCandidateIdRouteImport.update({
|
||||
id: '/candidates/$candidateId',
|
||||
path: '/candidates/$candidateId',
|
||||
getParentRoute: () => ThreadsThreadIdRoute,
|
||||
id: '/threads/$threadId/candidates/$candidateId',
|
||||
path: '/threads/$threadId/candidates/$candidateId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
@@ -170,6 +170,8 @@ export interface RootRouteChildren {
|
||||
UsersUserIdRoute: typeof UsersUserIdRoute
|
||||
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||
GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute
|
||||
ThreadsThreadIdIndexRoute: typeof ThreadsThreadIdIndexRoute
|
||||
ThreadsThreadIdCandidatesCandidateIdRoute: typeof ThreadsThreadIdCandidatesCandidateIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -239,17 +241,17 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
'/threads/$threadId/': {
|
||||
id: '/threads/$threadId/'
|
||||
path: '/'
|
||||
path: '/threads/$threadId'
|
||||
fullPath: '/threads/$threadId/'
|
||||
preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport
|
||||
parentRoute: typeof ThreadsThreadIdRoute
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/threads/$threadId/candidates/$candidateId': {
|
||||
id: '/threads/$threadId/candidates/$candidateId'
|
||||
path: '/candidates/$candidateId'
|
||||
path: '/threads/$threadId/candidates/$candidateId'
|
||||
fullPath: '/threads/$threadId/candidates/$candidateId'
|
||||
preLoaderRoute: typeof ThreadsThreadIdCandidatesCandidateIdRouteImport
|
||||
parentRoute: typeof ThreadsThreadIdRoute
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +266,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
UsersUserIdRoute: UsersUserIdRoute,
|
||||
CollectionIndexRoute: CollectionIndexRoute,
|
||||
GlobalItemsIndexRoute: GlobalItemsIndexRoute,
|
||||
ThreadsThreadIdIndexRoute: ThreadsThreadIdIndexRoute,
|
||||
ThreadsThreadIdCandidatesCandidateIdRoute:
|
||||
ThreadsThreadIdCandidatesCandidateIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -62,7 +62,7 @@ function GlobalItemDetail() {
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
@@ -84,6 +84,25 @@ function GlobalItemDetail() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
</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 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { db as prodDb } from "../../db/index.ts";
|
||||
import { verifyApiKey } from "../services/auth.service.ts";
|
||||
import { verifyAccessToken } from "../services/oauth.service.ts";
|
||||
import { getCollectionSummary } from "./resources/collection.ts";
|
||||
import {
|
||||
catalogToolDefinitions,
|
||||
registerCatalogTools,
|
||||
} from "./tools/catalog.ts";
|
||||
import {
|
||||
categoryToolDefinitions,
|
||||
registerCategoryTools,
|
||||
@@ -55,6 +59,13 @@ function createMcpServer(db: Db, userId: number): McpServer {
|
||||
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
|
||||
server.resource(
|
||||
"collection-summary",
|
||||
|
||||
134
src/server/mcp/tools/catalog.ts
Normal file
134
src/server/mcp/tools/catalog.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
bulkUpsertGlobalItemsSchema,
|
||||
upsertGlobalItemSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
bulkUpsertGlobalItems,
|
||||
getGlobalItemWithOwnerCount,
|
||||
searchGlobalItems,
|
||||
upsertGlobalItem,
|
||||
} from "../services/global-item.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
@@ -32,4 +39,24 @@ app.get("/:id", async (c) => {
|
||||
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 };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db as prodDb } from "../../db/index.ts";
|
||||
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +102,25 @@ export const searchGlobalItemsSchema = z.object({
|
||||
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
|
||||
export const updateProfileSchema = z.object({
|
||||
displayName: z.string().max(100).optional(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
threads,
|
||||
} from "../db/schema.ts";
|
||||
import type {
|
||||
bulkUpsertGlobalItemsSchema,
|
||||
createCandidateSchema,
|
||||
createCategorySchema,
|
||||
createItemSchema,
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
updateProfileSchema,
|
||||
updateSetupSchema,
|
||||
updateThreadSchema,
|
||||
upsertGlobalItemSchema,
|
||||
} from "./schemas.ts";
|
||||
|
||||
// Types inferred from Zod schemas
|
||||
@@ -50,6 +52,10 @@ export type UpdateClassification = z.infer<typeof updateClassificationSchema>;
|
||||
// Global item types
|
||||
export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>;
|
||||
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
|
||||
export type Item = typeof items.$inferSelect;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
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 { registerItemTools } from "../../src/server/mcp/tools/items.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", () => {
|
||||
test("user 2 cannot see user 1's items via MCP tools", async () => {
|
||||
const { db, userId } = await createTestDb();
|
||||
|
||||
@@ -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", () => {
|
||||
it("returns item with ownerCount", async () => {
|
||||
const gi = await insertGlobalItem(db, "MSR", "PocketRocket 2");
|
||||
|
||||
@@ -312,9 +312,13 @@ describe("Global Item Service", () => {
|
||||
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.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 () => {
|
||||
|
||||
Reference in New Issue
Block a user