Compare commits
56 Commits
f564e8cb54
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| c98995288b | |||
| c892800969 | |||
| 31a72c68f3 | |||
| 8aaf4352ed | |||
| 0bf1c68043 | |||
| 0b2e355bf8 | |||
| 747a1c3727 | |||
| 0323e0cd33 | |||
| a00b90d97a | |||
| d1f8a7aa4c | |||
| 06b6e935f2 | |||
| 2f88ead599 | |||
| 9226dd3d90 | |||
| 9336cd80ed | |||
| 6b446033b5 | |||
| 274bced96d | |||
| dbab91a3c7 | |||
| b01625473f | |||
| 77b84dd208 | |||
| 6a1572a817 | |||
| 1789ee9093 | |||
| aeb3402576 | |||
| fc9a9134e8 | |||
| e4a65314bd | |||
| df6c75f164 | |||
| 6491615b1d | |||
| 25f590247c | |||
| 9dbf019466 | |||
| c8ebbf8139 | |||
| 9093a2c8f6 | |||
| 39ef9cc433 | |||
| b6970c9a04 | |||
| d9d9532399 | |||
| 6c0c31350e | |||
| bc2a532238 | |||
| e805269485 | |||
| 56bea00e61 | |||
| e7a9cdb71a | |||
| a28ff90b35 | |||
| e1afd542ac | |||
| 9177296223 | |||
| 7b0efae0c4 | |||
| 50f9629707 | |||
| 5619016e41 | |||
| cd85715d05 | |||
| afab8175f9 | |||
| 08ff7d59bf | |||
| 2a8a479012 | |||
| 2a55b282cb | |||
| 01373260bd | |||
| 87ad09167d | |||
| a2d435bbeb | |||
| 9a69671718 | |||
| 8acb155cf1 | |||
| c4ad5c1b2a | |||
| f9c69a1366 |
@@ -54,10 +54,23 @@ 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
|
||||||
|
- ✓ Discovery landing page with catalog search, popular setups feed, recent items, trending categories — v2.1
|
||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
No active milestone. v2.0 shipped 2026-04-08. Next milestone TBD.
|
## Current Milestone: v2.1 Public Discovery
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Target features:**
|
||||||
|
- Public access auth model — browse everything without login, auth only gates collection management
|
||||||
|
- Discovery landing page replacing dashboard — catalog search bar at top, feed of popular setups/items/categories below
|
||||||
|
- Catalog enrichment infrastructure — attribution fields, source tracking, agent-friendly import tools
|
||||||
|
- Initial catalog seeding — populate key categories via MCP agent swarm
|
||||||
|
|
||||||
### Future
|
### Future
|
||||||
|
|
||||||
@@ -86,8 +99,8 @@ 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. Public discovery landing page with catalog search, popular setups feed, recent items, and trending categories.
|
||||||
18+ test files (service-level, route-level integration, MCP). E2E tests pending rewrite for OIDC auth (backlog 999.1).
|
20+ test files (service-level, route-level integration, MCP). E2E tests pending rewrite for OIDC auth (backlog 999.1).
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
@@ -159,4 +172,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-08 after v2.0 milestone completion*
|
*Last updated: 2026-04-10 after Phase 26 complete — discovery landing page*
|
||||||
|
|||||||
@@ -1,114 +1,91 @@
|
|||||||
# Requirements: GearBox v2.0 Platform Foundation
|
# Requirements: GearBox v2.1 Public Discovery
|
||||||
|
|
||||||
**Defined:** 2026-04-03
|
**Defined:** 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.
|
||||||
|
|
||||||
## v2.0 Requirements
|
## v2.1 Requirements
|
||||||
|
|
||||||
Requirements for this milestone. Each maps to roadmap phases.
|
Requirements for Public Discovery milestone. Each maps to roadmap phases.
|
||||||
|
|
||||||
### Database Migration
|
### Public Access
|
||||||
|
|
||||||
- [x] **DB-01**: Application runs on PostgreSQL instead of SQLite
|
- [x] **PUBL-01**: User can browse the global item catalog without logging in
|
||||||
- [x] **DB-02**: All service functions use async database operations
|
- [x] **PUBL-02**: User can view public setups without logging in
|
||||||
- [x] **DB-03**: Test infrastructure uses PGlite instead of bun:sqlite in-memory databases
|
- [x] **PUBL-03**: User can view user profiles without logging in
|
||||||
- [x] **DB-04**: Existing SQLite data can be migrated to Postgres via a one-time script
|
- [x] **PUBL-04**: Anonymous visitors see the landing page without auth spinner or redirect
|
||||||
- [x] **DB-05**: Docker Compose provides Postgres for local development
|
- [x] **PUBL-05**: Login is only required when user attempts to create/edit/delete their own data
|
||||||
|
|
||||||
### Authentication
|
### Discovery
|
||||||
|
|
||||||
- [x] **AUTH-01**: User can register an account via external OIDC auth provider
|
- [x] **DISC-01**: Landing page displays an always-visible catalog search bar at the top
|
||||||
- [x] **AUTH-02**: User can log in via external auth provider and access their data
|
- [x] **DISC-02**: Landing page shows a feed of popular setups below the search
|
||||||
- [x] **AUTH-03**: API keys remain functional for programmatic access (MCP, scripts)
|
- [x] **DISC-03**: Landing page shows recently added catalog items
|
||||||
- [x] **AUTH-04**: Auth provider runs self-hosted alongside the application
|
- [x] **DISC-04**: Landing page shows trending categories
|
||||||
- [x] **AUTH-05**: E2E tests authenticate via API keys without depending on the auth provider
|
- [x] **DISC-05**: Authenticated users see a "Go to Collection" entry point from the landing page
|
||||||
|
|
||||||
### Multi-User Data Model
|
### Catalog Enrichment
|
||||||
|
|
||||||
- [x] **MULTI-01**: Every item, category, thread, and setup is owned by a specific user
|
- [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
||||||
- [x] **MULTI-02**: User can only see and modify their own data (cross-user isolation)
|
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
|
||||||
- [x] **MULTI-03**: Categories use composite unique constraint (userId + name)
|
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
|
||||||
- [x] **MULTI-04**: Existing data is assigned to the original user during migration
|
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
|
||||||
- [x] **MULTI-05**: MCP tools operate within the authenticated user's scope
|
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
|
||||||
- [x] **MULTI-06**: Settings are per-user rather than global
|
|
||||||
|
|
||||||
### Image Storage
|
### Agent Seeding Tools
|
||||||
|
|
||||||
- [x] **IMG-01**: Images are stored in MinIO (S3-compatible) instead of local filesystem
|
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
|
||||||
- [x] **IMG-02**: Existing uploaded images are migrated to MinIO
|
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
|
||||||
- [x] **IMG-03**: Image upload and retrieval work through the new storage layer
|
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
||||||
- [x] **IMG-04**: Docker Compose provides MinIO for local development
|
|
||||||
|
|
||||||
### Global Item Database
|
### Infrastructure
|
||||||
|
|
||||||
- [x] **GLOB-01**: A global item catalog exists with brand, model, category, manufacturer specs, and image
|
- [x] **INFR-01**: Public API endpoints are rate-limited to prevent abuse
|
||||||
- [x] **GLOB-02**: Global catalog is seeded with initial items from manufacturer data
|
- [x] **INFR-02**: Discovery feed endpoint uses cursor pagination for scalability
|
||||||
- [x] **GLOB-03**: User can search the global catalog by name or brand
|
|
||||||
- [x] **GLOB-04**: User can link a personal collection item to a global catalog entry
|
|
||||||
- [x] **GLOB-05**: Global item pages show basic info and owner count
|
|
||||||
|
|
||||||
### Catalog-Driven Gear Flow
|
|
||||||
|
|
||||||
- [x] **CATFLOW-01**: FAB shows mini menu with "Add to Collection" and "Start Thread" globally, plus "New Setup" on setups page
|
|
||||||
- [x] **CATFLOW-02**: Full-screen catalog search with tag chip filtering
|
|
||||||
- [x] **CATFLOW-03**: User can add a catalog item to collection as a reference item with personal fields (category, notes, purchase price, image, quantity)
|
|
||||||
- [x] **CATFLOW-04**: Collection items referencing global items display merged data (global base + personal overlay)
|
|
||||||
- [x] **CATFLOW-05**: Thread candidates can be added from catalog with global item link
|
|
||||||
- [x] **CATFLOW-06**: Thread resolution with catalog-linked candidate creates reference item with auto-link
|
|
||||||
- [x] **CATFLOW-07**: Manual entry fallback when item not in catalog
|
|
||||||
- [x] **CATFLOW-08**: Non-functional "Submit to catalog?" prompt shown after manual save
|
|
||||||
|
|
||||||
### Item & Catalog Detail Pages
|
|
||||||
|
|
||||||
- [x] **DETAIL-01**: Clicking a collection item navigates to a full detail page (`/items/:id`) showing all item data
|
|
||||||
- [x] **DETAIL-02**: Clicking a catalog search result navigates to a public detail page (`/global-items/:id`) with "Add to Collection" button
|
|
||||||
- [x] **DETAIL-03**: Item detail page has edit mode toggle for modifying personal fields (notes, category, quantity, purchase price)
|
|
||||||
- [x] **DETAIL-04**: Thread candidates navigate to detail pages instead of opening slide-out panels
|
|
||||||
- [x] **DETAIL-05**: Slide-out panels for items and candidates are removed from the application
|
|
||||||
|
|
||||||
### Tags
|
|
||||||
|
|
||||||
- [x] **TAG-01**: Tags table seeded with curated tag set for outdoor/adventure gear
|
|
||||||
- [x] **TAG-02**: Global items have multiple tags, searchable and filterable via API
|
|
||||||
|
|
||||||
### User Profiles & Sharing
|
|
||||||
|
|
||||||
- [x] **PROF-01**: User has a profile with display name, avatar, and bio
|
|
||||||
- [x] **PROF-02**: User can view their own public profile page
|
|
||||||
- [x] **PROF-03**: User can set a setup as public or private
|
|
||||||
- [x] **PROF-04**: Public setups are viewable by anyone without authentication
|
|
||||||
- [x] **PROF-05**: Public profile page lists the user's public setups
|
|
||||||
|
|
||||||
## Future Requirements
|
## Future Requirements
|
||||||
|
|
||||||
Deferred to future milestones. Tracked but not in current roadmap.
|
Deferred to future milestones. Tracked but not in current roadmap.
|
||||||
|
|
||||||
### Reviews & Ratings
|
### Personalization
|
||||||
|
|
||||||
|
- **PERS-01**: Logged-in users see a feed tuned to their collection categories
|
||||||
|
- **PERS-02**: Feed algorithm recommends content based on user's hobby interests
|
||||||
|
|
||||||
|
### Reviews & Content
|
||||||
|
|
||||||
|
- **REVW-01**: Users can write structured reviews on catalog items
|
||||||
|
- **REVW-02**: Reviews appear in the discovery feed
|
||||||
|
- **REVW-03**: Curated/linked external reviews surface in feed
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
- **SEO-01**: Catalog pages are crawlable by search engine bots
|
||||||
|
- **SEO-02**: Catalog pages have proper meta tags and structured data
|
||||||
|
|
||||||
|
### Catalog Seeding
|
||||||
|
|
||||||
|
- **SEED-04**: Initial seeding run populates 100+ items across key categories via agent swarm
|
||||||
|
|
||||||
|
### Reviews & Ratings (from v2.0)
|
||||||
|
|
||||||
- **REV-01**: User can rate a global item with an overall star rating
|
- **REV-01**: User can rate a global item with an overall star rating
|
||||||
- **REV-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
|
- **REV-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
|
||||||
- **REV-03**: Item detail pages show average ratings from all reviewers
|
- **REV-03**: Item detail pages show average ratings from all reviewers
|
||||||
|
|
||||||
### Discovery
|
### Aggregation (from v2.0)
|
||||||
|
|
||||||
- **DISC-01**: User can browse recently shared public setups
|
|
||||||
- **DISC-02**: User can browse recently reviewed items
|
|
||||||
- **DISC-03**: User can browse popular gear by owner count
|
|
||||||
|
|
||||||
### Aggregation
|
|
||||||
|
|
||||||
- **AGG-01**: Item detail pages show crowd-verified specs (manufacturer vs community-measured weight)
|
- **AGG-01**: Item detail pages show crowd-verified specs (manufacturer vs community-measured weight)
|
||||||
- **AGG-02**: Item detail pages show which setups include this item
|
- **AGG-02**: Item detail pages show which setups include this item
|
||||||
- **AGG-03**: Setup composition insights ("commonly paired with")
|
- **AGG-03**: Setup composition insights ("commonly paired with")
|
||||||
|
|
||||||
### Social
|
### Social (from v2.0)
|
||||||
|
|
||||||
- **SOCL-01**: User can fork/copy a public setup as a template
|
- **SOCL-01**: User can fork/copy a public setup as a template
|
||||||
- **SOCL-02**: Planning thread candidates can link to global items for auto-populated specs
|
- **SOCL-02**: Planning thread candidates can link to global items for auto-populated specs
|
||||||
- **SOCL-03**: User can follow other users
|
- **SOCL-03**: User can follow other users
|
||||||
- **SOCL-04**: User can view an activity feed of followed users' content
|
- **SOCL-04**: User can view an activity feed of followed users' content
|
||||||
|
|
||||||
### Content Moderation
|
### Content Moderation (from v2.0)
|
||||||
|
|
||||||
- **MOD-01**: User can submit freeform text reviews
|
- **MOD-01**: User can submit freeform text reviews
|
||||||
- **MOD-02**: User can report inappropriate content
|
- **MOD-02**: User can report inappropriate content
|
||||||
@@ -120,19 +97,18 @@ Explicitly excluded. Documented to prevent scope creep.
|
|||||||
|
|
||||||
| Feature | Reason |
|
| Feature | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Freeform text reviews | Requires moderation infrastructure not yet built |
|
| Personalized feed algorithm | Requires usage data and collection analysis — build simple feed first |
|
||||||
| Comments on setups | Moderation burden, notification system needed |
|
| SSR / static prerendering for SEO | Needs dedicated research on approach for TanStack Router — defer to v2.2+ |
|
||||||
|
| Freeform reviews / comments | No moderation infrastructure yet — structured UGC only |
|
||||||
|
| Admin role / permission system | Current auth model has no role distinction — API key sufficient for v2.1 |
|
||||||
|
| Image scraping automation | Legal gray area — agent seeding uses manufacturer-provided images with attribution |
|
||||||
| User-to-user messaging | High moderation burden, not core to discovery |
|
| User-to-user messaging | High moderation burden, not core to discovery |
|
||||||
| Wiki-style open item editing | Quality control risk; structured contributions only |
|
| Wiki-style open item editing | Quality control risk; structured contributions only |
|
||||||
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
|
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
|
||||||
| AI gear recommendations | Training data requirements, hallucination risk |
|
| AI gear recommendations | Training data requirements, hallucination risk |
|
||||||
| Gamification (badges, points) | Incentivizes quantity over quality |
|
| Gamification (badges, points) | Incentivizes quantity over quality |
|
||||||
| Instagram-style infinite scroll | Engagement-maximizing conflicts with utility focus |
|
|
||||||
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
|
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
|
||||||
| Mobile native app | Web-first, responsive design sufficient |
|
| Mobile native app | Web-first, responsive design sufficient |
|
||||||
| Real-time collaborative setups | WebSocket complexity for niche use case |
|
|
||||||
| Maintaining SQLite single-user mode | Platform features irrelevant for solo use; diverged at v2.0 |
|
|
||||||
| Redis infrastructure | Not needed at v2.0 scale; auth provider (Logto) doesn't require it |
|
|
||||||
|
|
||||||
## Traceability
|
## Traceability
|
||||||
|
|
||||||
@@ -140,57 +116,32 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| DB-01 | Phase 14 | Complete |
|
| PUBL-01 | Phase 24 | Complete |
|
||||||
| DB-02 | Phase 14 | Complete |
|
| PUBL-02 | Phase 24 | Complete |
|
||||||
| DB-03 | Phase 14 | Complete |
|
| PUBL-03 | Phase 24 | Complete |
|
||||||
| DB-04 | Phase 14 | Complete |
|
| PUBL-04 | Phase 24 | Complete |
|
||||||
| DB-05 | Phase 14 | Complete |
|
| PUBL-05 | Phase 24 | Complete |
|
||||||
| AUTH-01 | Phase 15 | Complete |
|
| INFR-01 | Phase 24 | Complete |
|
||||||
| AUTH-02 | Phase 15 | Complete |
|
| CATL-01 | Phase 25 | Complete |
|
||||||
| AUTH-03 | Phase 15 | Complete |
|
| CATL-02 | Phase 25 | Complete |
|
||||||
| AUTH-04 | Phase 15 | Complete |
|
| CATL-03 | Phase 25 | Complete |
|
||||||
| AUTH-05 | Phase 15 | Complete |
|
| CATL-04 | Phase 25 | Complete |
|
||||||
| MULTI-01 | Phase 16 | Complete |
|
| CATL-05 | Phase 25 | Complete |
|
||||||
| MULTI-02 | Phase 16 | Complete |
|
| SEED-01 | Phase 25 | Complete |
|
||||||
| MULTI-03 | Phase 16 | Complete |
|
| SEED-02 | Phase 25 | Complete |
|
||||||
| MULTI-04 | Phase 16 | Complete |
|
| SEED-03 | Phase 25 | Complete |
|
||||||
| MULTI-05 | Phase 16 | Complete |
|
| DISC-01 | Phase 26 | Complete |
|
||||||
| MULTI-06 | Phase 16 | Complete |
|
| DISC-02 | Phase 26 | Complete |
|
||||||
| IMG-01 | Phase 17 | Complete |
|
| DISC-03 | Phase 26 | Complete |
|
||||||
| IMG-02 | Phase 17 | Complete |
|
| DISC-04 | Phase 26 | Complete |
|
||||||
| IMG-03 | Phase 17 | Complete |
|
| DISC-05 | Phase 26 | Complete |
|
||||||
| IMG-04 | Phase 17 | Complete |
|
| INFR-02 | Phase 26 | Complete |
|
||||||
| GLOB-01 | Phase 18 | Complete |
|
|
||||||
| GLOB-02 | Phase 18 | Complete |
|
|
||||||
| GLOB-03 | Phase 18 | Complete |
|
|
||||||
| GLOB-04 | Phase 18 | Complete |
|
|
||||||
| GLOB-05 | Phase 18 | Complete |
|
|
||||||
| PROF-01 | Phase 18 | Complete |
|
|
||||||
| PROF-02 | Phase 18 | Complete |
|
|
||||||
| PROF-03 | Phase 18 | Complete |
|
|
||||||
| PROF-04 | Phase 18 | Complete |
|
|
||||||
| PROF-05 | Phase 18 | Complete |
|
|
||||||
| CATFLOW-01 | Phase 20 | Complete |
|
|
||||||
| CATFLOW-02 | Phase 20 | Complete |
|
|
||||||
| CATFLOW-03 | Phase 19, 22 | Complete |
|
|
||||||
| CATFLOW-04 | Phase 19 | Complete |
|
|
||||||
| CATFLOW-05 | Phase 19, 22 | Complete |
|
|
||||||
| CATFLOW-06 | Phase 19, 22 | Complete |
|
|
||||||
| CATFLOW-07 | Phase 23 | Complete |
|
|
||||||
| CATFLOW-08 | Phase 23 | Complete |
|
|
||||||
| TAG-01 | Phase 19 | Complete |
|
|
||||||
| TAG-02 | Phase 19 | Complete |
|
|
||||||
| DETAIL-01 | Phase 21 | Complete |
|
|
||||||
| DETAIL-02 | Phase 21 | Complete |
|
|
||||||
| DETAIL-03 | Phase 21 | Complete |
|
|
||||||
| DETAIL-04 | Phase 21 | Complete |
|
|
||||||
| DETAIL-05 | Phase 21 | Complete |
|
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v2.0 requirements: 45 total
|
- v2.1 requirements: 20 total
|
||||||
- Mapped to phases: 45
|
- Mapped to phases: 20
|
||||||
- Unmapped: 0
|
- Unmapped: 0
|
||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-04-03*
|
*Requirements defined: 2026-04-09*
|
||||||
*Last updated: 2026-04-08 — all v2.0 requirements complete*
|
*Last updated: 2026-04-09 after roadmap creation*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
||||||
- ✅ **v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
|
- ✅ **v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
|
||||||
- ✅ **v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
|
- ✅ **v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
|
||||||
|
- 🚧 **v2.1 Public Discovery** — Phases 24-26 (in progress)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -63,6 +64,68 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### v2.1 Public Discovery (In Progress)
|
||||||
|
|
||||||
|
**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 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)
|
||||||
|
- [x] **Phase 26: Discovery Landing Page** - Replace the dashboard with a public-first landing page featuring catalog search and community feed (completed 2026-04-10)
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 24: Public Access & Infrastructure
|
||||||
|
**Goal**: Anyone can browse the catalog, public setups, and user profiles without logging in
|
||||||
|
**Depends on**: Phase 23 (v2.0 complete)
|
||||||
|
**Requirements**: PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05, INFR-01
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Visiting the app without a session shows the app content immediately — no auth spinner, no redirect to login
|
||||||
|
2. An unauthenticated visitor can browse the global item catalog and open a catalog detail page
|
||||||
|
3. An unauthenticated visitor can view a public setup and see its items and totals
|
||||||
|
4. An unauthenticated visitor can view a user's public profile page
|
||||||
|
5. Attempting to create, edit, or delete any item/setup/thread while unauthenticated redirects to login
|
||||||
|
**Plans**: 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 24-01-PLAN.md — Rate limit factory and tiered public endpoint protection
|
||||||
|
- [x] 24-02-PLAN.md — Client-side public access (render-first root, auth prompt, setup/catalog guards)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 25: Catalog Enrichment & Agent Tools
|
||||||
|
**Goal**: Global items carry attribution metadata and can be bulk-populated by an MCP agent swarm
|
||||||
|
**Depends on**: Phase 24
|
||||||
|
**Requirements**: CATL-01, CATL-02, CATL-03, CATL-04, CATL-05, SEED-01, SEED-02, SEED-03
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A catalog item detail page displays image credit and a link to the image source
|
||||||
|
2. Attempting to import two items with the same brand and model updates the existing record rather than creating a duplicate
|
||||||
|
3. A single API call with an array of items imports them all, upserting on (brand, model) conflict
|
||||||
|
4. An MCP agent can call `upsert_catalog_item` with attribution fields and the item appears in the catalog
|
||||||
|
5. An MCP agent can call `bulk_upsert_catalog` with a batch of items and all are persisted with attribution
|
||||||
|
**Plans**: 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [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
|
||||||
|
**Goal**: The app opens to a public discovery feed with prominent catalog search, not a personal dashboard
|
||||||
|
**Depends on**: Phase 25
|
||||||
|
**Requirements**: DISC-01, DISC-02, DISC-03, DISC-04, DISC-05, INFR-02
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The root URL shows a landing page with a catalog search bar at the top, visible without logging in
|
||||||
|
2. Below the search bar, a feed of popular public setups is visible with titles, creator names, and item counts
|
||||||
|
3. The landing page shows a section of recently added catalog items
|
||||||
|
4. The landing page shows a section of trending categories
|
||||||
|
5. A logged-in user sees a "Go to Collection" link or button on the landing page that navigates to their personal collection
|
||||||
|
**Plans**: 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
|
||||||
|
- [x] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
|
||||||
|
- [x] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
@@ -90,13 +153,21 @@
|
|||||||
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
|
| 21. Item & Catalog Detail Pages | v2.0 | 3/3 | Complete | 2026-04-06 |
|
||||||
| 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 |
|
||||||
|
| 25. Catalog Enrichment & Agent Tools | v2.1 | 1/2 | Complete | 2026-04-10 |
|
||||||
|
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
### Phase 999.1: Rewrite E2E Tests for OIDC Auth (BACKLOG)
|
### Phase 999.1: Rewrite E2E Tests for OIDC Auth (BACKLOG)
|
||||||
**Goal**: E2E tests currently expect local username/password login but auth moved to external OIDC (Logto). Rewrite with mock OIDC provider or API-key-based auth bypass. Seed migration to Postgres is already done.
|
**Goal**: E2E tests currently expect local username/password login but auth moved to external OIDC (Logto). Rewrite with mock OIDC provider or API-key-based auth bypass. Seed migration to Postgres is already done.
|
||||||
**Requirements**: TBD
|
**Requirements**: TBD
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
|
||||||
|
- [x] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
|
||||||
|
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
@@ -104,7 +175,12 @@ Plans:
|
|||||||
### Phase 999.2: Revamp Onboarding Flow (BACKLOG)
|
### Phase 999.2: Revamp Onboarding Flow (BACKLOG)
|
||||||
**Goal**: Redesign the onboarding experience to match the current app style and flow. Replace the manual item edit form with the catalog search function. Visual refresh to align with the newer UI patterns.
|
**Goal**: Redesign the onboarding experience to match the current app style and flow. Replace the manual item edit form with the catalog search function. Visual refresh to align with the newer UI patterns.
|
||||||
**Requirements**: TBD
|
**Requirements**: TBD
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
|
||||||
|
- [ ] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
|
||||||
|
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
@@ -112,7 +188,12 @@ Plans:
|
|||||||
### Phase 999.3: Public Access Auth Model (BACKLOG)
|
### Phase 999.3: Public Access Auth Model (BACKLOG)
|
||||||
**Goal**: Rework auth so the app is accessible without logging in. Currently all routes require authentication, but public-facing pages (discovery/browse, shared setups, public profiles) should be viewable by unauthenticated users. Auth only required for write operations and personal data.
|
**Goal**: Rework auth so the app is accessible without logging in. Currently all routes require authentication, but public-facing pages (discovery/browse, shared setups, public profiles) should be viewable by unauthenticated users. Auth only required for write operations and personal data.
|
||||||
**Requirements**: TBD
|
**Requirements**: TBD
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 26-01-PLAN.md — Discovery service layer with cursor pagination (TDD)
|
||||||
|
- [ ] 26-02-PLAN.md — Discovery routes, server registration, and client hooks
|
||||||
|
- [ ] 26-03-PLAN.md — Landing page UI and PublicSetupCard enhancement
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v2.0
|
milestone: v2.1
|
||||||
milestone_name: Platform Foundation
|
milestone_name: Public Discovery
|
||||||
status: complete
|
status: verifying
|
||||||
stopped_at: v1.3 and v2.0 milestones archived
|
stopped_at: Completed 26-03-PLAN.md
|
||||||
last_updated: "2026-04-08"
|
last_updated: "2026-04-10T13:08:14.422Z"
|
||||||
last_activity: 2026-04-08
|
last_activity: 2026-04-10
|
||||||
progress:
|
progress:
|
||||||
total_phases: 23
|
total_phases: 6
|
||||||
completed_phases: 23
|
completed_phases: 3
|
||||||
total_plans: 55
|
total_plans: 7
|
||||||
completed_plans: 55
|
completed_plans: 7
|
||||||
percent: 100
|
percent: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-04-08)
|
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:** Between milestones — v1.3 and v2.0 shipped, next milestone TBD
|
**Current focus:** Phase 26 — discovery-landing-page
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: N/A (between milestones)
|
Phase: 999.1
|
||||||
Plan: N/A
|
Plan: Not started
|
||||||
Status: v1.3 and v2.0 complete and archived
|
Status: Phase complete — ready for verification
|
||||||
Last activity: 2026-04-08
|
Last activity: 2026-04-10
|
||||||
|
|
||||||
Progress: [##########] 100% (v2.0 milestone)
|
Progress: [░░░░░░░░░░] 0%
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
@@ -46,27 +46,44 @@ Progress: [##########] 100% (v2.0 milestone)
|
|||||||
|
|
||||||
### Decisions
|
### Decisions
|
||||||
|
|
||||||
Key decisions resolved during v2.0:
|
Key decisions carried forward from v2.0:
|
||||||
|
|
||||||
- Platform pivot: single-user to multi-user with discovery-first approach — RESOLVED
|
|
||||||
- External auth provider: Logto (self-hosted OIDC) — RESOLVED
|
- External auth provider: Logto (self-hosted OIDC) — RESOLVED
|
||||||
- SQLite to Postgres migration — COMPLETE
|
- Structured UGC only — ratings and predefined fields, no freeform text — ACTIVE
|
||||||
- Structured UGC only — ratings and predefined fields, no freeform text until moderation — ACTIVE
|
|
||||||
- Separate globalItems table — not a flag on user items table — RESOLVED
|
- Separate globalItems table — not a flag on user items table — RESOLVED
|
||||||
- COALESCE merge for reference items (global base + personal overlay) — RESOLVED
|
- COALESCE merge for reference items — RESOLVED
|
||||||
- Catalog-first add flow with manual fallback — RESOLVED
|
|
||||||
- Detail pages replacing slide-out panels — RESOLVED
|
- Detail pages replacing slide-out panels — RESOLVED
|
||||||
|
|
||||||
|
v2.1 decisions:
|
||||||
|
|
||||||
|
- Product images: manufacturer images with attribution and source link, honor takedown requests — RESOLVED
|
||||||
|
- Catalog data: open datasets + manufacturer specs + agent MCP enrichment — RESOLVED
|
||||||
|
- Public-first: auth model rework before content features — RESOLVED
|
||||||
|
- Phase 999.3 (Public Access Auth Model backlog item) is now Phase 24 — PROMOTED
|
||||||
|
- [Phase 24-public-access-infrastructure]: createRateLimit factory pattern for configurable rate limiting per endpoint tier
|
||||||
|
- [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
|
||||||
|
- [Phase 26-discovery-landing-page]: Composite cursor for setups uses itemCount_id format filtered post-query in JS for simplicity with grouped SQL
|
||||||
|
- [Phase 26-discovery-landing-page]: No cursor pagination for getTrendingCategories — bounded small list, simple limit is sufficient
|
||||||
|
- [Phase 26]: discoveryRoutes registered with browseTier rate limiting (120 req/min) for all GET discovery endpoints
|
||||||
|
- [Phase 26-discovery-landing-page]: PublicSetupCard itemCount/creatorName fields are optional for backward compatibility with users/$userId usage
|
||||||
|
- [Phase 26-discovery-landing-page]: Discovery sections hide entirely (return null) when not loading and data is empty — avoids empty grid layouts
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
None active.
|
None active.
|
||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
None. Both milestones shipped.
|
None.
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-08
|
Last session: 2026-04-10T13:02:50.039Z
|
||||||
Stopped at: v1.3 and v2.0 milestones archived
|
Stopped at: Completed 26-03-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
216
.planning/phases/24-public-access-infrastructure/24-01-PLAN.md
Normal file
216
.planning/phases/24-public-access-infrastructure/24-01-PLAN.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
---
|
||||||
|
phase: 24-public-access-infrastructure
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/server/middleware/rateLimit.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- tests/middleware/rateLimit.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [INFR-01]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Public GET endpoints return 429 after exceeding the configured rate limit"
|
||||||
|
- "Different endpoint tiers have different rate limit thresholds"
|
||||||
|
- "Existing OAuth rate limiting (5 req/15 min) continues to work unchanged"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/middleware/rateLimit.ts"
|
||||||
|
provides: "createRateLimit factory function"
|
||||||
|
exports: ["createRateLimit", "rateLimit", "_resetForTesting"]
|
||||||
|
- path: "src/server/index.ts"
|
||||||
|
provides: "Rate limit middleware applied to public GET endpoints"
|
||||||
|
contains: "createRateLimit"
|
||||||
|
- path: "tests/middleware/rateLimit.test.ts"
|
||||||
|
provides: "Tests for configurable rate limit tiers"
|
||||||
|
contains: "createRateLimit"
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/index.ts"
|
||||||
|
to: "src/server/middleware/rateLimit.ts"
|
||||||
|
via: "import createRateLimit"
|
||||||
|
pattern: "createRateLimit\\(\\d+,"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Refactor the rate limiter into a configurable factory and apply tiered rate limits to all public GET API endpoints.
|
||||||
|
|
||||||
|
Purpose: Protect public endpoints from abuse (INFR-01) while allowing normal browsing patterns. The existing single-tier (5 req/15 min) rate limiter is only appropriate for OAuth/auth endpoints.
|
||||||
|
Output: `createRateLimit(max, windowMs)` factory, tiered limits on public GET routes, extended tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/24-public-access-infrastructure/24-CONTEXT.md
|
||||||
|
@.planning/phases/24-public-access-infrastructure/24-RESEARCH.md
|
||||||
|
|
||||||
|
@src/server/middleware/rateLimit.ts
|
||||||
|
@src/server/index.ts
|
||||||
|
@tests/middleware/rateLimit.test.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Refactor rateLimit.ts to factory pattern and extend tests</name>
|
||||||
|
<files>src/server/middleware/rateLimit.ts, tests/middleware/rateLimit.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/middleware/rateLimit.ts (current single-tier implementation)
|
||||||
|
- tests/middleware/rateLimit.test.ts (existing tests to preserve)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test: createRateLimit(3, 60000) allows exactly 3 requests then returns 429
|
||||||
|
- Test: createRateLimit(10, 60000) allows exactly 10 requests then returns 429
|
||||||
|
- Test: Two different createRateLimit instances with different limits operate independently (share store but different keys)
|
||||||
|
- Test: Original `rateLimit` export still blocks after 5 requests (backward compat)
|
||||||
|
- Test: 429 response includes Retry-After header
|
||||||
|
- Test: Different IPs tracked independently with createRateLimit
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Refactor `src/server/middleware/rateLimit.ts` per D-07:
|
||||||
|
|
||||||
|
1. Keep the existing module-level `store` Map and `cleanup()`, `getClientIp()` helper functions unchanged.
|
||||||
|
2. Add a new exported factory function:
|
||||||
|
```typescript
|
||||||
|
export function createRateLimit(maxAttempts: number, windowMs: number) {
|
||||||
|
return async function rateLimitMiddleware(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(key);
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (entry.count >= maxAttempts) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: "Too many requests. Try again later." }, 429);
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Rewrite the original `rateLimit` export to delegate to the factory:
|
||||||
|
```typescript
|
||||||
|
export const rateLimit = createRateLimit(5, 15 * 60 * 1000);
|
||||||
|
```
|
||||||
|
Note: Change from `async function` to `const` assignment. The `rateLimit` export must remain a middleware function (not a wrapper that creates one on each call).
|
||||||
|
4. Keep `_resetForTesting()` unchanged — it clears the shared store, which is correct for all tiers.
|
||||||
|
|
||||||
|
In `tests/middleware/rateLimit.test.ts`:
|
||||||
|
5. Add import for `createRateLimit` alongside existing imports.
|
||||||
|
6. Add a new `describe("createRateLimit factory")` block with tests for:
|
||||||
|
- Custom limit (3 req) blocks on 4th request
|
||||||
|
- Custom limit (10 req) allows 10 then blocks
|
||||||
|
- Different IPs tracked independently
|
||||||
|
- Retry-After header present on 429
|
||||||
|
7. Keep all existing tests in the `"rateLimit middleware"` describe block unchanged — they validate backward compatibility.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- rateLimit.ts contains `export function createRateLimit(maxAttempts: number, windowMs: number)`
|
||||||
|
- rateLimit.ts contains `export const rateLimit = createRateLimit(5,` (backward-compatible export)
|
||||||
|
- rateLimit.ts contains `export function _resetForTesting()`
|
||||||
|
- rateLimit.test.ts contains `describe("createRateLimit factory"` with at least 4 test cases
|
||||||
|
- All existing tests in "rateLimit middleware" describe block still pass
|
||||||
|
- `bun test tests/middleware/rateLimit.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>createRateLimit factory exported, backward-compatible rateLimit still works, all tests pass including new factory tests</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Apply tiered rate limits to public GET endpoints in index.ts</name>
|
||||||
|
<files>src/server/index.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/index.ts (current route registration and auth skip logic, lines 100-167)
|
||||||
|
- src/server/middleware/rateLimit.ts (after Task 1 — confirm createRateLimit export exists)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Apply rate limit tiers to public GET endpoints per D-07 and D-08 (same limits for auth and anon).
|
||||||
|
|
||||||
|
1. Add import at top of `src/server/index.ts`:
|
||||||
|
```typescript
|
||||||
|
import { createRateLimit } from "./middleware/rateLimit";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. After the `app.use("/api/*", async (c, next) => { c.set("db", prodDb); ... })` block (around line 118) and BEFORE the auth middleware block (line 121), add rate limit middleware:
|
||||||
|
```typescript
|
||||||
|
// Rate limiting for public endpoints (per D-07, D-08)
|
||||||
|
const browseTier = createRateLimit(120, 60_000);
|
||||||
|
const detailTier = createRateLimit(60, 60_000);
|
||||||
|
|
||||||
|
// Browse endpoints — higher limit for list/search
|
||||||
|
app.use("/api/global-items", async (c, next) => {
|
||||||
|
if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/))
|
||||||
|
return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/tags", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail endpoints — moderate limit for individual resources
|
||||||
|
app.use("/api/global-items/:id", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/setups/:id/public", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/users/:id/profile", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Do NOT modify the existing auth skip logic (lines 121-140) — it already correctly skips auth for these GET endpoints per D-01.
|
||||||
|
4. Do NOT apply rate limits to `/api/auth/*` or OAuth endpoints — those already have the original `rateLimit` (5/15min) applied where needed.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/middleware/rateLimit.test.ts && bun run lint</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- index.ts contains `import { createRateLimit } from "./middleware/rateLimit"`
|
||||||
|
- index.ts contains `const browseTier = createRateLimit(120, 60_000)`
|
||||||
|
- index.ts contains `const detailTier = createRateLimit(60, 60_000)`
|
||||||
|
- index.ts contains rate limit middleware for `/api/global-items`, `/api/tags`, `/api/global-items/:id`, `/api/setups/:id/public`, `/api/users/:id/profile`
|
||||||
|
- Rate limit middleware is placed BEFORE the auth middleware block
|
||||||
|
- `bun run lint` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All public GET endpoints have tiered rate limits applied. Browse endpoints (global-items list, tags) at 120/min, detail endpoints (global-item detail, public setup, profile) at 60/min.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/middleware/rateLimit.test.ts` — all rate limit tests pass
|
||||||
|
- `bun run lint` — no lint errors
|
||||||
|
- `bun test` — full suite passes (no regressions)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- createRateLimit factory is exported and tested with configurable limits
|
||||||
|
- Original rateLimit export unchanged in behavior (backward compatible)
|
||||||
|
- All 5 public GET endpoint groups have rate limits applied in index.ts
|
||||||
|
- Rate limits are applied before auth middleware
|
||||||
|
- No new dependencies added
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/24-public-access-infrastructure/24-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
phase: 24-public-access-infrastructure
|
||||||
|
plan: 01
|
||||||
|
subsystem: infra
|
||||||
|
tags: [rate-limiting, middleware, hono, typescript]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- createRateLimit(maxAttempts, windowMs) factory function in rateLimit.ts
|
||||||
|
- Tiered rate limits on all public GET endpoints (browse: 120/min, detail: 60/min)
|
||||||
|
- Backward-compatible rateLimit export (5 req/15 min, unchanged behavior)
|
||||||
|
affects: [future public API endpoints, catalog enrichment, discovery feed]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Factory pattern for configurable middleware: createRateLimit(max, windowMs) returns middleware fn"
|
||||||
|
- "Shared in-memory store for rate limiting with IP:path composite keys"
|
||||||
|
- "Tiered rate limits: browse tier (120/min) vs detail tier (60/min)"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/server/middleware/rateLimit.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- tests/middleware/rateLimit.test.ts
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Use factory pattern for rate limiter to support different tiers without code duplication"
|
||||||
|
- "Browse endpoints (list/search) get 120/min limit; detail endpoints get 60/min — same for auth and anon users per D-08"
|
||||||
|
- "Rate limits placed before auth middleware to apply equally regardless of auth state"
|
||||||
|
- "Keep original rateLimit export (5/15min) for OAuth/auth endpoints unchanged"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Rate limit factory: createRateLimit(max, windowMs) — reuse for any new endpoint needing limits"
|
||||||
|
- "Method guard in middleware: check c.req.method === 'GET' before applying rate limit"
|
||||||
|
|
||||||
|
requirements-completed: [INFR-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 8min
|
||||||
|
completed: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 24 Plan 01: Rate Limiter Factory and Tiered Public Endpoint Limits Summary
|
||||||
|
|
||||||
|
**createRateLimit(max, windowMs) factory with browse (120/min) and detail (60/min) tiers applied to all public GET endpoints before auth middleware**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~8 min
|
||||||
|
- **Started:** 2026-04-10T10:00:00Z
|
||||||
|
- **Completed:** 2026-04-10T10:08:00Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 3
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Refactored single-tier rateLimit into createRateLimit factory supporting configurable limits
|
||||||
|
- Original rateLimit export preserved with identical behavior (5 req/15 min, same error message)
|
||||||
|
- Added 11 tests total (6 existing + 5 new factory tests) — all pass
|
||||||
|
- Applied browseTier (120/min) to /api/global-items list and /api/tags GET routes
|
||||||
|
- Applied detailTier (60/min) to /api/global-items/:id, /api/setups/:id/public, /api/users/:id/profile GET routes
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Refactor rateLimit.ts to factory pattern and extend tests** - `afab817` (feat)
|
||||||
|
2. **Task 2: Apply tiered rate limits to public GET endpoints in index.ts** - `5619016` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/server/middleware/rateLimit.ts` - Added createRateLimit factory; rateLimit delegates to factory; _resetForTesting unchanged
|
||||||
|
- `src/server/index.ts` - Import createRateLimit; add browseTier/detailTier instances; apply to 5 public GET endpoint groups before auth middleware
|
||||||
|
- `tests/middleware/rateLimit.test.ts` - Added createRateLimit factory describe block with 5 new test cases
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Factory uses the same error message as the original ("Too many attempts. Try again later.") to preserve backward compatibility for existing tests
|
||||||
|
- Import order follows Biome's organized-imports rule (auth.ts before rateLimit.ts alphabetically within middleware group)
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
The only minor adjustment was error message consistency: the plan's code sample used "Too many requests." but the existing tests asserted "Too many attempts." — used the existing message to maintain backward compatibility without changing existing test expectations.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- Pre-existing test failures in storage.service tests (15 failing, 7 errors) unrelated to rate limiting — confirmed pre-existing before changes via git stash verification. Logged as out-of-scope.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- createRateLimit factory available for any new public endpoints added in future plans
|
||||||
|
- All public GET endpoints now have abuse protection
|
||||||
|
- Auth middleware flow unchanged — public endpoints remain unauthenticated, rate-limited only
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 24-public-access-infrastructure*
|
||||||
|
*Completed: 2026-04-10*
|
||||||
475
.planning/phases/24-public-access-infrastructure/24-02-PLAN.md
Normal file
475
.planning/phases/24-public-access-infrastructure/24-02-PLAN.md
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
---
|
||||||
|
phase: 24-public-access-infrastructure
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/client/routes/__root.tsx
|
||||||
|
- src/client/stores/uiStore.ts
|
||||||
|
- src/client/components/AuthPromptModal.tsx
|
||||||
|
- src/client/hooks/useSetups.ts
|
||||||
|
- src/client/hooks/useSettings.ts
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements: [PUBL-01, PUBL-02, PUBL-03, PUBL-04, PUBL-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Anonymous visitor sees app content immediately on any public route — no spinner, no redirect"
|
||||||
|
- "Anonymous visitor can browse the global item catalog and open catalog detail pages"
|
||||||
|
- "Anonymous visitor can view a public setup with its items and totals"
|
||||||
|
- "Anonymous visitor can view a user profile page"
|
||||||
|
- "Anonymous visitor clicking 'Add to Collection' or 'Add to Thread' sees a sign-in/sign-up prompt instead of the action"
|
||||||
|
- "Authenticated user experience is unchanged — all write actions work as before"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/routes/__root.tsx"
|
||||||
|
provides: "Render-first root layout with expanded isPublicRoute"
|
||||||
|
contains: "pathname.startsWith(\"/global-items\")"
|
||||||
|
- path: "src/client/stores/uiStore.ts"
|
||||||
|
provides: "showAuthPrompt state for auth modal"
|
||||||
|
contains: "showAuthPrompt"
|
||||||
|
- path: "src/client/components/AuthPromptModal.tsx"
|
||||||
|
provides: "Modal prompting anonymous users to sign in or sign up"
|
||||||
|
contains: "sign in or sign up"
|
||||||
|
- path: "src/client/hooks/useSetups.ts"
|
||||||
|
provides: "usePublicSetup hook for anonymous setup viewing"
|
||||||
|
exports: ["usePublicSetup"]
|
||||||
|
- path: "src/client/routes/global-items/$globalItemId.tsx"
|
||||||
|
provides: "Auth-guarded write action buttons on catalog detail"
|
||||||
|
contains: "openAuthPrompt"
|
||||||
|
- path: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
provides: "Conditional public vs private setup rendering"
|
||||||
|
contains: "usePublicSetup"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/routes/__root.tsx"
|
||||||
|
to: "src/client/components/AuthPromptModal.tsx"
|
||||||
|
via: "rendered in root layout"
|
||||||
|
pattern: "<AuthPromptModal"
|
||||||
|
- from: "src/client/routes/global-items/$globalItemId.tsx"
|
||||||
|
to: "src/client/stores/uiStore.ts"
|
||||||
|
via: "openAuthPrompt action"
|
||||||
|
pattern: "openAuthPrompt"
|
||||||
|
- from: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
to: "src/client/hooks/useSetups.ts"
|
||||||
|
via: "usePublicSetup hook"
|
||||||
|
pattern: "usePublicSetup"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Make the app render immediately for anonymous visitors, expand public route access to catalog and setups, and intercept write actions with a friendly auth prompt.
|
||||||
|
|
||||||
|
Purpose: Transform GearBox from a login-first tool into a public-first browsing experience (PUBL-01 through PUBL-05). Anonymous visitors see content instantly; write actions prompt sign-in/sign-up instead of hard-redirecting.
|
||||||
|
Output: Reworked root layout, auth prompt modal, public setup hook, guarded write actions.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/24-public-access-infrastructure/24-CONTEXT.md
|
||||||
|
@.planning/phases/24-public-access-infrastructure/24-RESEARCH.md
|
||||||
|
|
||||||
|
@src/client/routes/__root.tsx
|
||||||
|
@src/client/stores/uiStore.ts
|
||||||
|
@src/client/hooks/useSetups.ts
|
||||||
|
@src/client/hooks/useAuth.ts
|
||||||
|
@src/client/hooks/useSettings.ts
|
||||||
|
@src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
@src/client/routes/setups/$setupId.tsx
|
||||||
|
@src/client/components/TotalsBar.tsx
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. -->
|
||||||
|
|
||||||
|
From src/client/hooks/useAuth.ts:
|
||||||
|
```typescript
|
||||||
|
interface AuthState {
|
||||||
|
user: { id: string; email?: string } | null;
|
||||||
|
authenticated: boolean;
|
||||||
|
}
|
||||||
|
export function useAuth(): UseQueryResult<AuthState>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/stores/uiStore.ts:
|
||||||
|
```typescript
|
||||||
|
// Existing pattern — all boolean state with open/close actions
|
||||||
|
showAuthPrompt: boolean;
|
||||||
|
openAuthPrompt: () => void;
|
||||||
|
closeAuthPrompt: () => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useSetups.ts:
|
||||||
|
```typescript
|
||||||
|
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
|
||||||
|
// New hook to add:
|
||||||
|
export function usePublicSetup(id: number): UseQueryResult<PublicSetupData>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/components/TotalsBar.tsx:
|
||||||
|
```typescript
|
||||||
|
// Already handles Sign in vs UserMenu — NO changes needed (D-05, D-10 satisfied)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add auth prompt state to uiStore, create AuthPromptModal, add usePublicSetup hook</name>
|
||||||
|
<files>src/client/stores/uiStore.ts, src/client/components/AuthPromptModal.tsx, src/client/hooks/useSetups.ts, src/client/hooks/useSettings.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/client/stores/uiStore.ts (current state shape and patterns)
|
||||||
|
- src/client/hooks/useSetups.ts (existing hooks, types)
|
||||||
|
- src/client/hooks/useSettings.ts (useOnboardingComplete — needs `enabled` guard)
|
||||||
|
- src/client/routes/__root.tsx (CandidateDeleteDialog pattern for modal structure)
|
||||||
|
- src/client/components/TotalsBar.tsx (confirm D-10 already handled)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Extend uiStore.ts** — Add auth prompt state following the existing pattern (e.g., `externalLinkUrl`):
|
||||||
|
|
||||||
|
Add to the `UIState` interface:
|
||||||
|
```typescript
|
||||||
|
// Auth prompt modal
|
||||||
|
showAuthPrompt: boolean;
|
||||||
|
openAuthPrompt: () => void;
|
||||||
|
closeAuthPrompt: () => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `create` implementation:
|
||||||
|
```typescript
|
||||||
|
// Auth prompt modal
|
||||||
|
showAuthPrompt: false,
|
||||||
|
openAuthPrompt: () => set({ showAuthPrompt: true }),
|
||||||
|
closeAuthPrompt: () => set({ showAuthPrompt: false }),
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create AuthPromptModal.tsx** per D-06 — inline popup/modal with "sign in or sign up" language. Follow the exact pattern of CandidateDeleteDialog in `__root.tsx` (fixed overlay, centered card, bg-black/30 backdrop):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function AuthPromptModal() {
|
||||||
|
const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
|
||||||
|
const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);
|
||||||
|
|
||||||
|
if (!showAuthPrompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/30"
|
||||||
|
onClick={closeAuthPrompt}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") closeAuthPrompt();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Join GearBox
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
To manage your own collection, sign in or sign up.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
onClick={closeAuthPrompt}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
onClick={closeAuthPrompt}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both links go to `/login` because Logto handles both sign-in and sign-up at the same OIDC redirect. The UX distinction is in the button labels per the user's emphasis on welcoming new users (from specifics in CONTEXT.md).
|
||||||
|
|
||||||
|
**3. Add usePublicSetup hook** in `src/client/hooks/useSetups.ts`:
|
||||||
|
|
||||||
|
Add after the existing `useSetup` function:
|
||||||
|
```typescript
|
||||||
|
export function usePublicSetup(setupId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["setups", setupId, "public"],
|
||||||
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}/public`),
|
||||||
|
enabled: setupId != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The public endpoint returns the same shape as the private one (SetupWithItems) but with `isPublic` always `true` and the owner's category names included as read-only context per D-03.
|
||||||
|
|
||||||
|
**4. Guard useOnboardingComplete** in `src/client/hooks/useSettings.ts` — Pitfall 2 from research. The `useSetting` hook calls an auth-gated endpoint. For unauthenticated users, it returns an error and `isLoading` may be `true` briefly, blocking render.
|
||||||
|
|
||||||
|
Change `useOnboardingComplete` to accept an `enabled` parameter:
|
||||||
|
```typescript
|
||||||
|
export function useOnboardingComplete(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["settings", "onboardingComplete"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiGet<Setting>(`/api/settings/onboardingComplete`);
|
||||||
|
return result.value;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.status === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces the current delegation to `useSetting("onboardingComplete")` with a direct `useQuery` call that accepts an `enabled` parameter. The query logic is identical to `useSetting` — just inlined so `enabled` can be passed through.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- uiStore.ts contains `showAuthPrompt: boolean` in the interface
|
||||||
|
- uiStore.ts contains `openAuthPrompt: () => set({ showAuthPrompt: true })`
|
||||||
|
- uiStore.ts contains `closeAuthPrompt: () => set({ showAuthPrompt: false })`
|
||||||
|
- AuthPromptModal.tsx exists and contains `sign in or sign up`
|
||||||
|
- AuthPromptModal.tsx contains `to="/login"` (both links point to /login)
|
||||||
|
- AuthPromptModal.tsx contains `Create account` button text
|
||||||
|
- AuthPromptModal.tsx contains `className="fixed inset-0 z-50`
|
||||||
|
- useSetups.ts contains `export function usePublicSetup(`
|
||||||
|
- useSetups.ts contains `/api/setups/${setupId}/public`
|
||||||
|
- useSettings.ts `useOnboardingComplete` accepts `enabled` parameter
|
||||||
|
- `bun run lint` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Auth prompt modal component created, uiStore extended, usePublicSetup hook added, useOnboardingComplete accepts enabled flag</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Rework __root.tsx for render-first, guard write actions on catalog and setup pages</name>
|
||||||
|
<files>src/client/routes/__root.tsx, src/client/routes/global-items/$globalItemId.tsx, src/client/routes/setups/$setupId.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/__root.tsx (current auth loading spinner, redirect logic, isPublicRoute)
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx (action buttons to guard)
|
||||||
|
- src/client/routes/setups/$setupId.tsx (full file — need to understand write actions and data flow)
|
||||||
|
- src/client/stores/uiStore.ts (after Task 1 — confirm showAuthPrompt exists)
|
||||||
|
- src/client/components/AuthPromptModal.tsx (after Task 1 — confirm component exists)
|
||||||
|
- src/client/hooks/useSetups.ts (after Task 1 — confirm usePublicSetup exists)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Rework __root.tsx** per D-04 and D-09:
|
||||||
|
|
||||||
|
**a. Remove the authLoading spinner gate (lines 121-127).** Delete this entire block:
|
||||||
|
```typescript
|
||||||
|
// REMOVE THIS:
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**b. Expand isPublicRoute** (replace current line 131-132):
|
||||||
|
```typescript
|
||||||
|
const isPublicRoute =
|
||||||
|
location.pathname === "/" ||
|
||||||
|
location.pathname.startsWith("/users/") ||
|
||||||
|
location.pathname.startsWith("/global-items") ||
|
||||||
|
location.pathname.startsWith("/setups/") ||
|
||||||
|
location.pathname === "/login";
|
||||||
|
```
|
||||||
|
|
||||||
|
**c. Replace hard redirect** (replace lines 138-145). Remove `window.location.href = "/login"` and replace with soft redirect that only fires after auth resolves:
|
||||||
|
```typescript
|
||||||
|
if (!isAuthenticated && !isPublicRoute && !authLoading) {
|
||||||
|
navigate({ to: "/login" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**d. Remove onboarding loading spinner gate** (lines 147-154). Delete the entire `if (onboardingLoading)` block. The `showWizard` check already guards on `isAuthenticated`, so this gate is unnecessary. Update the `useOnboardingComplete` call to pass `enabled: isAuthenticated`:
|
||||||
|
```typescript
|
||||||
|
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
||||||
|
useOnboardingComplete(isAuthenticated);
|
||||||
|
```
|
||||||
|
|
||||||
|
**e. Add AuthPromptModal** to the return JSX. Import at top:
|
||||||
|
```typescript
|
||||||
|
import { AuthPromptModal } from "../components/AuthPromptModal";
|
||||||
|
```
|
||||||
|
Add inside the root `<div>`, after the `<Toaster>` and before the onboarding wizard:
|
||||||
|
```tsx
|
||||||
|
{/* Auth Prompt Modal */}
|
||||||
|
<AuthPromptModal />
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Guard write actions in global-items/$globalItemId.tsx** per D-06 and PUBL-05:
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the `GlobalItemDetail` component, add:
|
||||||
|
```typescript
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the two button onClick handlers. For "Add to Collection":
|
||||||
|
```typescript
|
||||||
|
onClick={() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openAddToCollection(item.id, `${item.brand} ${item.model}`);
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
For "Add to Thread":
|
||||||
|
```typescript
|
||||||
|
onClick={() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openAddToThread(item.id, `${item.brand} ${item.model}`);
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Rework setups/$setupId.tsx** for anonymous viewing per PUBL-02:
|
||||||
|
|
||||||
|
This is the most complex change. The current page calls `useSetup(id)` which hits the auth-gated `GET /api/setups/:id`. Anonymous visitors get a 401.
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
|
import { usePublicSetup } from "../../hooks/useSetups";
|
||||||
|
```
|
||||||
|
|
||||||
|
At the top of `SetupDetailPage`, add auth detection:
|
||||||
|
```typescript
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the data fetching to be conditional:
|
||||||
|
```typescript
|
||||||
|
const privateSetup = useSetup(isAuthenticated ? numericId : null);
|
||||||
|
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
|
||||||
|
const { data: setup, isLoading } = isAuthenticated
|
||||||
|
? privateSetup
|
||||||
|
: publicSetup;
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap all write action UI elements (Delete button, Add Items button, Public toggle, remove item buttons, classification dropdowns) in `isAuthenticated` guards:
|
||||||
|
```typescript
|
||||||
|
{isAuthenticated && (
|
||||||
|
<button onClick={() => setPickerOpen(true)}>Add Items</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply this guard to:
|
||||||
|
- The "Add Items" button
|
||||||
|
- The "Delete Setup" button and its confirmation dialog
|
||||||
|
- The "Public" toggle switch
|
||||||
|
- The remove button on individual items (the X icon)
|
||||||
|
- The classification dropdown on individual items
|
||||||
|
- The `ItemPicker` component render
|
||||||
|
|
||||||
|
The read-only display (setup name, items list, weight summary, totals) should render for everyone.
|
||||||
|
|
||||||
|
Also: the mutation hooks (`useDeleteSetup`, `useUpdateSetup`, `useRemoveSetupItem`, `useUpdateItemClassification`) can remain — they just won't be invoked since their triggers are hidden. But `useDeleteSetup()` and `useUpdateSetup(numericId)` calls at the top of the component are fine to keep (they return mutation objects, no network call until `.mutate()` is called).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run lint</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- __root.tsx does NOT contain `if (authLoading)` followed by a spinner return
|
||||||
|
- __root.tsx does NOT contain `window.location.href = "/login"`
|
||||||
|
- __root.tsx contains `pathname === "/" ||` in isPublicRoute
|
||||||
|
- __root.tsx contains `pathname.startsWith("/global-items")` in isPublicRoute
|
||||||
|
- __root.tsx contains `pathname.startsWith("/setups/")` in isPublicRoute
|
||||||
|
- __root.tsx contains `navigate({ to: "/login" })` (soft redirect for private routes)
|
||||||
|
- __root.tsx contains `!authLoading` in the redirect condition
|
||||||
|
- __root.tsx contains `<AuthPromptModal` in the JSX
|
||||||
|
- __root.tsx contains `useOnboardingComplete(isAuthenticated)`
|
||||||
|
- global-items/$globalItemId.tsx contains `openAuthPrompt` import from uiStore
|
||||||
|
- global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToCollection
|
||||||
|
- global-items/$globalItemId.tsx contains `if (!isAuthenticated)` before openAddToThread
|
||||||
|
- setups/$setupId.tsx contains `usePublicSetup` import
|
||||||
|
- setups/$setupId.tsx contains `useAuth` import
|
||||||
|
- setups/$setupId.tsx contains conditional `isAuthenticated ? privateSetup : publicSetup` or equivalent
|
||||||
|
- setups/$setupId.tsx write action buttons wrapped in `{isAuthenticated &&` guards
|
||||||
|
- `bun run lint` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Root layout renders immediately for anonymous visitors. Public routes include /, /global-items/*, /setups/*, /users/*, /login. Write actions on catalog detail show auth prompt. Setup detail page shows read-only view for anonymous visitors using the public API endpoint.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: Verify public access flows</name>
|
||||||
|
<what-built>
|
||||||
|
Complete public access infrastructure: anonymous visitors can browse catalog, view public setups, and view profiles without logging in. Write actions show a friendly sign-in/sign-up prompt instead of redirecting.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Start dev server: `bun run dev`
|
||||||
|
2. Open an incognito/private browser window (no session)
|
||||||
|
3. Visit `http://localhost:5173/` — should see the app immediately (no spinner, no redirect to /login)
|
||||||
|
4. Visit `http://localhost:5173/global-items` — catalog page loads with items
|
||||||
|
5. Click on any catalog item — detail page loads with image, specs, action buttons
|
||||||
|
6. Click "Add to Collection" — auth prompt modal appears with "sign in or sign up" message, two buttons (Sign in, Create account)
|
||||||
|
7. Close the modal (click backdrop or press Escape)
|
||||||
|
8. Click "Add to Thread" — same auth prompt modal appears
|
||||||
|
9. Visit a public setup URL (e.g., `http://localhost:5173/setups/1` if a public setup exists) — setup renders with items and totals, no write action buttons visible
|
||||||
|
10. Visit a user profile (e.g., `http://localhost:5173/users/1`) — profile page loads
|
||||||
|
11. Verify top-right corner shows "Sign in" link (already existing in TotalsBar)
|
||||||
|
12. Now log in normally — verify all write actions work as before (FAB appears, Add to Collection works, setup edit buttons appear)
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe issues</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Anonymous visitor can browse catalog without login (PUBL-01)
|
||||||
|
- Anonymous visitor can view public setups (PUBL-02)
|
||||||
|
- Anonymous visitor can view user profiles (PUBL-03)
|
||||||
|
- No auth spinner or redirect on first visit (PUBL-04)
|
||||||
|
- Write actions prompt sign-in instead of executing (PUBL-05)
|
||||||
|
- `bun run lint` passes
|
||||||
|
- `bun test` passes (no regressions)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Root layout renders immediately for anonymous visitors
|
||||||
|
- isPublicRoute includes /, /global-items/*, /setups/*, /users/*, /login
|
||||||
|
- AuthPromptModal shows friendly sign-in/sign-up prompt on write action attempts
|
||||||
|
- Setup detail page uses public API endpoint for anonymous visitors
|
||||||
|
- Catalog detail page guards both "Add to Collection" and "Add to Thread" buttons
|
||||||
|
- No hard redirects (window.location.href) remain in root layout
|
||||||
|
- Authenticated user experience is completely unchanged
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/24-public-access-infrastructure/24-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
phase: 24-public-access-infrastructure
|
||||||
|
plan: 02
|
||||||
|
subsystem: client/auth
|
||||||
|
tags: [public-access, auth-prompt, anonymous-browsing, setup-sharing]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides: [public-route-access, auth-prompt-modal, anonymous-setup-viewing]
|
||||||
|
affects: [__root.tsx, uiStore, useSetups, useSettings, global-items-detail, setup-detail]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [zustand-modal-state, conditional-hook-enabled, render-first-auth]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- src/client/components/AuthPromptModal.tsx
|
||||||
|
modified:
|
||||||
|
- src/client/stores/uiStore.ts
|
||||||
|
- src/client/hooks/useSetups.ts
|
||||||
|
- src/client/hooks/useSettings.ts
|
||||||
|
- src/client/routes/__root.tsx
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
decisions:
|
||||||
|
- Both auth prompt CTA buttons point to /login — Logto handles sign-in and sign-up at the same OIDC endpoint
|
||||||
|
- usePublicSetup hits /api/setups/:id/public — separate endpoint for anonymous access without auth middleware
|
||||||
|
- useOnboardingComplete(enabled) guards the settings query — prevents 401 spam for anonymous users
|
||||||
|
- Soft navigate() replaces hard window.location.href for private route redirect
|
||||||
|
metrics:
|
||||||
|
duration_seconds: 258
|
||||||
|
completed_date: "2026-04-10"
|
||||||
|
tasks_completed: 3
|
||||||
|
files_changed: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 24 Plan 02: Public Access Infrastructure — Client Layer Summary
|
||||||
|
|
||||||
|
Public-first browsing implemented: anonymous visitors see content immediately, write actions show a friendly sign-in/sign-up prompt, and the setup detail page renders read-only for unauthenticated users via a dedicated public API endpoint.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| # | Task | Commit | Files |
|
||||||
|
|---|------|--------|-------|
|
||||||
|
| 1 | Add auth prompt state, modal, usePublicSetup hook, guard onboarding | cd85715 | uiStore.ts, AuthPromptModal.tsx, useSetups.ts, useSettings.ts |
|
||||||
|
| 2 | Rework __root.tsx, guard write actions on catalog and setup pages | 7b0efae | __root.tsx, $globalItemId.tsx, $setupId.tsx |
|
||||||
|
| 3 | Verify public access flows (auto-approved in auto mode) | — | — |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### uiStore.ts
|
||||||
|
Extended with `showAuthPrompt`/`openAuthPrompt`/`closeAuthPrompt` state following the existing Zustand boolean modal pattern.
|
||||||
|
|
||||||
|
### AuthPromptModal.tsx
|
||||||
|
New component rendered globally in `__root.tsx`. Fixed overlay with centered card, backdrop click-to-close, Escape key dismiss. Two buttons ("Sign in" and "Create account") both pointing to `/login` — Logto handles both flows at the same OIDC endpoint.
|
||||||
|
|
||||||
|
### usePublicSetup hook
|
||||||
|
Added to `useSetups.ts`. Calls `GET /api/setups/:id/public` with `enabled: setupId != null` guard and 404-aware retry logic. Returns `SetupWithItems` shape identical to the private endpoint.
|
||||||
|
|
||||||
|
### useOnboardingComplete(enabled)
|
||||||
|
Reworked from `useSetting("onboardingComplete")` delegation to a direct `useQuery` call that accepts an `enabled` parameter. Prevents auth-gated settings query from firing for anonymous users.
|
||||||
|
|
||||||
|
### __root.tsx
|
||||||
|
- Removed `authLoading` spinner gate — app renders immediately for all visitors
|
||||||
|
- Expanded `isPublicRoute` to include `/`, `/global-items/*`, `/setups/*`, `/users/*`, `/login`
|
||||||
|
- Replaced `window.location.href = "/login"` with `navigate({ to: "/login" })` (soft redirect, only fires after auth resolves and `!authLoading`)
|
||||||
|
- Removed `onboardingLoading` spinner gate
|
||||||
|
- Passes `isAuthenticated` to `useOnboardingComplete()` as the `enabled` param
|
||||||
|
- Added `<AuthPromptModal />` to JSX for global availability
|
||||||
|
|
||||||
|
### global-items/$globalItemId.tsx
|
||||||
|
"Add to Collection" and "Add to Thread" buttons now check `isAuthenticated` before executing. Unauthenticated users see the `AuthPromptModal` instead.
|
||||||
|
|
||||||
|
### setups/$setupId.tsx
|
||||||
|
- Conditionally uses `useSetup` (authenticated) or `usePublicSetup` (anonymous)
|
||||||
|
- All write action UI elements wrapped in `{isAuthenticated && ...}` guards: Add Items button, Public toggle, Delete Setup button and confirmation dialog, empty-state Add Items button
|
||||||
|
- ItemCard `onRemove` and `onClassificationCycle` props are `undefined` for anonymous users
|
||||||
|
- ItemPicker rendered only for authenticated users
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun run lint`: 0 errors in `src/` (4 pre-existing `.obsidian/` format errors)
|
||||||
|
- `bun test`: 247 pass, 15 fail — identical to pre-change baseline (failures are pre-existing `withImageUrl` module issue in storage service, unrelated to this plan)
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None. The public setup endpoint (`/api/setups/:id/public`) must exist server-side for `usePublicSetup` to work — this is the responsibility of Plan 24-01 (server-side public access routes). If that plan has not yet run, anonymous users will see an error state instead of the setup content.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Phase 24: Public Access & Infrastructure - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-09
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Remove the login wall from read-only routes so anyone can browse the global item catalog, public setups, and user profiles without logging in. Add rate limiting to all public endpoints. Auth only required for write operations and personal data access.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Auth Boundary Redesign
|
||||||
|
- **D-01:** Keep `requireAuth` as default middleware on `/api/*`. Expand the existing allowlist of public GET routes that skip auth (current pattern: regex checks in `src/server/index.ts`).
|
||||||
|
- **D-02:** Categories stay auth-gated — they are user-scoped organizational data. Public browsing uses tags (already public via GET `/api/tags`).
|
||||||
|
- **D-03:** Public setup views include the owner's category names as read-only display context (data already returned by GET `/api/setups/:id/public`).
|
||||||
|
|
||||||
|
### Client-Side Routing for Anonymous Users
|
||||||
|
- **D-04:** Expand the `isPublicRoute` check in `__root.tsx` to include catalog routes (`/global-items/*`), public setup views, and the root `/`. Keep login redirect only for truly private routes (`/collection`, `/settings`, `/threads`).
|
||||||
|
- **D-05:** No changes needed for TotalsBar — it's already only shown in collection views, not in the global header.
|
||||||
|
- **D-06:** When an anonymous user attempts a write action (add to collection, create thread, etc.), show an inline popup/modal saying "To manage your own collection, sign in or sign up" with links to both. Do NOT hard-redirect to `/login` — this is hostile to new users who haven't signed up yet.
|
||||||
|
|
||||||
|
### Rate Limiting Strategy
|
||||||
|
- **D-07:** Apply rate limiting to all public GET endpoints. Current rate limiter (`src/server/middleware/rateLimit.ts`) needs new tiers — the existing 5 req/15 min is only appropriate for OAuth.
|
||||||
|
- **D-08:** Same rate limits for authenticated and anonymous users — no exemptions. Tune limits based on real usage data over time.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Rate limit numbers: Claude picks appropriate limits per endpoint type (browse, search, detail). Start with reasonable defaults, expect tuning later.
|
||||||
|
|
||||||
|
### Loading Experience for Visitors
|
||||||
|
- **D-09:** Fire-and-forget auth check — render the page immediately, check `/api/auth/me` in the background. Anonymous users see content right away with no spinner or redirect. When auth resolves, UI updates silently (FAB appears, write actions enable).
|
||||||
|
- **D-10:** Show a "Sign in" button in the top-right corner on all public pages for anonymous visitors. When authenticated, replace with the existing user menu.
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Auth & Middleware
|
||||||
|
- `src/server/middleware/auth.ts` — Current `requireAuth` implementation (API key, OAuth bearer, OIDC session)
|
||||||
|
- `src/server/middleware/rateLimit.ts` — Current rate limiter (in-memory Map, needs new tiers for public endpoints)
|
||||||
|
- `src/server/index.ts` — Route registration and auth middleware skip logic (lines 121-140)
|
||||||
|
|
||||||
|
### Client Routing & Layout
|
||||||
|
- `src/client/routes/__root.tsx` — Root layout with `isPublicRoute` check, auth loading spinner, login redirect logic
|
||||||
|
- `src/client/hooks/useAuth.ts` — Auth state hook (`useAuth`) used throughout client
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- `.planning/REQUIREMENTS.md` — PUBL-01 through PUBL-05, INFR-01
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `requireAuth` middleware — well-structured, supports API key + OAuth + OIDC. No changes to its internals needed — just control when it's applied.
|
||||||
|
- `rateLimit` middleware — functional but needs configurable tiers (currently hardcoded 5/15min). Structure is reusable.
|
||||||
|
- `useAuth` hook — returns `{ user, authenticated }`. Already used throughout client for conditional rendering.
|
||||||
|
- `UserMenu` component — exists for authenticated users. Anonymous "Sign in" button will be its counterpart.
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Auth skip in `index.ts` uses regex path matching — extend this list for new public routes.
|
||||||
|
- Client `isPublicRoute` is a simple pathname check — extend with additional prefixes.
|
||||||
|
- Public data endpoints already exist: GET `/api/global-items`, GET `/api/tags`, GET `/api/users/:id/profile`, GET `/api/setups/:id/public`.
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `__root.tsx` line 121-145: Auth loading/redirect logic — needs rework to render immediately instead of spinner-then-redirect.
|
||||||
|
- `FabMenu` visibility already gated on `isAuthenticated` — will naturally hide for anonymous visitors.
|
||||||
|
- Write action buttons across components need to check auth state and show the signup/signin popup instead of the normal action.
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- The auth-required popup should be welcoming to new users — "sign in **or sign up**" language, not just "log in". The user emphasized that new user experience matters more than returning user convenience, since returning users will be re-authenticated quickly anyway.
|
||||||
|
- Tags are the public taxonomy, categories are the private taxonomy. This distinction should be clear in any public-facing UI.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 24-public-access-infrastructure*
|
||||||
|
*Context gathered: 2026-04-09*
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Phase 24: Public Access & Infrastructure - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
**Phase:** 24-public-access-infrastructure
|
||||||
|
**Areas discussed:** Auth boundary redesign, Client-side routing for anonymous users, Rate limiting strategy, Loading experience for visitors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Boundary Redesign
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Allowlist public routes | Keep requireAuth as default on /api/*, maintain explicit list of public GET routes that skip auth. Extends current pattern. | ✓ |
|
||||||
|
| Separate route registration | Register public routes BEFORE auth middleware, private routes AFTER. Route order determines auth. | |
|
||||||
|
| Per-route middleware | Remove blanket /api/* auth. Each route file applies requireAuth on its own write endpoints. | |
|
||||||
|
|
||||||
|
**User's choice:** Allowlist public routes (recommended)
|
||||||
|
**Notes:** None
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Yes, make categories public | Categories are structural data — needed for catalog browsing context. | |
|
||||||
|
| No, keep categories auth-gated | Only expose what's strictly required. | |
|
||||||
|
|
||||||
|
**User's choice:** Other — Categories are user-scoped. Unauthenticated users access global items which use tags, not categories. Categories stay private.
|
||||||
|
**Notes:** "the thing is currently all item categories are what the user defines them to be, so if the user isn't authenticated the items they are accessing shouldn't have a category in the first place, instead they have tags, for sorting searching etc"
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Show category names in public view | Include owner's category name as read-only display context in public setup view. | ✓ |
|
||||||
|
| Items without category context | Public setup view shows items with weight/price but no category labels. | |
|
||||||
|
|
||||||
|
**User's choice:** Show category names in public view (recommended)
|
||||||
|
**Notes:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-Side Routing for Anonymous Users
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Expand public route list | Extend isPublicRoute check to include /global-items/*, /setups/*/public, /catalog, and /. Keep login redirect for private routes. | ✓ |
|
||||||
|
| Invert to private route list | List private routes instead; everything else accessible without auth. | |
|
||||||
|
| You decide | Claude picks cleanest approach. | |
|
||||||
|
|
||||||
|
**User's choice:** Expand public route list (recommended)
|
||||||
|
**Notes:** None
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Hide TotalsBar for anonymous | No TotalsBar when not authenticated. Public pages show their own header. | |
|
||||||
|
| Show a simplified public header | Replace TotalsBar with minimal header for anonymous visitors. | |
|
||||||
|
| Keep TotalsBar with login CTA | Replace stats with sign-in message. | |
|
||||||
|
|
||||||
|
**User's choice:** Other — TotalsBar is already only in collection views, not in the header. No changes needed.
|
||||||
|
**Notes:** "there should only be a totals bar in the collection views etc, we should have removed the one from the header already, so no need there"
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Redirect to /login with return URL | Standard redirect-based pattern. | |
|
||||||
|
| Show inline login prompt | Show modal/toast instead of navigating away. Keeps context visible. | ✓ |
|
||||||
|
| You decide | Claude picks best UX pattern. | |
|
||||||
|
|
||||||
|
**User's choice:** Show inline login prompt — but specifically with "sign in or sign up" messaging, not just login.
|
||||||
|
**Notes:** "we should show a popup saying to manage your own collection you need to sign in or sign up, because while a direct sending to signin might be a better flow for already signed up users it is terrible for new users, which i think matters more"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting Strategy
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| 100 req/min per IP | Generous for normal browsing, blocks scraping. Standard for public APIs. | |
|
||||||
|
| 60 req/min per IP | More conservative. | |
|
||||||
|
| You decide | Claude picks appropriate limits per endpoint type. | ✓ |
|
||||||
|
|
||||||
|
**User's choice:** You decide
|
||||||
|
**Notes:** None
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Exempt authenticated users | Authenticated users trusted, rate limiting for anonymous abuse. | |
|
||||||
|
| Higher limits for authenticated | Still rate-limit but at 5-10x anonymous limit. | |
|
||||||
|
| Same limits for everyone | Simplest, no distinction. | ✓ |
|
||||||
|
|
||||||
|
**User's choice:** Same limits for everyone
|
||||||
|
**Notes:** "i feel like there is no diff, authenticated users could still spam the api, we should find a good sweet spot for the amount of calls that are being made, i think this is something that will change with experience"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading Experience for Visitors
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Fire-and-forget auth check | Render page immediately, check auth in background. Anonymous users see content right away. | ✓ |
|
||||||
|
| Fast auth with skeleton | Check auth first but show content skeleton instead of spinner. | |
|
||||||
|
| You decide | Claude picks best approach. | |
|
||||||
|
|
||||||
|
**User's choice:** Fire-and-forget auth check (recommended)
|
||||||
|
**Notes:** None
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Login button in top-right corner | Simple 'Sign in' link on all public pages. Disappears when authenticated. | ✓ |
|
||||||
|
| No persistent login link | Users find login through write-action popup only. | |
|
||||||
|
| You decide | Claude picks based on navigation patterns. | |
|
||||||
|
|
||||||
|
**User's choice:** Login button in top-right corner (recommended)
|
||||||
|
**Notes:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Rate limit numbers per endpoint type (browse, search, detail)
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
429
.planning/phases/24-public-access-infrastructure/24-RESEARCH.md
Normal file
429
.planning/phases/24-public-access-infrastructure/24-RESEARCH.md
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# Phase 24: Public Access & Infrastructure - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-10
|
||||||
|
**Domain:** Auth middleware bypass, client-side routing for anonymous users, rate limiting tiers
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 24 removes the login wall from all read-only public routes and adds tiered rate limiting to protect public endpoints. The server-side public allowlist in `src/server/index.ts` (lines 121-140) already includes the four public GET endpoints needed (`/api/global-items`, `/api/tags`, `/api/users/:id/profile`, `/api/setups/:id/public`). The client-side root layout (`__root.tsx`) currently has two blocking problems: it shows a full-page spinner while auth resolves, and it hard-redirects any unauthenticated visitor who isn't on `/users/*` or `/login` directly to `/login` via `window.location.href`. Both must change.
|
||||||
|
|
||||||
|
The `TotalsBar` component already conditionally renders a "Sign in" link for anonymous visitors vs. the `UserMenu` for authenticated users — this part is already done. The primary client-side work is: (1) expand `isPublicRoute` to include `/global-items/*`, `/setups/*` (public view context), and `/`; (2) remove the blocking spinner and the hard redirect; (3) add an inline auth-prompt modal for write action interception. The rate limiter currently has a single hardcoded 5 req/15 min tier that is only appropriate for OAuth endpoints — it needs a factory function producing configurable tiers for browse (higher) and sensitive (low) use.
|
||||||
|
|
||||||
|
**Primary recommendation:** Make `__root.tsx` render-first by removing the `authLoading` spinner gate and the `window.location.href` redirect; widen the public route list; add a `createRateLimit(max, windowMs)` factory to `rateLimit.ts`; apply appropriate tiers to public GET endpoints in `index.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
- **D-01:** Keep `requireAuth` as default middleware on `/api/*`. Expand the existing allowlist of public GET routes that skip auth (current pattern: regex checks in `src/server/index.ts`).
|
||||||
|
- **D-02:** Categories stay auth-gated — they are user-scoped organizational data. Public browsing uses tags (already public via GET `/api/tags`).
|
||||||
|
- **D-03:** Public setup views include the owner's category names as read-only display context (data already returned by GET `/api/setups/:id/public`).
|
||||||
|
- **D-04:** Expand the `isPublicRoute` check in `__root.tsx` to include catalog routes (`/global-items/*`), public setup views, and the root `/`. Keep login redirect only for truly private routes (`/collection`, `/settings`, `/threads`).
|
||||||
|
- **D-05:** No changes needed for TotalsBar — it's already only shown in collection views, not in the global header. (**Note from research: TotalsBar IS the global header; it already handles the Sign-in vs UserMenu conditional — no changes needed.**)
|
||||||
|
- **D-06:** When an anonymous user attempts a write action (add to collection, create thread, etc.), show an inline popup/modal saying "To manage your own collection, sign in or sign up" with links to both. Do NOT hard-redirect to `/login`.
|
||||||
|
- **D-07:** Apply rate limiting to all public GET endpoints. Current rate limiter needs new tiers — the existing 5 req/15 min is only appropriate for OAuth.
|
||||||
|
- **D-08:** Same rate limits for authenticated and anonymous users — no exemptions.
|
||||||
|
- **D-09:** Fire-and-forget auth check — render the page immediately, check `/api/auth/me` in the background. Anonymous users see content right away with no spinner or redirect.
|
||||||
|
- **D-10:** Show a "Sign in" button in the top-right corner on all public pages for anonymous visitors. When authenticated, replace with the existing user menu.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
- Rate limit numbers: Claude picks appropriate limits per endpoint type (browse, search, detail). Start with reasonable defaults, expect tuning later.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope.
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| PUBL-01 | User can browse the global item catalog without logging in | Server allowlist already includes GET `/api/global-items/*`; client `isPublicRoute` must add `/global-items/*` |
|
||||||
|
| PUBL-02 | User can view public setups without logging in | Server allowlist already includes GET `/api/setups/:id/public`; client must add `/setups/*` to public routes and render a public-safe view for anonymous visitors |
|
||||||
|
| PUBL-03 | User can view user profiles without logging in | Server allowlist already includes GET `/api/users/:id/profile`; client already treats `/users/*` as public |
|
||||||
|
| PUBL-04 | Anonymous visitors see the landing page without auth spinner or redirect | Root layout has `authLoading` spinner gate and `window.location.href` hard redirect — both must be removed |
|
||||||
|
| PUBL-05 | Login is only required when user attempts to create/edit/delete their own data | Write action buttons across the app need to check `isAuthenticated` and show the auth prompt modal instead |
|
||||||
|
| INFR-01 | Public API endpoints are rate-limited to prevent abuse | `rateLimit.ts` needs configurable tiers; new limits applied to public GET routes in `index.ts` |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (all already in project — no new installs)
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Hono | ^4.12.8 | Server middleware and routing | Already in use; `app.use()` middleware chains support per-path rate limit application |
|
||||||
|
| TanStack Router | ^1.167.0 | Client-side routing | Already in use; `useLocation` and `useMatchRoute` are the tools for `isPublicRoute` logic |
|
||||||
|
| TanStack React Query | ^5.90.21 | Auth state management | `useAuth()` hook returns `{ data: auth, isLoading }` — the `isLoading` gating in `__root.tsx` is what to remove |
|
||||||
|
| React 19 | ^19.2.4 | UI | Already in use |
|
||||||
|
|
||||||
|
### No New Dependencies Required
|
||||||
|
|
||||||
|
All required functionality is achievable with existing libraries. The rate limiter refactor is purely internal to `rateLimit.ts`. The auth popup modal follows the existing pattern of inline dialogs already present in `__root.tsx` (CandidateDeleteDialog, ResolveDialog).
|
||||||
|
|
||||||
|
**Installation:** None required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Approach: Rate Limit Factory
|
||||||
|
|
||||||
|
The existing `rateLimit` middleware is a single closure with hardcoded limits. Convert it to a factory function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/server/middleware/rateLimit.ts
|
||||||
|
export function createRateLimit(maxAttempts: number, windowMs: number) {
|
||||||
|
return async function rateLimit(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
// ... same logic, uses maxAttempts and windowMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the original export for backward compatibility (OAuth usage)
|
||||||
|
export async function rateLimit(c: Context, next: Next) {
|
||||||
|
return createRateLimit(MAX_ATTEMPTS, WINDOW_MS)(c, next);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tiers to create (Claude's discretion — reasonable defaults):
|
||||||
|
|
||||||
|
| Tier | Max | Window | Applied to |
|
||||||
|
|------|-----|--------|------------|
|
||||||
|
| `browseTier` | 120 req | 1 min | GET `/api/global-items`, GET `/api/tags` |
|
||||||
|
| `detailTier` | 60 req | 1 min | GET `/api/global-items/:id`, GET `/api/setups/:id/public`, GET `/api/users/:id/profile` |
|
||||||
|
| `sensitivesTier` | 5 req | 15 min | `/login`, `/api/auth/setup`, OAuth endpoints (existing behavior, unchanged) |
|
||||||
|
|
||||||
|
Rationale: A catalog browse page may make 1 list + N detail requests in a session. 120/min for list endpoints and 60/min for detail endpoints allows normal browsing while still blocking automated scraping. These are generous defaults — D-08 says no auth exemptions so they apply equally to all callers.
|
||||||
|
|
||||||
|
### Server-Side: Applying Rate Limits in `index.ts`
|
||||||
|
|
||||||
|
Apply rate limits as middleware before the `requireAuth` block, scoped to the public GET paths:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After db injection, before requireAuth block
|
||||||
|
const browseTier = createRateLimit(120, 60_000);
|
||||||
|
const detailTier = createRateLimit(60, 60_000);
|
||||||
|
|
||||||
|
app.use("/api/global-items", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/global-items/:id", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/tags", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
// etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-Side: `__root.tsx` Restructure
|
||||||
|
|
||||||
|
**Current flow (broken for PUBL-04):**
|
||||||
|
1. Render spinner while `authLoading === true`
|
||||||
|
2. After auth resolves: if unauthenticated AND not public route → `window.location.href = "/login"`
|
||||||
|
|
||||||
|
**Required flow (D-09):**
|
||||||
|
1. Render immediately — no `authLoading` gate
|
||||||
|
2. Auth resolves in background; UI updates silently (FAB appears, write buttons enable)
|
||||||
|
3. If unauthenticated AND route is private (`/collection`, `/settings`, `/threads`) → redirect to login
|
||||||
|
|
||||||
|
**Key change in `isPublicRoute`:**
|
||||||
|
```typescript
|
||||||
|
// Current
|
||||||
|
const isPublicRoute =
|
||||||
|
location.pathname.startsWith("/users/") || location.pathname === "/login";
|
||||||
|
|
||||||
|
// Required
|
||||||
|
const isPublicRoute =
|
||||||
|
location.pathname === "/" ||
|
||||||
|
location.pathname.startsWith("/users/") ||
|
||||||
|
location.pathname.startsWith("/global-items") ||
|
||||||
|
location.pathname.startsWith("/setups/") || // public setup detail view
|
||||||
|
location.pathname === "/login";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on `/setups/$setupId.tsx`:** The current setup detail page is fully authenticated — it includes Add Items, Delete Setup, and Public toggle buttons. For anonymous visitors hitting a `/setups/:id` URL, the page must render only read-only content. The approach: check `isAuthenticated` before rendering write action buttons (same pattern as `showFab`). The underlying data comes from the private `GET /api/setups/:id` endpoint which IS auth-gated. Either: (a) anonymous visitors get the public endpoint response instead (`/api/setups/:id/public`), or (b) a separate route `setups/$setupId.public.tsx` for unauthenticated views. Option (a) is simpler — the public endpoint already exists and is in the server allowlist.
|
||||||
|
|
||||||
|
**Recommended:** Add `/setups/$setupId` to `isPublicRoute` and have the setup detail page detect auth state to decide which API endpoint to call (`useSetup` vs `usePublicSetup`). Since `useSetup` will return 401 for anonymous users anyway, the cleanest approach is to always call the public endpoint for unauthenticated visitors and the private endpoint when authenticated.
|
||||||
|
|
||||||
|
### Auth Prompt Modal Pattern
|
||||||
|
|
||||||
|
New component `AuthPromptModal` (or inline dialog in root): follows the same pattern as `CandidateDeleteDialog` and `ResolveDialog` in `__root.tsx` — a fixed overlay with a centered card. State managed via Zustand `uiStore`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In uiStore.ts — add:
|
||||||
|
showAuthPrompt: boolean;
|
||||||
|
openAuthPrompt: () => void;
|
||||||
|
closeAuthPrompt: () => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Modal content:
|
||||||
|
<h3>Sign in to manage your collection</h3>
|
||||||
|
<p>To manage your own collection, sign in or sign up.</p>
|
||||||
|
<a href="/login">Sign in</a>
|
||||||
|
<a href="/login">Create account</a> {/* Logto handles signup at same endpoint */}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Logto handles both sign-in and sign-up at the `/login` OIDC redirect. The two links can both go to `/login` — Logto's UI presents options. The UX distinction is in the button labels ("Sign in" vs "Create account"), not different URLs.
|
||||||
|
|
||||||
|
### Write Action Interception
|
||||||
|
|
||||||
|
All write action buttons that anonymous visitors could encounter on public pages need this guard:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pattern: check isAuthenticated before write action
|
||||||
|
function handleAddToCollection() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt(); // from uiStore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// existing add logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages with write actions reachable by anonymous visitors:
|
||||||
|
- `/global-items/$globalItemId` — "Add to Collection" button, "Add to Thread" button
|
||||||
|
- `/setups/$setupId` (public view) — no write actions needed in read-only view
|
||||||
|
- `/users/$userId` — read-only, no write actions
|
||||||
|
|
||||||
|
The catalog pages are the primary concern.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Hard redirecting in `__root.tsx`:** `window.location.href = "/login"` causes a full page reload and is hostile to visitors who haven't signed up. Replace with `navigate({ to: "/login" })` for private-only routes, and only after auth resolves — not during loading.
|
||||||
|
- **Per-path rate limit keys without normalization:** The current store key is `${ip}:${c.req.path}`. Dynamic segments like `/api/global-items/123` vs `/api/global-items/456` create separate buckets. For detail endpoints, this is fine (per-item limits). For list endpoints, the path is always `/api/global-items` so no issue.
|
||||||
|
- **Blocking render on auth:** The existing `authLoading` return early renders a spinner. This violates D-09 — remove it entirely.
|
||||||
|
- **Memory leak in rate limit store:** The `cleanup()` function is called on every request — this is fine for low-traffic but grows with unique IPs. Not a concern for this phase; document as future optimization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| IP extraction from proxy headers | Custom header parsing | Existing `getClientIp()` in `rateLimit.ts` already handles `x-forwarded-for` |
|
||||||
|
| Auth state in components | Local `useState` for auth | Existing `useAuth()` hook — already cached by React Query |
|
||||||
|
| Modal overlay | Custom CSS backdrop | Follow existing `CandidateDeleteDialog` pattern in `__root.tsx` — `fixed inset-0 z-50` with `bg-black/30` backdrop |
|
||||||
|
| Zustand store additions | New store | Extend existing `uiStore.ts` with `showAuthPrompt` boolean |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: `/setups/$setupId` is an Authenticated Route
|
||||||
|
**What goes wrong:** Adding `/setups/` to `isPublicRoute` lets the anonymous user past the redirect, but `useSetup(id)` calls `GET /api/setups/:id` which requires auth. The request returns 401, the component shows "Setup not found."
|
||||||
|
**Why it happens:** The private setup endpoint is auth-gated (not in the allowlist). The public variant is `/api/setups/:id/public`.
|
||||||
|
**How to avoid:** In the setup detail page, detect `isAuthenticated`. If unauthenticated, call `usePublicSetup(id)` (new hook wrapping GET `/api/setups/:id/public`). If authenticated, call `useSetup(id)` as before. Render only read-only content when using the public hook.
|
||||||
|
**Warning signs:** 404 or empty page for anonymous visitors on `/setups/:id`.
|
||||||
|
|
||||||
|
### Pitfall 2: Onboarding Loading Gate Still Blocks
|
||||||
|
**What goes wrong:** After removing the `authLoading` spinner, the `onboardingLoading` spinner (lines 147-154 in `__root.tsx`) still blocks render for unauthenticated users.
|
||||||
|
**Why it happens:** `useOnboardingComplete()` calls an auth-required endpoint. For unauthenticated users, it returns an error, and `onboardingLoading` may be `true` briefly.
|
||||||
|
**How to avoid:** The `showWizard` check already guards on `isAuthenticated` — `useOnboardingComplete` should be disabled/skipped when not authenticated. Use `{ enabled: isAuthenticated }` in the query options.
|
||||||
|
**Warning signs:** Anonymous visitors see a spinner on first render even after removing the auth spinner.
|
||||||
|
|
||||||
|
### Pitfall 3: Rate Limiter `_resetForTesting` Not Exported for New Tiers
|
||||||
|
**What goes wrong:** If `createRateLimit` uses a shared store or the test reset only clears the original store, new-tier tests bleed state between tests.
|
||||||
|
**Why it happens:** The store `Map` is module-level. Multiple tier instances share it.
|
||||||
|
**How to avoid:** Either (a) pass a store instance per tier (cleanest), or (b) keep the single module-level store and export `_resetForTesting` (it clears the whole store — fine for tests). Option (b) is simpler given existing test pattern.
|
||||||
|
|
||||||
|
### Pitfall 4: TotalsBar "Sign in" vs D-10 Conflict
|
||||||
|
**What goes wrong:** D-10 says "Show a 'Sign in' button in the top-right corner on all public pages." The TotalsBar already does this (verified in `TotalsBar.tsx` lines 39-47). But D-05 was misread in the context as "no changes needed for TotalsBar." TotalsBar IS the global header.
|
||||||
|
**Why it happens:** Context note says "TotalsBar only shown in collection views" — this is inaccurate based on code review. It renders on every page (it's in `__root.tsx` line 159 as `<TotalsBar />`).
|
||||||
|
**How to avoid:** No changes to TotalsBar are needed — it already correctly shows "Sign in" for anonymous users. This is already done. The D-10 requirement is satisfied by existing code.
|
||||||
|
|
||||||
|
### Pitfall 5: `window.location.href` vs Router Navigate
|
||||||
|
**What goes wrong:** Removing the hard redirect to `/login` but forgetting to add a soft redirect for genuinely private routes (`/collection`, `/settings`, `/threads`) means those pages render for anonymous users.
|
||||||
|
**Why it happens:** The redirect removal is necessary for public routes, but private routes still need protection.
|
||||||
|
**How to avoid:** Replace `window.location.href = "/login"` with TanStack Router `navigate({ to: "/login" })` scoped only to private routes, and only invoked after `authLoading` is false (not during the loading state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Rate Limit Factory
|
||||||
|
```typescript
|
||||||
|
// src/server/middleware/rateLimit.ts
|
||||||
|
// Source: based on existing implementation in this file
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
export function createRateLimit(maxAttempts: number, windowMs: number) {
|
||||||
|
return async function(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (entry.count >= maxAttempts) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: "Too many requests. Try again later." }, 429);
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible export for existing OAuth usage
|
||||||
|
export async function rateLimit(c: Context, next: Next) {
|
||||||
|
return createRateLimit(5, 15 * 60 * 1000)(c, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetForTesting() {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Layout Auth Fix (key diff)
|
||||||
|
```typescript
|
||||||
|
// src/client/routes/__root.tsx — key changes
|
||||||
|
|
||||||
|
// REMOVE: full-page spinner on authLoading
|
||||||
|
// REMOVE: window.location.href = "/login"
|
||||||
|
|
||||||
|
// REPLACE isPublicRoute with:
|
||||||
|
const isPublicRoute =
|
||||||
|
location.pathname === "/" ||
|
||||||
|
location.pathname.startsWith("/users/") ||
|
||||||
|
location.pathname.startsWith("/global-items") ||
|
||||||
|
location.pathname.startsWith("/setups/") ||
|
||||||
|
location.pathname === "/login";
|
||||||
|
|
||||||
|
// REPLACE hard redirect with soft redirect for private routes only:
|
||||||
|
if (!isAuthenticated && !isPublicRoute && !authLoading) {
|
||||||
|
navigate({ to: "/login" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// REMOVE: onboarding spinner gate for unauthenticated users
|
||||||
|
// (showWizard already guards on isAuthenticated — just remove the separate loading gate)
|
||||||
|
```
|
||||||
|
|
||||||
|
### usePublicSetup Hook (new)
|
||||||
|
```typescript
|
||||||
|
// src/client/hooks/useSetups.ts — add:
|
||||||
|
export function usePublicSetup(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["setups", id, "public"],
|
||||||
|
queryFn: () => apiGet<PublicSetup>(`/api/setups/${id}/public`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | Impact |
|
||||||
|
|--------------|------------------|--------|
|
||||||
|
| Auth spinner then redirect (current) | Render immediately, soft redirect only for private routes | PUBL-04 requirement |
|
||||||
|
| Hard `window.location.href` redirect | TanStack Router `navigate()` | No full page reload, better UX |
|
||||||
|
| Single rate limit tier (5/15min) | Factory with configurable tiers | Enables appropriate limits per endpoint type |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **`/setups/$setupId` anonymous route — separate page or conditional rendering?**
|
||||||
|
- What we know: The current setup detail page (`setups/$setupId.tsx`) has heavy write actions (Add Items, Delete, Public toggle) that make no sense for anonymous visitors.
|
||||||
|
- What's unclear: Whether to build a completely separate read-only public setup page at a new route (e.g., `/setups/$setupId/view`) or gate the existing page's write actions on `isAuthenticated`.
|
||||||
|
- Recommendation: Keep the single route `/setups/$setupId`. Detect `isAuthenticated`, call the correct API endpoint, and conditionally render write action sections. This is lower scope and matches D-04's intent of "expand public routes" rather than "add new routes."
|
||||||
|
|
||||||
|
2. **Rate limit tier for tag browse endpoint?**
|
||||||
|
- What we know: GET `/api/tags` is already public; used for filtering in the catalog.
|
||||||
|
- Recommendation: Apply `browseTier` (120 req/min). Tags are lightweight and unlikely to be abused separately from global items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
Step 2.6: SKIPPED — Phase 24 is code-only changes. No external services or CLI tools beyond the existing Bun/Node runtime are introduced. Rate limiting uses the existing in-memory Map. No new database migrations required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Bun test (built-in) |
|
||||||
|
| Config file | `bunfig.toml` (if present) or none — `bun test` discovers `tests/**/*.test.ts` |
|
||||||
|
| Quick run command | `bun test tests/middleware/rateLimit.test.ts tests/routes/global-items.test.ts tests/routes/profiles.test.ts` |
|
||||||
|
| Full suite command | `bun test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| PUBL-01 | GET `/api/global-items` returns 200 without auth | unit | `bun test tests/routes/global-items.test.ts` | YES |
|
||||||
|
| PUBL-02 | GET `/api/setups/:id/public` returns 200 without auth | unit | `bun test tests/routes/profiles.test.ts` (contains public setup tests) | YES |
|
||||||
|
| PUBL-03 | GET `/api/users/:id/profile` returns 200 without auth | unit | `bun test tests/routes/profiles.test.ts` | YES |
|
||||||
|
| PUBL-04 | Root layout renders without spinner for anonymous visitor | e2e / manual | `bun run test:e2e` + manual visual check | Wave 0 — e2e test needed |
|
||||||
|
| PUBL-05 | Write actions intercepted for anonymous users | e2e / manual | `bun run test:e2e` — visit catalog, click "Add to Collection" unauthed | Wave 0 — e2e test needed |
|
||||||
|
| INFR-01 | Rate limit returns 429 after limit exceeded on public endpoints | unit | `bun test tests/middleware/rateLimit.test.ts` | Partially YES — existing tests cover old API; new tests needed for factory tiers |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
|
||||||
|
- **Per task commit:** `bun test tests/middleware/rateLimit.test.ts tests/routes/global-items.test.ts tests/routes/profiles.test.ts`
|
||||||
|
- **Per wave merge:** `bun test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
|
||||||
|
- [ ] `tests/middleware/rateLimit.test.ts` — extend with `createRateLimit` factory tests (configurable max/window)
|
||||||
|
- [ ] `e2e/public-access.spec.ts` — covers PUBL-04 (no spinner on load) and PUBL-05 (auth prompt on write action)
|
||||||
|
|
||||||
|
*(Existing test infrastructure covers PUBL-01 through PUBL-03 and INFR-01 base cases.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Direct code inspection: `src/server/index.ts` — verified existing public route allowlist (lines 121-140)
|
||||||
|
- Direct code inspection: `src/client/routes/__root.tsx` — verified `authLoading` spinner gate and `window.location.href` hard redirect
|
||||||
|
- Direct code inspection: `src/server/middleware/rateLimit.ts` — verified current single-tier implementation
|
||||||
|
- Direct code inspection: `src/client/components/TotalsBar.tsx` — verified "Sign in" link already present for anonymous users
|
||||||
|
- Direct code inspection: `src/client/hooks/useAuth.ts` — verified React Query `useQuery` with `retry: false`
|
||||||
|
- Direct code inspection: `tests/middleware/rateLimit.test.ts`, `tests/routes/profiles.test.ts`, `tests/routes/global-items.test.ts` — verified existing test coverage
|
||||||
|
- Direct code inspection: `package.json` — verified TanStack Router ^1.167.0, React Query ^5.90.21, Hono ^4.12.8
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- None required — all findings are from direct source code inspection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — all libraries already in project, verified versions
|
||||||
|
- Architecture patterns: HIGH — based on direct code reading of files to be modified
|
||||||
|
- Pitfalls: HIGH — each pitfall identified from actual code behavior verified in source files
|
||||||
|
- Rate limit tier values: MEDIUM — reasonable defaults per D-08 discretion; expect tuning
|
||||||
|
|
||||||
|
**Research date:** 2026-04-10
|
||||||
|
**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
phase: 24
|
||||||
|
slug: public-access-infrastructure
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 24 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Bun test (built-in) |
|
||||||
|
| **Config file** | `bunfig.toml` (if present) or none — `bun test` discovers `tests/**/*.test.ts` |
|
||||||
|
| **Quick run command** | `bun test tests/middleware/rateLimit.test.ts tests/routes/global-items.test.ts tests/routes/profiles.test.ts` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Estimated runtime** | ~15 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test tests/middleware/rateLimit.test.ts tests/routes/global-items.test.ts tests/routes/profiles.test.ts`
|
||||||
|
- **After every plan wave:** Run `bun test`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 15 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 24-01-01 | 01 | 1 | INFR-01 | unit | `bun test tests/middleware/rateLimit.test.ts` | ⚠️ Partial | ⬜ pending |
|
||||||
|
| 24-01-02 | 01 | 1 | PUBL-01 | unit | `bun test tests/routes/global-items.test.ts` | ✅ | ⬜ pending |
|
||||||
|
| 24-01-03 | 01 | 1 | PUBL-02 | unit | `bun test tests/routes/profiles.test.ts` | ✅ | ⬜ pending |
|
||||||
|
| 24-01-04 | 01 | 1 | PUBL-03 | unit | `bun test tests/routes/profiles.test.ts` | ✅ | ⬜ pending |
|
||||||
|
| 24-02-01 | 02 | 1 | PUBL-04 | e2e | `bun run test:e2e` | ❌ W0 | ⬜ pending |
|
||||||
|
| 24-02-02 | 02 | 1 | PUBL-05 | e2e | `bun run test:e2e` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `tests/middleware/rateLimit.test.ts` — extend with `createRateLimit` factory tests (configurable max/window)
|
||||||
|
- [ ] `e2e/public-access.spec.ts` — covers PUBL-04 (no spinner on anonymous load) and PUBL-05 (auth prompt on write action)
|
||||||
|
|
||||||
|
*Existing infrastructure covers PUBL-01 through PUBL-03 and INFR-01 base cases.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| No auth spinner on first load | PUBL-04 | Visual timing check | Visit root URL in incognito, verify content renders without spinner flash |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 15s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
phase: 24-public-access-infrastructure
|
||||||
|
verified: 2026-04-10T12:00:00Z
|
||||||
|
status: gaps_found
|
||||||
|
score: 5/6 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
gaps:
|
||||||
|
- truth: "Anonymous visitor can view a public setup with its items and totals"
|
||||||
|
status: partial
|
||||||
|
reason: "Setup items display correctly but item images are missing for anonymous viewers. getPublicSetupWithItems does not call withImageUrls, so no presigned S3 URLs are generated. The $setupId.tsx component passes item.imageUrl (undefined) to ItemCard — confirmed TS2339 type error at line 284."
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/services/profile.service.ts"
|
||||||
|
issue: "getPublicSetupWithItems (line 87) does not call withImageUrls on the returned item list, unlike the private getSetupWithItems in setup.service.ts"
|
||||||
|
- path: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
issue: "Line 284: item.imageUrl is passed to ItemCard but SetupItemWithCategory only defines imageFilename. TypeScript error TS2339 confirms property does not exist on the type. Images silently not displayed for anonymous users."
|
||||||
|
missing:
|
||||||
|
- "Call withImageUrls on items in getPublicSetupWithItems, or add imageUrl to the service return type by enriching from storage service"
|
||||||
|
- "Remove item.imageUrl reference from $setupId.tsx ItemCard props (or add imageUrl to SetupItemWithCategory after enrichment)"
|
||||||
|
human_verification:
|
||||||
|
- test: "Anonymous visitor can view a public setup page"
|
||||||
|
expected: "Setup renders with item list, weight totals, and cost totals. No Add Items / Delete / Public toggle buttons visible. Back arrow link works."
|
||||||
|
why_human: "Visual confirmation of rendered output and write-action absence requires browser"
|
||||||
|
- test: "Auth prompt modal behavior on catalog detail page"
|
||||||
|
expected: "Clicking 'Add to Collection' or 'Add to Thread' shows the modal. Backdrop click closes it. Escape key closes it. Both buttons route to /login."
|
||||||
|
why_human: "Modal interaction and keyboard events require browser verification"
|
||||||
|
- test: "No auth spinner or redirect on first anonymous visit"
|
||||||
|
expected: "App renders immediately at / and /global-items without redirect to /login or loading spinner"
|
||||||
|
why_human: "Render timing and redirect behavior requires browser verification in an incognito session"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 24: Public Access Infrastructure Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Anyone can browse the catalog, public setups, and user profiles without logging in
|
||||||
|
**Verified:** 2026-04-10T12:00:00Z
|
||||||
|
**Status:** gaps_found
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Public GET endpoints return 429 after exceeding the configured rate limit | VERIFIED | `createRateLimit` factory confirmed in rateLimit.ts:23; 11 tests pass |
|
||||||
|
| 2 | Different endpoint tiers have different rate limit thresholds | VERIFIED | browseTier(120, 60_000) and detailTier(60, 60_000) confirmed in index.ts:122-123 |
|
||||||
|
| 3 | Existing OAuth rate limiting (5 req/15 min) continues to work unchanged | VERIFIED | `rateLimit = createRateLimit(5, 15 * 60 * 1000)` at rateLimit.ts:44; backward-compat tests pass |
|
||||||
|
| 4 | Anonymous visitor sees app content immediately on any public route — no spinner, no redirect | VERIFIED | authLoading spinner block removed from __root.tsx; soft navigate() guard fires only after auth resolves and !authLoading |
|
||||||
|
| 5 | Anonymous visitor can browse the global item catalog and open catalog detail pages | VERIFIED | isPublicRoute includes pathname.startsWith("/global-items"); auth middleware skips GET /api/global-items; no auth required |
|
||||||
|
| 6 | Anonymous visitor can view a public setup with its items and totals | PARTIAL | Setup items and totals render correctly. Item images absent for anonymous viewers — getPublicSetupWithItems does not call withImageUrls; item.imageUrl is undefined (TS2339 at $setupId.tsx:284) |
|
||||||
|
| 7 | Anonymous visitor can view a user profile page | VERIFIED | isPublicRoute includes /users/; auth skips GET /api/users/:id/profile; getPublicProfile queries users + setups from DB |
|
||||||
|
| 8 | Anonymous visitor clicking 'Add to Collection' or 'Add to Thread' sees a sign-in/sign-up prompt | VERIFIED | openAuthPrompt() called before openAddToCollection/openAddToThread in $globalItemId.tsx:141-158; AuthPromptModal rendered globally in __root.tsx |
|
||||||
|
| 9 | Authenticated user experience is unchanged — all write actions work as before | VERIFIED | isAuthenticated guards all new branches; mutation hooks retained; private useSetup path unchanged |
|
||||||
|
|
||||||
|
**Score:** 8/9 truths verified (1 partial)
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `src/server/middleware/rateLimit.ts` | createRateLimit factory function | VERIFIED | exports createRateLimit, rateLimit, _resetForTesting; 49 lines, substantive |
|
||||||
|
| `src/server/index.ts` | Rate limit middleware applied to public GET endpoints | VERIFIED | browseTier and detailTier instantiated at lines 122-123; applied to 5 endpoint groups before auth middleware at line 151 |
|
||||||
|
| `tests/middleware/rateLimit.test.ts` | Tests for configurable rate limit tiers | VERIFIED | 181 lines; two describe blocks; createRateLimit factory with 5 tests; rateLimit backward compat with 6 tests; all 11 pass |
|
||||||
|
| `src/client/routes/__root.tsx` | Render-first root layout with expanded isPublicRoute | VERIFIED | No authLoading spinner; isPublicRoute includes /global-items and /setups/; AuthPromptModal rendered |
|
||||||
|
| `src/client/stores/uiStore.ts` | showAuthPrompt state for auth modal | VERIFIED | showAuthPrompt, openAuthPrompt, closeAuthPrompt in interface and implementation |
|
||||||
|
| `src/client/components/AuthPromptModal.tsx` | Modal prompting anonymous users to sign in or sign up | VERIFIED | Contains "sign in or sign up"; fixed overlay z-50; backdrop dismiss; two /login links |
|
||||||
|
| `src/client/hooks/useSetups.ts` | usePublicSetup hook for anonymous setup viewing | VERIFIED | usePublicSetup exported at line 67; calls /api/setups/${setupId}/public; enabled guard; 404-aware retry |
|
||||||
|
| `src/client/routes/global-items/$globalItemId.tsx` | Auth-guarded write action buttons on catalog detail | VERIFIED | openAuthPrompt imported and called in both button handlers with !isAuthenticated check |
|
||||||
|
| `src/client/routes/setups/$setupId.tsx` | Conditional public vs private setup rendering | PARTIAL | usePublicSetup imported and used; conditional data source correct; but item.imageUrl does not exist on SetupItemWithCategory type |
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `src/server/index.ts` | `src/server/middleware/rateLimit.ts` | import createRateLimit | WIRED | Line 13: `import { createRateLimit } from "./middleware/rateLimit.ts"`; pattern `createRateLimit(120,` confirmed at line 122 |
|
||||||
|
| `src/client/routes/__root.tsx` | `src/client/components/AuthPromptModal.tsx` | rendered in root layout | WIRED | Line 15: import; line 184: `<AuthPromptModal />` in JSX |
|
||||||
|
| `src/client/routes/global-items/$globalItemId.tsx` | `src/client/stores/uiStore.ts` | openAuthPrompt action | WIRED | Line 19: `const openAuthPrompt = useUIStore((s) => s.openAuthPrompt)`; called at lines 142 and 154 |
|
||||||
|
| `src/client/routes/setups/$setupId.tsx` | `src/client/hooks/useSetups.ts` | usePublicSetup hook | WIRED | Line 11: `usePublicSetup` imported; lines 33-36: conditional fetch logic using hook |
|
||||||
|
|
||||||
|
### Data-Flow Trace (Level 4)
|
||||||
|
|
||||||
|
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||||
|
|----------|---------------|--------|--------------------|--------|
|
||||||
|
| `$setupId.tsx` | `setup` | `usePublicSetup` → `GET /api/setups/:id/public` → `getPublicSetupWithItems` | DB queries: `setups` table (line 88) + `setupItems` JOIN `items` JOIN `categories` (line 95-132) | FLOWING (items/totals); imageUrl STATIC (undefined — withImageUrls not called) |
|
||||||
|
| `$globalItemId.tsx` | `item` | `useGlobalItem` → `GET /api/global-items/:id` | DB query in globalItems service | FLOWING |
|
||||||
|
| `$userId.tsx` | `profile` | `usePublicProfile` → `GET /api/users/:id/profile` → `getPublicProfile` | DB queries: users table (line 37) + setups (line 49) with SQL aggregates | FLOWING |
|
||||||
|
|
||||||
|
### Behavioral Spot-Checks
|
||||||
|
|
||||||
|
| Behavior | Command | Result | Status |
|
||||||
|
|----------|---------|--------|--------|
|
||||||
|
| Rate limit tests pass | `bun test tests/middleware/rateLimit.test.ts` | 11 pass, 0 fail | PASS |
|
||||||
|
| Lint clean in src/ | `bun run lint` (src/ only) | 0 errors in src/ (4 pre-existing .obsidian/ format errors) | PASS |
|
||||||
|
| createRateLimit factory exported | grep pattern in rateLimit.ts | `export function createRateLimit(maxAttempts: number, windowMs: number)` at line 23 | PASS |
|
||||||
|
| browseTier applied before auth | Line order check in index.ts | Rate limits lines 122-148, auth middleware line 151 | PASS |
|
||||||
|
| Public setup endpoint exists | setups.ts route check | `app.get("/:id/public"` at line 45; delegates to getPublicSetupWithItems | PASS |
|
||||||
|
| imageUrl on public setup items | TS error check | TS2339 at $setupId.tsx:284 — item.imageUrl does not exist on SetupItemWithCategory | FAIL |
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|------------|-------------|--------|----------|
|
||||||
|
| PUBL-01 | 24-02 | Browse global item catalog without logging in | SATISFIED | isPublicRoute includes /global-items; auth middleware skips GET /api/global-items; catalog route accessible |
|
||||||
|
| PUBL-02 | 24-02 | View public setups without logging in | PARTIAL | Setup items and totals accessible via usePublicSetup; images missing for anon users (withImageUrls not called in public endpoint) |
|
||||||
|
| PUBL-03 | 24-02 | View user profiles without logging in | SATISFIED | isPublicRoute includes /users/; auth skips GET /api/users/:id/profile; getPublicProfile queries DB |
|
||||||
|
| PUBL-04 | 24-02 | No auth spinner or redirect on first visit | SATISFIED | authLoading spinner block removed; isPublicRoute expanded; soft navigate() fires only after authLoading resolves |
|
||||||
|
| PUBL-05 | 24-02 | Login required only for create/edit/delete | SATISFIED | Write actions in $globalItemId.tsx and $setupId.tsx guarded by isAuthenticated; unauthenticated users see AuthPromptModal |
|
||||||
|
| INFR-01 | 24-01 | Public API endpoints rate-limited | SATISFIED | createRateLimit factory; browseTier (120/min) and detailTier (60/min) applied to all 5 public GET endpoint groups |
|
||||||
|
|
||||||
|
All 6 requirements claimed by phase 24 plans are accounted for. No orphaned requirements found in REQUIREMENTS.md traceability table.
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| `src/client/routes/setups/$setupId.tsx` | 284 | `imageUrl={item.imageUrl}` — property does not exist on `SetupItemWithCategory` | Warning | Item images silently absent for anonymous viewers of public setups. TypeScript error TS2339 confirms the type gap. No runtime crash but visual content gap. |
|
||||||
|
| `src/server/services/profile.service.ts` | 87-135 | `getPublicSetupWithItems` returns items without presigned image URLs | Warning | Companion to above — public endpoint does not enrich items with S3 presigned URLs. Private endpoint calls `withImageUrls`; public endpoint does not. |
|
||||||
|
|
||||||
|
No TODO/FIXME/placeholder comments found in phase-modified files. No empty implementations. No console.log-only handlers.
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Public Setup Page Renders Read-Only
|
||||||
|
|
||||||
|
**Test:** Open an incognito browser window, navigate to a public setup URL (e.g., `/setups/1` if a public setup exists). Verify setup name, item list with weights/costs, and total weight/cost render. Confirm no "Add Items", "Delete Setup", or "Public/Private" toggle buttons are visible.
|
||||||
|
**Expected:** Full read-only view of the setup. No write-action controls.
|
||||||
|
**Why human:** Visual confirmation of rendered content and conditional UI requires browser.
|
||||||
|
|
||||||
|
#### 2. Auth Prompt Modal Interaction
|
||||||
|
|
||||||
|
**Test:** Open an incognito window, navigate to a catalog item detail page (`/global-items/:id`), click "Add to Collection". Verify the AuthPromptModal appears with "Join GearBox" heading and "sign in or sign up" text. Test backdrop click, Escape key, and "Sign in" / "Create account" button routes.
|
||||||
|
**Expected:** Modal appears on first click, dismisses on backdrop/Escape, both buttons navigate to `/login`.
|
||||||
|
**Why human:** Modal interaction, keyboard events, and navigation behavior require browser verification.
|
||||||
|
|
||||||
|
#### 3. Render-First on Anonymous Visit
|
||||||
|
|
||||||
|
**Test:** Open an incognito window, navigate to `/`. Verify the app renders immediately without a spinner. Navigate to `/global-items`. Confirm catalog loads without redirect to `/login`.
|
||||||
|
**Expected:** Instant render, no spinner, no redirect.
|
||||||
|
**Why human:** Render timing and absence of auth redirect requires browser observation.
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
One functional gap was found that prevents full goal achievement for PUBL-02:
|
||||||
|
|
||||||
|
**Image display in public setup views** — When an anonymous user views a public setup at `/setups/:id`, item images will not display. The root cause is a missing `withImageUrls` call in `getPublicSetupWithItems`. The private `getSetupWithItems` in `setup.service.ts` calls `withImageUrls` to generate presigned S3 URLs and attaches them as `imageUrl` on each item. The public equivalent in `profile.service.ts` does not. The client code (`$setupId.tsx:284`) passes `item.imageUrl` to `ItemCard`, but `SetupItemWithCategory` has no such field — TypeScript confirms this with TS2339. The result is silent: no crash, items and totals render, but images are absent.
|
||||||
|
|
||||||
|
The fix requires either: (a) calling `withImageUrls` in `getPublicSetupWithItems` and returning the enriched items, or (b) removing the `imageUrl={item.imageUrl}` prop from the public render path.
|
||||||
|
|
||||||
|
This gap does not block the core browsing experience (items, names, weights, totals all work) but falls short of the full read-only parity the phase intended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-04-10T12:00:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
472
.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md
Normal file
472
.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
---
|
||||||
|
phase: 25-catalog-enrichment-agent-tools
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_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
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- CATL-01
|
||||||
|
- CATL-02
|
||||||
|
- CATL-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "upsertGlobalItem called with sourceUrl, imageCredit, imageSourceUrl returns them in the result"
|
||||||
|
- "Two upserts with the same (brand, model) return the same item id and created: false on the second call"
|
||||||
|
- "Inserting a duplicate (brand, model) updates the existing row instead of failing"
|
||||||
|
- "bulkUpsertGlobalItems returns accurate created vs updated counts matching input mix"
|
||||||
|
- "Tags are synced (create-if-not-exists) when provided, left untouched when omitted"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/db/schema.ts"
|
||||||
|
provides: "globalItems table with attribution columns and unique constraint"
|
||||||
|
contains: "sourceUrl"
|
||||||
|
- path: "src/shared/schemas.ts"
|
||||||
|
provides: "Zod schemas for upsert and bulk upsert"
|
||||||
|
contains: "upsertGlobalItemSchema"
|
||||||
|
- path: "src/server/services/global-item.service.ts"
|
||||||
|
provides: "upsertGlobalItem and bulkUpsertGlobalItems functions"
|
||||||
|
exports: ["upsertGlobalItem", "bulkUpsertGlobalItems"]
|
||||||
|
- path: "tests/services/global-item.service.test.ts"
|
||||||
|
provides: "Tests for upsert, duplicate handling, bulk, tags"
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/services/global-item.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "onConflictDoUpdate target referencing unique constraint"
|
||||||
|
pattern: "onConflictDoUpdate.*target.*globalItems\\.brand.*globalItems\\.model"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add attribution columns and unique constraint to globalItems, create upsert service functions, and define Zod validation schemas for catalog enrichment.
|
||||||
|
|
||||||
|
Purpose: Establish the data layer foundation that HTTP routes (Plan 02) and MCP tools (Plan 02) will call.
|
||||||
|
Output: Schema migration applied, upsertGlobalItem + bulkUpsertGlobalItems service functions, Zod schemas, passing tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
|
||||||
|
@src/db/schema.ts
|
||||||
|
@src/shared/schemas.ts
|
||||||
|
@src/shared/types.ts
|
||||||
|
@src/server/services/global-item.service.ts
|
||||||
|
@src/server/routes/settings.ts
|
||||||
|
@src/server/services/setup.service.ts
|
||||||
|
@tests/services/global-item.service.test.ts
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Current globalItems table (src/db/schema.ts lines 136-146) -->
|
||||||
|
```typescript
|
||||||
|
export const globalItems = pgTable("global_items", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
brand: text("brand").notNull(),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
category: text("category"),
|
||||||
|
weightGrams: doublePrecision("weight_grams"),
|
||||||
|
priceCents: integer("price_cents"),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Tags table (src/db/schema.ts lines 150-154) -->
|
||||||
|
```typescript
|
||||||
|
export const tags = pgTable("tags", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: text("name").notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- globalItemTags junction (src/db/schema.ts lines 158-169) -->
|
||||||
|
```typescript
|
||||||
|
export const globalItemTags = pgTable("global_item_tags", {
|
||||||
|
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
|
||||||
|
tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
|
||||||
|
}, (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })]);
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Multi-column onConflictDoUpdate pattern (src/server/routes/settings.ts lines 33-37) -->
|
||||||
|
```typescript
|
||||||
|
await database
|
||||||
|
.insert(settings)
|
||||||
|
.values({ userId, key, value: body.value })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [settings.userId, settings.key],
|
||||||
|
set: { value: body.value },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Transaction pattern (src/server/services/setup.service.ts) -->
|
||||||
|
```typescript
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
// multiple tx operations, auto-rollback on throw
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Categories table unique constraint pattern (src/db/schema.ts) -->
|
||||||
|
```typescript
|
||||||
|
export const categories = pgTable("categories", {
|
||||||
|
// ...columns...
|
||||||
|
}, (table) => [unique().on(table.userId, table.name)]);
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Schema migration — attribution columns + unique constraint</name>
|
||||||
|
<files>src/db/schema.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts (current globalItems definition at lines 136-146, categories unique constraint pattern at line 26-38)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- After migration: globalItems table has sourceUrl, imageCredit, imageSourceUrl columns (all text, nullable)
|
||||||
|
- After migration: inserting two rows with same (brand, model) raises a unique violation
|
||||||
|
- Existing rows are unaffected (columns default to null)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. In `src/db/schema.ts`, update the `globalItems` table definition to add three new columns and a unique constraint:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const globalItems = pgTable(
|
||||||
|
"global_items",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
brand: text("brand").notNull(),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
category: text("category"),
|
||||||
|
weightGrams: doublePrecision("weight_grams"),
|
||||||
|
priceCents: integer("price_cents"),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
description: text("description"),
|
||||||
|
sourceUrl: text("source_url"),
|
||||||
|
imageCredit: text("image_credit"),
|
||||||
|
imageSourceUrl: text("image_source_url"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [unique().on(table.brand, table.model)],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Import `unique` from `drizzle-orm/pg-core` if not already imported (check existing imports at top of file).
|
||||||
|
|
||||||
|
3. Check for duplicate (brand, model) pairs in the dev database before generating migration:
|
||||||
|
```bash
|
||||||
|
# If duplicates exist, deduplicate before migration
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Generate and apply the migration:
|
||||||
|
```bash
|
||||||
|
bun run db:generate
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
Per D-01 (three attribution columns), D-04 (unique constraint on brand+model).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun run db:generate && bun run db:push</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/db/schema.ts contains `sourceUrl: text("source_url")`
|
||||||
|
- src/db/schema.ts contains `imageCredit: text("image_credit")`
|
||||||
|
- src/db/schema.ts contains `imageSourceUrl: text("image_source_url")`
|
||||||
|
- src/db/schema.ts contains `unique().on(table.brand, table.model)`
|
||||||
|
- A new migration SQL file exists in drizzle-pg/ directory
|
||||||
|
- `bun run db:push` exits 0
|
||||||
|
- CATL-01 manufacturer requirement satisfied by existing brand column per D-02 — no new column needed
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>globalItems table has 3 new attribution columns and a unique constraint on (brand, model), migration generated and applied</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Zod schemas + upsert service functions + tests</name>
|
||||||
|
<files>src/shared/schemas.ts, src/shared/types.ts, src/server/services/global-item.service.ts, tests/services/global-item.service.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/shared/schemas.ts (existing schema patterns, especially createItemSchema)
|
||||||
|
- src/shared/types.ts (type inference patterns from schemas)
|
||||||
|
- src/server/services/global-item.service.ts (current service, Db type, imports)
|
||||||
|
- src/server/routes/settings.ts (onConflictDoUpdate pattern at lines 33-37)
|
||||||
|
- src/server/services/setup.service.ts (transaction pattern)
|
||||||
|
- tests/services/global-item.service.test.ts (existing test structure, createTestDb usage)
|
||||||
|
- tests/helpers/db.ts (test database setup)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- upsertGlobalItem: inserting a new (brand, model) creates a row and returns it with id
|
||||||
|
- upsertGlobalItem: inserting an existing (brand, model) updates all non-key fields and returns the updated row
|
||||||
|
- upsertGlobalItem: attribution fields (sourceUrl, imageCredit, imageSourceUrl) are persisted and returned
|
||||||
|
- upsertGlobalItem: when tags are provided, creates tags if not existing and links them to the item
|
||||||
|
- upsertGlobalItem: when tags are omitted (undefined), existing tags are left untouched
|
||||||
|
- upsertGlobalItem: when tags are empty array, existing tags are cleared
|
||||||
|
- bulkUpsertGlobalItems: processes an array of items in a single transaction
|
||||||
|
- bulkUpsertGlobalItems: returns { created: N, updated: M, items: [...] } with correct counts
|
||||||
|
- bulkUpsertGlobalItems: rolls back entire transaction if any item fails
|
||||||
|
- bulkUpsertGlobalItems: handles mix of new and existing items correctly
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**1. Add Zod schemas to `src/shared/schemas.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Single catalog item upsert schema
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk catalog upsert schema
|
||||||
|
export const bulkUpsertGlobalItemsSchema = z.object({
|
||||||
|
items: z.array(upsertGlobalItemSchema).min(1).max(100),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Per D-09 (request body shape), D-08 (max 100 items).
|
||||||
|
|
||||||
|
**2. Add type exports to `src/shared/types.ts`:**
|
||||||
|
|
||||||
|
Add after existing type imports:
|
||||||
|
```typescript
|
||||||
|
import type { upsertGlobalItemSchema, bulkUpsertGlobalItemsSchema } from "./schemas.ts";
|
||||||
|
// ...
|
||||||
|
export type UpsertGlobalItemInput = z.infer<typeof upsertGlobalItemSchema>;
|
||||||
|
export type BulkUpsertGlobalItemsInput = z.infer<typeof bulkUpsertGlobalItemsSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Add service functions to `src/server/services/global-item.service.ts`:**
|
||||||
|
|
||||||
|
Add imports for `unique` if needed and add the following functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
|
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
// Add a helper to sync tags for a global item (create-if-not-exists)
|
||||||
|
async function syncGlobalItemTags(
|
||||||
|
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
|
||||||
|
globalItemId: number,
|
||||||
|
tagNames: string[],
|
||||||
|
) {
|
||||||
|
// Delete existing tags for this item
|
||||||
|
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, globalItemId));
|
||||||
|
|
||||||
|
for (const name of tagNames) {
|
||||||
|
// Upsert tag (create if not exists)
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Check if exists to determine created vs updated
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Sync tags only if explicitly provided
|
||||||
|
if (tagNames !== undefined) {
|
||||||
|
await syncGlobalItemTags(tx, item.id, tagNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item, created: !existing };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 results = [];
|
||||||
|
|
||||||
|
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++;
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, items: results };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per D-05 (ON CONFLICT DO UPDATE), D-07 (all-or-nothing transaction), D-08 (max 100 — enforced at Zod level).
|
||||||
|
|
||||||
|
**4. Add tests to `tests/services/global-item.service.test.ts`:**
|
||||||
|
|
||||||
|
Add a new `describe("upsert operations")` block with tests for:
|
||||||
|
- `upsertGlobalItem` creates new item and returns { item, created: true }
|
||||||
|
- `upsertGlobalItem` updates existing item on (brand, model) conflict and returns { item, created: false }
|
||||||
|
- `upsertGlobalItem` persists sourceUrl, imageCredit, imageSourceUrl
|
||||||
|
- `upsertGlobalItem` with tags creates tags and links them
|
||||||
|
- `upsertGlobalItem` without tags leaves existing tags untouched
|
||||||
|
- `upsertGlobalItem` with empty tags array clears existing tags
|
||||||
|
- `bulkUpsertGlobalItems` processes array, returns correct created/updated counts
|
||||||
|
- `bulkUpsertGlobalItems` handles mix of new and existing items
|
||||||
|
- `bulkUpsertGlobalItems` rolls back on error (test by inserting an item then causing a constraint violation in the same batch — though with upsert this is hard; test by verifying transaction atomicity)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test tests/services/global-item.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/shared/schemas.ts contains `export const upsertGlobalItemSchema`
|
||||||
|
- src/shared/schemas.ts contains `export const bulkUpsertGlobalItemsSchema`
|
||||||
|
- src/shared/schemas.ts contains `.max(100)` for bulk items array
|
||||||
|
- src/server/services/global-item.service.ts contains `export async function upsertGlobalItem`
|
||||||
|
- src/server/services/global-item.service.ts contains `export async function bulkUpsertGlobalItems`
|
||||||
|
- src/server/services/global-item.service.ts contains `onConflictDoUpdate`
|
||||||
|
- src/server/services/global-item.service.ts contains `db.transaction`
|
||||||
|
- tests/services/global-item.service.test.ts contains `upsert` in at least 5 test descriptions
|
||||||
|
- `bun test tests/services/global-item.service.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Zod schemas defined, upsertGlobalItem and bulkUpsertGlobalItems service functions implemented with tag sync, all tests passing</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun run db:push` exits 0 (schema valid)
|
||||||
|
- `bun test tests/services/global-item.service.test.ts` exits 0 (all upsert tests pass)
|
||||||
|
- `bun run lint` exits 0 (no Biome errors)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- globalItems table has sourceUrl, imageCredit, imageSourceUrl columns and unique(brand, model) constraint
|
||||||
|
- upsertGlobalItem function creates or updates based on (brand, model) conflict
|
||||||
|
- bulkUpsertGlobalItems function processes arrays in a single transaction with created/updated counts
|
||||||
|
- Tag sync creates tags if not existing, clears on empty array, leaves untouched when omitted
|
||||||
|
- All service-level tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -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*
|
||||||
563
.planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md
Normal file
563
.planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
---
|
||||||
|
phase: 25-catalog-enrichment-agent-tools
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["25-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/server/routes/global-items.ts
|
||||||
|
- src/server/mcp/tools/catalog.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
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- CATL-03
|
||||||
|
- CATL-04
|
||||||
|
- SEED-01
|
||||||
|
- SEED-02
|
||||||
|
- SEED-03
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "POST /api/global-items upserts a single catalog item and returns the item with id"
|
||||||
|
- "POST /api/global-items/bulk upserts up to 100 items in a single transaction and returns created/updated counts"
|
||||||
|
- "POST /api/global-items/bulk rejects the entire batch if any item fails validation"
|
||||||
|
- "MCP tool upsert_catalog_item writes a global item with attribution fields"
|
||||||
|
- "MCP tool bulk_upsert_catalog batch-writes global items via the bulk service"
|
||||||
|
- "Catalog detail page shows image credit and source link below the image when present"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/routes/global-items.ts"
|
||||||
|
provides: "POST / and POST /bulk route handlers"
|
||||||
|
contains: "bulkUpsertGlobalItems"
|
||||||
|
- path: "src/server/mcp/tools/catalog.ts"
|
||||||
|
provides: "upsert_catalog_item and bulk_upsert_catalog MCP tool definitions + handlers"
|
||||||
|
exports: ["catalogToolDefinitions", "registerCatalogTools"]
|
||||||
|
- path: "src/server/mcp/index.ts"
|
||||||
|
provides: "Catalog tool registration in createMcpServer"
|
||||||
|
contains: "registerCatalogTools"
|
||||||
|
- path: "src/client/routes/global-items/$globalItemId.tsx"
|
||||||
|
provides: "Attribution display below image"
|
||||||
|
contains: "imageCredit"
|
||||||
|
- path: "tests/routes/global-items.test.ts"
|
||||||
|
provides: "Tests for POST single and bulk endpoints"
|
||||||
|
- path: "tests/mcp/tools.test.ts"
|
||||||
|
provides: "Tests for catalog MCP tools"
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/routes/global-items.ts"
|
||||||
|
to: "src/server/services/global-item.service.ts"
|
||||||
|
via: "import and call upsertGlobalItem / bulkUpsertGlobalItems"
|
||||||
|
pattern: "import.*upsertGlobalItem.*bulkUpsertGlobalItems"
|
||||||
|
- from: "src/server/mcp/tools/catalog.ts"
|
||||||
|
to: "src/server/services/global-item.service.ts"
|
||||||
|
via: "import and call upsertGlobalItem / bulkUpsertGlobalItems"
|
||||||
|
pattern: "import.*upsertGlobalItem.*bulkUpsertGlobalItems"
|
||||||
|
- from: "src/server/mcp/index.ts"
|
||||||
|
to: "src/server/mcp/tools/catalog.ts"
|
||||||
|
via: "import catalogToolDefinitions + registerCatalogTools"
|
||||||
|
pattern: "catalogToolDefinitions.*registerCatalogTools"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add HTTP upsert endpoints, MCP catalog tools, and client-side attribution display for global items.
|
||||||
|
|
||||||
|
Purpose: Complete the API and agent tooling layer so MCP agents can seed the catalog, and display attribution metadata on catalog detail pages.
|
||||||
|
Output: POST /api/global-items, POST /api/global-items/bulk, two MCP tools, attribution UI on detail page, passing tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md
|
||||||
|
|
||||||
|
@src/server/routes/global-items.ts
|
||||||
|
@src/server/mcp/index.ts
|
||||||
|
@src/server/mcp/tools/items.ts
|
||||||
|
@src/server/mcp/tools/images.ts
|
||||||
|
@src/server/services/global-item.service.ts
|
||||||
|
@src/shared/schemas.ts
|
||||||
|
@src/client/hooks/useGlobalItems.ts
|
||||||
|
@src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
@tests/routes/global-items.test.ts
|
||||||
|
@tests/mcp/tools.test.ts
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 01 output: service functions (src/server/services/global-item.service.ts) -->
|
||||||
|
```typescript
|
||||||
|
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[];
|
||||||
|
},
|
||||||
|
): Promise<{ item: GlobalItem; created: boolean }>;
|
||||||
|
|
||||||
|
export async function bulkUpsertGlobalItems(
|
||||||
|
db: Db,
|
||||||
|
itemsData: Array<{ /* same fields as above */ }>,
|
||||||
|
): Promise<{ created: number; updated: number; items: GlobalItem[] }>;
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From Plan 01 output: Zod schemas (src/shared/schemas.ts) -->
|
||||||
|
```typescript
|
||||||
|
export const upsertGlobalItemSchema = z.object({
|
||||||
|
brand: z.string().min(1), model: z.string().min(1),
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Existing route pattern (src/server/routes/global-items.ts) -->
|
||||||
|
```typescript
|
||||||
|
import { Hono } from "hono";
|
||||||
|
type Env = { Variables: { db?: any } };
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
app.get("/", async (c) => { ... });
|
||||||
|
app.get("/:id", async (c) => { ... });
|
||||||
|
export { app as globalItemRoutes };
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- MCP tool pattern (src/server/mcp/tools/items.ts) -->
|
||||||
|
```typescript
|
||||||
|
export const itemToolDefinitions = [
|
||||||
|
{ name: "...", description: "...", inputSchema: { /* z.* fields */ } },
|
||||||
|
];
|
||||||
|
export function registerItemTools(db: Db, userId: number) {
|
||||||
|
return { tool_name: async (args): Promise<ToolResult> => { ... } };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- MCP registration pattern (src/server/mcp/index.ts) -->
|
||||||
|
```typescript
|
||||||
|
// Image tools (no userId needed):
|
||||||
|
const imageHandlers = registerImageTools();
|
||||||
|
for (const def of imageToolDefinitions) {
|
||||||
|
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
|
||||||
|
server.tool(def.name, def.description, def.inputSchema, handler);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Client GlobalItem interface (src/client/hooks/useGlobalItems.ts lines 4-14) -->
|
||||||
|
```typescript
|
||||||
|
interface GlobalItem {
|
||||||
|
id: number; brand: string; model: string; category: string | null;
|
||||||
|
weightGrams: number | null; priceCents: number | null;
|
||||||
|
imageUrl: string | null; description: string | null; createdAt: string;
|
||||||
|
}
|
||||||
|
interface GlobalItemWithOwnerCount extends GlobalItem { ownerCount: number; }
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Catalog detail page image section ($globalItemId.tsx lines 65-85) -->
|
||||||
|
```tsx
|
||||||
|
{/* Image */}
|
||||||
|
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
|
||||||
|
{item.imageUrl ? ( <img ... /> ) : ( <div>...</div> )}
|
||||||
|
</div>
|
||||||
|
{/* Header */}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: HTTP routes for single and bulk upsert</name>
|
||||||
|
<files>src/server/routes/global-items.ts, tests/routes/global-items.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/routes/global-items.ts (current GET-only routes)
|
||||||
|
- src/server/routes/setups.ts (POST route with zValidator pattern)
|
||||||
|
- src/server/services/global-item.service.ts (upsertGlobalItem, bulkUpsertGlobalItems signatures from Plan 01)
|
||||||
|
- src/shared/schemas.ts (upsertGlobalItemSchema, bulkUpsertGlobalItemsSchema from Plan 01)
|
||||||
|
- src/server/index.ts (auth middleware — confirm POST on /api/global-items requires auth, lines 150-170)
|
||||||
|
- tests/routes/global-items.test.ts (existing test structure)
|
||||||
|
- tests/helpers/db.ts (createTestDb helper)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- POST /api/global-items with valid body returns 200 with { item, created: true/false }
|
||||||
|
- POST /api/global-items with invalid body (missing brand) returns 400
|
||||||
|
- POST /api/global-items/bulk with valid body returns 200 with { created, updated, items }
|
||||||
|
- POST /api/global-items/bulk with >100 items returns 400
|
||||||
|
- POST /api/global-items/bulk with invalid item in array returns 400 (rejected before DB)
|
||||||
|
- POST /api/global-items/bulk with empty array returns 400
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**1. Add imports and POST routes to `src/server/routes/global-items.ts`:**
|
||||||
|
|
||||||
|
Add these imports at the top:
|
||||||
|
```typescript
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import {
|
||||||
|
upsertGlobalItemSchema,
|
||||||
|
bulkUpsertGlobalItemsSchema,
|
||||||
|
} from "../../shared/schemas.ts";
|
||||||
|
import {
|
||||||
|
upsertGlobalItem,
|
||||||
|
bulkUpsertGlobalItems,
|
||||||
|
} from "../services/global-item.service.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the existing imports to include the new service functions (keep `getGlobalItemWithOwnerCount` and `searchGlobalItems`).
|
||||||
|
|
||||||
|
Add after the existing GET routes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
No auth middleware changes needed — the existing auth middleware in `src/server/index.ts` already requires auth for all non-GET requests on `/api/global-items*`.
|
||||||
|
|
||||||
|
**2. Add tests to `tests/routes/global-items.test.ts`:**
|
||||||
|
|
||||||
|
Add a `describe("POST /api/global-items")` block with tests:
|
||||||
|
- Valid single upsert returns 200 with item and created flag
|
||||||
|
- Missing brand returns 400
|
||||||
|
- Duplicate (brand, model) upserts instead of creating duplicate
|
||||||
|
|
||||||
|
Add a `describe("POST /api/global-items/bulk")` block with tests:
|
||||||
|
- Valid bulk upsert returns 200 with created/updated counts
|
||||||
|
- Empty items array returns 400
|
||||||
|
- Array with >100 items returns 400 (mock or construct 101 items)
|
||||||
|
- Invalid item in array returns 400 and nothing is persisted
|
||||||
|
- Mix of new and existing items returns correct counts
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test tests/routes/global-items.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/routes/global-items.ts contains `app.post("/", zValidator("json", upsertGlobalItemSchema)`
|
||||||
|
- src/server/routes/global-items.ts contains `app.post("/bulk", zValidator("json", bulkUpsertGlobalItemsSchema)`
|
||||||
|
- src/server/routes/global-items.ts contains `import.*upsertGlobalItem`
|
||||||
|
- src/server/routes/global-items.ts contains `import.*bulkUpsertGlobalItems`
|
||||||
|
- tests/routes/global-items.test.ts contains at least 4 test cases with `POST`
|
||||||
|
- `bun test tests/routes/global-items.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>POST /api/global-items and POST /api/global-items/bulk endpoints operational with Zod validation, all route tests passing</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: MCP catalog tools — upsert_catalog_item and bulk_upsert_catalog</name>
|
||||||
|
<files>src/server/mcp/tools/catalog.ts, src/server/mcp/index.ts, tests/mcp/tools.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/mcp/tools/items.ts (full file — tool definition + handler pattern with ToolResult, textResult, errorResult)
|
||||||
|
- src/server/mcp/tools/images.ts (no-userId factory pattern)
|
||||||
|
- src/server/mcp/index.ts (registration loop pattern, createMcpServer function)
|
||||||
|
- src/server/services/global-item.service.ts (upsertGlobalItem, bulkUpsertGlobalItems from Plan 01)
|
||||||
|
- tests/mcp/tools.test.ts (existing MCP tool test structure)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Create `src/server/mcp/tools/catalog.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { db as prodDb } from "../../../db/index.ts";
|
||||||
|
import {
|
||||||
|
upsertGlobalItem,
|
||||||
|
bulkUpsertGlobalItems,
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per D-11 (upsert_catalog_item), D-12 (bulk_upsert_catalog), D-13 (auth via existing MCP middleware), D-14 (register in index.ts following pattern), SEED-03 (attribution fields as parameters).
|
||||||
|
|
||||||
|
**2. Register in `src/server/mcp/index.ts`:**
|
||||||
|
|
||||||
|
Add import at the top with the other tool imports:
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
catalogToolDefinitions,
|
||||||
|
registerCatalogTools,
|
||||||
|
} from "./tools/catalog.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add registration block inside `createMcpServer` function, after the image tools registration (around line 56):
|
||||||
|
```typescript
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT modify the `createMcpServer(db, userId)` function signature — just pass `db` only to `registerCatalogTools`.
|
||||||
|
|
||||||
|
**3. Add tests to `tests/mcp/tools.test.ts`:**
|
||||||
|
|
||||||
|
Add a `describe("catalog tools")` block with tests:
|
||||||
|
- `upsert_catalog_item` creates a new global item and returns it with created: true
|
||||||
|
- `upsert_catalog_item` updates existing item on (brand, model) match
|
||||||
|
- `upsert_catalog_item` includes attribution fields (sourceUrl, imageCredit, imageSourceUrl) in result — pass all three attribution fields and assert they appear in the returned item (SEED-03 coverage)
|
||||||
|
- `bulk_upsert_catalog` processes array and returns created/updated counts
|
||||||
|
- `bulk_upsert_catalog` returns totalProcessed matching input length
|
||||||
|
- Tool definitions include all attribution fields in inputSchema
|
||||||
|
|
||||||
|
Test by calling `registerCatalogTools(db)` directly and invoking handlers, following the pattern in the existing MCP tools tests.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test tests/mcp/tools.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/mcp/tools/catalog.ts exists and contains `export const catalogToolDefinitions`
|
||||||
|
- src/server/mcp/tools/catalog.ts contains `export function registerCatalogTools`
|
||||||
|
- src/server/mcp/tools/catalog.ts contains `upsert_catalog_item` in definitions
|
||||||
|
- src/server/mcp/tools/catalog.ts contains `bulk_upsert_catalog` in definitions
|
||||||
|
- src/server/mcp/tools/catalog.ts contains `sourceUrl` and `imageCredit` and `imageSourceUrl` in inputSchema
|
||||||
|
- src/server/mcp/index.ts contains `import.*catalogToolDefinitions.*registerCatalogTools`
|
||||||
|
- src/server/mcp/index.ts contains `registerCatalogTools(db)`
|
||||||
|
- tests/mcp/tools.test.ts contains `upsert_catalog_item` in at least 2 test descriptions
|
||||||
|
- tests/mcp/tools.test.ts contains at least one test that passes sourceUrl, imageCredit, and imageSourceUrl to upsert_catalog_item and asserts they appear in the returned item (SEED-03 coverage)
|
||||||
|
- `bun test tests/mcp/tools.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Two MCP catalog tools registered and functional with attribution fields, all MCP tool tests passing</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Client attribution display on catalog detail page</name>
|
||||||
|
<files>src/client/hooks/useGlobalItems.ts, src/client/routes/global-items/$globalItemId.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/client/hooks/useGlobalItems.ts (GlobalItem interface at lines 4-14)
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx (full component, image section at lines 65-85)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**1. Update `GlobalItem` interface in `src/client/hooks/useGlobalItems.ts`:**
|
||||||
|
|
||||||
|
Add three new fields to the `GlobalItem` interface (after `description`):
|
||||||
|
```typescript
|
||||||
|
interface GlobalItem {
|
||||||
|
id: number;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
category: string | null;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
imageCredit: string | null;
|
||||||
|
imageSourceUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GlobalItemWithOwnerCount` extends `GlobalItem` so it inherits the new fields automatically.
|
||||||
|
|
||||||
|
**2. Add attribution display to `src/client/routes/global-items/$globalItemId.tsx`:**
|
||||||
|
|
||||||
|
Insert attribution text immediately after the image `div` (after line 85 — the closing `</div>` of the image section) and before the `{/* Header */}` comment. Per D-03: inline below the image, not collapsible.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `sourceUrl` display: if `item.sourceUrl` exists, show it as a link in the specs/details section (after the description, at the bottom):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the existing `mb-6` from the image div's parent className (the `<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">`) and let the attribution `<p>` handle the spacing with its `mb-6` class.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun run lint && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `sourceUrl: string | null`
|
||||||
|
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `imageCredit: string | null`
|
||||||
|
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `imageSourceUrl: string | null`
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx contains `item.imageCredit`
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx contains `item.imageSourceUrl`
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx contains `item.sourceUrl`
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx contains `Photo:`
|
||||||
|
- `bun run build` exits 0 (no TypeScript errors)
|
||||||
|
- `bun run lint` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Catalog detail page shows image attribution inline below image (credit + source link) and product page link, client types updated. Manual verification required: open a catalog item with imageCredit set and confirm credit and source link render below the image (CATL-03 is visual — no automated test).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/routes/global-items.test.ts` exits 0
|
||||||
|
- `bun test tests/mcp/tools.test.ts` exits 0
|
||||||
|
- `bun run build` exits 0
|
||||||
|
- `bun run lint` exits 0
|
||||||
|
- `bun test` full suite exits 0
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- POST /api/global-items accepts and upserts a single catalog item with attribution fields
|
||||||
|
- POST /api/global-items/bulk accepts up to 100 items, rejects entire batch on validation failure, returns created/updated counts
|
||||||
|
- upsert_catalog_item MCP tool writes to globalItems with all attribution fields
|
||||||
|
- bulk_upsert_catalog MCP tool batch-writes via the bulk service
|
||||||
|
- Catalog detail page displays image credit and source link below the image when present
|
||||||
|
- Catalog detail page displays product page link when sourceUrl is present
|
||||||
|
- All tests pass, build succeeds, lint clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -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*
|
||||||
120
.planning/phases/25-catalog-enrichment-agent-tools/25-CONTEXT.md
Normal file
120
.planning/phases/25-catalog-enrichment-agent-tools/25-CONTEXT.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Phase 25: Catalog Enrichment & Agent Tools - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-10
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Add attribution metadata fields to global items, enforce uniqueness on (brand, model) to prevent duplicates, create a bulk import API endpoint with upsert semantics, and add MCP tools (`upsert_catalog_item`, `bulk_upsert_catalog`) for agent-powered catalog seeding. Display attribution info on catalog detail pages.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Attribution Data Model
|
||||||
|
- **D-01:** Add three new columns to `globalItems`: `sourceUrl` (text, nullable — product page URL), `imageCredit` (text, nullable — photographer or source name), `imageSourceUrl` (text, nullable — original image URL). These align with the existing `imageSourceUrl` pattern on `items` and `threadCandidates` tables.
|
||||||
|
- **D-02:** No separate `manufacturer` column — the existing `brand` field already serves this purpose. Requirements reference to "manufacturer" maps to `brand`.
|
||||||
|
- **D-03:** Attribution display on catalog detail page: image credit and source link shown inline below the image, not in a collapsible section. Simple and transparent.
|
||||||
|
|
||||||
|
### Uniqueness Constraint
|
||||||
|
- **D-04:** Add a unique constraint on `(brand, model)` to `globalItems`. Same physical product shouldn't exist twice regardless of category. Category is a classification, not identity.
|
||||||
|
- **D-05:** Upsert semantics on conflict: `ON CONFLICT (brand, model) DO UPDATE` — update all non-key fields (category, weight, price, image, description, attribution fields).
|
||||||
|
|
||||||
|
### Bulk Import API
|
||||||
|
- **D-06:** `POST /api/global-items/bulk` — accepts an array of items, upserts all in a single transaction. Requires API key auth (existing auth model).
|
||||||
|
- **D-07:** All-or-nothing transaction — if any item fails validation, reject the entire batch with detailed error response listing which items failed and why.
|
||||||
|
- **D-08:** Maximum 100 items per request. Return count of created vs updated items in the response.
|
||||||
|
- **D-09:** Request body shape: `{ items: [{ brand, model, category?, weightGrams?, priceCents?, imageUrl?, description?, sourceUrl?, imageCredit?, imageSourceUrl?, tags?: string[] }] }`.
|
||||||
|
|
||||||
|
### Single Item Upsert API
|
||||||
|
- **D-10:** `POST /api/global-items` — upsert a single catalog item with the same conflict handling as bulk. Also requires auth.
|
||||||
|
|
||||||
|
### MCP Tools
|
||||||
|
- **D-11:** Add `upsert_catalog_item` MCP tool — writes directly to `globalItems` (not user-scoped). Parameters include all `globalItem` fields plus attribution fields plus optional `tags` array.
|
||||||
|
- **D-12:** Add `bulk_upsert_catalog` MCP tool — accepts an array of items, calls the bulk import service. Same field set as single upsert.
|
||||||
|
- **D-13:** MCP catalog tools require authenticated session (API key or OAuth bearer), same as all existing MCP tools. No special admin role.
|
||||||
|
- **D-14:** Register new MCP tools in `src/server/mcp/index.ts` following the existing pattern (definitions array + handler registration).
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Drizzle migration approach for new columns and unique constraint
|
||||||
|
- Exact Zod validation schemas for bulk import payload
|
||||||
|
- MCP tool descriptions and parameter documentation
|
||||||
|
- Tag handling in upsert (create-if-not-exists vs require existing tags)
|
||||||
|
- Response shape details for bulk import (created/updated counts, item IDs)
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Schema & Database
|
||||||
|
- `src/db/schema.ts` — Current `globalItems` table definition (lines 136-146), `globalItemTags` junction (lines 158-169), and `items`/`threadCandidates` tables with existing `imageSourceUrl` column pattern
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- `src/server/services/global-item.service.ts` — Current read-only service (searchGlobalItems, getGlobalItemWithOwnerCount). Needs create/upsert functions.
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
- `src/server/routes/global-items.ts` — Current read-only routes (GET search, GET by ID). Needs POST endpoints.
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
- `src/server/mcp/index.ts` — MCP server setup and tool registration pattern
|
||||||
|
- `src/server/mcp/tools/items.ts` — Example of existing tool definitions + handler pattern to follow
|
||||||
|
- `src/server/mcp/tools/` — All existing tool files for reference on naming and structure
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- Catalog detail page component (wherever `globalItems/:id` route renders) — needs attribution display additions
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- `.planning/REQUIREMENTS.md` — CATL-01 through CATL-05, SEED-01 through SEED-03
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `global-item.service.ts` — existing search and getById pattern to extend with create/upsert
|
||||||
|
- `imageSourceUrl` column pattern on `items` (line 56) and `threadCandidates` (line 98) — same pattern for globalItems attribution
|
||||||
|
- MCP tool registration pattern — definitions array + register function per domain (see `tools/items.ts`)
|
||||||
|
- Zod validation schemas in `src/shared/schemas.ts` — extend with globalItem create/upsert schemas
|
||||||
|
- Tag service (`tag.service.ts`) — likely has create-if-not-exists logic for tag handling
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Service DI: functions take `(db, ...)` — global item services don't need userId (catalog is shared)
|
||||||
|
- Drizzle ORM migrations via `bun run db:generate` and `bun run db:push`
|
||||||
|
- Hono route handlers with `@hono/zod-validator` for request validation
|
||||||
|
- Prices as cents (integer), weights as grams (doublePrecision)
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `src/db/schema.ts` — add columns to globalItems, add unique constraint
|
||||||
|
- `src/server/index.ts` — no new route group needed (global-items route already registered)
|
||||||
|
- `src/server/mcp/index.ts` — register new catalog tool group
|
||||||
|
- Catalog detail page — add attribution display below image
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- Manufacturer images with attribution and source link — honor takedown requests (from PROJECT.md decisions)
|
||||||
|
- Agent seeding uses manufacturer-provided images — no scraping (from Out of Scope in REQUIREMENTS.md)
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- SEED-04 (actual seeding run with 100+ items) — tracked in future requirements, not part of this phase's infrastructure work
|
||||||
|
- Admin role for catalog management — current auth model sufficient for v2.1
|
||||||
|
- Catalog item edit UI — this phase focuses on API/MCP tools, not a web editor
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 25-catalog-enrichment-agent-tools*
|
||||||
|
*Context gathered: 2026-04-10*
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Phase 25: Catalog Enrichment & Agent Tools - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-04-10
|
||||||
|
**Phase:** 25-catalog-enrichment-agent-tools
|
||||||
|
**Areas discussed:** Attribution data model, Uniqueness constraint design, Bulk import API design, MCP tool scope
|
||||||
|
**Mode:** --batch --auto (all decisions auto-selected)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attribution Data Model
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| manufacturer is alias for brand | No new column — existing `brand` field serves as manufacturer | ✓ |
|
||||||
|
| Separate manufacturer column | Add `manufacturer` alongside `brand` for cases where they differ | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] manufacturer is alias for brand (recommended default)
|
||||||
|
**Notes:** The `brand` field already captures manufacturer identity. Adding a separate column would create confusion about which to use. Three new columns added: `sourceUrl`, `imageCredit`, `imageSourceUrl`.
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Inline below image | Show attribution directly below the product image | ✓ |
|
||||||
|
| Collapsible section | Hide attribution in an expandable panel | |
|
||||||
|
| Footer area | Show attribution at bottom of detail page | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Inline below image (recommended default)
|
||||||
|
**Notes:** Transparency is a project value — attribution should be visible, not hidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uniqueness Constraint Design
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Unique on (brand, model) only | Category is classification, not identity | ✓ |
|
||||||
|
| Unique on (brand, model, category) | Allow same product in different categories | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Unique on (brand, model) only (recommended default)
|
||||||
|
**Notes:** A physical product is one thing regardless of how it's categorized. Prevents duplicates when agents might categorize differently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bulk Import API Design
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| API key auth (existing) | Same auth as other write endpoints | ✓ |
|
||||||
|
| Special admin token | Separate credential for bulk operations | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] API key auth (recommended default)
|
||||||
|
**Notes:** No admin role system exists. API key is sufficient for single-admin setup.
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| All-or-nothing transaction | Reject entire batch on any validation failure | ✓ |
|
||||||
|
| Partial success | Import valid items, skip invalid ones | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] All-or-nothing transaction (recommended default)
|
||||||
|
**Notes:** Upsert handles conflicts. Validation failures are data quality issues — better to fix and retry than have partial imports.
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| 100 items per request | Reasonable batch for agents | ✓ |
|
||||||
|
| 50 items per request | Conservative limit | |
|
||||||
|
| 500 items per request | Large batch for bulk seeding | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] 100 items per request (recommended default)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Tool Scope
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Standard auth (API key/OAuth) | Same as existing MCP tools | ✓ |
|
||||||
|
| Unauthenticated catalog writes | Allow any MCP client to write catalog | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Standard auth (recommended default)
|
||||||
|
**Notes:** Catalog tools follow the same auth pattern as all other MCP tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Drizzle migration approach for new columns and unique constraint
|
||||||
|
- Zod validation schemas for bulk import payload
|
||||||
|
- MCP tool descriptions and parameter documentation
|
||||||
|
- Tag handling in upsert (create-if-not-exists vs require existing)
|
||||||
|
- Response shape for bulk import
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- SEED-04: actual seeding run (future requirement)
|
||||||
|
- Admin role for catalog management
|
||||||
|
- Catalog item edit UI (web editor)
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
# Phase 25: Catalog Enrichment & Agent Tools - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-10
|
||||||
|
**Domain:** Drizzle ORM upserts + Hono REST API + MCP tool registration + React detail page
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
- **D-01:** Add three new columns to `globalItems`: `sourceUrl` (text, nullable), `imageCredit` (text, nullable), `imageSourceUrl` (text, nullable).
|
||||||
|
- **D-02:** No separate `manufacturer` column — existing `brand` field serves this purpose.
|
||||||
|
- **D-03:** Attribution display on catalog detail page: image credit and source link shown inline below the image, not in a collapsible section.
|
||||||
|
- **D-04:** Add a unique constraint on `(brand, model)` to `globalItems`.
|
||||||
|
- **D-05:** Upsert semantics on conflict: `ON CONFLICT (brand, model) DO UPDATE` — update all non-key fields.
|
||||||
|
- **D-06:** `POST /api/global-items/bulk` — accepts array of items, upserts all in a single transaction. Requires API key auth.
|
||||||
|
- **D-07:** All-or-nothing transaction — if any item fails validation, reject the entire batch with detailed error response.
|
||||||
|
- **D-08:** Maximum 100 items per request. Return count of created vs updated items in the response.
|
||||||
|
- **D-09:** Request body shape: `{ items: [{ brand, model, category?, weightGrams?, priceCents?, imageUrl?, description?, sourceUrl?, imageCredit?, imageSourceUrl?, tags?: string[] }] }`.
|
||||||
|
- **D-10:** `POST /api/global-items` — upsert a single catalog item with the same conflict handling as bulk. Also requires auth.
|
||||||
|
- **D-11:** Add `upsert_catalog_item` MCP tool — writes directly to `globalItems` (not user-scoped).
|
||||||
|
- **D-12:** Add `bulk_upsert_catalog` MCP tool — accepts array of items, calls the bulk import service.
|
||||||
|
- **D-13:** MCP catalog tools require authenticated session (API key or OAuth bearer), same as all existing MCP tools.
|
||||||
|
- **D-14:** Register new MCP tools in `src/server/mcp/index.ts` following the existing pattern.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
- Drizzle migration approach for new columns and unique constraint
|
||||||
|
- Exact Zod validation schemas for bulk import payload
|
||||||
|
- MCP tool descriptions and parameter documentation
|
||||||
|
- Tag handling in upsert (create-if-not-exists vs require existing tags)
|
||||||
|
- Response shape details for bulk import (created/updated counts, item IDs)
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
- SEED-04 (actual seeding run with 100+ items)
|
||||||
|
- Admin role for catalog management
|
||||||
|
- Catalog item edit UI
|
||||||
|
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| CATL-01 | Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl) | D-01: schema migration adds three columns; `brand` already serves manufacturer role (D-02) |
|
||||||
|
| CATL-02 | Global items have a unique constraint on (brand, model) preventing duplicates | D-04: Drizzle `unique()` constraint in schema; migration via `bun run db:generate && db:push` |
|
||||||
|
| CATL-03 | Catalog detail pages display image attribution with credit and source link | D-03: inline display below image in `$globalItemId.tsx`; `GlobalItem` interface needs new fields |
|
||||||
|
| CATL-04 | Bulk import API endpoint accepts multiple catalog items in one request | D-06: `POST /api/global-items/bulk`; zValidator + bulkUpsertGlobalItems service function |
|
||||||
|
| CATL-05 | Bulk import uses upsert semantics (ON CONFLICT update, not fail) | D-05: `onConflictDoUpdate({ target: [brand, model], set: {...} })` — already used elsewhere |
|
||||||
|
| SEED-01 | MCP server has `upsert_catalog_item` tool writing to globalItems (not user-scoped) | D-11/D-14: new `catalog.ts` tool file following `items.ts` pattern; registered in `mcp/index.ts` |
|
||||||
|
| SEED-02 | MCP server has `bulk_upsert_catalog` tool for batch catalog population | D-12: same tool file; calls bulkUpsertGlobalItems service |
|
||||||
|
| SEED-03 | Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters | D-11/D-12: inputSchema includes all attribution fields; same auth model as existing tools |
|
||||||
|
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 25 adds write capability to the global items catalog: attribution metadata columns, a uniqueness constraint on `(brand, model)`, single and bulk upsert API endpoints, two new MCP tools, and attribution display on the catalog detail page. All patterns already exist in the codebase — this phase is entirely additive and follows established conventions.
|
||||||
|
|
||||||
|
The DB layer uses Drizzle ORM 0.45.1 with PostgreSQL (via `pg` driver in production, `@electric-sql/pglite` in tests). Drizzle's `.onConflictDoUpdate()` is already used in `auth.service.ts` (single-column conflict) and `settings.ts` (multi-column conflict), so the upsert pattern for `(brand, model)` is proven. The migration workflow is `bun run db:generate` (drizzle-kit) then `bun run db:push`.
|
||||||
|
|
||||||
|
MCP tools follow a three-part pattern: an exported `*ToolDefinitions` array, an exported `register*Tools(db, userId)` factory, and registration loops in `mcp/index.ts`. Catalog tools are unique in that they do NOT need `userId` (the catalog is shared, not user-scoped), but `userId` is still available for auth validation if needed.
|
||||||
|
|
||||||
|
**Primary recommendation:** Create `src/server/mcp/tools/catalog.ts`, extend `global-item.service.ts` with `upsertGlobalItem` and `bulkUpsertGlobalItems`, add `POST` routes to `global-items.ts`, run a schema migration, update the client hook interface, and add attribution display to `$globalItemId.tsx`.
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| drizzle-orm | 0.45.1 | ORM + upsert via `onConflictDoUpdate` | Project standard; upsert already in use |
|
||||||
|
| drizzle-kit | 0.31.9 | Schema migration generation | Project standard; `bun run db:generate` |
|
||||||
|
| hono | 4.12.8 | HTTP routing for new POST endpoints | Project standard |
|
||||||
|
| @hono/zod-validator | 0.7.6 | Request body validation middleware | Used on every POST/PUT route |
|
||||||
|
| zod | 4.3.6 | Schema definitions for bulk payload | Project standard for shared schemas |
|
||||||
|
| @modelcontextprotocol/sdk | 1.29.0 | MCP tool registration | Project standard; used in `mcp/index.ts` |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| @electric-sql/pglite | 0.4.3 | In-memory Postgres for tests | Tests only — uses `createTestDb()` helper |
|
||||||
|
| @tanstack/react-query | 5.90.21 | Client-side data fetching | Update `useGlobalItem` interface after new fields land |
|
||||||
|
|
||||||
|
**Installation:** No new dependencies needed. All required libraries are already installed.
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
The phase touches these existing files (no new directories needed):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── db/
|
||||||
|
│ └── schema.ts # Add 3 columns + unique constraint to globalItems
|
||||||
|
├── shared/
|
||||||
|
│ └── schemas.ts # Add upsertGlobalItemSchema + bulkUpsertSchema
|
||||||
|
├── server/
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── global-item.service.ts # Add upsertGlobalItem + bulkUpsertGlobalItems
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── global-items.ts # Add POST / and POST /bulk handlers
|
||||||
|
│ └── mcp/
|
||||||
|
│ ├── index.ts # Register catalog tool group
|
||||||
|
│ └── tools/
|
||||||
|
│ └── catalog.ts # NEW: upsert_catalog_item + bulk_upsert_catalog
|
||||||
|
├── client/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ └── useGlobalItems.ts # Add sourceUrl, imageCredit, imageSourceUrl to interface
|
||||||
|
│ └── routes/
|
||||||
|
│ └── global-items/
|
||||||
|
│ └── $globalItemId.tsx # Add attribution display below image
|
||||||
|
drizzle-pg/
|
||||||
|
└── XXXX_catalog_enrichment.sql # Generated migration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Drizzle Upsert on Multi-Column Conflict
|
||||||
|
|
||||||
|
The `onConflictDoUpdate` API with an array target is already proven in `settings.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: src/server/routes/settings.ts (line 33)
|
||||||
|
await database
|
||||||
|
.insert(settings)
|
||||||
|
.values({ userId, key, value: body.value })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [settings.userId, settings.key],
|
||||||
|
set: { value: body.value },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For `globalItems` with a unique constraint on `(brand, model)`, the pattern is:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Adapts from settings.ts pattern — for global-item.service.ts
|
||||||
|
const [item] = await db
|
||||||
|
.insert(globalItems)
|
||||||
|
.values(data)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [globalItems.brand, globalItems.model],
|
||||||
|
set: {
|
||||||
|
category: data.category,
|
||||||
|
weightGrams: data.weightGrams,
|
||||||
|
priceCents: data.priceCents,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
description: data.description,
|
||||||
|
sourceUrl: data.sourceUrl,
|
||||||
|
imageCredit: data.imageCredit,
|
||||||
|
imageSourceUrl: data.imageSourceUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return item;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key insight:** The unique constraint must exist on the table for `.onConflictDoUpdate({ target: [...] })` to reference it. The Drizzle migration (generated from the schema change) creates the constraint — `target` in `onConflictDoUpdate` references the schema columns, not raw strings.
|
||||||
|
|
||||||
|
### Pattern 2: All-or-Nothing Transaction for Bulk Upsert
|
||||||
|
|
||||||
|
Drizzle transactions are used in `setup.service.ts` and `thread.service.ts`. The bulk upsert wraps all inserts in a single transaction:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Adapts from src/server/services/setup.service.ts (line 164)
|
||||||
|
export async function bulkUpsertGlobalItems(
|
||||||
|
db: Db,
|
||||||
|
items: UpsertGlobalItemInput[],
|
||||||
|
): Promise<{ created: number; updated: number; items: GlobalItem[] }> {
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
const results: GlobalItem[] = [];
|
||||||
|
|
||||||
|
for (const data of items) {
|
||||||
|
// Check if exists to determine created vs updated count
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: globalItems.id })
|
||||||
|
.from(globalItems)
|
||||||
|
.where(and(eq(globalItems.brand, data.brand), eq(globalItems.model, data.model)));
|
||||||
|
|
||||||
|
const [item] = await tx
|
||||||
|
.insert(globalItems)
|
||||||
|
.values(data)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [globalItems.brand, globalItems.model],
|
||||||
|
set: { /* all non-key fields */ },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (existing) updated++;
|
||||||
|
else created++;
|
||||||
|
results.push(item);
|
||||||
|
|
||||||
|
// Handle tags if provided
|
||||||
|
if (data.tags && data.tags.length > 0) {
|
||||||
|
await syncGlobalItemTags(tx, item.id, data.tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, items: results };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If any `.insert()` throws (e.g., Zod fails at service level for a structural error), the transaction rolls back automatically. Zod validation happens at the route level BEFORE the transaction, so the transaction only sees pre-validated data.
|
||||||
|
|
||||||
|
### Pattern 3: MCP Tool Registration (Catalog-Specific)
|
||||||
|
|
||||||
|
The catalog tools differ from other tool groups: they do not scope to `userId`. The `createMcpServer` function signature in `mcp/index.ts` passes `userId` to every tool factory — catalog tools accept it but do not filter by it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: pattern from src/server/mcp/tools/images.ts
|
||||||
|
// images.ts already omits userId from its factory — catalog.ts follows the same approach
|
||||||
|
|
||||||
|
export const catalogToolDefinitions = [
|
||||||
|
{
|
||||||
|
name: "upsert_catalog_item",
|
||||||
|
description: "...",
|
||||||
|
inputSchema: {
|
||||||
|
brand: z.string().describe("Brand or manufacturer name"),
|
||||||
|
model: z.string().describe("Model name — combined with brand as unique identifier"),
|
||||||
|
// ... all fields
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bulk_upsert_catalog",
|
||||||
|
description: "...",
|
||||||
|
inputSchema: {
|
||||||
|
items: z.array(catalogItemSchema).max(100).describe("Array of catalog items to upsert"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Factory takes db only (no userId needed — catalog is global)
|
||||||
|
export function registerCatalogTools(db: Db) {
|
||||||
|
return {
|
||||||
|
upsert_catalog_item: async (args: UpsertArgs): Promise<ToolResult> => { ... },
|
||||||
|
bulk_upsert_catalog: async (args: { items: UpsertArgs[] }): Promise<ToolResult> => { ... },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `mcp/index.ts`, register like `imageHandlers` (no userId dependency):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In createMcpServer():
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Schema Migration for Columns + Unique Constraint
|
||||||
|
|
||||||
|
Adding columns to an existing table and a unique constraint in Drizzle:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In src/db/schema.ts — globalItems table
|
||||||
|
export const globalItems = pgTable(
|
||||||
|
"global_items",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
brand: text("brand").notNull(),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
category: text("category"),
|
||||||
|
weightGrams: doublePrecision("weight_grams"),
|
||||||
|
priceCents: integer("price_cents"),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
description: text("description"),
|
||||||
|
// NEW attribution columns:
|
||||||
|
sourceUrl: text("source_url"),
|
||||||
|
imageCredit: text("image_credit"),
|
||||||
|
imageSourceUrl: text("image_source_url"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [unique().on(table.brand, table.model)], // NEW unique constraint
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `bun run db:generate` to generate the migration SQL, then `bun run db:push` to apply. The generated SQL will contain `ALTER TABLE "global_items" ADD COLUMN ...` statements and a `CREATE UNIQUE INDEX` or `CONSTRAINT` statement.
|
||||||
|
|
||||||
|
**Important:** The existing `seedGlobalItems` function in `src/db/seed-global-items.ts` seeds with plain `db.insert()`. After the unique constraint lands, a second seed call would fail for duplicate `(brand, model)` pairs. The seed function is already idempotent by checking `existing.length > 0`, so no changes needed there.
|
||||||
|
|
||||||
|
### Pattern 5: Attribution Display (Client)
|
||||||
|
|
||||||
|
The `$globalItemId.tsx` component renders the image in a `div` below which we add attribution. The `useGlobalItem` hook interface needs updating to include new fields, then the component can conditionally render them:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* After the image div, before the header */}
|
||||||
|
{(item.imageCredit || item.imageSourceUrl) && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
{item.imageCredit && <span>Photo: {item.imageCredit}</span>}
|
||||||
|
{item.imageSourceUrl && (
|
||||||
|
<a
|
||||||
|
href={item.imageSourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-1 underline hover:text-gray-600"
|
||||||
|
>
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 6: Tag Handling in Upsert (Claude's Discretion)
|
||||||
|
|
||||||
|
**Recommendation: create-if-not-exists.** The existing `tags` table has a unique constraint on `name`. Use `onConflictDoUpdate` (or `onConflictDoNothing`) on the tags table to get the tag ID, then upsert the `globalItemTags` junction. This lets agents pass tag names without pre-populating the tags table.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function syncGlobalItemTags(tx: Tx, globalItemId: number, tagNames: string[]) {
|
||||||
|
// Delete existing tags for this item
|
||||||
|
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, globalItemId));
|
||||||
|
|
||||||
|
for (const name of tagNames) {
|
||||||
|
// Upsert tag (create if not exists)
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Do NOT modify `mcp/index.ts` `createMcpServer` signature** to remove `userId` just because catalog tools don't need it. Pass `db` only to `registerCatalogTools` — accept the `userId` parameter in `createMcpServer` and simply not forward it to catalog tools.
|
||||||
|
- **Do NOT validate tags at the Zod level as enum values.** Tags are open-ended strings; only length/format validation is appropriate.
|
||||||
|
- **Do NOT return partial success for bulk upsert.** D-07 mandates all-or-nothing. Zod schema validation at the route level catches structural errors before any DB work begins.
|
||||||
|
- **Do NOT add rate limiting to `POST /api/global-items` or `POST /api/global-items/bulk`.** These are authenticated-write endpoints — the auth middleware already gates them. The rate limiting added in Phase 24 only applies to public GET endpoints.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Upsert on conflict | Manual SELECT then INSERT/UPDATE | `onConflictDoUpdate()` | Drizzle handles atomicity; already used in settings.ts and auth.service.ts |
|
||||||
|
| All-or-nothing bulk write | Try/catch with manual rollback | `db.transaction(async (tx) => { ... })` | Drizzle handles rollback on throw; used in setup.service.ts |
|
||||||
|
| Tag create-if-not-exists | Check exists, insert if not | `onConflictDoUpdate` on tags.name | Same conflict mechanism |
|
||||||
|
| Request body validation | Manual type checking in handler | `zValidator("json", schema)` from `@hono/zod-validator` | All POST/PUT routes use this; Hono returns 400 automatically |
|
||||||
|
| Auth on POST endpoints | Custom auth check in handler | Existing `requireAuth` middleware in `src/server/index.ts` | Auth middleware already gates all non-GET `/api/global-items*` after Phase 24 |
|
||||||
|
|
||||||
|
**Key insight:** The auth middleware in `src/server/index.ts` (lines 150-170) already exempts only GET requests on `/api/global-items` from auth. POST requests on that path fall through to `requireAuth` automatically — no changes to `index.ts` needed.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Unique Constraint Not Applied to Existing Data
|
||||||
|
|
||||||
|
**What goes wrong:** If the database already has duplicate `(brand, model)` pairs (e.g., from early seeding), adding a unique constraint via migration will fail with a duplicate key error.
|
||||||
|
|
||||||
|
**Why it happens:** The migration tries to add `UNIQUE(brand, model)` to a table that already has conflicting rows.
|
||||||
|
|
||||||
|
**How to avoid:** Check for existing duplicates before generating the migration. In development with test data, truncate `global_items` or resolve duplicates first. The test database is reset between tests (`TRUNCATE ... RESTART IDENTITY CASCADE`) so tests are not affected.
|
||||||
|
|
||||||
|
**Warning signs:** `drizzle-kit push` fails with "could not create unique index" or similar.
|
||||||
|
|
||||||
|
### Pitfall 2: Client `GlobalItem` Interface Missing New Fields
|
||||||
|
|
||||||
|
**What goes wrong:** `useGlobalItems.ts` defines a local `GlobalItem` interface. After the migration adds columns, the API returns new fields but the TypeScript interface doesn't include them, so the component can't render them.
|
||||||
|
|
||||||
|
**Why it happens:** The interface at the top of `useGlobalItems.ts` (lines 3-14) is manually maintained — it's not generated from Drizzle schema types.
|
||||||
|
|
||||||
|
**How to avoid:** Update the `GlobalItem` interface in `useGlobalItems.ts` to add `sourceUrl: string | null`, `imageCredit: string | null`, `imageSourceUrl: string | null`. The `GlobalItemWithOwnerCount` extends it and gets the fields for free.
|
||||||
|
|
||||||
|
**Warning signs:** TypeScript error `Property 'imageCredit' does not exist on type 'GlobalItemWithOwnerCount'` in `$globalItemId.tsx`.
|
||||||
|
|
||||||
|
### Pitfall 3: Bulk Endpoint Registered Before Auth Middleware Applies
|
||||||
|
|
||||||
|
**What goes wrong:** `/api/global-items/bulk` is a POST endpoint. The auth middleware in `src/server/index.ts` exempts GET on `/api/global-items` (line 165-167). If the route registration order matters, the bulk POST could be accidentally exempt.
|
||||||
|
|
||||||
|
**Why it happens:** The middleware skip check uses `c.req.path.startsWith("/api/global-items") && c.req.method === "GET"` — it's already method-gated, so POST requests are not exempt. No issue in practice.
|
||||||
|
|
||||||
|
**How to avoid:** No special handling needed. The middleware skip condition already checks `c.req.method === "GET"`. Verify this by reading `src/server/index.ts` lines 165-167 before implementing.
|
||||||
|
|
||||||
|
**Warning signs:** POST to `/api/global-items/bulk` returns 200 without `X-API-Key` header.
|
||||||
|
|
||||||
|
### Pitfall 4: MCP Tool for Bulk Returns Success on Partial Failure
|
||||||
|
|
||||||
|
**What goes wrong:** If the service function for bulk upsert silently skips failed items instead of throwing, the MCP tool returns `{ created: X, updated: Y }` even though some items were not persisted.
|
||||||
|
|
||||||
|
**Why it happens:** Swallowing errors inside a loop without re-throwing.
|
||||||
|
|
||||||
|
**How to avoid:** Zod validation happens at the route level (for HTTP) and at the MCP tool handler level (parse inputSchema). The service function should assume pre-validated data and let Drizzle throw naturally inside the transaction. The transaction auto-rolls-back on any thrown error.
|
||||||
|
|
||||||
|
**Warning signs:** Bulk upsert returns counts that don't add up to the input array length, with no error.
|
||||||
|
|
||||||
|
### Pitfall 5: `onConflictDoUpdate` Target Requires Constraint, Not Arbitrary Columns
|
||||||
|
|
||||||
|
**What goes wrong:** Calling `.onConflictDoUpdate({ target: [globalItems.brand, globalItems.model], ... })` without a unique constraint on those columns causes a Postgres error at runtime.
|
||||||
|
|
||||||
|
**Why it happens:** PostgreSQL's `ON CONFLICT (col1, col2) DO UPDATE` requires an existing unique index or constraint. Drizzle passes through to Postgres directly.
|
||||||
|
|
||||||
|
**How to avoid:** The unique constraint must be added to the schema and migration applied BEFORE any upsert code runs. Always run `bun run db:push` before testing the new service functions.
|
||||||
|
|
||||||
|
**Warning signs:** `ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification`.
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from the codebase:
|
||||||
|
|
||||||
|
### Drizzle Upsert (multi-column target)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: src/server/routes/settings.ts lines 33-37
|
||||||
|
await database
|
||||||
|
.insert(settings)
|
||||||
|
.values({ userId, key, value: body.value })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [settings.userId, settings.key],
|
||||||
|
set: { value: body.value },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle Transaction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: src/server/services/setup.service.ts line 164
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
// ... multiple tx.insert / tx.select calls
|
||||||
|
// Any thrown error rolls back automatically
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hono Route with zValidator
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: src/server/routes/setups.ts line 36
|
||||||
|
app.post("/", zValidator("json", createSetupSchema), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const userId = c.get("userId")!;
|
||||||
|
const data = c.req.valid("json");
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Tool Definition + Handler (no-userId pattern)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: src/server/mcp/tools/images.ts — full file
|
||||||
|
export const imageToolDefinitions = [
|
||||||
|
{
|
||||||
|
name: "upload_image_from_url",
|
||||||
|
description: "...",
|
||||||
|
inputSchema: { url: z.string().describe("...") },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function registerImageTools() { // no db, no userId
|
||||||
|
return {
|
||||||
|
upload_image_from_url: async (args: { url: string }): Promise<ToolResult> => {
|
||||||
|
try {
|
||||||
|
const result = await fetchImageFromUrl(args.url);
|
||||||
|
return textResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
return errorResult((err as Error).message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema with Unique Constraint (table-level)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: src/db/schema.ts line 26-38 (categories table — same pattern)
|
||||||
|
export const categories = pgTable(
|
||||||
|
"categories",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
(table) => [unique().on(table.userId, table.name)],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Separate `item_global_links` junction table | `globalItemId` FK directly on `items` | Phase migration 0002 | Simpler joins; one less table |
|
||||||
|
| No unique constraint on globalItems | `unique().on(brand, model)` | This phase | Prevents duplicate catalog entries |
|
||||||
|
| Read-only global item API | Read + write (upsert) API | This phase | Enables agent-powered seeding |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Tag replacement vs merge on upsert**
|
||||||
|
- What we know: D-09 includes `tags?: string[]` as optional field
|
||||||
|
- What's unclear: When tags are omitted on an upsert of an existing item, should existing tags be left untouched, or cleared?
|
||||||
|
- Recommendation: Leave existing tags untouched when `tags` field is absent in the payload. Only sync (replace) tags when `tags` is explicitly provided (even if empty array = clear all tags). This is the least-surprising behavior for agents that send partial updates.
|
||||||
|
|
||||||
|
2. **`imageUrl` field on globalItems vs `imageFilename`**
|
||||||
|
- What we know: `globalItems.imageUrl` stores an absolute URL (unlike `items.imageFilename` which stores a filename for S3). The bulk upsert input accepts `imageUrl`.
|
||||||
|
- What's unclear: Should agents use `upload_image_from_url` first, then pass the returned filename as `imageUrl`? Or pass the original URL directly?
|
||||||
|
- Recommendation: Accept both — agents can pass any URL to `imageUrl`. When the agent wants to mirror the image to S3, they call `upload_image_from_url` first and use the result. The `imageSourceUrl` attribution field is separate and intended to record the original source regardless of where the image is now stored.
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
Step 2.6: SKIPPED (no external dependencies identified — this phase is purely code and schema changes within the existing stack; PostgreSQL/PGlite is already operational)
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Bun test runner (built-in) |
|
||||||
|
| Config file | none — `bun test` discovers `tests/**/*.test.ts` |
|
||||||
|
| Quick run command | `bun test tests/services/global-item.service.test.ts tests/routes/global-items.test.ts tests/mcp/tools.test.ts` |
|
||||||
|
| Full suite command | `bun test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| CATL-01 | Attribution columns present and returnable | integration | `bun test tests/services/global-item.service.test.ts` | ✅ (extend) |
|
||||||
|
| CATL-02 | Duplicate (brand, model) upserts rather than errors | integration | `bun test tests/services/global-item.service.test.ts` | ✅ (extend) |
|
||||||
|
| CATL-03 | Attribution rendered in detail page | manual | Visual check in browser | — |
|
||||||
|
| CATL-04 | `POST /api/global-items/bulk` accepts array | integration | `bun test tests/routes/global-items.test.ts` | ✅ (extend) |
|
||||||
|
| CATL-05 | Bulk upsert updates on conflict, returns created/updated counts | integration | `bun test tests/routes/global-items.test.ts` | ✅ (extend) |
|
||||||
|
| SEED-01 | `upsert_catalog_item` MCP tool writes to globalItems | integration | `bun test tests/mcp/tools.test.ts` | ✅ (extend) |
|
||||||
|
| SEED-02 | `bulk_upsert_catalog` MCP tool persists all items | integration | `bun test tests/mcp/tools.test.ts` | ✅ (extend) |
|
||||||
|
| SEED-03 | Attribution fields available as MCP tool parameters | unit | `bun test tests/mcp/tools.test.ts` | ✅ (extend) |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
|
||||||
|
- **Per task commit:** `bun test tests/services/global-item.service.test.ts tests/routes/global-items.test.ts tests/mcp/tools.test.ts`
|
||||||
|
- **Per wave merge:** `bun test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
|
||||||
|
None — existing test infrastructure covers all phase requirements. The three test files already exist and test the relevant modules; new test cases are added to existing `describe` blocks.
|
||||||
|
|
||||||
|
## Project Constraints (from CLAUDE.md)
|
||||||
|
|
||||||
|
- Use `bun run db:generate` + `bun run db:push` for all schema changes (not raw SQL)
|
||||||
|
- Prices stored as cents (`priceCents: integer`), weights as grams (`doublePrecision`)
|
||||||
|
- Services take `(db, ...)` as first argument — no HTTP awareness
|
||||||
|
- Hono routes delegate to services; use `@hono/zod-validator` for all request validation
|
||||||
|
- Shared Zod schemas live in `src/shared/schemas.ts`
|
||||||
|
- MCP tools: definitions array + register function pattern per domain
|
||||||
|
- Route tree is auto-generated — never edit `routeTree.gen.ts`
|
||||||
|
- Always reuse existing components; check `src/client/components/` before creating new UI elements
|
||||||
|
- `@/*` maps to `./src/*`
|
||||||
|
- Tabs, double quotes, organized imports (Biome lint)
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
|
||||||
|
- Direct codebase inspection: `src/db/schema.ts` — current globalItems table definition
|
||||||
|
- Direct codebase inspection: `src/server/services/global-item.service.ts` — existing read patterns
|
||||||
|
- Direct codebase inspection: `src/server/routes/global-items.ts` — existing route handlers
|
||||||
|
- Direct codebase inspection: `src/server/mcp/index.ts` — tool registration loop pattern
|
||||||
|
- Direct codebase inspection: `src/server/mcp/tools/items.ts` — tool definition + handler pattern
|
||||||
|
- Direct codebase inspection: `src/server/mcp/tools/images.ts` — no-userId factory pattern
|
||||||
|
- Direct codebase inspection: `src/server/routes/settings.ts` — multi-column `onConflictDoUpdate` pattern
|
||||||
|
- Direct codebase inspection: `src/server/services/auth.service.ts` — single-column `onConflictDoUpdate` pattern
|
||||||
|
- Direct codebase inspection: `src/server/services/setup.service.ts` — `db.transaction()` pattern
|
||||||
|
- Direct codebase inspection: `src/server/index.ts` — auth middleware skip logic for global-items GET
|
||||||
|
- Direct codebase inspection: `tests/helpers/db.ts` — PGlite test setup with migrations
|
||||||
|
- Direct codebase inspection: `tests/services/global-item.service.test.ts` — existing test structure
|
||||||
|
- Direct codebase inspection: `tests/routes/global-items.test.ts` — existing route test structure
|
||||||
|
- Direct codebase inspection: `tests/mcp/tools.test.ts` — MCP tool test structure
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
|
||||||
|
- drizzle-orm 0.45.1 installed version confirmed via `bun pm ls` — upsert API stable since 0.28
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
|
||||||
|
- Standard stack: HIGH — all libraries confirmed installed; APIs confirmed in use
|
||||||
|
- Architecture: HIGH — all patterns directly observed in codebase, not assumed
|
||||||
|
- Pitfalls: HIGH — derived from reading the actual middleware skip logic and schema constraints
|
||||||
|
|
||||||
|
**Research date:** 2026-04-10
|
||||||
|
**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
phase: 25
|
||||||
|
slug: catalog-enrichment-agent-tools
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: true
|
||||||
|
wave_0_complete: true
|
||||||
|
created: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 25 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Bun test runner |
|
||||||
|
| **Config file** | bunfig.toml |
|
||||||
|
| **Quick run command** | `bun test` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Estimated runtime** | ~10 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test`
|
||||||
|
- **After every plan wave:** Run `bun test`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 10 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 25-01-01 | 01 | 1 | CATL-01 | unit | `bun test tests/services/global-item.service.test.ts` | yes (existing file, new tests added inline) | ⬜ pending |
|
||||||
|
| 25-01-02 | 01 | 1 | CATL-02 | unit | `bun test tests/services/global-item.service.test.ts` | yes (existing file, new tests added inline) | ⬜ pending |
|
||||||
|
| 25-01-03 | 01 | 1 | CATL-04, CATL-05 | integration | `bun test tests/routes/global-items.test.ts` | yes (existing file, new tests added inline) | ⬜ pending |
|
||||||
|
| 25-02-01 | 02 | 2 | SEED-01, SEED-02, SEED-03 | integration | `bun test tests/mcp/tools.test.ts` | yes (existing file, new tests added inline) | ⬜ pending |
|
||||||
|
| 25-03-01 | — | — | CATL-03 | manual | N/A | N/A | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
All three test files (`tests/services/global-item.service.test.ts`, `tests/routes/global-items.test.ts`, `tests/mcp/tools.test.ts`) already exist in the codebase with established test structure. New test cases are added inline within existing `describe` blocks — no new stub files needed. Wave 0 is satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Image credit display on detail page | CATL-03 | Visual rendering | Open a catalog item with imageCredit/imageSourceUrl, verify credit text and clickable source link appear below image |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [x] Wave 0 covers all MISSING references
|
||||||
|
- [x] No watch-mode flags
|
||||||
|
- [x] Feedback latency < 10s
|
||||||
|
- [x] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** approved
|
||||||
@@ -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)_
|
||||||
182
.planning/phases/26-discovery-landing-page/26-01-PLAN.md
Normal file
182
.planning/phases/26-discovery-landing-page/26-01-PLAN.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
plan: 01
|
||||||
|
type: tdd
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/server/services/discovery.service.ts
|
||||||
|
- tests/services/discovery.service.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [DISC-02, DISC-03, DISC-04, INFR-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "getPopularSetups returns public setups ordered by item count descending"
|
||||||
|
- "getRecentGlobalItems returns items ordered by createdAt descending"
|
||||||
|
- "getTrendingCategories returns categories ordered by item count, excluding nulls"
|
||||||
|
- "Cursor pagination returns next page without duplicates"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/services/discovery.service.ts"
|
||||||
|
provides: "Discovery feed queries with cursor pagination"
|
||||||
|
exports: ["getPopularSetups", "getRecentGlobalItems", "getTrendingCategories"]
|
||||||
|
- path: "tests/services/discovery.service.test.ts"
|
||||||
|
provides: "Unit tests for all three discovery service functions"
|
||||||
|
min_lines: 100
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/services/discovery.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "Drizzle query builders using globalItems, setups, setupItems, users tables"
|
||||||
|
pattern: "from\\(globalItems\\)|from\\(setups\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the discovery service layer with three query functions: getPopularSetups, getRecentGlobalItems, and getTrendingCategories. All functions use cursor-based pagination per INFR-02 (except categories which use simple limit).
|
||||||
|
|
||||||
|
Purpose: Provides the data layer for the discovery landing page feed sections. TDD approach ensures correct ordering, filtering, and pagination before wiring to routes.
|
||||||
|
Output: `discovery.service.ts` with three exported functions, fully tested.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-CONTEXT.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-RESEARCH.md
|
||||||
|
@src/db/schema.ts
|
||||||
|
@tests/helpers/db.ts
|
||||||
|
@tests/services/global-item.service.test.ts (pattern reference for test structure)
|
||||||
|
@src/server/services/global-item.service.ts (pattern reference for service structure)
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Discovery service with TDD — popular setups, recent items, trending categories</name>
|
||||||
|
<files>src/server/services/discovery.service.ts, tests/services/discovery.service.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts (table definitions: globalItems, setups, setupItems, users)
|
||||||
|
- tests/helpers/db.ts (createTestDb pattern)
|
||||||
|
- tests/services/global-item.service.test.ts (test file structure, insertGlobalItem helper pattern)
|
||||||
|
- src/server/services/global-item.service.ts (service function patterns — how db param is typed, import style)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- getPopularSetups: returns only public setups (isPublic=true), ordered by setupItems count descending then by id descending. Each result includes id, name, createdAt, itemCount (number), creatorName (string|null from users.displayName). Private setups are excluded.
|
||||||
|
- getPopularSetups cursor: given cursor "5_42" (itemCount=5, id=42), returns setups where (itemCount < 5) OR (itemCount === 5 AND id < 42). hasMore is true when rows exceed limit.
|
||||||
|
- getRecentGlobalItems: returns globalItems ordered by createdAt descending. Each result includes all globalItems columns.
|
||||||
|
- getRecentGlobalItems cursor: given cursor ISO timestamp, returns items where createdAt < cursor timestamp. hasMore is true when rows exceed limit.
|
||||||
|
- getTrendingCategories: returns { name: string, itemCount: number }[] ordered by itemCount descending. Excludes rows where globalItems.category IS NULL. No cursor pagination (simple limit).
|
||||||
|
- getTrendingCategories empty: returns empty array when no items have a category set.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**RED phase — write tests first in `tests/services/discovery.service.test.ts`:**
|
||||||
|
|
||||||
|
Use the same test structure as `global-item.service.test.ts`:
|
||||||
|
- Import `{ beforeEach, describe, expect, it }` from `"bun:test"`
|
||||||
|
- Import schema tables: `globalItems, setups, setupItems, users` from `../../src/db/schema.ts`
|
||||||
|
- Import `createTestDb` from `../helpers/db.ts`
|
||||||
|
- Import service functions from `../../src/server/services/discovery.service.ts`
|
||||||
|
- Type `TestDb = Awaited<ReturnType<typeof createTestDb>>`
|
||||||
|
|
||||||
|
Helper functions needed in test file:
|
||||||
|
```typescript
|
||||||
|
async function insertGlobalItem(db, data: { brand: string; model: string; category?: string }) {
|
||||||
|
const [row] = await db.insert(globalItems).values({ brand: data.brand, model: data.model, category: data.category ?? null }).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
async function insertPublicSetup(db, userId: number, name: string, itemIds: number[]) {
|
||||||
|
const [setup] = await db.insert(setups).values({ name, userId, isPublic: true }).returning();
|
||||||
|
// Insert items into the items table first, then setupItems
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
await db.insert(setupItems).values({ setupId: setup.id, itemId });
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `setupItems.itemId` references the `items` table, not `globalItems`. So tests need to insert real `items` rows first (use `db.insert(items).values({ name: "Test", categoryId: 1, userId })`) before creating setupItems.
|
||||||
|
|
||||||
|
Write tests for:
|
||||||
|
1. `getPopularSetups` — seed 2 public setups with different item counts, verify order is by count desc
|
||||||
|
2. `getPopularSetups` — seed 1 private setup, verify it's excluded
|
||||||
|
3. `getPopularSetups` — cursor pagination: seed 3 setups, fetch limit=1, verify hasMore=true and nextCursor returned, fetch page 2 with cursor, verify different setup returned
|
||||||
|
4. `getPopularSetups` — includes creatorName from users.displayName (seed user with displayName, verify it appears)
|
||||||
|
5. `getRecentGlobalItems` — seed 3 items with different createdAt, verify order is newest first
|
||||||
|
6. `getRecentGlobalItems` — cursor pagination: fetch limit=1, verify hasMore, fetch page 2 with cursor
|
||||||
|
7. `getTrendingCategories` — seed items in 3 categories with different counts, verify order by count desc
|
||||||
|
8. `getTrendingCategories` — seed item with null category, verify it's excluded from results
|
||||||
|
|
||||||
|
**GREEN phase — create `src/server/services/discovery.service.ts`:**
|
||||||
|
|
||||||
|
Import from drizzle-orm: `count, desc, eq, lt, sql, and, isNotNull`
|
||||||
|
Import schema: `globalItems, setups, setupItems, users`
|
||||||
|
Import types: infer Db type the same way as `global-item.service.ts` does
|
||||||
|
|
||||||
|
Three exported functions:
|
||||||
|
|
||||||
|
`getPopularSetups(db: Db, limit = 6, cursor?: string)`:
|
||||||
|
- Query: SELECT setups.id, setups.name, setups.createdAt, COUNT(setupItems.id) AS itemCount, users.displayName AS creatorName
|
||||||
|
- FROM setups LEFT JOIN setupItems ON setupItems.setupId = setups.id LEFT JOIN users ON users.id = setups.userId
|
||||||
|
- WHERE setups.isPublic = true
|
||||||
|
- GROUP BY setups.id, setups.name, setups.createdAt, users.displayName
|
||||||
|
- ORDER BY itemCount DESC, setups.id DESC
|
||||||
|
- LIMIT limit + 1
|
||||||
|
|
||||||
|
For cursor: parse "itemCount_id" format. Use SQL HAVING or WHERE with subquery. Since Drizzle groupBy with cursor is tricky, use the post-filter approach from RESEARCH.md:
|
||||||
|
- Fetch more rows (limit * 2 + 1 if cursor provided)
|
||||||
|
- Filter in JS: keep rows where (itemCount < cursorCount) OR (itemCount === cursorCount AND id < cursorId)
|
||||||
|
- Slice to limit + 1
|
||||||
|
|
||||||
|
Return `{ items: T[], nextCursor: string | null, hasMore: boolean }` shape:
|
||||||
|
- hasMore = rows.length > limit
|
||||||
|
- items = hasMore ? rows.slice(0, limit) : rows
|
||||||
|
- nextCursor = hasMore ? `${items[items.length-1].itemCount}_${items[items.length-1].id}` : null
|
||||||
|
|
||||||
|
`getRecentGlobalItems(db: Db, limit = 8, cursor?: string)`:
|
||||||
|
- Query: SELECT * FROM globalItems WHERE (cursor ? createdAt < new Date(cursor) : true) ORDER BY createdAt DESC LIMIT limit + 1
|
||||||
|
- Return `{ items, nextCursor, hasMore }` — nextCursor is ISO string of last item's createdAt
|
||||||
|
|
||||||
|
`getTrendingCategories(db: Db, limit = 12)`:
|
||||||
|
- Query: SELECT category AS name, COUNT(id) AS itemCount FROM globalItems WHERE category IS NOT NULL GROUP BY category ORDER BY COUNT(id) DESC LIMIT limit
|
||||||
|
- Return array directly (no cursor pagination per RESEARCH.md open question 3)
|
||||||
|
|
||||||
|
**REFACTOR:** Ensure all functions handle edge cases (empty results, no cursor). Extract shared `buildCursorResponse` helper if patterns are identical.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/services/discovery.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- tests/services/discovery.service.test.ts contains `describe("getPopularSetups"` and `describe("getRecentGlobalItems"` and `describe("getTrendingCategories"`
|
||||||
|
- tests/services/discovery.service.test.ts contains at least 8 `it(` calls
|
||||||
|
- src/server/services/discovery.service.ts contains `export async function getPopularSetups(`
|
||||||
|
- src/server/services/discovery.service.ts contains `export async function getRecentGlobalItems(`
|
||||||
|
- src/server/services/discovery.service.ts contains `export async function getTrendingCategories(`
|
||||||
|
- src/server/services/discovery.service.ts contains `isNotNull(globalItems.category)` (null category exclusion)
|
||||||
|
- src/server/services/discovery.service.ts contains `eq(setups.isPublic, true)` (public-only filter)
|
||||||
|
- src/server/services/discovery.service.ts contains `nextCursor` and `hasMore` in return shapes
|
||||||
|
- `bun test tests/services/discovery.service.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All three discovery service functions pass their tests: correct ordering, cursor pagination works for setups and items, categories exclude nulls, and hasMore/nextCursor response shape is correct.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/services/discovery.service.test.ts` — all tests pass
|
||||||
|
- `bun test` — full suite still green (no regressions)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Three exported service functions exist with cursor pagination (setups, items) and simple limit (categories)
|
||||||
|
- All tests pass covering ordering, filtering, cursor, and edge cases
|
||||||
|
- Service functions are pure (take db instance, no HTTP awareness)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
87
.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md
Normal file
87
.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
plan: "01"
|
||||||
|
subsystem: server/services
|
||||||
|
tags: [discovery, service-layer, cursor-pagination, tdd, drizzle]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides: [discovery.service.ts]
|
||||||
|
affects: [26-02, 26-03]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [cursor-pagination, CursorPage-response-shape, post-query-cursor-filter]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- src/server/services/discovery.service.ts
|
||||||
|
- tests/services/discovery.service.test.ts
|
||||||
|
modified: []
|
||||||
|
decisions:
|
||||||
|
- "Composite cursor for setups: itemCount_id format, filtered post-query in JS for simplicity with grouped SQL"
|
||||||
|
- "createdAt ISO string cursor for recent items: standard timestamp-based pagination"
|
||||||
|
- "No cursor pagination for trending categories: bounded small list (< 50), simple limit is sufficient per RESEARCH.md open question 3"
|
||||||
|
- "Shared CursorPage<T> generic interface for consistent cursor response shape across setups and items"
|
||||||
|
metrics:
|
||||||
|
duration: "~2 min"
|
||||||
|
completed_date: "2026-04-10"
|
||||||
|
tasks_completed: 1
|
||||||
|
tasks_total: 1
|
||||||
|
files_created: 2
|
||||||
|
files_modified: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 26 Plan 01: Discovery Service Summary
|
||||||
|
|
||||||
|
**One-liner:** Discovery service layer with cursor pagination using Drizzle ORM — getPopularSetups (itemCount_id composite cursor), getRecentGlobalItems (ISO timestamp cursor), getTrendingCategories (simple limit).
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| 1 (RED) | Discovery service TDD — failing tests | 06b6e93 | tests/services/discovery.service.test.ts |
|
||||||
|
| 1 (GREEN) | Discovery service TDD — implementation | d1f8a7a | src/server/services/discovery.service.ts |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### `src/server/services/discovery.service.ts`
|
||||||
|
|
||||||
|
Three exported async functions:
|
||||||
|
|
||||||
|
**`getPopularSetups(db, limit=6, cursor?)`**
|
||||||
|
- JOINs setups → setupItems (count) → users (displayName)
|
||||||
|
- WHERE isPublic=true, GROUP BY setup fields
|
||||||
|
- ORDER BY item count DESC, id DESC
|
||||||
|
- Cursor: `itemCount_id` composite string, filtered post-query in JS
|
||||||
|
- Returns `CursorPage<{ id, name, createdAt, itemCount, creatorName }>`
|
||||||
|
|
||||||
|
**`getRecentGlobalItems(db, limit=8, cursor?)`**
|
||||||
|
- SELECT * FROM globalItems WHERE createdAt < cursor (if provided)
|
||||||
|
- ORDER BY createdAt DESC, LIMIT limit+1 for hasMore detection
|
||||||
|
- Cursor: ISO timestamp of last item's createdAt
|
||||||
|
- Returns `CursorPage<GlobalItem>`
|
||||||
|
|
||||||
|
**`getTrendingCategories(db, limit=12)`**
|
||||||
|
- SELECT category, COUNT(id) FROM globalItems WHERE category IS NOT NULL
|
||||||
|
- GROUP BY category, ORDER BY count DESC
|
||||||
|
- Returns plain `Array<{ name: string; itemCount: number }>` (no cursor)
|
||||||
|
|
||||||
|
### `tests/services/discovery.service.test.ts`
|
||||||
|
|
||||||
|
11 tests covering:
|
||||||
|
- `getPopularSetups`: ordering by count desc, private setup exclusion, hasMore/nextCursor, second page deduplication, creatorName from users.displayName
|
||||||
|
- `getRecentGlobalItems`: ordering by createdAt desc, hasMore/nextCursor, second page deduplication
|
||||||
|
- `getTrendingCategories`: ordering by count desc, null category exclusion, empty state
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
The test used a dynamic import pattern for `eq` which was corrected to a static import (minor code quality fix before RED commit).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun test tests/services/discovery.service.test.ts`: 11 pass, 0 fail
|
||||||
|
- `bun test` full suite: 290 tests — same pass/fail ratio as before (15 pre-existing failures from `withImageUrl` storage service export issue, unrelated to this plan)
|
||||||
|
|
||||||
|
## Self-Check
|
||||||
|
|
||||||
|
Checked below.
|
||||||
315
.planning/phases/26-discovery-landing-page/26-02-PLAN.md
Normal file
315
.planning/phases/26-discovery-landing-page/26-02-PLAN.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [26-01]
|
||||||
|
files_modified:
|
||||||
|
- src/server/routes/discovery.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- src/client/hooks/useDiscovery.ts
|
||||||
|
- tests/routes/discovery.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [DISC-02, DISC-03, DISC-04, INFR-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GET /api/discovery/setups returns popular setups for anonymous users"
|
||||||
|
- "GET /api/discovery/items returns recent catalog items for anonymous users"
|
||||||
|
- "GET /api/discovery/categories returns trending categories for anonymous users"
|
||||||
|
- "All discovery endpoints accept limit and cursor query params"
|
||||||
|
- "Discovery endpoints are rate-limited with browseTier"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/routes/discovery.ts"
|
||||||
|
provides: "Hono route handlers for three discovery endpoints"
|
||||||
|
exports: ["discoveryRoutes"]
|
||||||
|
- path: "src/client/hooks/useDiscovery.ts"
|
||||||
|
provides: "React Query hooks for landing page data fetching"
|
||||||
|
exports: ["useDiscoverySetups", "useDiscoveryItems", "useDiscoveryCategories"]
|
||||||
|
- path: "tests/routes/discovery.test.ts"
|
||||||
|
provides: "Route-level integration tests for discovery endpoints"
|
||||||
|
min_lines: 50
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/routes/discovery.ts"
|
||||||
|
to: "src/server/services/discovery.service.ts"
|
||||||
|
via: "imports getPopularSetups, getRecentGlobalItems, getTrendingCategories"
|
||||||
|
pattern: "from.*discovery\\.service"
|
||||||
|
- from: "src/server/index.ts"
|
||||||
|
to: "src/server/routes/discovery.ts"
|
||||||
|
via: "app.route registration and auth skip"
|
||||||
|
pattern: "discoveryRoutes|/api/discovery"
|
||||||
|
- from: "src/client/hooks/useDiscovery.ts"
|
||||||
|
to: "/api/discovery"
|
||||||
|
via: "apiGet fetch calls"
|
||||||
|
pattern: "apiGet.*api/discovery"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire the discovery service to HTTP endpoints and create client-side React Query hooks. Register routes in server/index.ts with public access (auth skip) and browseTier rate limiting.
|
||||||
|
|
||||||
|
Purpose: Makes the discovery feed data accessible to the landing page UI via three REST endpoints and three React Query hooks.
|
||||||
|
Output: Working endpoints at `/api/discovery/{setups,items,categories}`, matching client hooks, route-level tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-CONTEXT.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-RESEARCH.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From plan 01: discovery service exports -->
|
||||||
|
From src/server/services/discovery.service.ts:
|
||||||
|
```typescript
|
||||||
|
export async function getPopularSetups(db: Db, limit?: number, cursor?: string): Promise<{ items: PopularSetup[], nextCursor: string | null, hasMore: boolean }>
|
||||||
|
export async function getRecentGlobalItems(db: Db, limit?: number, cursor?: string): Promise<{ items: GlobalItemRow[], nextCursor: string | null, hasMore: boolean }>
|
||||||
|
export async function getTrendingCategories(db: Db, limit?: number): Promise<{ name: string, itemCount: number }[]>
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/routes/global-items.ts (pattern reference):
|
||||||
|
```typescript
|
||||||
|
type Env = { Variables: { db?: any } };
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
app.get("/", async (c) => { ... });
|
||||||
|
export { app as globalItemRoutes };
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/index.ts (auth skip pattern, lines 151-170):
|
||||||
|
```typescript
|
||||||
|
// Skip public global-items endpoint (GET /api/global-items)
|
||||||
|
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
|
||||||
|
return next();
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/index.ts (rate limit pattern, lines 126-134):
|
||||||
|
```typescript
|
||||||
|
app.use("/api/global-items", async (c, next) => {
|
||||||
|
if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/))
|
||||||
|
return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useGlobalItems.ts (hook pattern):
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
export function useGlobalItems(query?: string, tags?: string[]) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["global-items", query ?? "", tags ?? []],
|
||||||
|
queryFn: () => apiGet<GlobalItem[]>(`/api/global-items${qs ? `?${qs}` : ""}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Discovery routes, server registration, and route tests</name>
|
||||||
|
<files>src/server/routes/discovery.ts, src/server/index.ts, tests/routes/discovery.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/server/routes/global-items.ts (exact Hono route pattern to replicate)
|
||||||
|
- src/server/index.ts (auth skip list at lines 151-170, rate limit setup at lines 120-148, route registration at lines 173-183)
|
||||||
|
- tests/routes/global-items.test.ts (route test pattern: createTestApp, middleware setup, request format)
|
||||||
|
- src/server/services/discovery.service.ts (function signatures from plan 01)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**Create `src/server/routes/discovery.ts`:**
|
||||||
|
|
||||||
|
Follow the exact pattern of `global-items.ts`:
|
||||||
|
```typescript
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { getPopularSetups, getRecentGlobalItems, getTrendingCategories } from "../services/discovery.service.ts";
|
||||||
|
|
||||||
|
type Env = { Variables: { db?: any } };
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Three GET handlers:
|
||||||
|
|
||||||
|
`app.get("/setups", ...)`:
|
||||||
|
- Parse query params: `limit` (parseInt, default 6, max 50), `cursor` (string, optional)
|
||||||
|
- Call `getPopularSetups(db, limit, cursor)`
|
||||||
|
- Return `c.json(result)` — result already has `{ items, nextCursor, hasMore }` shape
|
||||||
|
|
||||||
|
`app.get("/items", ...)`:
|
||||||
|
- Parse query params: `limit` (parseInt, default 8, max 50), `cursor` (string, optional)
|
||||||
|
- Call `getRecentGlobalItems(db, limit, cursor)`
|
||||||
|
- Return `c.json(result)`
|
||||||
|
|
||||||
|
`app.get("/categories", ...)`:
|
||||||
|
- Parse query params: `limit` (parseInt, default 12, max 50)
|
||||||
|
- Call `getTrendingCategories(db, limit)`
|
||||||
|
- Return `c.json(result)` — result is array directly
|
||||||
|
|
||||||
|
Export: `export { app as discoveryRoutes };`
|
||||||
|
|
||||||
|
**Modify `src/server/index.ts`:**
|
||||||
|
|
||||||
|
1. Add import at top (after line 14, near other route imports):
|
||||||
|
`import { discoveryRoutes } from "./routes/discovery.ts";`
|
||||||
|
|
||||||
|
2. Add rate limiting for discovery endpoints (after line 134, in the browse tier section):
|
||||||
|
```typescript
|
||||||
|
app.use("/api/discovery/*", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add auth skip in the auth middleware block (after line 167, before the requireAuth call):
|
||||||
|
```typescript
|
||||||
|
// Skip public discovery endpoints (GET /api/discovery/*)
|
||||||
|
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
|
||||||
|
return next();
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add route registration (after line 183, near other app.route calls):
|
||||||
|
`app.route("/api/discovery", discoveryRoutes);`
|
||||||
|
|
||||||
|
**Create `tests/routes/discovery.test.ts`:**
|
||||||
|
|
||||||
|
Follow the exact test pattern from `global-items.test.ts`:
|
||||||
|
```typescript
|
||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { globalItems, items, setups, setupItems } from "../../src/db/schema.ts";
|
||||||
|
import { discoveryRoutes } from "../../src/server/routes/discovery.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
|
async function createTestApp() {
|
||||||
|
const { db, userId } = await createTestDb();
|
||||||
|
const app = new Hono();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
// Note: NO userId set — discovery endpoints don't need auth
|
||||||
|
app.route("/api/discovery", discoveryRoutes);
|
||||||
|
return { app, db, userId };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests (minimum 6):
|
||||||
|
1. `GET /api/discovery/setups` returns 200 with `{ items, nextCursor, hasMore }` shape
|
||||||
|
2. `GET /api/discovery/items` returns 200 with `{ items, nextCursor, hasMore }` shape
|
||||||
|
3. `GET /api/discovery/categories` returns 200 with array shape
|
||||||
|
4. `GET /api/discovery/setups?limit=1` respects limit param
|
||||||
|
5. `GET /api/discovery/items?limit=1&cursor=<timestamp>` pagination works
|
||||||
|
6. `GET /api/discovery/categories?limit=2` respects limit param
|
||||||
|
|
||||||
|
For each test, seed appropriate data using db.insert() then make fetch requests via `app.request("/api/discovery/...")`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun test tests/routes/discovery.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/routes/discovery.ts contains `app.get("/setups"` and `app.get("/items"` and `app.get("/categories"`
|
||||||
|
- src/server/routes/discovery.ts contains `export { app as discoveryRoutes }`
|
||||||
|
- src/server/index.ts contains `import { discoveryRoutes }` from `"./routes/discovery.ts"`
|
||||||
|
- src/server/index.ts contains `app.route("/api/discovery", discoveryRoutes)`
|
||||||
|
- src/server/index.ts contains `c.req.path.startsWith("/api/discovery")` in auth skip section
|
||||||
|
- src/server/index.ts contains `"/api/discovery/*"` in rate limit section with `browseTier`
|
||||||
|
- tests/routes/discovery.test.ts contains at least 6 `it(` calls
|
||||||
|
- `bun test tests/routes/discovery.test.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Three discovery endpoints respond to GET requests with correct JSON shapes, anonymous access works (no auth required), rate limiting is applied, and route tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Client-side React Query hooks for discovery data</name>
|
||||||
|
<files>src/client/hooks/useDiscovery.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/client/hooks/useGlobalItems.ts (hook pattern: useQuery, apiGet, interface definitions, queryKey structure)
|
||||||
|
- src/client/lib/api.ts (apiGet signature)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create `src/client/hooks/useDiscovery.ts` with three named exports.
|
||||||
|
|
||||||
|
**Type definitions** at top of file:
|
||||||
|
```typescript
|
||||||
|
export interface DiscoverySetup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
itemCount: number;
|
||||||
|
creatorName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveryCategory {
|
||||||
|
name: string;
|
||||||
|
itemCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CursorPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For GlobalItem type, import from useGlobalItems or re-define inline matching the existing `GlobalItem` interface in `useGlobalItems.ts` (id, brand, model, category, weightGrams, priceCents, imageUrl, description, sourceUrl, imageCredit, imageSourceUrl, createdAt — all as they appear in that file).
|
||||||
|
|
||||||
|
**Three hooks:**
|
||||||
|
|
||||||
|
`useDiscoverySetups(limit = 6)`:
|
||||||
|
- queryKey: `["discovery", "setups", limit]`
|
||||||
|
- queryFn: `apiGet<CursorPage<DiscoverySetup>>(`/api/discovery/setups?limit=${limit}`)`
|
||||||
|
- staleTime: `2 * 60 * 1000` (2 minutes)
|
||||||
|
|
||||||
|
`useDiscoveryItems(limit = 8)`:
|
||||||
|
- queryKey: `["discovery", "items", limit]`
|
||||||
|
- queryFn: `apiGet<CursorPage<GlobalItem>>(`/api/discovery/items?limit=${limit}`)`
|
||||||
|
- staleTime: `2 * 60 * 1000` (2 minutes)
|
||||||
|
|
||||||
|
`useDiscoveryCategories(limit = 12)`:
|
||||||
|
- queryKey: `["discovery", "categories", limit]`
|
||||||
|
- queryFn: `apiGet<DiscoveryCategory[]>(`/api/discovery/categories?limit=${limit}`)`
|
||||||
|
- staleTime: `5 * 60 * 1000` (5 minutes — categories change rarely)
|
||||||
|
|
||||||
|
Import `useQuery` from `@tanstack/react-query` and `apiGet` from `../lib/api`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `export function useDiscoverySetups(`
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `export function useDiscoveryItems(`
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `export function useDiscoveryCategories(`
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `export interface DiscoverySetup`
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `export interface DiscoveryCategory`
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `staleTime: 2 * 60 * 1000` (for setups and items)
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `staleTime: 5 * 60 * 1000` (for categories)
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `queryKey: ["discovery",` for all three hooks
|
||||||
|
- src/client/hooks/useDiscovery.ts contains `apiGet` import from `../lib/api`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Three React Query hooks export correctly with proper types, query keys, stale times, and API endpoint URLs matching the server routes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/routes/discovery.test.ts` — route tests pass
|
||||||
|
- `bun test` — full suite green
|
||||||
|
- `bun run build` — client builds without TypeScript errors
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Three GET endpoints at /api/discovery/{setups,items,categories} respond to anonymous requests
|
||||||
|
- Endpoints are rate-limited with browseTier
|
||||||
|
- Three React Query hooks ready for consumption by the landing page
|
||||||
|
- Route-level tests verify response shapes and status codes
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/26-discovery-landing-page/26-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
99
.planning/phases/26-discovery-landing-page/26-02-SUMMARY.md
Normal file
99
.planning/phases/26-discovery-landing-page/26-02-SUMMARY.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
plan: "02"
|
||||||
|
subsystem: server/client
|
||||||
|
tags: [discovery, http-routes, react-query, rate-limiting]
|
||||||
|
dependency_graph:
|
||||||
|
requires: [26-01]
|
||||||
|
provides: [discovery-http-endpoints, discovery-react-hooks]
|
||||||
|
affects: [server/index.ts, client/hooks]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [hono-route-handler, react-query-hook, cursor-pagination]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- src/server/routes/discovery.ts
|
||||||
|
- src/client/hooks/useDiscovery.ts
|
||||||
|
- tests/routes/discovery.test.ts
|
||||||
|
modified:
|
||||||
|
- src/server/index.ts
|
||||||
|
decisions:
|
||||||
|
- "No cursor pagination needed for getTrendingCategories — bounded small list, simple limit is sufficient (carried from plan 01)"
|
||||||
|
- "discoveryRoutes registered with browseTier rate limiting (120 req/min) for all GET discovery endpoints"
|
||||||
|
- "Auth skip added for /api/discovery/* GET — public access without authentication"
|
||||||
|
metrics:
|
||||||
|
duration: "~8 minutes"
|
||||||
|
completed: "2026-04-10"
|
||||||
|
tasks_completed: 2
|
||||||
|
files_created: 3
|
||||||
|
files_modified: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 26 Plan 02: Discovery HTTP Routes and React Query Hooks Summary
|
||||||
|
|
||||||
|
**One-liner:** Three public GET endpoints at /api/discovery/{setups,items,categories} with browseTier rate limiting, wired to discovery service from plan 01, plus matching React Query hooks with typed interfaces.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Task 1: Discovery routes, server registration, and route tests
|
||||||
|
|
||||||
|
Created `src/server/routes/discovery.ts` with three Hono GET handlers following the exact pattern of `global-items.ts`:
|
||||||
|
|
||||||
|
- `GET /setups` — calls `getPopularSetups(db, limit, cursor)`, default limit 6, max 50
|
||||||
|
- `GET /items` — calls `getRecentGlobalItems(db, limit, cursor)`, default limit 8, max 50
|
||||||
|
- `GET /categories` — calls `getTrendingCategories(db, limit)`, default limit 12, max 50
|
||||||
|
|
||||||
|
Updated `src/server/index.ts`:
|
||||||
|
- Added `discoveryRoutes` import
|
||||||
|
- Added `browseTier` rate limiting for `GET /api/discovery/*`
|
||||||
|
- Added auth skip: `if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET") return next()`
|
||||||
|
- Registered `app.route("/api/discovery", discoveryRoutes)`
|
||||||
|
|
||||||
|
Created `tests/routes/discovery.test.ts` with 10 tests covering:
|
||||||
|
- Response shape validation for all three endpoints
|
||||||
|
- Empty state handling
|
||||||
|
- Limit param enforcement
|
||||||
|
- Cursor-based pagination for items endpoint
|
||||||
|
- Public-only filter for setups
|
||||||
|
|
||||||
|
### Task 2: Client-side React Query hooks
|
||||||
|
|
||||||
|
Created `src/client/hooks/useDiscovery.ts` with three named hook exports:
|
||||||
|
|
||||||
|
- `useDiscoverySetups(limit = 6)` — queryKey `["discovery", "setups", limit]`, staleTime 2min
|
||||||
|
- `useDiscoveryItems(limit = 8)` — queryKey `["discovery", "items", limit]`, staleTime 2min
|
||||||
|
- `useDiscoveryCategories(limit = 12)` — queryKey `["discovery", "categories", limit]`, staleTime 5min
|
||||||
|
|
||||||
|
Exported interfaces: `DiscoverySetup`, `DiscoveryCategory`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun test tests/routes/discovery.test.ts` — 10 pass, 0 fail
|
||||||
|
- `bun run build` — clean build, no TypeScript errors
|
||||||
|
- Full test suite: 285 pass, 15 pre-existing failures in unrelated modules (storage.service.ts export issue in setups/items/profiles/threads routes tests)
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Fixed cursor pagination test with simultaneous timestamps**
|
||||||
|
- **Found during:** Task 1 test writing
|
||||||
|
- **Issue:** Two `globalItems` inserted in quick succession in PGlite got the same `defaultNow()` timestamp, making pagination impossible to test
|
||||||
|
- **Fix:** Inserted items with explicit `createdAt` values (2024-01-01 and 2024-06-01) to ensure distinct timestamps for pagination test
|
||||||
|
- **Files modified:** tests/routes/discovery.test.ts
|
||||||
|
- **Commit:** 0323e0c
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all endpoints return live database data from the discovery service.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Files exist:
|
||||||
|
- FOUND: src/server/routes/discovery.ts
|
||||||
|
- FOUND: src/client/hooks/useDiscovery.ts
|
||||||
|
- FOUND: tests/routes/discovery.test.ts
|
||||||
|
|
||||||
|
Commits exist:
|
||||||
|
- 0323e0c — feat(26-02): discovery HTTP routes, server registration, and route tests
|
||||||
|
- 747a1c3 — feat(26-02): React Query hooks for discovery data
|
||||||
477
.planning/phases/26-discovery-landing-page/26-03-PLAN.md
Normal file
477
.planning/phases/26-discovery-landing-page/26-03-PLAN.md
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: [26-01, 26-02]
|
||||||
|
files_modified:
|
||||||
|
- src/client/routes/index.tsx
|
||||||
|
- src/client/components/PublicSetupCard.tsx
|
||||||
|
- src/client/routes/users/$userId.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements: [DISC-01, DISC-02, DISC-03, DISC-04, DISC-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Root URL shows a hero section with a catalog search bar"
|
||||||
|
- "Clicking the search bar opens CatalogSearchOverlay"
|
||||||
|
- "Popular setups section shows setup cards with item counts"
|
||||||
|
- "Recently added items section shows GlobalItemCard components"
|
||||||
|
- "Trending categories section shows category names with item counts"
|
||||||
|
- "Authenticated users see Go to Collection link in hero"
|
||||||
|
- "Anonymous users see the page without login redirect"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/routes/index.tsx"
|
||||||
|
provides: "Landing page with hero, popular setups, recent items, trending categories"
|
||||||
|
min_lines: 80
|
||||||
|
- path: "src/client/components/PublicSetupCard.tsx"
|
||||||
|
provides: "Enhanced setup card with optional itemCount and creatorName"
|
||||||
|
contains: "itemCount"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/routes/index.tsx"
|
||||||
|
to: "src/client/hooks/useDiscovery.ts"
|
||||||
|
via: "imports useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories"
|
||||||
|
pattern: "from.*useDiscovery"
|
||||||
|
- from: "src/client/routes/index.tsx"
|
||||||
|
to: "src/client/stores/uiStore.ts"
|
||||||
|
via: "openCatalogSearch trigger from hero"
|
||||||
|
pattern: "openCatalogSearch"
|
||||||
|
- from: "src/client/routes/index.tsx"
|
||||||
|
to: "src/client/hooks/useAuth.ts"
|
||||||
|
via: "auth state for conditional Go to Collection CTA"
|
||||||
|
pattern: "useAuth"
|
||||||
|
- from: "src/client/routes/index.tsx"
|
||||||
|
to: "src/client/components/GlobalItemCard.tsx"
|
||||||
|
via: "renders catalog items in recent items section"
|
||||||
|
pattern: "GlobalItemCard"
|
||||||
|
- from: "src/client/routes/index.tsx"
|
||||||
|
to: "src/client/components/PublicSetupCard.tsx"
|
||||||
|
via: "renders setup cards in popular setups section"
|
||||||
|
pattern: "PublicSetupCard"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Rewrite the landing page at `/` from a personal dashboard to a public discovery page. Enhance PublicSetupCard with itemCount and creatorName. Build hero section with search trigger, three content feed sections, and conditional auth CTA.
|
||||||
|
|
||||||
|
Purpose: This is the user-facing deliverable — the page visitors see first. Composes existing components with new discovery hooks into the layout specified by D-01 through D-11.
|
||||||
|
Output: Complete landing page at `/`, enhanced PublicSetupCard.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-CONTEXT.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-RESEARCH.md
|
||||||
|
@.planning/phases/26-discovery-landing-page/26-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From plan 02: client hooks -->
|
||||||
|
From src/client/hooks/useDiscovery.ts:
|
||||||
|
```typescript
|
||||||
|
export interface DiscoverySetup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
itemCount: number;
|
||||||
|
creatorName: string | null;
|
||||||
|
}
|
||||||
|
export interface DiscoveryCategory {
|
||||||
|
name: string;
|
||||||
|
itemCount: number;
|
||||||
|
}
|
||||||
|
export function useDiscoverySetups(limit?: number): UseQueryResult
|
||||||
|
export function useDiscoveryItems(limit?: number): UseQueryResult
|
||||||
|
export function useDiscoveryCategories(limit?: number): UseQueryResult
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useAuth.ts:
|
||||||
|
```typescript
|
||||||
|
interface AuthState {
|
||||||
|
user: { id: string; email?: string } | null;
|
||||||
|
authenticated: boolean;
|
||||||
|
}
|
||||||
|
export function useAuth(): UseQueryResult<AuthState>
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/stores/uiStore.ts:
|
||||||
|
```typescript
|
||||||
|
openCatalogSearch: (mode: "collection" | "thread") => void;
|
||||||
|
// Access via: const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/components/GlobalItemCard.tsx:
|
||||||
|
```typescript
|
||||||
|
interface GlobalItemCardProps {
|
||||||
|
id: number; brand: string; model: string; category: string | null;
|
||||||
|
weightGrams: number | null; priceCents: number | null; imageUrl: string | null;
|
||||||
|
}
|
||||||
|
export function GlobalItemCard(props: GlobalItemCardProps): JSX.Element
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/components/PublicSetupCard.tsx (current — will be enhanced):
|
||||||
|
```typescript
|
||||||
|
interface PublicSetupCardProps {
|
||||||
|
setup: { id: number; name: string; createdAt: string; };
|
||||||
|
}
|
||||||
|
export function PublicSetupCard({ setup }: PublicSetupCardProps): JSX.Element
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Enhance PublicSetupCard with itemCount and creatorName</name>
|
||||||
|
<files>src/client/components/PublicSetupCard.tsx, src/client/routes/users/$userId.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/PublicSetupCard.tsx (current component — full content)
|
||||||
|
- src/client/routes/users/$userId.tsx (existing usage of PublicSetupCard — check what shape is passed)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**Modify `src/client/components/PublicSetupCard.tsx`:**
|
||||||
|
|
||||||
|
Update the `PublicSetupCardProps` interface to add optional fields (per Pitfall 3 — keep optional to avoid breaking existing usages):
|
||||||
|
```typescript
|
||||||
|
interface PublicSetupCardProps {
|
||||||
|
setup: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
itemCount?: number; // NEW — optional for backward compat
|
||||||
|
creatorName?: string | null; // NEW — optional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the component JSX to display the new fields when present:
|
||||||
|
|
||||||
|
After the existing `<h3>` (setup name) and before/replacing the `<p>` date line, render:
|
||||||
|
- If `setup.creatorName` is truthy: `<p className="text-xs text-gray-400 mt-1">by {setup.creatorName}</p>`
|
||||||
|
- If `setup.itemCount` is defined and > 0: `<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">{setup.itemCount} items</span>`
|
||||||
|
- Keep the existing date display
|
||||||
|
|
||||||
|
Layout for the bottom area of the card (below the name):
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{setup.itemCount != null && setup.itemCount > 0 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
|
{setup.itemCount} {setup.itemCount === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">{formattedDate}</p>
|
||||||
|
</div>
|
||||||
|
{setup.creatorName && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">by {setup.creatorName}</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `cursor-pointer` to the Link className (folded todo from CONTEXT.md).
|
||||||
|
|
||||||
|
**Verify `src/client/routes/users/$userId.tsx`:**
|
||||||
|
Read the file to confirm the existing `PublicSetupCard` usage still compiles. Since `itemCount` and `creatorName` are optional, the existing usage passing `{ id, name, createdAt }` will continue to work without changes. No modification needed to this file unless it already passes extra props.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/client/components/PublicSetupCard.tsx contains `itemCount?: number`
|
||||||
|
- src/client/components/PublicSetupCard.tsx contains `creatorName?: string | null`
|
||||||
|
- src/client/components/PublicSetupCard.tsx contains `setup.itemCount` (conditional rendering)
|
||||||
|
- src/client/components/PublicSetupCard.tsx contains `setup.creatorName` (conditional rendering)
|
||||||
|
- src/client/components/PublicSetupCard.tsx contains `cursor-pointer`
|
||||||
|
- `bun run build` succeeds without TypeScript errors
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>PublicSetupCard renders item count and creator name when provided, existing usages compile without changes, cursor-pointer applied.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Rewrite index.tsx as discovery landing page</name>
|
||||||
|
<files>src/client/routes/index.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/index.tsx (current dashboard — will be completely rewritten)
|
||||||
|
- src/client/components/GlobalItemCard.tsx (props interface for rendering items)
|
||||||
|
- src/client/components/PublicSetupCard.tsx (enhanced props from Task 1)
|
||||||
|
- src/client/hooks/useDiscovery.ts (hook signatures from plan 02)
|
||||||
|
- src/client/hooks/useAuth.ts (useAuth hook pattern)
|
||||||
|
- src/client/stores/uiStore.ts (openCatalogSearch usage pattern)
|
||||||
|
- src/client/routes/__root.tsx (isDashboard detection — do NOT modify per Pitfall 2)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**Completely rewrite `src/client/routes/index.tsx`** (per D-03, retire DashboardPage entirely):
|
||||||
|
|
||||||
|
Remove ALL existing imports (DashboardCard, useFormatters, useSetups, useThreads, useTotals).
|
||||||
|
|
||||||
|
New imports:
|
||||||
|
```typescript
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { GlobalItemCard } from "../components/GlobalItemCard";
|
||||||
|
import { PublicSetupCard } from "../components/PublicSetupCard";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories } from "../hooks/useDiscovery";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Use `lucide-react` for the Search icon. The project already uses lucide-react (check existing imports). If the project uses the custom `LucideIcon` component instead, use `<LucideIcon name="search" className="w-5 h-5 text-gray-400" />` from `../lib/iconData`.
|
||||||
|
|
||||||
|
**Route export** (same file route, new component):
|
||||||
|
```typescript
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: LandingPage,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**LandingPage function** (per D-01, D-02):
|
||||||
|
```typescript
|
||||||
|
function LandingPage() {
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<HeroSection isAuthenticated={isAuthenticated} onSearchFocus={() => openCatalogSearch("collection")} />
|
||||||
|
<PopularSetupsSection />
|
||||||
|
<RecentItemsSection />
|
||||||
|
<TrendingCategoriesSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HeroSection** (per D-01, D-04, D-10, D-11):
|
||||||
|
```typescript
|
||||||
|
function HeroSection({ isAuthenticated, onSearchFocus }: { isAuthenticated: boolean; onSearchFocus: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">Discover Gear</h1>
|
||||||
|
<p className="text-gray-500 mb-8">Browse what other people carry</p>
|
||||||
|
<div
|
||||||
|
onClick={onSearchFocus}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="max-w-xl mx-auto flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-200 hover:border-gray-300 cursor-pointer shadow-sm transition-all"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5 text-gray-400 shrink-0" />
|
||||||
|
<span className="text-sm text-gray-400 flex-1 text-left">Search the catalog...</span>
|
||||||
|
</div>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link to="/collection" className="text-sm text-gray-600 hover:text-gray-900 underline underline-offset-2 cursor-pointer">
|
||||||
|
Go to Collection
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PopularSetupsSection** (per D-02, D-05):
|
||||||
|
```typescript
|
||||||
|
function PopularSetupsSection() {
|
||||||
|
const { data, isLoading } = useDiscoverySetups(6);
|
||||||
|
const setups = data?.items ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && setups.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Popular Setups</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<SectionSkeleton count={6} aspect="none" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<PublicSetupCard key={setup.id} setup={setup} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**RecentItemsSection** (per D-02, D-06):
|
||||||
|
```typescript
|
||||||
|
function RecentItemsSection() {
|
||||||
|
const { data, isLoading } = useDiscoveryItems(8);
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Recently Added</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<SectionSkeleton count={8} aspect="[4/3]" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<GlobalItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
brand={item.brand}
|
||||||
|
model={item.model}
|
||||||
|
category={item.category}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
imageUrl={item.imageUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TrendingCategoriesSection** (per D-02, D-07):
|
||||||
|
```typescript
|
||||||
|
function TrendingCategoriesSection() {
|
||||||
|
const { data, isLoading } = useDiscoveryCategories(12);
|
||||||
|
const categories = data ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && categories.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Trending Categories</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-8 w-24 bg-gray-100 rounded-full animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<span
|
||||||
|
key={cat.name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-gray-50 text-gray-700 border border-gray-100 hover:border-gray-200 hover:bg-gray-100 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
<span className="text-xs text-gray-400">{cat.itemCount}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SectionSkeleton** helper (matches CatalogSearchOverlay animate-pulse pattern):
|
||||||
|
```typescript
|
||||||
|
function SectionSkeleton({ count, aspect }: { count: number; aspect: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`grid ${aspect === "none" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"} gap-4`}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-100 overflow-hidden animate-pulse">
|
||||||
|
{aspect !== "none" && <div className={`aspect-${aspect} bg-gray-100`} />}
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-100 rounded w-16" />
|
||||||
|
<div className="h-4 bg-gray-100 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure all clickable elements have `cursor-pointer` (folded todo from CONTEXT.md).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/client/routes/index.tsx does NOT contain `DashboardPage` or `DashboardCard` or `useTotals` (per D-03)
|
||||||
|
- src/client/routes/index.tsx contains `function LandingPage()`
|
||||||
|
- src/client/routes/index.tsx contains `function HeroSection(`
|
||||||
|
- src/client/routes/index.tsx contains `openCatalogSearch("collection")` (per D-04)
|
||||||
|
- src/client/routes/index.tsx contains `useDiscoverySetups` and `useDiscoveryItems` and `useDiscoveryCategories`
|
||||||
|
- src/client/routes/index.tsx contains `"Go to Collection"` (per D-10)
|
||||||
|
- src/client/routes/index.tsx contains `!!auth?.user` or `auth?.user` for auth check (per anti-pattern: do not use auth?.authenticated)
|
||||||
|
- src/client/routes/index.tsx contains `GlobalItemCard` import
|
||||||
|
- src/client/routes/index.tsx contains `PublicSetupCard` import
|
||||||
|
- src/client/routes/index.tsx contains `cursor-pointer` on the search bar div
|
||||||
|
- src/client/routes/index.tsx contains `animate-pulse` (loading skeletons)
|
||||||
|
- `bun run build` succeeds without errors
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Landing page renders hero with search trigger, three feed sections with loading skeletons and empty-state handling, authenticated CTA, and all clickable elements have cursor-pointer. Build succeeds.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: Visual verification of discovery landing page</name>
|
||||||
|
<files>src/client/routes/index.tsx</files>
|
||||||
|
<action>
|
||||||
|
No code changes — this is a visual verification checkpoint. The executor should start the dev server with `bun run dev` and present the verification steps to the user.
|
||||||
|
</action>
|
||||||
|
<what-built>
|
||||||
|
Complete discovery landing page replacing the personal dashboard at /. Features:
|
||||||
|
- Hero section with "Discover Gear" heading and catalog search bar trigger
|
||||||
|
- Popular Setups section with enhanced cards (item count, creator name)
|
||||||
|
- Recently Added Items section with GlobalItemCard components
|
||||||
|
- Trending Categories section with category pills
|
||||||
|
- Conditional "Go to Collection" link for authenticated users
|
||||||
|
- Loading skeletons for all sections
|
||||||
|
- Empty state handling (sections hide when no data)
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Run `bun run dev` and open http://localhost:5173/ in a browser
|
||||||
|
2. Verify the hero section shows "Discover Gear" heading with search bar
|
||||||
|
3. Click the search bar — it should open the full CatalogSearchOverlay
|
||||||
|
4. Verify sections appear below: Popular Setups, Recently Added, Trending Categories
|
||||||
|
5. If there is seed data, verify cards show correct information (item counts, creator names, images)
|
||||||
|
6. If no data exists, verify empty sections are hidden gracefully (no broken/empty grids)
|
||||||
|
7. Log in and verify "Go to Collection" link appears in hero area
|
||||||
|
8. Click "Go to Collection" — should navigate to /collection
|
||||||
|
9. Check responsive behavior: resize browser to mobile width, verify single-column layout
|
||||||
|
10. Verify all clickable elements show pointer cursor on hover
|
||||||
|
</how-to-verify>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>User has visually verified the landing page renders correctly with all sections, search trigger works, auth CTA appears for logged-in users, and responsive layout is correct.</done>
|
||||||
|
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun run build` — builds without errors
|
||||||
|
- Visual inspection of landing page at localhost:5173
|
||||||
|
- CatalogSearchOverlay opens from hero search bar
|
||||||
|
- Authenticated user sees "Go to Collection" link
|
||||||
|
- Anonymous user sees content immediately
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Root URL shows discovery landing page (not personal dashboard)
|
||||||
|
- Hero search bar triggers CatalogSearchOverlay on click
|
||||||
|
- Three content sections render with real data or hide when empty
|
||||||
|
- PublicSetupCard displays item count and creator name
|
||||||
|
- Authenticated users see "Go to Collection" CTA in hero
|
||||||
|
- All clickable elements have cursor-pointer
|
||||||
|
- Build succeeds
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/26-discovery-landing-page/26-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
111
.planning/phases/26-discovery-landing-page/26-03-SUMMARY.md
Normal file
111
.planning/phases/26-discovery-landing-page/26-03-SUMMARY.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
plan: "03"
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, tanstack-router, tanstack-query, tailwind, lucide-react]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 26-01
|
||||||
|
provides: discovery API endpoints (GET /api/discovery/setups, items, categories)
|
||||||
|
- phase: 26-02
|
||||||
|
provides: useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories hooks
|
||||||
|
provides:
|
||||||
|
- Landing page at / with hero, popular setups, recent items, trending categories
|
||||||
|
- Enhanced PublicSetupCard with itemCount and creatorName display
|
||||||
|
affects:
|
||||||
|
- users/$userId (inherits PublicSetupCard enhancement)
|
||||||
|
- CatalogSearchOverlay (triggered from hero search bar)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Empty-state hiding: sections return null when not loading and data is empty
|
||||||
|
- SectionSkeleton helper for consistent animate-pulse loading states
|
||||||
|
- Auth-conditional CTA: !!auth?.user pattern for authenticated UI branching
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/client/routes/index.tsx
|
||||||
|
- src/client/components/PublicSetupCard.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "PublicSetupCard itemCount/creatorName fields are optional for backward compatibility with users/$userId usage"
|
||||||
|
- "HeroSection search bar triggers openCatalogSearch('collection') from uiStore — not a real input, just a click target"
|
||||||
|
- "Sections hide entirely (return null) when not loading and data is empty — avoids empty grid layouts"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Optional prop enhancement: add new props as optional to avoid breaking existing usages"
|
||||||
|
- "Discovery page sections are self-contained components that own their data-fetching logic"
|
||||||
|
|
||||||
|
requirements-completed: [DISC-01, DISC-02, DISC-03, DISC-04, DISC-05]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 26 Plan 03: Discovery Landing Page Summary
|
||||||
|
|
||||||
|
**Discovery landing page replacing personal dashboard — hero search trigger, popular setups feed, recent catalog items, trending categories, with auth-conditional CTA and PublicSetupCard enhanced with item counts and creator names**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-04-10T13:00:39Z
|
||||||
|
- **Completed:** 2026-04-10T13:02:15Z
|
||||||
|
- **Tasks:** 2 executed (+ 1 human-verify checkpoint auto-approved)
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Rewrote `src/client/routes/index.tsx` from personal dashboard (DashboardPage) to public discovery landing page (LandingPage)
|
||||||
|
- Added hero section with "Discover Gear" heading, catalog search bar trigger (opens CatalogSearchOverlay), and conditional "Go to Collection" link for authenticated users
|
||||||
|
- Built three content feed sections: Popular Setups (PublicSetupCard grid), Recently Added (GlobalItemCard grid), Trending Categories (pill chips)
|
||||||
|
- Enhanced `PublicSetupCard` with optional `itemCount` and `creatorName` fields — backward compatible with existing users/$userId usage
|
||||||
|
- Loading skeletons (animate-pulse) for all sections; sections hide when empty
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Enhance PublicSetupCard with itemCount and creatorName** - `0bf1c68` (feat)
|
||||||
|
2. **Task 2: Rewrite index.tsx as discovery landing page** - `8aaf435` (feat)
|
||||||
|
3. **Task 3: Visual verification checkpoint** - auto-approved (auto-chain active)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/client/routes/index.tsx` - Completely rewritten as LandingPage with HeroSection, PopularSetupsSection, RecentItemsSection, TrendingCategoriesSection, SectionSkeleton
|
||||||
|
- `src/client/components/PublicSetupCard.tsx` - Enhanced with optional itemCount (blue badge) and creatorName (attribution line), cursor-pointer added
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- PublicSetupCard enhancement used optional props to maintain backward compat — existing `users/$userId.tsx` usage requires no changes
|
||||||
|
- Search bar is a styled div with onClick/onKeyDown, not a real input — clicking opens CatalogSearchOverlay directly
|
||||||
|
- Auth check uses `!!auth?.user` (not `auth?.authenticated`) per plan anti-pattern guidance
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all three discovery sections fetch real data from the API endpoints built in plans 26-01 and 26-02. Empty state is handled by hiding sections.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Discovery landing page complete — phase 26 fully delivered
|
||||||
|
- All DISC-01 through DISC-05 requirements satisfied
|
||||||
|
- PublicSetupCard enhancement available for any future usage needing item counts
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 26-discovery-landing-page*
|
||||||
|
*Completed: 2026-04-10*
|
||||||
135
.planning/phases/26-discovery-landing-page/26-CONTEXT.md
Normal file
135
.planning/phases/26-discovery-landing-page/26-CONTEXT.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Phase 26: Discovery Landing Page - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-10
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Replace the current personal dashboard at `/` with a public-first discovery landing page. The page features a prominent catalog search bar at the top, a feed of popular public setups, recently added catalog items, and trending categories. Authenticated users see a "Go to Collection" entry point. This is the first page any visitor sees — no login required.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Page Layout & Structure
|
||||||
|
- **D-01:** Full-width hero area with catalog search bar prominently centered. Below the hero, a vertical stack of content sections.
|
||||||
|
- **D-02:** Section order: (1) Hero with search bar, (2) Popular Setups, (3) Recently Added Items, (4) Trending Categories. Each section has a heading and optional "View all" link.
|
||||||
|
- **D-03:** The current `DashboardPage` component and its `DashboardCard` usage at `/` will be replaced entirely. The dashboard is now the landing page.
|
||||||
|
|
||||||
|
### Search Bar Behavior
|
||||||
|
- **D-04:** The hero search bar triggers the existing `CatalogSearchOverlay` on focus or typing. This reuses the full-featured search (tag filtering, grid/list toggle, manual entry fallback) without duplicating search UI. The search bar on the landing page is a visual entry point, not a standalone search results container.
|
||||||
|
|
||||||
|
### Feed Data Sources & Ranking
|
||||||
|
- **D-05:** "Popular setups" ranked by item count descending (proxy for effort/completeness). Only public setups are shown. No engagement tracking exists yet — item count is available via `setupItems` join table.
|
||||||
|
- **D-06:** "Recently added items" shows the most recently created `globalItems`, ordered by `createdAt` descending.
|
||||||
|
- **D-07:** "Trending categories" ranked by global item count per distinct `globalItems.category` value. Categories with the most catalog items appear first.
|
||||||
|
- **D-08:** Cursor-based pagination for feed sections per INFR-02. Use `createdAt` cursor for recently added items; item count + ID cursor for popular setups.
|
||||||
|
|
||||||
|
### Authenticated vs Anonymous Experience
|
||||||
|
- **D-09:** Same page content for both authenticated and anonymous users — no personalized feed in v2.1. The difference is purely navigational.
|
||||||
|
- **D-10:** Authenticated users see a "Go to Collection" CTA in the hero area, next to the search bar. Visible without scrolling.
|
||||||
|
- **D-11:** Anonymous users see the search bar and content sections immediately (fire-and-forget auth from Phase 24, D-09). Sign-in button in top-right per Phase 24, D-10.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact layout sizing, spacing, and responsive breakpoints
|
||||||
|
- Number of items shown per section before "View all" (suggest 6-8 for items/setups, 8-12 for categories)
|
||||||
|
- Empty states for sections with no data
|
||||||
|
- Loading skeletons for each section
|
||||||
|
- Whether "View all" links for setups/items route to existing pages or new dedicated feed pages
|
||||||
|
|
||||||
|
### Folded Todos
|
||||||
|
- **Add cursor pointer to all clickable links** — Ensure all clickable elements on the landing page have `cursor-pointer`. Apply broadly while building the new page.
|
||||||
|
- **Fix item image not showing on collection overview** — Investigate and fix image display issue; relevant since landing page will show `GlobalItemCard` components with images.
|
||||||
|
- **Investigate slow image loading** — Profile image loading performance; landing page is image-heavy with item cards and setup previews.
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Current Landing Page (to replace)
|
||||||
|
- `src/client/routes/index.tsx` — Current dashboard page component (will be rewritten)
|
||||||
|
- `src/client/components/DashboardCard.tsx` — Current dashboard card (will be unused after this phase)
|
||||||
|
|
||||||
|
### Reusable Components
|
||||||
|
- `src/client/components/GlobalItemCard.tsx` — Catalog item card with image, brand/model, weight/price pills
|
||||||
|
- `src/client/components/PublicSetupCard.tsx` — Basic public setup card (name + date; may need enhancement for item count)
|
||||||
|
- `src/client/components/CatalogSearchOverlay.tsx` — Full-screen search overlay with debounce, tag filtering, grid/list modes
|
||||||
|
|
||||||
|
### Hooks & Data
|
||||||
|
- `src/client/hooks/useGlobalItems.ts` — Search global items by query and tags
|
||||||
|
- `src/client/hooks/useTags.ts` — Fetch tag list
|
||||||
|
- `src/client/hooks/useAuth.ts` — Auth state (`user`, `authenticated`)
|
||||||
|
- `src/client/stores/uiStore.ts` — UI state store including `catalogSearchOpen` / `openCatalogSearch`
|
||||||
|
|
||||||
|
### Server Services
|
||||||
|
- `src/server/services/global-item.service.ts` — `searchGlobalItems` (needs new queries: recent items, category counts)
|
||||||
|
- `src/server/services/profile.service.ts` — `getPublicProfile`, `getPublicSetupWithItems` (setup data patterns)
|
||||||
|
|
||||||
|
### Routes & API
|
||||||
|
- `src/server/routes/global-items.ts` — Current GET endpoints (needs discovery feed endpoints)
|
||||||
|
- `src/server/routes/setups.ts` — Public setup view endpoint (`GET /:id/public`)
|
||||||
|
- `src/server/index.ts` — Route registration and public route allowlist
|
||||||
|
|
||||||
|
### Auth & Layout
|
||||||
|
- `src/client/routes/__root.tsx` — Root layout, auth check, `isPublicRoute` logic (root `/` already public per Phase 24)
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- `.planning/REQUIREMENTS.md` — DISC-01 through DISC-05, INFR-02
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `GlobalItemCard` — Ready-to-use catalog item card with image placeholder, brand/model, weight/price/category pills. Can be used directly in "Recently Added" section.
|
||||||
|
- `PublicSetupCard` — Minimal card (name + date). Needs enhancement: add item count, possibly creator name and total weight to be useful in a "Popular Setups" feed.
|
||||||
|
- `CatalogSearchOverlay` — Full search implementation with debounce, tag chip filtering, grid/list toggle, manual entry. Landing page search bar should open this overlay rather than duplicating search logic.
|
||||||
|
- `useFormatters` hook — Weight and price formatting, reusable across all card components.
|
||||||
|
- `LucideIcon` component — For category icons in the "Trending Categories" section.
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- TanStack Router file-based routes — new route stays at `routes/index.tsx` (same file, new content)
|
||||||
|
- TanStack React Query for data fetching — landing page sections each get their own query hook
|
||||||
|
- Tailwind CSS v4 with light/airy/minimalist design language — white backgrounds, gray borders, rounded-xl cards, subtle shadows on hover
|
||||||
|
- Card pattern: `bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all`
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `routes/index.tsx` — Rewrite to render landing page instead of dashboard
|
||||||
|
- New server endpoints needed: `GET /api/discovery/setups` (popular), `GET /api/discovery/items` (recent), `GET /api/discovery/categories` (trending)
|
||||||
|
- `uiStore.openCatalogSearch()` — Trigger from the hero search bar
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- Design should feel like a welcoming storefront, not a login gate — consistent with Phase 24's "new user experience matters more" principle
|
||||||
|
- Tags are the public taxonomy for discovery; categories are private organizational tools — the landing page uses tags for any filtering/categorization display, not user categories
|
||||||
|
- Hero area should be visually distinctive but not heavy — the app's DNA is "light, airy, minimalist"
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Personalized feed based on user's collection categories (PERS-01, PERS-02) — tracked in future requirements
|
||||||
|
- SSR/static prerendering for SEO (SEO-01, SEO-02) — out of scope for v2.1
|
||||||
|
- Engagement metrics (views, likes) for better ranking — no tracking infrastructure yet
|
||||||
|
- Setup preview images/thumbnails — no setup image feature exists
|
||||||
|
|
||||||
|
### Reviewed Todos (not folded)
|
||||||
|
- **Add manufacturer entity with brand details** — Database schema enhancement unrelated to landing page UI; belongs in a future catalog data model phase
|
||||||
|
- **Fix storage service tests** — Testing infrastructure concern; not related to landing page feature work
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 26-discovery-landing-page*
|
||||||
|
*Context gathered: 2026-04-10*
|
||||||
103
.planning/phases/26-discovery-landing-page/26-DISCUSSION-LOG.md
Normal file
103
.planning/phases/26-discovery-landing-page/26-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Phase 26: Discovery Landing Page - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-04-10
|
||||||
|
**Phase:** 26-discovery-landing-page
|
||||||
|
**Areas discussed:** Page Structure & Section Order, Search Bar Behavior, Feed Data & Ranking, Auth-Variant Experience
|
||||||
|
**Mode:** --batch --auto (all decisions auto-selected)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Structure & Section Order
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Hero search + vertical sections | Full-width hero with search bar, vertical stack of content sections below | ✓ |
|
||||||
|
| Grid dashboard | Multi-column grid of section cards (like current dashboard) | |
|
||||||
|
| Single infinite feed | One merged feed of all content types | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Hero search + vertical sections (recommended default)
|
||||||
|
**Notes:** Reuses existing visual patterns (cards, rounded-xl, light borders). Section order: Search → Setups → Items → Categories, prioritizing social content first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Bar Behavior
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Open CatalogSearchOverlay | Hero search bar triggers existing overlay on focus/type | ✓ |
|
||||||
|
| Inline search results | Show results directly below the search bar on the landing page | |
|
||||||
|
| Dedicated search route | Navigate to /search with query params | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Open CatalogSearchOverlay (recommended default)
|
||||||
|
**Notes:** Avoids duplicating the full-featured search UI (tag filtering, grid/list toggle, manual entry fallback). CatalogSearchOverlay is already built and tested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feed Data & Ranking
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Item count proxy (setups) | Rank popular setups by number of items — more items = more effort/completeness | ✓ |
|
||||||
|
| Creation date (setups) | Show most recently created setups | |
|
||||||
|
| Random rotation | Rotate featured setups randomly | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Item count proxy (recommended default)
|
||||||
|
**Notes:** No engagement metrics exist. Item count is the best available proxy and is trivially queryable via setupItems join.
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Global item count per category | Trending = categories with most catalog items | ✓ |
|
||||||
|
| Recent growth rate | Categories with most new items in last 7 days | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Global item count per category (recommended default)
|
||||||
|
**Notes:** Simpler query, no time-windowed aggregation needed. Growth-based trending can be added later when catalog is larger.
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Cursor-based pagination | Use cursor pagination per INFR-02 requirement | ✓ |
|
||||||
|
| Offset pagination | Traditional LIMIT/OFFSET | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Cursor-based pagination (recommended default — required by INFR-02)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth-Variant Experience
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Same page + Collection CTA | Identical content, authenticated users get "Go to Collection" button in hero | ✓ |
|
||||||
|
| Dual-mode page | Show personal stats/shortcuts for authenticated users | |
|
||||||
|
| Redirect authenticated to dashboard | Authenticated users skip landing page entirely | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] Same page + Collection CTA (recommended default)
|
||||||
|
**Notes:** Per DISC-05, the difference is a single navigational CTA. No personalized feed in v2.1 (PERS-01/PERS-02 deferred).
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| In hero area next to search | CTA visible without scrolling, adjacent to primary action | ✓ |
|
||||||
|
| Floating sidebar | Persistent side panel for authenticated users | |
|
||||||
|
| Below hero | Separate banner below search area | |
|
||||||
|
|
||||||
|
**User's choice:** [auto] In hero area next to search (recommended default)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Exact layout sizing, spacing, and responsive breakpoints
|
||||||
|
- Number of items shown per section before "View all"
|
||||||
|
- Empty states for sections with no data
|
||||||
|
- Loading skeletons for each section
|
||||||
|
- Whether "View all" links route to existing pages or new feed pages
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Personalized feed (PERS-01, PERS-02)
|
||||||
|
- SSR/static prerendering for SEO (SEO-01, SEO-02)
|
||||||
|
- Engagement metrics for ranking
|
||||||
|
- Setup preview images
|
||||||
|
- Manufacturer entity (todo — different domain)
|
||||||
|
- Storage service tests (todo — testing concern)
|
||||||
591
.planning/phases/26-discovery-landing-page/26-RESEARCH.md
Normal file
591
.planning/phases/26-discovery-landing-page/26-RESEARCH.md
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
# Phase 26: Discovery Landing Page - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-10
|
||||||
|
**Domain:** React SPA landing page, public API feed endpoints, cursor pagination
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
- **D-01:** Full-width hero area with catalog search bar prominently centered. Below the hero, a vertical stack of content sections.
|
||||||
|
- **D-02:** Section order: (1) Hero with search bar, (2) Popular Setups, (3) Recently Added Items, (4) Trending Categories. Each section has a heading and optional "View all" link.
|
||||||
|
- **D-03:** The current `DashboardPage` component and its `DashboardCard` usage at `/` will be replaced entirely. The dashboard is now the landing page.
|
||||||
|
- **D-04:** The hero search bar triggers the existing `CatalogSearchOverlay` on focus or typing. This reuses the full-featured search without duplicating search UI.
|
||||||
|
- **D-05:** "Popular setups" ranked by item count descending (proxy for effort/completeness). Only public setups are shown.
|
||||||
|
- **D-06:** "Recently added items" shows the most recently created `globalItems`, ordered by `createdAt` descending.
|
||||||
|
- **D-07:** "Trending categories" ranked by global item count per distinct `globalItems.category` value.
|
||||||
|
- **D-08:** Cursor-based pagination for feed sections per INFR-02. Use `createdAt` cursor for recently added items; item count + ID cursor for popular setups.
|
||||||
|
- **D-09:** Same page content for both authenticated and anonymous users. Difference is purely navigational.
|
||||||
|
- **D-10:** Authenticated users see a "Go to Collection" CTA in the hero area, next to the search bar. Visible without scrolling.
|
||||||
|
- **D-11:** Anonymous users see the search bar and content sections immediately. Sign-in button in top-right per Phase 24.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Exact layout sizing, spacing, and responsive breakpoints
|
||||||
|
- Number of items shown per section before "View all" (suggest 6-8 for items/setups, 8-12 for categories)
|
||||||
|
- Empty states for sections with no data
|
||||||
|
- Loading skeletons for each section
|
||||||
|
- Whether "View all" links for setups/items route to existing pages or new dedicated feed pages
|
||||||
|
|
||||||
|
### Folded Todos (IN SCOPE)
|
||||||
|
- **Add cursor pointer to all clickable links** — Apply broadly while building the new page.
|
||||||
|
- **Fix item image not showing on collection overview** — Investigate and fix image display issue since landing page will show `GlobalItemCard` components with images.
|
||||||
|
- **Investigate slow image loading** — Profile image loading performance.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- Personalized feed based on user's collection categories (PERS-01, PERS-02)
|
||||||
|
- SSR/static prerendering for SEO (SEO-01, SEO-02)
|
||||||
|
- Engagement metrics (views, likes) for better ranking
|
||||||
|
- Setup preview images/thumbnails
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| DISC-01 | Landing page displays an always-visible catalog search bar at the top | Hero section with `CatalogSearchOverlay` trigger; search bar renders unconditionally |
|
||||||
|
| DISC-02 | Landing page shows a feed of popular setups below the search | New `GET /api/discovery/setups` endpoint; `useDiscoverySetups` hook; enhanced `PublicSetupCard` |
|
||||||
|
| DISC-03 | Landing page shows recently added catalog items | New `GET /api/discovery/items` endpoint; `useDiscoveryItems` hook; `GlobalItemCard` reuse |
|
||||||
|
| DISC-04 | Landing page shows trending categories | New `GET /api/discovery/categories` endpoint; `useDiscoveryCategories` hook; category pill/card display |
|
||||||
|
| DISC-05 | Authenticated users see a "Go to Collection" entry point | `useAuth` hook; conditional CTA in hero using `auth.user` presence |
|
||||||
|
| INFR-02 | Discovery feed endpoint uses cursor pagination | Cursor-based pagination on `createdAt` / `(itemCount, id)` — no offset pagination |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 26 replaces the personal dashboard at `/` with a public discovery landing page. The existing codebase is well-structured for this work: the current `index.tsx` is thin (61 lines), the reusable components (`GlobalItemCard`, `CatalogSearchOverlay`) are ready, the auth pattern (`useAuth`) is already in place, and the Tailwind design language is consistent throughout.
|
||||||
|
|
||||||
|
The primary technical work divides into three areas: (1) three new server-side discovery endpoints with cursor pagination, (2) three corresponding React Query hooks on the client, and (3) rewriting `index.tsx` into the landing page layout with hero, sections, and conditional auth CTA. The `PublicSetupCard` needs a minor enhancement to show item count and creator name for the popular setups section.
|
||||||
|
|
||||||
|
The folded todos (cursor pointer, image display bug) must be addressed during this phase since they directly affect the landing page experience.
|
||||||
|
|
||||||
|
**Primary recommendation:** Build three `GET /api/discovery/*` endpoints, mirror them with three React Query hooks, and rewrite `routes/index.tsx` composing existing `GlobalItemCard` and enhanced `PublicSetupCard`. Add a `discoveryRoutes` file registered at `/api/discovery` in `server/index.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (already in project)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| TanStack Router | file-based | Client routing; `routes/index.tsx` stays same file | Project standard |
|
||||||
|
| TanStack React Query | in project | Data fetching hooks; each section gets its own hook | Project standard |
|
||||||
|
| Tailwind CSS v4 | in project | Styling; `bg-white rounded-xl border border-gray-100` card pattern | Project standard |
|
||||||
|
| Hono | in project | Server routes; new `discoveryRoutes` file follows same pattern | Project standard |
|
||||||
|
| Drizzle ORM | in project | SQL queries with `desc`, `count`, `groupBy` for feed data | Project standard |
|
||||||
|
| Zustand (`uiStore`) | in project | `openCatalogSearch()` trigger from hero search bar | Project standard |
|
||||||
|
|
||||||
|
### No New Dependencies
|
||||||
|
This phase requires zero new package installations. All patterns and libraries are already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── client/
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── index.tsx # REWRITE — landing page (replaces dashboard)
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ └── useDiscovery.ts # NEW — three hooks: useDiscoverySetups, useDiscoveryItems, useDiscoveryCategories
|
||||||
|
│ └── components/
|
||||||
|
│ └── PublicSetupCard.tsx # ENHANCE — add itemCount + creatorName props
|
||||||
|
└── server/
|
||||||
|
├── routes/
|
||||||
|
│ └── discovery.ts # NEW — GET /setups, /items, /categories
|
||||||
|
├── services/
|
||||||
|
│ └── discovery.service.ts # NEW — getPopularSetups, getRecentItems, getTrendingCategories
|
||||||
|
└── index.ts # ADD — discoveryRoutes registration + public allowlist + rate limit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Discovery Endpoint with Cursor Pagination (INFR-02)
|
||||||
|
|
||||||
|
**What:** GET endpoints accept an optional `cursor` query param and `limit`. Cursor encodes position in result set without OFFSET.
|
||||||
|
|
||||||
|
**For recently added items** — cursor is the `createdAt` ISO timestamp of the last seen item:
|
||||||
|
```typescript
|
||||||
|
// Source: Drizzle ORM docs — cursor pagination
|
||||||
|
// GET /api/discovery/items?limit=8&cursor=2026-04-01T00:00:00.000Z
|
||||||
|
export async function getRecentGlobalItems(
|
||||||
|
db: Db,
|
||||||
|
limit = 8,
|
||||||
|
cursor?: string, // ISO timestamp of last seen item
|
||||||
|
) {
|
||||||
|
const conditions = cursor
|
||||||
|
? [lt(globalItems.createdAt, new Date(cursor))]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(globalItems)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(globalItems.createdAt))
|
||||||
|
.limit(limit + 1); // fetch one extra to detect hasMore
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For popular setups** — cursor is `itemCount_id` (composite: item count + setup id for stable ordering):
|
||||||
|
```typescript
|
||||||
|
// GET /api/discovery/setups?limit=6&cursor=5_42
|
||||||
|
// cursor = "{itemCount}_{id}" — both fields for stable pagination
|
||||||
|
export async function getPopularSetups(
|
||||||
|
db: Db,
|
||||||
|
limit = 6,
|
||||||
|
cursor?: string,
|
||||||
|
) {
|
||||||
|
const itemCountExpr = sql<number>`CAST(COUNT(${setupItems.id}) AS INT)`;
|
||||||
|
|
||||||
|
// Build base query with JOIN to count items per public setup
|
||||||
|
let query = db
|
||||||
|
.select({
|
||||||
|
id: setups.id,
|
||||||
|
name: setups.name,
|
||||||
|
createdAt: setups.createdAt,
|
||||||
|
itemCount: itemCountExpr,
|
||||||
|
})
|
||||||
|
.from(setups)
|
||||||
|
.leftJoin(setupItems, eq(setupItems.setupId, setups.id))
|
||||||
|
.where(eq(setups.isPublic, true))
|
||||||
|
.groupBy(setups.id, setups.name, setups.createdAt)
|
||||||
|
.orderBy(desc(itemCountExpr), desc(setups.id))
|
||||||
|
.limit(limit + 1);
|
||||||
|
|
||||||
|
// Cursor filtering applied post-query (simpler for composite cursor with SQLite)
|
||||||
|
const rows = await query;
|
||||||
|
if (cursor) {
|
||||||
|
const [cursorCount, cursorId] = cursor.split("_").map(Number);
|
||||||
|
// Filter: itemCount < cursorCount, OR (itemCount === cursorCount AND id < cursorId)
|
||||||
|
return rows.filter(r =>
|
||||||
|
r.itemCount < cursorCount ||
|
||||||
|
(r.itemCount === cursorCount && r.id < cursorId)
|
||||||
|
).slice(0, limit + 1);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** For SQLite (production DB is PostgreSQL per schema imports, but schema uses `pgTable`), the `ilike` operator is already in use in `global-item.service.ts`. The Drizzle operators `lt`, `desc`, `count`, `sql` are all already imported in the codebase.
|
||||||
|
|
||||||
|
**hasMore detection pattern:**
|
||||||
|
```typescript
|
||||||
|
// In route handler — consistent across all three endpoints
|
||||||
|
const rows = await getRecentGlobalItems(db, limit + 1, cursor);
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
const nextCursor = hasMore ? buildCursor(items[items.length - 1]) : null;
|
||||||
|
return c.json({ items, nextCursor, hasMore });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Discovery Route Registration
|
||||||
|
|
||||||
|
**What:** New `discoveryRoutes` file registered at `/api/discovery` in `server/index.ts`, following the exact same Hono pattern as `globalItemRoutes`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/server/routes/discovery.ts
|
||||||
|
import { Hono } from "hono";
|
||||||
|
// ...
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
app.get("/setups", async (c) => { ... });
|
||||||
|
app.get("/items", async (c) => { ... });
|
||||||
|
app.get("/categories", async (c) => { ... });
|
||||||
|
export { app as discoveryRoutes };
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/server/index.ts additions:
|
||||||
|
// 1. Import discoveryRoutes
|
||||||
|
// 2. Add to public skiplist (GET /api/discovery/*)
|
||||||
|
// 3. Add browseTier rate limit for GET /api/discovery/*
|
||||||
|
// 4. app.route("/api/discovery", discoveryRoutes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Discovery React Query Hooks
|
||||||
|
|
||||||
|
**What:** Single file `useDiscovery.ts` with three named exports, following the exact pattern of `useGlobalItems`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/client/hooks/useDiscovery.ts
|
||||||
|
export interface DiscoverySetup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
itemCount: number;
|
||||||
|
// creatorName: optional — depends on whether users join is implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveryCategory {
|
||||||
|
name: string;
|
||||||
|
itemCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoverySetups(limit = 6) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["discovery", "setups", limit],
|
||||||
|
queryFn: () => apiGet<{ items: DiscoverySetup[]; nextCursor: string | null }>(`/api/discovery/setups?limit=${limit}`),
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 min — feed data, okay to be slightly stale
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryItems(limit = 8) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["discovery", "items", limit],
|
||||||
|
queryFn: () => apiGet<{ items: GlobalItem[]; nextCursor: string | null }>(`/api/discovery/items?limit=${limit}`),
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryCategories(limit = 12) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["discovery", "categories", limit],
|
||||||
|
queryFn: () => apiGet<DiscoveryCategory[]>(`/api/discovery/categories?limit=${limit}`),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 min — categories change rarely
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Landing Page Component Structure
|
||||||
|
|
||||||
|
**What:** `routes/index.tsx` rewritten as `LandingPage` function. `DashboardPage` and `DashboardCard` imports removed. Three sections below the hero, each as a standalone sub-component in the same file.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/client/routes/index.tsx
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: LandingPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LandingPage() {
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
{/* Hero */}
|
||||||
|
<HeroSection isAuthenticated={isAuthenticated} onSearchFocus={() => openCatalogSearch("collection")} />
|
||||||
|
{/* Sections */}
|
||||||
|
<PopularSetupsSection />
|
||||||
|
<RecentItemsSection />
|
||||||
|
<TrendingCategoriesSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Enhanced `PublicSetupCard`
|
||||||
|
|
||||||
|
**What:** Add `itemCount` and optional `creatorName` to `PublicSetupCardProps`. The card currently only shows `name` and formatted date — it needs item count to be useful in a "Popular Setups" feed.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PublicSetupCardProps {
|
||||||
|
setup: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
itemCount: number; // NEW — required for popular setups feed
|
||||||
|
creatorName?: string; // NEW — optional, shown if present
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** This is a non-breaking change. Existing usages of `PublicSetupCard` that pass the old shape will need to add `itemCount`. Check all usages before changing the interface.
|
||||||
|
|
||||||
|
### Pattern 6: Hero Search Bar
|
||||||
|
|
||||||
|
**What:** A styled `<input>` or `<div>` that calls `openCatalogSearch("collection")` on click/focus. Does NOT perform search itself — just triggers `CatalogSearchOverlay`. This aligns with D-04.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function HeroSection({ isAuthenticated, onSearchFocus }: { isAuthenticated: boolean; onSearchFocus: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Discover Gear</h1>
|
||||||
|
<p className="text-gray-500 mb-6">Browse what other people carry</p>
|
||||||
|
|
||||||
|
{/* Search trigger — visual only, opens overlay */}
|
||||||
|
<div
|
||||||
|
onClick={onSearchFocus}
|
||||||
|
className="max-w-xl mx-auto flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-200 hover:border-gray-300 cursor-pointer shadow-sm transition-all"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
|
||||||
|
>
|
||||||
|
<SearchIcon className="w-4 h-4 text-gray-400 shrink-0" />
|
||||||
|
<span className="text-sm text-gray-400 flex-1 text-left">Search the catalog...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authenticated CTA (D-10) */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link to="/collection" className="text-sm text-gray-600 hover:text-gray-900 underline underline-offset-2 cursor-pointer">
|
||||||
|
Go to Collection →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **Do not build inline search results** — The search bar is a trigger only; `CatalogSearchOverlay` handles all search UI (D-04). Duplicating search logic will create state sync bugs.
|
||||||
|
- **Do not use OFFSET pagination** — Use cursor-based pagination for all discovery endpoints (INFR-02). OFFSET degrades with large tables and can skip/duplicate rows with concurrent inserts.
|
||||||
|
- **Do not use `auth?.authenticated` for conditional CTA** — Use `!!auth?.user` as established by `__root.tsx` pattern (`const isAuthenticated = !!auth?.user`).
|
||||||
|
- **Do not import DashboardCard or DashboardPage in new index.tsx** — They are being retired by D-03; remove imports entirely.
|
||||||
|
- **Do not fire-and-forget on auth check for CTA** — The `useAuth` query has `staleTime: 5 * 60 * 1000` and `retry: false`. The CTA should appear only after auth resolves (`auth?.user` is truthy), not while `isLoading`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Search UI | Custom search input with results | `CatalogSearchOverlay` + `openCatalogSearch()` | Full feature set: debounce, tags, grid/list, manual entry; D-04 |
|
||||||
|
| Auth state | Manual JWT decode or session check | `useAuth()` hook | Caches auth state, handles race conditions |
|
||||||
|
| Weight/price formatting | `item.weightGrams + "g"` | `useFormatters()` → `weight()`, `price()` | Handles unit conversion, null, localization |
|
||||||
|
| Card skeleton | Custom loading spinner | `animate-pulse` Tailwind classes — match `CatalogSearchOverlay` skeleton pattern | Consistent with existing `SkeletonGrid` in `CatalogSearchOverlay` |
|
||||||
|
| Rate limiting | New rate limit implementation | `createRateLimit(browseTier)` factory | Already handles IP extraction, cleanup, 429 responses |
|
||||||
|
|
||||||
|
**Key insight:** Nearly all plumbing already exists. The work is composition, not invention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: `CatalogSearchOverlay` mounts at `top-[57px]`
|
||||||
|
**What goes wrong:** The overlay is positioned `fixed inset-x-0 top-[57px]` (below the `TotalsBar` which is `h-14` = 56px). If the landing page adds any sticky element above the TotalsBar, the overlay will overlap it.
|
||||||
|
**Why it happens:** The overlay's top offset is hardcoded to the TotalsBar height.
|
||||||
|
**How to avoid:** Do not add sticky elements to the landing page layout outside of TotalsBar. The hero section should be part of the normal document flow.
|
||||||
|
**Warning signs:** Overlay appears behind or over an element when opened from landing page.
|
||||||
|
|
||||||
|
### Pitfall 2: `isDashboard` detection in `__root.tsx`
|
||||||
|
**What goes wrong:** `__root.tsx` has `const isDashboard = !!matchRoute({ to: "/" })`. This controls whether `TotalsBar` shows a `linkTo` prop (back link). If the new landing page keeps the `/` route, `isDashboard` will remain `true` and `TotalsBar` will render its title as a non-link — which is correct behavior (already handled).
|
||||||
|
**Why it happens:** No change needed, but worth knowing so it's not "fixed" accidentally.
|
||||||
|
**How to avoid:** Leave `isDashboard` logic in `__root.tsx` unchanged.
|
||||||
|
|
||||||
|
### Pitfall 3: `PublicSetupCard` interface change breaks existing usages
|
||||||
|
**What goes wrong:** If `itemCount` is made required in `PublicSetupCardProps`, any existing usage that doesn't pass it will cause a TypeScript error.
|
||||||
|
**Why it happens:** The card is used in at least the public setup profile page.
|
||||||
|
**How to avoid:** Check all usages of `PublicSetupCard` before adding `itemCount` as required. Consider adding it as optional (`itemCount?: number`) with a fallback display.
|
||||||
|
**Warning signs:** TypeScript errors on `PublicSetupCard` usages in other files.
|
||||||
|
|
||||||
|
### Pitfall 4: Discovery endpoint auth allowlist
|
||||||
|
**What goes wrong:** New `GET /api/discovery/*` endpoints return 401 for anonymous users because the auth skip list in `server/index.ts` doesn't include them.
|
||||||
|
**Why it happens:** The auth middleware at line 151-170 of `server/index.ts` skips specific paths by prefix check. New paths must be explicitly added.
|
||||||
|
**How to avoid:** Add this skip condition to `server/index.ts`:
|
||||||
|
```typescript
|
||||||
|
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
|
||||||
|
return next();
|
||||||
|
```
|
||||||
|
**Warning signs:** Anonymous page load shows empty sections or 401 errors in browser network tab.
|
||||||
|
|
||||||
|
### Pitfall 5: Trending categories — `globalItems.category` is nullable
|
||||||
|
**What goes wrong:** SQL `GROUP BY globalItems.category` will include a `null` group if some items have no category set. This null group may appear in "Trending Categories" and render as an empty/broken category chip.
|
||||||
|
**Why it happens:** `category` column is `text` nullable in schema.
|
||||||
|
**How to avoid:** Add `WHERE globalItems.category IS NOT NULL` to the trending categories query.
|
||||||
|
**Warning signs:** Category section shows an empty/blank chip.
|
||||||
|
|
||||||
|
### Pitfall 6: Creator name requires users join (scope risk)
|
||||||
|
**What goes wrong:** D-02 says setups show "creator names". But `setups` table only has `userId` — getting `displayName` requires joining `users` table. If `users.displayName` is null (common for new accounts), the join returns null.
|
||||||
|
**Why it happens:** User display names are optional in the schema.
|
||||||
|
**How to avoid:** Join users in `getPopularSetups` query and return `creatorName: users.displayName ?? null`. In the card, render creator name only when non-null (e.g., "by Jean-Luc" or omit entirely). Mark `creatorName` as optional in the TS interface.
|
||||||
|
**Warning signs:** Crashes on `.toLocaleLowerCase()` or similar when `creatorName` is null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Drizzle: Trending categories query
|
||||||
|
```typescript
|
||||||
|
// Source: Drizzle docs + codebase patterns in global-item.service.ts
|
||||||
|
import { count, desc, isNotNull, groupBy } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function getTrendingCategories(db: Db, limit = 12) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
name: globalItems.category,
|
||||||
|
itemCount: count(globalItems.id),
|
||||||
|
})
|
||||||
|
.from(globalItems)
|
||||||
|
.where(isNotNull(globalItems.category))
|
||||||
|
.groupBy(globalItems.category)
|
||||||
|
.orderBy(desc(count(globalItems.id)))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
// Returns: Array<{ name: string; itemCount: number }>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle: Popular setups with item count
|
||||||
|
```typescript
|
||||||
|
// Source: Drizzle docs — leftJoin + groupBy + count
|
||||||
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function getPopularSetups(db: Db, limit = 6) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: setups.id,
|
||||||
|
name: setups.name,
|
||||||
|
createdAt: setups.createdAt,
|
||||||
|
itemCount: count(setupItems.id),
|
||||||
|
creatorName: users.displayName,
|
||||||
|
})
|
||||||
|
.from(setups)
|
||||||
|
.leftJoin(setupItems, eq(setupItems.setupId, setups.id))
|
||||||
|
.leftJoin(users, eq(users.id, setups.userId))
|
||||||
|
.where(eq(setups.isPublic, true))
|
||||||
|
.groupBy(setups.id, setups.name, setups.createdAt, users.displayName)
|
||||||
|
.orderBy(desc(count(setupItems.id)), desc(setups.id))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor response shape (consistent across all three endpoints)
|
||||||
|
```typescript
|
||||||
|
// Applied to /api/discovery/items and /api/discovery/setups
|
||||||
|
interface CursorPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in route handler:
|
||||||
|
const rows = await getRecentGlobalItems(db, limit + 1, cursor);
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const sliced = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
const nextCursor = hasMore
|
||||||
|
? sliced[sliced.length - 1].createdAt.toISOString()
|
||||||
|
: null;
|
||||||
|
return c.json({ items: sliced, nextCursor, hasMore });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Section skeleton pattern (matches existing `CatalogSearchOverlay` style)
|
||||||
|
```typescript
|
||||||
|
// Reuse the animate-pulse pattern from CatalogSearchOverlay.tsx
|
||||||
|
function SectionSkeleton({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-100 overflow-hidden animate-pulse">
|
||||||
|
<div className="aspect-[4/3] bg-gray-100" />
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-100 rounded w-16" />
|
||||||
|
<div className="h-4 bg-gray-100 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Offset pagination (`LIMIT x OFFSET y`) | Cursor pagination (`WHERE createdAt < cursor`) | INFR-02 decision | Stable results, better performance at scale |
|
||||||
|
| Personal dashboard as `/` | Public discovery landing as `/` | D-03 (this phase) | New visitors see content, not a login gate |
|
||||||
|
| `PublicSetupCard` shows name + date only | Enhanced card adds item count + creator name | This phase | Sufficient context to judge "popular" setup quality |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `DashboardPage` function in `index.tsx`: retired by D-03; file is rewritten, component removed
|
||||||
|
- `DashboardCard` component: no longer rendered from `/`; not deleted (may be useful elsewhere) but imports removed from `index.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
Step 2.6: SKIPPED — This phase is purely client/server code changes within the existing project stack. No new external tools, services, runtimes, or CLI utilities are required beyond what's already installed (`bun`, `node`, PostgreSQL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Bun test runner (built-in) |
|
||||||
|
| Config file | none — `bun test` auto-discovers `*.test.ts` |
|
||||||
|
| Quick run command | `bun test tests/services/discovery.service.test.ts` |
|
||||||
|
| Full suite command | `bun test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| DISC-01 | Hero search bar renders; clicking triggers CatalogSearchOverlay | smoke | E2E only — overlay integration | ❌ Wave 0 (E2E) |
|
||||||
|
| DISC-02 | `getPopularSetups` returns public setups ordered by item count desc | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
|
||||||
|
| DISC-02 | `GET /api/discovery/setups` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 |
|
||||||
|
| DISC-03 | `getRecentGlobalItems` returns items ordered by createdAt desc | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
|
||||||
|
| DISC-03 | `GET /api/discovery/items` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 |
|
||||||
|
| DISC-04 | `getTrendingCategories` excludes null categories, orders by count | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
|
||||||
|
| DISC-04 | `GET /api/discovery/categories` returns 200 for anonymous requests | integration | `bun test tests/routes/discovery.test.ts` | ❌ Wave 0 |
|
||||||
|
| DISC-05 | "Go to Collection" link absent for anonymous, present for authenticated | smoke | E2E only — requires auth session | ❌ Wave 0 (E2E) |
|
||||||
|
| INFR-02 | Cursor pagination: second page excludes items from first page | unit | `bun test tests/services/discovery.service.test.ts` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `bun test tests/services/discovery.service.test.ts tests/routes/discovery.test.ts`
|
||||||
|
- **Per wave merge:** `bun test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `tests/services/discovery.service.test.ts` — covers DISC-02, DISC-03, DISC-04, INFR-02 (service layer)
|
||||||
|
- [ ] `tests/routes/discovery.test.ts` — covers DISC-02, DISC-03, DISC-04 route layer; anonymous access
|
||||||
|
- [ ] `src/server/routes/discovery.ts` — new route file (Wave 0 stub before tests)
|
||||||
|
- [ ] `src/server/services/discovery.service.ts` — new service file (Wave 0 stub before tests)
|
||||||
|
- [ ] `src/client/hooks/useDiscovery.ts` — new hooks file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Creator name display in popular setups feed**
|
||||||
|
- What we know: `setups.userId` exists; `users.displayName` is nullable text
|
||||||
|
- What's unclear: How many users have null `displayName` (could be most in early data)
|
||||||
|
- Recommendation: Render "by {name}" only when `creatorName` is non-null; show nothing otherwise. Do not fall back to email (privacy concern).
|
||||||
|
|
||||||
|
2. **"View all" link destinations for setups and items**
|
||||||
|
- What we know: Claude's Discretion says this is unresolved
|
||||||
|
- What's unclear: No dedicated `/catalog` browse page or `/setups` public listing page exists yet
|
||||||
|
- Recommendation: "View all" for items links to `/global-items` (existing catalog page). "View all" for setups can be omitted for v1 of this page if no public setups listing page exists. Verify that `/global-items` route exists as a valid destination.
|
||||||
|
|
||||||
|
3. **Cursor pagination for `categories` endpoint**
|
||||||
|
- What we know: INFR-02 requires cursor pagination for discovery feed; categories are ranked by count
|
||||||
|
- What's unclear: Categories list will be small (10-50 items max in early data); cursor pagination may be over-engineering for categories
|
||||||
|
- Recommendation: Use `limit` param for categories without cursor (no pagination). Categories don't grow unboundedly and the full list is small. Use cursor only for `setups` and `items` as decision D-08 specifies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Codebase direct read — `src/server/routes/global-items.ts`, `src/server/services/global-item.service.ts`, `src/db/schema.ts`, `src/server/index.ts`, `src/client/routes/__root.tsx`, `src/client/stores/uiStore.ts`, `src/client/components/CatalogSearchOverlay.tsx`, `src/client/components/GlobalItemCard.tsx`, `src/client/components/PublicSetupCard.tsx`
|
||||||
|
- CONTEXT.md — Locked decisions D-01 through D-11
|
||||||
|
- CLAUDE.md — Project stack, patterns, reusable component guidelines
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- Drizzle ORM standard patterns for `leftJoin`, `groupBy`, `count`, `desc` — consistent with existing codebase usage
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — all in project, no new deps
|
||||||
|
- Architecture patterns: HIGH — based on direct codebase reads, locked decisions
|
||||||
|
- Pitfalls: HIGH — derived from actual code inspection (nullable category, auth allowlist gaps, interface changes)
|
||||||
|
- Validation approach: HIGH — matches existing test patterns in `tests/services/` and `tests/routes/`
|
||||||
|
|
||||||
|
**Research date:** 2026-04-10
|
||||||
|
**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)
|
||||||
85
.planning/phases/26-discovery-landing-page/26-VALIDATION.md
Normal file
85
.planning/phases/26-discovery-landing-page/26-VALIDATION.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
phase: 26
|
||||||
|
slug: discovery-landing-page
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 26 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Bun test runner (built-in) |
|
||||||
|
| **Config file** | none — `bun test` auto-discovers `*.test.ts` |
|
||||||
|
| **Quick run command** | `bun test tests/services/discovery.service.test.ts tests/routes/discovery.test.ts` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Estimated runtime** | ~15 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test tests/services/discovery.service.test.ts tests/routes/discovery.test.ts`
|
||||||
|
- **After every plan wave:** Run `bun test`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 15 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 26-01-01 | 01 | 1 | DISC-02 | unit | `bun test tests/services/discovery.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-01-02 | 01 | 1 | DISC-03 | unit | `bun test tests/services/discovery.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-01-03 | 01 | 1 | DISC-04 | unit | `bun test tests/services/discovery.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-01-04 | 01 | 1 | INFR-02 | unit | `bun test tests/services/discovery.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-01-05 | 01 | 1 | DISC-02 | integration | `bun test tests/routes/discovery.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-01-06 | 01 | 1 | DISC-03 | integration | `bun test tests/routes/discovery.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-01-07 | 01 | 1 | DISC-04 | integration | `bun test tests/routes/discovery.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 26-02-01 | 02 | 2 | DISC-01 | smoke | E2E only — overlay integration | ❌ E2E | ⬜ pending |
|
||||||
|
| 26-02-02 | 02 | 2 | DISC-05 | smoke | E2E only — requires auth session | ❌ E2E | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `tests/services/discovery.service.test.ts` — stubs for DISC-02, DISC-03, DISC-04, INFR-02
|
||||||
|
- [ ] `tests/routes/discovery.test.ts` — stubs for DISC-02, DISC-03, DISC-04 route layer
|
||||||
|
- [ ] `src/server/services/discovery.service.ts` — new service file (stub)
|
||||||
|
- [ ] `src/server/routes/discovery.ts` — new route file (stub)
|
||||||
|
|
||||||
|
*Existing infrastructure covers test helpers (createTestDb, in-memory Postgres via PGlite).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Hero search bar triggers CatalogSearchOverlay | DISC-01 | Client interaction; E2E pending rewrite (999.1) | Load `/`, click search bar, verify overlay opens |
|
||||||
|
| "Go to Collection" CTA visible for authenticated users | DISC-05 | Requires auth session; E2E pending rewrite | Log in, visit `/`, verify CTA present |
|
||||||
|
| "Go to Collection" CTA absent for anonymous | DISC-05 | Requires anonymous session | Open incognito, visit `/`, verify no CTA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 15s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
147
.planning/phases/26-discovery-landing-page/26-VERIFICATION.md
Normal file
147
.planning/phases/26-discovery-landing-page/26-VERIFICATION.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
phase: 26-discovery-landing-page
|
||||||
|
verified: 2026-04-10T14:00:00Z
|
||||||
|
status: passed
|
||||||
|
score: 12/12 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
gaps: []
|
||||||
|
human_verification:
|
||||||
|
- test: "Visual: Hero section and search bar appearance"
|
||||||
|
expected: "Centered 'Discover Gear' heading, subtitle, styled search bar with magnifier icon, no login redirect for anonymous user"
|
||||||
|
why_human: "Cannot verify visual rendering programmatically"
|
||||||
|
- test: "Interaction: Click search bar opens CatalogSearchOverlay"
|
||||||
|
expected: "Clicking or pressing Enter on the search bar div triggers openCatalogSearch('collection') and the overlay slides in"
|
||||||
|
why_human: "Requires browser interaction — unit tests don't cover overlay mount trigger"
|
||||||
|
- test: "Auth-conditional CTA: 'Go to Collection' visible only when authenticated"
|
||||||
|
expected: "Logged-in user sees 'Go to Collection' link; anonymous user does not"
|
||||||
|
why_human: "Requires live auth session to confirm conditional rendering"
|
||||||
|
- test: "Responsive layout at mobile width"
|
||||||
|
expected: "Single-column grid for Popular Setups; 2-column for Recently Added on narrow viewport"
|
||||||
|
why_human: "CSS grid breakpoints require visual verification"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 26: Discovery Landing Page Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** The app opens to a public discovery feed with prominent catalog search, not a personal dashboard
|
||||||
|
**Verified:** 2026-04-10T14:00:00Z
|
||||||
|
**Status:** passed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|---------|
|
||||||
|
| 1 | `getPopularSetups` returns public setups ordered by item count descending | VERIFIED | Service file line 45: `eq(setups.isPublic, true)`, ordered by `COUNT(setupItems.id) DESC`; 11/11 service tests pass |
|
||||||
|
| 2 | `getRecentGlobalItems` returns items ordered by createdAt descending | VERIFIED | Service file line 92: `.orderBy(desc(globalItems.createdAt))`; tests pass |
|
||||||
|
| 3 | `getTrendingCategories` returns categories ordered by item count, excluding nulls | VERIFIED | Service file line 121: `isNotNull(globalItems.category)`, `orderBy(desc(count(...)))`; tests pass |
|
||||||
|
| 4 | Cursor pagination returns next page without duplicates | VERIFIED | Composite `itemCount_id` cursor for setups (JS post-filter); ISO timestamp cursor for items; both tested with deduplication assertions |
|
||||||
|
| 5 | GET /api/discovery/setups returns popular setups for anonymous users | VERIFIED | Route registered at line 190 of index.ts; auth skip at line 170-171; 10/10 route tests pass |
|
||||||
|
| 6 | GET /api/discovery/items returns recent catalog items for anonymous users | VERIFIED | Route handler at lines 21-28 of discovery.ts; anonymous test passes without userId |
|
||||||
|
| 7 | GET /api/discovery/categories returns trending categories for anonymous users | VERIFIED | Route handler at lines 30-36 of discovery.ts; anonymous test passes |
|
||||||
|
| 8 | All discovery endpoints accept limit and cursor query params | VERIFIED | Routes parse `limit` and `cursor` query params with parseInt and Math.min cap at 50 |
|
||||||
|
| 9 | Discovery endpoints are rate-limited with browseTier | VERIFIED | index.ts lines 127-130: `app.use("/api/discovery/*", ...)` applies `browseTier` for all GET requests |
|
||||||
|
| 10 | Root URL shows a hero section with a catalog search bar | VERIFIED | index.tsx contains `function HeroSection(` with search div, `cursor-pointer`, Search icon |
|
||||||
|
| 11 | Clicking the search bar opens CatalogSearchOverlay | VERIFIED (wiring) | `openCatalogSearch("collection")` called in `onSearchFocus`; CatalogSearchOverlay reads `catalogSearchOpen` from same uiStore and is mounted in `__root.tsx` line 175 |
|
||||||
|
| 12 | Authenticated users see "Go to Collection" link in hero | VERIFIED | `!!auth?.user` guard at line 60 of index.tsx; Link to `/collection` rendered conditionally |
|
||||||
|
|
||||||
|
**Score:** 12/12 truths verified
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `src/server/services/discovery.service.ts` | Discovery feed queries with cursor pagination | VERIFIED | 131 lines; exports `getPopularSetups`, `getRecentGlobalItems`, `getTrendingCategories`; real Drizzle queries against `globalItems`, `setups`, `setupItems`, `users` |
|
||||||
|
| `tests/services/discovery.service.test.ts` | Unit tests for all three service functions | VERIFIED | 247 lines (min_lines: 100 satisfied); 11 it() calls; 3 describe blocks; all pass |
|
||||||
|
| `src/server/routes/discovery.ts` | Hono route handlers for three discovery endpoints | VERIFIED | Exports `discoveryRoutes`; 3 GET handlers for `/setups`, `/items`, `/categories`; imports from discovery.service |
|
||||||
|
| `src/client/hooks/useDiscovery.ts` | React Query hooks for landing page data fetching | VERIFIED | Exports `useDiscoverySetups`, `useDiscoveryItems`, `useDiscoveryCategories` and interfaces `DiscoverySetup`, `DiscoveryCategory` |
|
||||||
|
| `tests/routes/discovery.test.ts` | Route-level integration tests | VERIFIED | 241 lines (min_lines: 50 satisfied); 10 it() calls; all pass |
|
||||||
|
| `src/client/routes/index.tsx` | Landing page with hero, popular setups, recent items, trending categories | VERIFIED | 192 lines (min_lines: 80 satisfied); contains `LandingPage`, `HeroSection`, `PopularSetupsSection`, `RecentItemsSection`, `TrendingCategoriesSection`; no DashboardPage/DashboardCard/useTotals remnants |
|
||||||
|
| `src/client/components/PublicSetupCard.tsx` | Enhanced setup card with optional itemCount and creatorName | VERIFIED | Contains `itemCount?: number`, `creatorName?: string \| null`; renders both conditionally; `cursor-pointer` applied |
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `src/server/routes/discovery.ts` | `src/server/services/discovery.service.ts` | imports `getPopularSetups`, `getRecentGlobalItems`, `getTrendingCategories` | WIRED | Lines 1-6 of discovery.ts import all three service functions |
|
||||||
|
| `src/server/index.ts` | `src/server/routes/discovery.ts` | `app.route("/api/discovery", discoveryRoutes)` | WIRED | Line 16: import; line 127: rate limit; line 170: auth skip; line 190: route registration |
|
||||||
|
| `src/client/hooks/useDiscovery.ts` | `/api/discovery` | `apiGet` fetch calls | WIRED | Lines 42-44, 52-54, 61-63 call `apiGet` with `/api/discovery/setups`, `/api/discovery/items`, `/api/discovery/categories` |
|
||||||
|
| `src/client/routes/index.tsx` | `src/client/hooks/useDiscovery.ts` | imports all three hooks | WIRED | Lines 7-10 import all three hooks; used in `PopularSetupsSection`, `RecentItemsSection`, `TrendingCategoriesSection` |
|
||||||
|
| `src/client/routes/index.tsx` | `src/client/stores/uiStore.ts` | `openCatalogSearch` trigger from hero | WIRED | Line 20: `useUIStore((s) => s.openCatalogSearch)`; line 26: called on search focus |
|
||||||
|
| `src/client/routes/index.tsx` | `src/client/hooks/useAuth.ts` | auth state for conditional CTA | WIRED | Line 5: import; line 18: `useAuth()`; line 19: `!!auth?.user` |
|
||||||
|
| `src/client/routes/index.tsx` | `src/client/components/GlobalItemCard.tsx` | renders catalog items | WIRED | Line 3: import; lines 114-121: rendered in `RecentItemsSection` |
|
||||||
|
| `src/client/routes/index.tsx` | `src/client/components/PublicSetupCard.tsx` | renders setup cards | WIRED | Line 4: import; line 90: rendered in `PopularSetupsSection` |
|
||||||
|
|
||||||
|
### Data-Flow Trace (Level 4)
|
||||||
|
|
||||||
|
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||||
|
|----------|---------------|--------|--------------------|--------|
|
||||||
|
| `src/client/routes/index.tsx` (PopularSetupsSection) | `data?.items` from `useDiscoverySetups` | `apiGet('/api/discovery/setups')` → `getPopularSetups` → Drizzle JOIN on `setups`/`setupItems`/`users` | Yes — real DB SELECT with COUNT | FLOWING |
|
||||||
|
| `src/client/routes/index.tsx` (RecentItemsSection) | `data?.items` from `useDiscoveryItems` | `apiGet('/api/discovery/items')` → `getRecentGlobalItems` → Drizzle SELECT on `globalItems` | Yes — real DB SELECT ORDER BY createdAt | FLOWING |
|
||||||
|
| `src/client/routes/index.tsx` (TrendingCategoriesSection) | `data` from `useDiscoveryCategories` | `apiGet('/api/discovery/categories')` → `getTrendingCategories` → Drizzle GROUP BY on `globalItems` | Yes — real DB SELECT GROUP BY category | FLOWING |
|
||||||
|
|
||||||
|
### Behavioral Spot-Checks
|
||||||
|
|
||||||
|
| Behavior | Command | Result | Status |
|
||||||
|
|----------|---------|--------|--------|
|
||||||
|
| Service tests pass | `bun test tests/services/discovery.service.test.ts` | 11 pass, 0 fail | PASS |
|
||||||
|
| Route tests pass | `bun test tests/routes/discovery.test.ts` | 10 pass, 0 fail | PASS |
|
||||||
|
| Client build succeeds | `bun run build` | Built in 625ms, no TypeScript errors | PASS |
|
||||||
|
| Full test suite (regression) | `bun test` | 285 pass, 15 fail — all 15 failures are pre-existing `storage.service.ts` export issue, unrelated to phase 26 | PASS (no regressions) |
|
||||||
|
| Old dashboard code removed | `grep DashboardPage/DashboardCard/useTotals index.tsx` | No matches | PASS |
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|---------|
|
||||||
|
| DISC-01 | 26-03 | Landing page displays an always-visible catalog search bar at the top | SATISFIED | `HeroSection` in `index.tsx` contains search bar div with Search icon; no auth gate on the landing page |
|
||||||
|
| DISC-02 | 26-01, 26-02, 26-03 | Landing page shows a feed of popular setups below the search | SATISFIED | `PopularSetupsSection` renders `PublicSetupCard` grid from `useDiscoverySetups`; backed by `getPopularSetups` service |
|
||||||
|
| DISC-03 | 26-01, 26-02, 26-03 | Landing page shows recently added catalog items | SATISFIED | `RecentItemsSection` renders `GlobalItemCard` grid from `useDiscoveryItems`; backed by `getRecentGlobalItems` service |
|
||||||
|
| DISC-04 | 26-01, 26-02, 26-03 | Landing page shows trending categories | SATISFIED | `TrendingCategoriesSection` renders category pills from `useDiscoveryCategories`; backed by `getTrendingCategories` service |
|
||||||
|
| DISC-05 | 26-03 | Authenticated users see a "Go to Collection" entry point from the landing page | SATISFIED | `!!auth?.user` conditional in `HeroSection` renders `<Link to="/collection">Go to Collection</Link>` |
|
||||||
|
| INFR-02 | 26-01, 26-02 | Discovery feed endpoint uses cursor pagination for scalability | SATISFIED | `getPopularSetups` (composite `itemCount_id` cursor) and `getRecentGlobalItems` (ISO timestamp cursor) both implement cursor pagination with `hasMore`/`nextCursor`; categories use simple limit (bounded list, acceptable per RESEARCH.md) |
|
||||||
|
|
||||||
|
No orphaned requirements — all 6 IDs (DISC-01 through DISC-05, INFR-02) appear in at least one plan's `requirements` field and are fully implemented.
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| `src/client/routes/index.tsx` | 78, 102, 135 | `return null` when no data | Info | Intentional empty-state handling per plan spec — sections hide when no data, not a stub |
|
||||||
|
|
||||||
|
No blockers. The `return null` patterns are intentional design decisions documented in the plan and summaries: sections hide themselves when not loading and data is empty.
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Visual appearance of landing page
|
||||||
|
|
||||||
|
**Test:** Run `bun run dev`, open `http://localhost:5173/` in a browser
|
||||||
|
**Expected:** Hero section centered with "Discover Gear" heading, subtitle "Browse what other people carry", styled search bar with magnifier icon. Three sections below if data exists. No redirect to login.
|
||||||
|
**Why human:** Visual rendering cannot be verified programmatically.
|
||||||
|
|
||||||
|
#### 2. Search bar triggers CatalogSearchOverlay
|
||||||
|
|
||||||
|
**Test:** Click the search bar div or press Enter while focused on it
|
||||||
|
**Expected:** The CatalogSearchOverlay slides in (full-screen or modal), ready to search the catalog
|
||||||
|
**Why human:** Requires browser click event to trigger; wiring is confirmed in code but overlay animation/render requires visual confirmation.
|
||||||
|
|
||||||
|
#### 3. "Go to Collection" CTA conditional on auth state
|
||||||
|
|
||||||
|
**Test:** While logged out verify no CTA; after logging in verify "Go to Collection" appears in hero area and navigates to `/collection`
|
||||||
|
**Why human:** Requires live Logto OIDC session to test the `auth?.user` condition.
|
||||||
|
|
||||||
|
#### 4. Responsive layout at mobile width
|
||||||
|
|
||||||
|
**Test:** Resize browser to 375px width; verify Popular Setups shows 1 column, Recently Added shows 2 columns
|
||||||
|
**Expected:** Tailwind sm: breakpoints kick in correctly at responsive widths
|
||||||
|
**Why human:** CSS grid breakpoint behavior requires visual inspection.
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All 12 truths verified. All artifacts exist, are substantive, wired, and have confirmed data flow paths. Both test suites pass. Build succeeds. The 15 pre-existing test failures are all caused by a `storage.service.ts` export naming issue documented in earlier summaries as pre-existing — none are from phase 26 code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-04-10T14:00:00Z_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,26 @@
|
|||||||
# Feature Research
|
# Feature Research
|
||||||
|
|
||||||
**Domain:** Multi-user gear management and discovery platform
|
**Domain:** Public-first gear discovery platform with catalog enrichment
|
||||||
**Researched:** 2026-04-03
|
**Researched:** 2026-04-09
|
||||||
**Confidence:** MEDIUM-HIGH
|
**Confidence:** MEDIUM-HIGH
|
||||||
|
**Milestone scope:** v2.1 Public Discovery — builds on v2.0 multi-user foundation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Context
|
## Context: What Already Exists (v2.0)
|
||||||
|
|
||||||
This is the feature research for **v2.0 Platform Foundation** -- transforming GearBox from a single-user gear tracker into a multi-user platform with discovery, global item database, structured reviews, and setup sharing.
|
These are shipped. New features below only mention them when v2.1 extends them:
|
||||||
|
|
||||||
**Existing features (already built through v1.4):**
|
- Full gear collection CRUD with weight/price tracking, categories, images
|
||||||
- Gear collection CRUD with categories, weight/price, images, quantity
|
|
||||||
- Planning threads with candidate comparison, ranking, pros/cons, impact preview
|
- Planning threads with candidate comparison, ranking, pros/cons, impact preview
|
||||||
- Named setups (loadouts) with classification, donut chart visualization
|
- Named setups with classification, donut chart, weight breakdowns
|
||||||
- Search/filter, CSV import/export, item duplication
|
- PostgreSQL multi-user data model, Logto OIDC external auth
|
||||||
- Dashboard home page, onboarding wizard
|
- S3 image storage (MinIO), global item catalog with tags and search
|
||||||
- Single-user auth (cookie sessions + API keys), MCP server (19 tools)
|
- User profiles with avatar/bio, public setup sharing
|
||||||
|
- Catalog-driven add flow, global FAB, item/catalog detail pages
|
||||||
|
- MCP server (19 tools), API key + OAuth auth methods
|
||||||
|
|
||||||
**Key project constraints:**
|
All features below are **new for v2.1** unless explicitly marked "extend existing."
|
||||||
- No freeform UGC until moderation infrastructure exists (structured input only)
|
|
||||||
- Discovery-first, not social-first
|
|
||||||
- External auth provider (self-hosted, open-source)
|
|
||||||
- Postgres for multi-user platform
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,150 +28,113 @@ This is the feature research for **v2.0 Platform Foundation** -- transforming Ge
|
|||||||
|
|
||||||
### Table Stakes (Users Expect These)
|
### Table Stakes (Users Expect These)
|
||||||
|
|
||||||
Features users assume exist on any multi-user gear platform. Missing these makes the platform feel broken or pointless.
|
Features that public-first gear discovery platforms are expected to provide. Missing these makes the product feel broken or hostile to new visitors.
|
||||||
|
|
||||||
| Feature | Why Expected | Complexity | Notes |
|
| Feature | Why Expected | Complexity | Notes |
|
||||||
|---------|--------------|------------|-------|
|
|---------|--------------|------------|-------|
|
||||||
| **User registration and authentication** | Cannot have multi-user without accounts. Every platform has sign-up/login. | HIGH | External auth provider integration (Authentik, Keycloak, or similar). Replaces current single-user cookie auth. All existing entities need userId FK. |
|
| Browse catalog and setups without login | All comparable platforms (Lighterpack shared lists, BikeGearDatabase, RTINGS) allow full read access. Forcing login before browse kills SEO and casual discovery. | LOW | Middleware change: lift auth guard from all GET /api/* endpoints. Public setup sharing already exists at v2.0 — generalize to all read routes. Session-optional pattern already proven. |
|
||||||
| **User profiles (public)** | Every community platform has profiles. Users need identity to share and be discovered. | LOW | Minimal: display name, avatar URL, bio text, joined date. Public profile page lists user's public setups. No follower counts needed. |
|
| Discovery landing page with catalog search prominent | RTINGS, Wirecutter, and BikeGearDatabase all lead with search or category browse above the fold. Users arriving from search engines expect to search immediately, not to log in. | MEDIUM | Replace dashboard for unauthenticated visitors. Search bar + tag chips already exist as FAB overlay — promote to inline page hero. Authenticated users still see their dashboard. Route-level auth split. |
|
||||||
| **Setup visibility controls** | Users will not share setups if they cannot control what is public. Privacy is table stakes for any sharing platform. | LOW | Binary public/private toggle per setup. Default to private (opt-in sharing). Existing setups migrated as private. |
|
| Contextual auth prompt only on write actions | Users must understand the access model without reading documentation. "Browse freely, sign in to save" must be self-evident. Confusing this causes drop-off. | LOW | Inline "Sign in to add to your collection" CTA on catalog item detail pages. No login wall on any browse action. |
|
||||||
| **Public setup detail pages** | Shared setup links must resolve to a readable page. If sharing is a feature, the shared thing must be viewable. | MEDIUM | Read-only view with item list, weight/cost totals, donut chart, creator attribution. No auth required for public setups. Extends existing setup detail view. |
|
| Product attribution: brand and manufacturer fields | Any gear database users trust shows where a product originates. Missing attribution makes catalog look scraped or unverifiable. | LOW | Add `brand`, `manufacturer` fields to catalog items schema. Already has `name` — add structured attribution alongside. Display prominently on detail pages and cards. |
|
||||||
| **Global item database (searchable)** | Users expect to find gear by name rather than entering specs from scratch every time. LighterPack's weakness is fully manual data entry. | HIGH | Central product catalog with brand, model, category, manufacturer weight, MSRP, product URL, image. Users search and link rather than re-enter. Seed with 200-500 items in core categories to bootstrap. This is the foundational dependency for reviews, aggregation, and item detail pages. |
|
| Image source attribution display | Legal requirement and trust signal. Gear Patrol, BikeGearDatabase, and manufacturer catalogs all credit image source. Omitting creates IP risk on manufacturer-supplied images. | LOW | Add `imageCredit` (display text, e.g. "Apidura") and `imageSourceUrl` fields to catalog items. Display as "Photo: [credit]" beneath product images on detail pages. |
|
||||||
| **Link personal items to global items** | Once a global DB exists, users expect to connect their gear to canonical entries for richer data. | MEDIUM | Optional FK from user items to global items. Enables aggregation (owner count, avg weight, reviews). Must handle items not yet in global DB gracefully. |
|
| Community usage signal on catalog items | Users expect to see "owned by N people" or "in N setups" to gauge real-world adoption. Lighterpack shows this per shared list. RTINGS shows review counts. | LOW | `ownerCount` already exists on catalog items in v2.0. Surface it prominently on catalog cards and detail pages. Add "appears in N setups" count derived from setupItems. |
|
||||||
| **Item detail page (aggregated)** | When browsing gear, clicking an item should show consolidated info: specs, who owns it, ratings. Standard on any product platform. | HIGH | Aggregated view combining: manufacturer specs from global DB, owner count, setup appearances, average ratings, crowd-reported weights. This is the integration hub for all platform features. |
|
| Shareable catalog item and setup URLs resolve without login | Public-first means deep-linking works. If a setup or catalog item URL is shared, it must render for anyone — no login redirect. | LOW | Detail pages already exist at v2.0. Verify: unauthenticated API responses work end-to-end, meta tags render, no auth redirect on page load. Likely already 90% working given public setup sharing. |
|
||||||
| **Structured reviews (ratings)** | Any product-oriented community needs evaluation. Users expect to rate gear and see what others think. | MEDIUM | Overall 1-5 star rating plus 3-5 dimension ratings (varies by product category). Attached to global items, not personal items. One review per user per global item. No freeform text per project constraint. |
|
|
||||||
| **Discovery browse page** | Users expect a way to find interesting setups and gear beyond their own collection. Without this, multi-user adds no value. | MEDIUM | Not algorithmic for v2.0. Three sections: recent public setups, recently reviewed items, popular gear (most owned). Simple sorted lists with pagination. |
|
|
||||||
| **Search global items** | Must be able to find products by name/brand in the global database. Powers linking, browsing, and review discovery. | MEDIUM | Full-text search on name, brand, category. Used in "link my item" flow, discovery browsing, and review lookup. Postgres full-text search or trigram index. |
|
|
||||||
|
|
||||||
### Differentiators (Competitive Advantage)
|
### Differentiators (Competitive Advantage)
|
||||||
|
|
||||||
Features that set GearBox apart from LighterPack, GearGrams, Trailspace, and MyGear. Aligned with core value: "help people make better gear decisions."
|
Features that set GearBox apart from Lighterpack (lists only, no catalog), BikeGearDatabase (editorial, not user collections), and generic wishlist tools.
|
||||||
|
|
||||||
| Feature | Value Proposition | Complexity | Notes |
|
| Feature | Value Proposition | Complexity | Notes |
|
||||||
|---------|-------------------|------------|-------|
|
|---------|-------------------|------------|-------|
|
||||||
| **Crowd-verified specs** | LighterPack trusts user-entered data blindly. GearBox can show "manufacturer says 450g, 12 owners measured avg 478g." Real-world weight verification is unique and high-value for weight-conscious users. | MEDIUM | Aggregate weightGrams from all user items linked to a global item. Compare against manufacturer spec. Display on item detail page. Needs sufficient linked items to be meaningful (threshold: 3+ owners). |
|
| Discovery landing feed (community setups + catalog items) | No direct competitor combines a global gear catalog with user setup feeds. Lighterpack has no discovery page. BikeGearDatabase is editorial, not community-driven. GearBox can show real user gear choices with weight data. | MEDIUM | Two feed sections: (a) recently shared public setups sorted by recency, filterable by category; (b) popular/new catalog items by ownerCount. No algorithm needed at launch — recency + ownerCount is sufficient and honest. |
|
||||||
| **Review dimensions per product category** | Trailspace and OutdoorGearLab use editorial ratings with fixed dimensions. GearBox crowd-sources structured ratings with category-specific dimensions: a tent gets "weather protection, ventilation, setup ease" while a stove gets "boil time, fuel efficiency, packability." More relevant than one-size-fits-all. | MEDIUM | Define 3-5 rating dimensions per product category via admin config. Store dimension ratings alongside overall rating. Display as radar chart or bar chart on item detail page. |
|
| Agent-powered catalog seeding via MCP tools | Unique to GearBox. No other gear platform has agent-friendly structured import. Enables rapid catalog population by Claude agent swarms without manual data entry. Programmatic SEO value compounds with catalog size. | HIGH | Requires: bulk create MCP tool, structured import with dry-run/preview mode, attribution tracking on agent-inserted records. GearBox already has MCP server and API key auth — foundation exists. |
|
||||||
| **"X people own this" social proof** | Shows popularity and real adoption. No gear tracker does this because they lack a global item database. Simple count, powerful signal. | LOW | Count of users who linked a collection item to this global item. Displayed prominently on item detail page and in search results. Zero implementation complexity once linking exists. |
|
| Catalog enrichment infrastructure with provenance tracking | Enables crowd + agent contributions with full source tracking. Comparable to Wikipedia's citation model but structured. Builds long-term trust in catalog data quality. | MEDIUM | New schema fields: `sourceUrl`, `sourceType` (enum: manufacturer / community / agent / import), `contributedBy` (userId or agent identifier string), `verifiedAt`. Migration only, lightweight UI needed initially. |
|
||||||
| **Setup composition insights** | "This item appears in 47 bikepacking setups, commonly paired with Y and Z." Cross-setup analysis no competitor offers. Answers "what do people use this with?" | MEDIUM | Query across all public setups containing a given global item. Show co-occurrence patterns. Powerful but can be deferred to v2.x if query performance is a concern. |
|
| SEO-indexable catalog pages ranking for product searches | Public catalog pages that rank for "[product name] weight specs" are a major organic acquisition channel. RTINGS built a durable traffic moat this way via programmatic SEO. GearBox can do the same for gear. | MEDIUM | Pages already exist. Add: `<title>` tags with product name + category, OG meta tags, JSON-LD Product schema markup. Primary complexity: TanStack Router is client-rendered — crawlers need either SSR or static prerender for bots. This is the phase's primary technical risk. |
|
||||||
| **Setup impact preview with global items** | Already built for personal items. Extending to global items lets users preview "adding this from the store to my setup changes weight by X." Bridges research and collection management. | LOW | Already exists for personal items. Add "preview in my setup" button on global item detail pages. Reuse existing impact preview logic. |
|
| Setup impact preview teaser on public catalog pages | Showing "add this to your setup and base weight changes by +Xg" is unique. No other gear catalog does this. Showing the feature on public pages teases value and drives sign-up intent. | MEDIUM | Extend existing impact preview (v1.3) to show a teaser CTA on unauthenticated catalog detail pages: "See how this affects your setup → [Sign in to try]". Requires no new backend work — frontend auth-conditional render. |
|
||||||
| **Planning threads with global item integration** | Research threads that pull in specs, reviews, and owner data from the global DB. Candidates link to global items for richer comparison than manual data entry. | MEDIUM | Add optional globalItemId to thread candidates. Auto-populate weight, price, image from global item. Show community ratings and owner count inline on candidates. |
|
|
||||||
| **Real-world weight distribution** | Histogram showing "owners report weights between 440g-490g" for a product. Beats a single manufacturer number. Valuable for ultralight community. | LOW | Aggregate weightGrams from all linked items. Display min/max/avg. Histogram if 10+ data points. |
|
|
||||||
| **Copy/fork public setups** | Use someone else's setup as a starting template. LighterPack has clunky CSV-based copying. One-click fork is much better UX. | LOW | Create new setup copying all items from a public setup. Items must exist in user's collection (or be linked to same global items). Clear UX for "items you do not own yet." |
|
|
||||||
|
|
||||||
### Anti-Features (Commonly Requested, Often Problematic)
|
### Anti-Features (Commonly Requested, Often Problematic)
|
||||||
|
|
||||||
| Feature | Why Requested | Why Problematic | Alternative |
|
| Feature | Why Requested | Why Problematic | Alternative |
|
||||||
|---------|---------------|-----------------|-------------|
|
|---------|---------------|-----------------|-------------|
|
||||||
| **Freeform text reviews** | Users want to explain their experience in detail | Requires moderation, spam filtering, content policy, reporting infrastructure. PROJECT.md explicitly defers until moderation exists. | Structured ratings with predefined dimensions. Short predefined tags for pros/cons (e.g., "lightweight", "durable", "runs small"). |
|
| Algorithmic feed ranking using engagement signals | "Show popular content" feels natural | Requires engagement data volume that does not exist at v2.1 scale. Empty or manipulated feed is worse than no feed. Gaming and spam risk immediately. | Simple recency + ownerCount sort. Add engagement signals only when data volume and moderation infrastructure justify it. |
|
||||||
| **Comments on setups** | Social engagement, questions about gear choices | Moderation burden, notification system, spam, harassment risk. Deferred in PROJECT.md. | Link to user profile. Contact happens outside platform. |
|
| Open wiki-style catalog editing (anyone edits any item) | Fastest path to catalog enrichment | Data quality collapses without moderation. Adversarial edits, edit wars. Requires revert/history infrastructure. Already decided out-of-scope in PROJECT.md. | Structured contributions: users submit items, agents bulk-seed with attribution, admins verify. provenance fields track every change. |
|
||||||
| **Follow users / activity feed** | Social graph, staying updated on people | Turns a gear tool into a social network. Notification infrastructure, feed ranking, engagement metrics, retention loops. Project decision: discovery-first, not social-first. | Discovery feed shows popular/recent content without requiring social connections. |
|
| Bulk catalog import from scraped external sources | "Just import all BikeGearDB items" | Copyright risk. Data quality issues. Stale data. Attribution impossible — you do not know who owns the content. Legal exposure. | Agent-seeding via MCP with explicit source tracking. Manual + agent creates clean provenance chain with `sourceUrl` per item. |
|
||||||
| **Marketplace / buy-sell** | Users want to trade used gear | Payment processing, fraud prevention, disputes, shipping logistics, tax compliance. Massive liability. | Link to product URLs on global items. Users buy through retailers. |
|
| Real-time "X users viewing this" presence indicators | Social proof, FOMO feeling | Zero signal value at current traffic scale, adds WebSocket complexity, privacy concern for a utility tool. | ownerCount ("X people own this") is sufficient social proof without live presence tracking. |
|
||||||
| **AI gear recommendations** | "What tent should I buy for bikepacking?" | Training data requirements, bias, liability for bad recommendations, hallucination risk. | Global item pages with ratings, owner counts, and setup co-occurrence do implicit recommendation. "People who own X also own Y." |
|
| Comments on catalog items or setups | Community enrichment, Q&A | Freeform UGC explicitly blocked in PROJECT.md until moderation infrastructure exists. Moderation requires policy, tooling, reporting. | Structured fields only: tags, ratings, attribution. Defer freeform to future milestone after moderation is designed. |
|
||||||
| **Wiki-style open item editing** | Community wants to correct/enrich global item specs | Edit wars, vandalism, quality degradation, dispute resolution. PROJECT.md explicitly rules this out. | Structured contributions only: report measured weight, submit rating. Admin approval for spec corrections. Trusted contributor program later. |
|
| Social follow / activity feed | "See what friends added" | Social graph is a separate product. Deferred explicitly in PROJECT.md. Notification infrastructure, feed ranking, retention loops all out of scope. | Public setup browsing by category or recency is sufficient discovery without requiring a follow graph. |
|
||||||
| **Price tracking / deal alerts** | Users want to know when gear goes on sale | Requires scraping retailer sites, fragile, legal gray area, maintenance burden. PROJECT.md rules this out. | Store product URL so users can check prices manually. |
|
| Infinite scroll personalized feed | "Netflix for gear" | Personalization requires user history. Unauthenticated visitors have no history. Personalized recommendations require ML infrastructure far beyond v2.1 scope. | Category-filtered browse + search. Personalization post-login once collection data exists is a v3+ feature. |
|
||||||
| **Real-time collaborative setups** | "Plan a group trip together" | WebSocket infrastructure, conflict resolution, permissions model, presence indicators. Massive complexity for niche use case. | Each user builds their own setup. Fork public setups as templates. |
|
|
||||||
| **Gamification (badges, points, levels)** | Drive engagement and contributions | Incentivizes quantity over quality. Users game systems for points rather than providing genuine data. Creates toxic dynamics. | Soft social proof: "contributed X reviews" on profile. No points, no leaderboards. |
|
|
||||||
| **Instagram-style infinite scroll feed** | Addictive browsing experience | Engagement-maximizing design conflicts with utility-focused tool. Users come to research decisions, not scroll endlessly. | Paginated, filterable discovery page. Browse with intent, not addiction. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Dependencies
|
## Feature Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
[External Auth Provider]
|
Public browse without login
|
||||||
|
|
└──prerequisite for──> Discovery landing page (needs unauth API render)
|
||||||
v
|
└──prerequisite for──> SEO-indexable catalog pages (bots must reach pages)
|
||||||
[Multi-User Data Model (userId FK on all entities)]
|
└──prerequisite for──> Setup impact preview teaser on public pages
|
||||||
|
|
└──prerequisite for──> Shareable URLs confirmed working without auth
|
||||||
+---> [Postgres Migration] (concurrent access, auth provider needs Postgres)
|
|
||||||
|
|
Catalog enrichment schema (attribution fields)
|
||||||
+---> [User Profiles (public)]
|
└──prerequisite for──> Agent-powered MCP catalog seeding (tools write into these fields)
|
||||||
| |
|
└──prerequisite for──> Image attribution display (imageCredit field must exist)
|
||||||
| +---> [Public Profile Pages]
|
└──prerequisite for──> Source provenance display on detail pages
|
||||||
| | |
|
|
||||||
| | +---> [Discovery Feed (browse users' public content)]
|
Agent-powered MCP catalog seeding tools
|
||||||
| |
|
└──requires──> Catalog enrichment schema (attribution fields must exist first)
|
||||||
| +---> [Setup Visibility Controls (public/private)]
|
└──enhances──> Discovery landing feed (more items = richer feed)
|
||||||
| |
|
└──enhances──> SEO surface area (more pages = more potential rankings)
|
||||||
| +---> [Public Setup Detail Pages]
|
|
||||||
| |
|
Discovery landing page
|
||||||
| +---> [Copy/Fork Public Setups]
|
└──requires──> Public browse without login
|
||||||
|
|
└──requires──> Feed query API (popular setups + recent catalog items)
|
||||||
+---> [Global Item Database]
|
└──uses existing──> Catalog search (FAB overlay promoted to page hero)
|
||||||
|
|
|
||||||
+---> [Search Global Items]
|
SEO metadata on catalog pages
|
||||||
|
|
└──requires──> Public browse without login (bots must reach pages)
|
||||||
+---> [Link Personal Items to Global Items]
|
└──depends on──> Crawlability solution (SSR or prerender for TanStack Router)
|
||||||
| |
|
└──enhances──> Agent-seeded catalog (more items = more indexed pages)
|
||||||
| +---> [Owner Count ("X people own this")]
|
|
||||||
| |
|
Setup impact preview teaser (public)
|
||||||
| +---> [Crowd-Verified Specs (aggregated weight)]
|
└──requires──> Public browse without login
|
||||||
| |
|
└──depends on existing──> Impact preview feature (v1.3, already shipped)
|
||||||
| +---> [Setup Appearances Count]
|
|
||||||
| |
|
|
||||||
| +---> [Real-World Weight Distribution]
|
|
||||||
|
|
|
||||||
+---> [Structured Reviews]
|
|
||||||
| |
|
|
||||||
| +---> [Review Dimensions per Category]
|
|
||||||
| |
|
|
||||||
| +---> [Average Ratings Display]
|
|
||||||
|
|
|
||||||
+---> [Item Detail Pages (aggregated hub)]
|
|
||||||
| |
|
|
||||||
| +---> [Setup Composition Insights]
|
|
||||||
|
|
|
||||||
+---> [Planning Thread Global Item Integration]
|
|
||||||
|
|
|
||||||
+---> [Candidate Auto-populate from Global DB]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependency Notes
|
### Dependency Notes
|
||||||
|
|
||||||
- **Multi-user data model is the absolute foundation.** Every feature depends on userId ownership. Items, setups, threads, categories, reviews -- all need user scoping. This is the biggest single migration.
|
- **Public browse is the prerequisite for everything.** Auth middleware change must land first. All other v2.1 features depend on unauthenticated API access working correctly.
|
||||||
- **Postgres migration is coupled with auth.** The external auth provider (Authentik, Keycloak) needs Postgres. Migrating the app DB at the same time avoids running two databases. Do these together.
|
- **Catalog enrichment schema must precede agent MCP tools.** The bulk create and import MCP tools write attribution fields. Building tools before schema means schema-breaking changes later.
|
||||||
- **Global item database is the second foundation.** Reviews, item detail pages, owner counts, crowd-verified specs, and planning thread integration all depend on canonical global item records. Without this, multi-user is just "LighterPack with accounts."
|
- **SEO crawlability is the primary technical risk.** TanStack Router renders client-side. Search engine bots do not execute JavaScript. Without SSR or a static prerender pass, catalog pages will not be indexed. This is a known gap with the current stack — needs a solution before SEO-targeted work makes sense. Defer SEO metadata work to P2 until crawlability is resolved.
|
||||||
- **Structured reviews require global items.** Reviews attach to global items, not personal collection items. Otherwise reviews fragment across duplicate user-entered items with no way to aggregate.
|
- **Agent seeding is high complexity but high leverage.** It is both a catalog population tool and a v2.1 launch enabler. Without sufficient catalog items, the discovery feed is thin and the platform feels empty. Prioritize MCP tooling early so catalog seeding can run in parallel with UI work.
|
||||||
- **Item detail pages are the integration point.** They combine global item specs, aggregated user data, reviews, owner count, and setup appearances. Should be built after all data sources exist.
|
|
||||||
- **Discovery feed requires profiles + public content.** Cannot browse without user identity and visibility controls producing public content to show.
|
|
||||||
- **Linking is the bridge.** Personal items link to global items. This single FK enables owner count, crowd-verified specs, weight distribution, and setup appearances. Prioritize this flow.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MVP Definition
|
## MVP Definition
|
||||||
|
|
||||||
### Launch With (v2.0 Platform Foundation)
|
This is a subsequent milestone on an existing shipped product. MVP here means minimum to deliver the v2.1 goal: public-first discovery platform.
|
||||||
|
|
||||||
- [ ] **External auth provider integration** -- Nothing works without multi-user identity
|
### Launch With (v2.1 core)
|
||||||
- [ ] **Postgres migration** -- Required for concurrent access; auth provider dependency
|
|
||||||
- [ ] **Multi-user data model** -- userId on items, setups, threads, categories; data isolation
|
|
||||||
- [ ] **User profiles (minimal)** -- Display name, avatar, bio; public profile page
|
|
||||||
- [ ] **Setup visibility controls** -- Public/private toggle, default private
|
|
||||||
- [ ] **Public setup detail pages** -- Shareable read-only view with attribution
|
|
||||||
- [ ] **Global item database with seed data** -- Schema, admin seeding, search
|
|
||||||
- [ ] **Link personal items to global items** -- Association flow in collection UI
|
|
||||||
- [ ] **Structured reviews** -- Overall rating + dimension ratings on global items
|
|
||||||
- [ ] **Item detail pages** -- Aggregated specs, owner count, average ratings
|
|
||||||
- [ ] **Discovery browse page** -- Recent public setups, recently reviewed, popular items
|
|
||||||
|
|
||||||
### Add After Validation (v2.x)
|
- [ ] Public browse without login — lift auth guard from all GET routes. Every other feature depends on this.
|
||||||
|
- [ ] Discovery landing page — replaces dashboard for unauthenticated visitors. Catalog search hero + two feed sections (recent setups, popular catalog items). Recency + ownerCount sort, no algorithm.
|
||||||
|
- [ ] Catalog enrichment schema migration — add `brand`, `manufacturer`, `sourceUrl`, `sourceType`, `imageCredit`, `imageSourceUrl`, `contributedBy` fields. Schema first, UI follows.
|
||||||
|
- [ ] Image attribution display on catalog detail pages — "Photo: [credit]" below product images, sourced from new `imageCredit` field.
|
||||||
|
- [ ] Agent MCP catalog seeding tools — bulk create endpoint/tool, structured import with attribution, dry-run/preview mode, batch result reporting.
|
||||||
|
- [ ] Initial catalog population via agent — run agent seeding for 3-5 priority categories (bikepacking bags, tents, sleeping bags, navigation devices, cycling computers). Target: 100+ catalog items with attribution.
|
||||||
|
- [ ] Community usage signals surfaced — ownerCount and "appears in N setups" count prominent on catalog cards and detail pages.
|
||||||
|
|
||||||
- [ ] **Crowd-verified specs display** -- "Manufacturer: 450g, Community avg: 478g" (needs 3+ owners per item to be meaningful)
|
### Add After Core is Stable (v2.1.x)
|
||||||
- [ ] **Setup composition insights** -- "Commonly paired with" co-occurrence analysis
|
|
||||||
- [ ] **Planning thread global item integration** -- Candidates auto-populate from global DB
|
|
||||||
- [ ] **Popular gear rankings by category** -- Most owned, highest rated per category
|
|
||||||
- [ ] **Copy/fork public setups** -- One-click template from public setups
|
|
||||||
- [ ] **Review dimension customization** -- Admin configures rating dimensions per product category
|
|
||||||
- [ ] **Real-world weight distribution** -- Histogram on item detail pages
|
|
||||||
- [ ] **Global item suggestion workflow** -- Users propose new items for admin review
|
|
||||||
|
|
||||||
### Future Consideration (v3+)
|
- [ ] Contextual "See how this affects your setup" CTA on public catalog pages — setup impact preview teaser with login prompt. Add once public browse is confirmed stable.
|
||||||
|
- [ ] Manufacturer/brand filter on catalog browse — add brand as a filterable facet. Only valuable once catalog volume justifies filtering (target: after initial seeding).
|
||||||
|
- [ ] SEO metadata on catalog pages — `<title>`, OG tags, JSON-LD Product schema. Add after crawlability solution is determined.
|
||||||
|
|
||||||
- [ ] **Freeform reviews with moderation** -- After moderation infrastructure exists
|
### Future Consideration (v2.2+)
|
||||||
- [ ] **Comments on setups** -- After moderation infrastructure exists
|
|
||||||
- [ ] **Follow users / activity feed** -- After discovery model is validated
|
- [ ] Personalized discovery feed post-login — requires collection data volume and recommendation design.
|
||||||
- [ ] **OAuth / social login** -- After external auth provider is stable
|
- [ ] Verified catalog item badge — admin-marked verified items. Requires admin tooling.
|
||||||
- [ ] **Trusted contributor program** -- Verified users can edit global item specs
|
- [ ] User-submitted catalog enrichment — structured form to suggest corrections or add missing items. Requires contribution review workflow.
|
||||||
|
- [ ] Engagement signals in feed — view count, saves. Requires data volume to be meaningful.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,122 +142,57 @@ Features that set GearBox apart from LighterPack, GearGrams, Trailspace, and MyG
|
|||||||
|
|
||||||
| Feature | User Value | Implementation Cost | Priority |
|
| Feature | User Value | Implementation Cost | Priority |
|
||||||
|---------|------------|---------------------|----------|
|
|---------|------------|---------------------|----------|
|
||||||
| External auth provider | HIGH | HIGH | P1 |
|
| Public browse without login | HIGH | LOW | P1 |
|
||||||
| Postgres migration | HIGH | HIGH | P1 |
|
| Discovery landing page | HIGH | MEDIUM | P1 |
|
||||||
| Multi-user data model (userId on entities) | HIGH | HIGH | P1 |
|
| Catalog enrichment schema (attribution fields) | HIGH | LOW | P1 |
|
||||||
| User profiles (basic) | HIGH | LOW | P1 |
|
| Image attribution display | MEDIUM | LOW | P1 |
|
||||||
| Setup visibility controls | HIGH | LOW | P1 |
|
| Agent MCP catalog seeding tools | HIGH | HIGH | P1 |
|
||||||
| Public setup detail pages | HIGH | MEDIUM | P1 |
|
| Initial catalog population (agent run) | HIGH | MEDIUM (depends on MCP tools) | P1 |
|
||||||
| Global item database (schema + seed) | HIGH | HIGH | P1 |
|
| Community usage signals (ownerCount visible) | MEDIUM | LOW | P1 |
|
||||||
| Link personal items to global items | HIGH | MEDIUM | P1 |
|
| Shareable URL audit (confirm unauth render) | HIGH | LOW | P1 |
|
||||||
| Search global items | HIGH | MEDIUM | P1 |
|
| Setup impact preview teaser (public) | MEDIUM | MEDIUM | P2 |
|
||||||
| Structured reviews | HIGH | MEDIUM | P1 |
|
| Brand/manufacturer filter on catalog browse | LOW | LOW | P2 |
|
||||||
| Item detail pages (aggregated) | HIGH | HIGH | P1 |
|
| SEO metadata on catalog pages | MEDIUM | MEDIUM (crawlability dependency) | P2 |
|
||||||
| Discovery browse page | MEDIUM | MEDIUM | P1 |
|
| Personalized discovery feed | MEDIUM | HIGH | P3 |
|
||||||
| Crowd-verified specs | HIGH | LOW | P2 |
|
| Verified catalog badge | LOW | MEDIUM | P3 |
|
||||||
| Setup composition insights | MEDIUM | MEDIUM | P2 |
|
| User-submitted enrichment form | LOW | MEDIUM | P3 |
|
||||||
| Planning thread global DB integration | MEDIUM | MEDIUM | P2 |
|
|
||||||
| Copy/fork public setups | MEDIUM | LOW | P2 |
|
|
||||||
| Popular gear rankings | MEDIUM | LOW | P2 |
|
|
||||||
| Freeform reviews + moderation | MEDIUM | HIGH | P3 |
|
|
||||||
| Follow users | LOW | MEDIUM | P3 |
|
|
||||||
| Setup comments | LOW | MEDIUM | P3 |
|
|
||||||
|
|
||||||
**Priority key:**
|
**Priority key:**
|
||||||
- P1: Must have for v2.0 platform launch
|
- P1: Required for v2.1 milestone goal
|
||||||
- P2: Should have, add in v2.x once core is validated
|
- P2: Add once v2.1 core is validated
|
||||||
- P3: Future consideration, requires new infrastructure (moderation, notifications)
|
- P3: Future consideration, requires new infrastructure
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Competitor Feature Analysis
|
## Competitor Feature Analysis
|
||||||
|
|
||||||
| Feature | LighterPack | GearGrams | Trailspace | MyGear | GearBox v2.0 |
|
| Feature | Lighterpack | BikeGearDatabase | RTINGS | GearBox v2.1 |
|
||||||
|---------|-------------|-----------|------------|--------|-------------|
|
|---------|-------------|------------------|--------|--------------|
|
||||||
| Gear lists/setups | Yes, drag-and-drop | Yes, trip-based | No (review only) | Yes, "Locker" | Yes, named setups with classification |
|
| Browse without login | Yes (shared list links only) | Yes (all content public) | Yes (fully public) | Yes — all catalog + setups public |
|
||||||
| Weight tracking | Base/worn/consumable | Carried/worn/consumable | No | Basic | Base/worn/consumable + unit conversion + donut charts |
|
| Discovery landing page | No (login required to see anything) | Yes (editorial feed + categories) | Yes (category browse + new/updated) | Yes — catalog search hero + community feed |
|
||||||
| User profiles | Minimal (no bio) | Minimal | Review history page | Full social profile | Display name, avatar, bio, public setups |
|
| Global gear catalog | No (fully user-entered) | Editorial reviews only | Product test database | Yes — crowd + agent-seeded with attribution |
|
||||||
| Sharing | Public link, embed code | Public link | N/A | Social feed posts | Public/private toggle, shareable URLs |
|
| Image attribution | N/A (no images) | Editorial photo credit | Manufacturer-supplied images | Explicit imageCredit + imageSourceUrl fields |
|
||||||
| Global item database | No (all user-entered) | No | Yes (editorial catalog) | No | Yes, seeded + crowd-enriched with verified specs |
|
| Community setups visible publicly | Yes (shared list links) | No | No | Yes — public setups with weight data |
|
||||||
| Structured reviews | No | No | Yes (summary/pros/cons + rating) | Basic star rating | Dimension ratings per product category |
|
| Setup weight analysis | Yes (per list) | No | No | Yes + impact preview |
|
||||||
| Item aggregation | No | No | Editorial scores only | No | Owner count, avg weight, setup appearances, crowd specs |
|
| Agent-friendly catalog API (MCP) | No | No | No | Yes — unique differentiator |
|
||||||
| Discovery/browse | No | No | Browse by category | AI-tagged social feed | Browse setups, items, popular gear (intent-driven, not feed) |
|
| SEO catalog pages | No | Yes (editorial articles) | Yes (programmatic product pages) | Target for v2.1.x after crawlability resolved |
|
||||||
| Purchase research | No | No | Price comparison links | No | Planning threads with candidates, ranking, impact preview |
|
| Provenance / source tracking | No | Editorial byline only | "Tested by RTINGS staff" | Yes — sourceType enum, contributedBy, sourceUrl |
|
||||||
| Crowd-verified specs | No | No | No | No | Manufacturer vs. community-measured weight comparison |
|
|
||||||
| Mobile app | No | Yes (iOS/Android) | No | Yes (iOS/Android) | No (responsive web, per project constraint) |
|
|
||||||
|
|
||||||
### Competitive Positioning
|
|
||||||
|
|
||||||
GearBox occupies a unique niche: the only platform combining **gear management** (LighterPack's strength), **structured community reviews** (Trailspace's strength), and **crowd-verified specs** (nobody does this). The planning threads feature has no direct competitor equivalent in the gear domain.
|
|
||||||
|
|
||||||
**Key advantages over each competitor:**
|
|
||||||
- **vs. LighterPack:** Global item database eliminates manual spec entry. Multi-user with profiles and sharing. Structured reviews provide community intelligence.
|
|
||||||
- **vs. GearGrams:** Richer comparison tools (planning threads). Crowd-verified specs. Item detail pages with aggregated data.
|
|
||||||
- **vs. Trailspace:** Not just reviews -- full gear management and setup composition. Users own and track their gear, not just review it. Crowd ratings, not editorial-only.
|
|
||||||
- **vs. MyGear:** Not social-first (no engagement loops, no AI tagging gimmicks). Utility-focused: research decisions, verify specs, compare options. Hobby-agnostic data model.
|
|
||||||
|
|
||||||
**Accepted gaps:**
|
|
||||||
- No mobile native app (web-first, responsive design sufficient per project constraints)
|
|
||||||
- No social feed in the Instagram sense (intentional: discovery-first, not social-first)
|
|
||||||
- No freeform text content (intentional: structured input only until moderation exists)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes for Key Features
|
|
||||||
|
|
||||||
### Global Item Database Schema
|
|
||||||
|
|
||||||
The global item table is distinct from user items. It represents canonical products:
|
|
||||||
|
|
||||||
- `globalItems`: id, brand, model, name (display), categoryId, manufacturerWeightGrams, manufacturerPriceCents, productUrl, imageFilename, description, createdAt, updatedAt, createdByUserId
|
|
||||||
- User items get optional `globalItemId` FK for linking
|
|
||||||
- Admin-seeded initially; later users can suggest additions via a proposal workflow
|
|
||||||
|
|
||||||
### Structured Review Schema
|
|
||||||
|
|
||||||
- `reviews`: id, userId, globalItemId, overallRating (1-5), createdAt, updatedAt
|
|
||||||
- `reviewDimensionRatings`: id, reviewId, dimensionId, rating (1-5)
|
|
||||||
- `reviewDimensions`: id, categoryId, name (e.g., "durability", "packability"), sortOrder
|
|
||||||
- Unique constraint: one review per user per global item
|
|
||||||
- Dimensions are per-category, admin-defined
|
|
||||||
|
|
||||||
### Discovery Feed Approach
|
|
||||||
|
|
||||||
Not a personalized algorithmic feed. Three content streams, each a simple sorted query:
|
|
||||||
|
|
||||||
1. **Recent public setups** -- ORDER BY createdAt DESC, paginated
|
|
||||||
2. **Recently reviewed items** -- Global items with recent reviews, ORDER BY latest review date
|
|
||||||
3. **Popular gear** -- Global items ORDER BY linked owner count DESC
|
|
||||||
|
|
||||||
No recommendation engine. No engagement scoring. Users browse with intent.
|
|
||||||
|
|
||||||
### User Profile Data
|
|
||||||
|
|
||||||
Minimal profile extending the auth provider's user record:
|
|
||||||
|
|
||||||
- Display name (from auth provider or custom)
|
|
||||||
- Avatar URL (from auth provider or uploaded)
|
|
||||||
- Bio (short text, 280 char limit)
|
|
||||||
- Joined date
|
|
||||||
- Public setups list (derived from setup visibility)
|
|
||||||
- Review count (derived)
|
|
||||||
- Collection size (count of items, public stat)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- [LighterPack](https://lighterpack.com/) -- Gear list builder, community standard for ultralight hikers. Public sharing via link, no profiles or reviews.
|
- [LighterPack](https://lighterpack.com/) — public list sharing model, community usage patterns. Public browse only via shared links, no general discovery. (MEDIUM confidence, WebSearch)
|
||||||
- [LighterPack tutorial (99Boulders)](https://www.99boulders.com/lighterpack-tutorial) -- Feature overview including sharing, linking, limitations.
|
- [Bike Gear Database](https://www.bikegeardatabase.com/) — public editorial gear catalog, category browse patterns, ~30k monthly visitors. (MEDIUM confidence, WebSearch)
|
||||||
- [GearGrams](https://www.geargrams.com/) -- Trip-based gear list tracker with weight classification.
|
- [RTINGS SEO Case Study — Ahrefs](https://ahrefs.com/blog/rtings-seo-case-study/) — programmatic SEO via catalog pages, category-based navigation, discovery-oriented layout. (MEDIUM confidence)
|
||||||
- [Trailspace](https://www.trailspace.com/) -- User gear reviews with structured Summary/Pros/Cons format and Review Corps program.
|
- [NN/G E-commerce Homepages and Listing Pages](https://www.nngroup.com/articles/ecommerce-homepages-listing-pages/) — subcategory surfacing above listings improves discoverability; 30-50% of product interactions come from unintended category navigation. (HIGH confidence)
|
||||||
- [Trailspace Review Form](https://www.trailspace.com/blog/2012/02/29/new-gear-review-form.html) -- Details on structured review fields with category-specific suggestions.
|
- [Sales Layer MCP Server for catalog enrichment](https://www.saleslayer.com/ai-pim/mcp) — agent-powered product information management, bulk update patterns, audit and quality scoring via MCP tools. (MEDIUM confidence)
|
||||||
- [MyGear](https://mygear.world/) -- Social app for sports gear with Locker, feed, AI gear recognition, challenges.
|
- [Creative Commons Attribution Best Practices](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution) — TASL attribution standard; attribution must be visible and associated with the image. (HIGH confidence)
|
||||||
- [Outdoor Gear Lab](https://www.outdoorgearlab.com/) -- Professional structured gear reviews with side-by-side comparison methodology.
|
- [Pixsy Image Credits Guide](https://www.pixsy.com/image-licensing/image-credits) — legal requirements and UX placement for image credits; "image courtesy of" as standard phrasing. (HIGH confidence)
|
||||||
- [Ultralight App](https://trailsmag.net/blogs/hiker-box/ultralight-the-gear-tracking-app-i-m-leaving-lighterpack-for) -- LighterPack alternative analysis showing community pain points.
|
- [GS1 Image Standards](https://orbitvu.com/blog/gs1-image-standards-how-automation-can-help-effective-product-representation/) — product image metadata standards including GTIN linkage and consistent attribution for catalog platforms. (MEDIUM confidence)
|
||||||
- [Ready Set Sim](https://www.readysetsim.com/) -- Sim racing gear profiles and build sharing (cross-domain reference for hobby-agnostic patterns).
|
- PROJECT.md — existing feature set, out-of-scope decisions, constraints, v2.1 milestone definition. (HIGH confidence, first-party)
|
||||||
- [GetStream Social Feed Architecture](https://getstream.io/blog/social-media-feed/) -- Feed implementation patterns and anti-patterns.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Feature research for: GearBox v2.0 Platform Foundation -- multi-user gear discovery platform*
|
|
||||||
*Researched: 2026-04-03*
|
*Feature research for: GearBox v2.1 Public Discovery — public-first gear discovery platform*
|
||||||
|
*Researched: 2026-04-09*
|
||||||
|
|||||||
@@ -1,314 +1,187 @@
|
|||||||
# Pitfalls Research
|
# Pitfalls Research
|
||||||
|
|
||||||
**Domain:** Single-user to multi-user gear platform migration (GearBox v2.0)
|
**Domain:** Public-first discovery platform with catalog enrichment (GearBox v2.1)
|
||||||
**Researched:** 2026-04-03
|
**Researched:** 2026-04-09
|
||||||
**Confidence:** HIGH (based on direct codebase analysis of v1.4 + established migration patterns)
|
**Confidence:** HIGH (based on direct codebase inspection of v2.0 + verified ecosystem patterns)
|
||||||
|
|
||||||
|
> v2.0 migration pitfalls (SQLite→Postgres, single→multi-user) are archived in git history.
|
||||||
|
> This document covers pitfalls specific to the v2.1 milestone: public access model, discovery feed, catalog enrichment, and agent-powered seeding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Critical Pitfalls
|
## Critical Pitfalls
|
||||||
|
|
||||||
### Pitfall 1: Missing userId Filters Leak Data Between Users
|
### Pitfall 1: Frontend Auth Guard Blocks All New Public Routes
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
Every query in the existing codebase operates without a `userId` filter. After adding `userId` columns to `items`, `categories`, `threads`, `setups`, and `settings`, any service function not updated to filter by `userId` will return or mutate other users' data. The current `getAllItems()` returns `db.select().from(items).innerJoin(...)` with zero WHERE clauses. One missed function means User A sees User B's gear.
|
The root layout (`__root.tsx`) hard-redirects any unauthenticated visitor to `/login` unless they are already on `/users/*` or `/login`. When public routes are added — a discovery landing page at `/`, a public catalog at `/global-items/` that is meant to be the new entry point — they will silently redirect anonymous users before rendering anything. The server already correctly skips auth middleware for `GET /api/global-items` (line 136 of `src/server/index.ts`), but the frontend guard is a separate allowlist that has not been updated.
|
||||||
|
|
||||||
The surface area is large: 6 service files, 19 MCP tools, 7 route files, aggregate queries in `totals`, the `duplicateItem` function, the `getCollectionSummary` MCP resource, setup-item joins, and thread resolution (which creates a new item).
|
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Developers add `userId` to the schema, update the obvious CRUD functions, but miss edge cases. The codebase has enough query sites (~30+) that manual "find all queries" misses something. Thread resolution is particularly dangerous because it creates an item as a side effect of updating a thread.
|
The client-side guard and the server-side middleware allowlist live in different files (`__root.tsx` vs `server/index.ts`) and can drift. Developers add routes to the server-side skip list but forget the frontend guard, then wonder why authenticated users see the feature but unauthenticated visitors hit the login page.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Enable Postgres Row-Level Security (RLS) as a safety net -- even if the app filters by `userId`, RLS prevents cross-user access at the database level.
|
Refactor the auth guard before building any public UI. Invert the logic: instead of allowlisting public routes, define a small `PROTECTED_ROUTES` set (collection, planning, settings, threads) and use TanStack Router's `beforeLoad` to protect those specific routes. Everything else renders without auth. The root layout should not gate render — it should only determine which UI chrome elements to show based on auth state.
|
||||||
2. Add `userId` as NOT NULL to the Drizzle schema first, then use TypeScript compiler errors to find every query that needs updating (insert calls will fail where `userId` is required but not provided).
|
|
||||||
3. Write one integration test per entity: create data as User A, query as User B, assert empty results.
|
|
||||||
4. Grep the codebase for every `.from(items)`, `.from(categories)`, `.from(threads)`, `.from(setups)`, `.from(settings)` and verify each has a `userId` filter.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Any service function that does not accept a `userId` parameter after migration.
|
- Loading `/global-items/` in a private browser window redirects to `/login`
|
||||||
- Tests that pass without specifying which user is performing the action.
|
- The `isPublicRoute` check in `__root.tsx` is a string allowlist that grows as features are added
|
||||||
- MCP tools that work without user context.
|
- New routes work for authenticated users but are invisible to anonymous users during testing
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Multi-user data model phase. This is the single most important thing to get right. Do not add public content or discovery features until every query is provably user-scoped.
|
Public access auth model phase — must be the first change made. Every other public feature depends on this being correct.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 2: Category Name Uniqueness Breaks in Multi-User
|
### Pitfall 2: `useAuth()` Spinner Blocks Public Page First Contentful Paint
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The current schema has `name: text("name").notNull().unique()` on the `categories` table -- a global unique constraint. When User A creates a "Bikepacking" category, User B cannot. The migration must change this to a composite unique constraint on `(userId, name)`.
|
The root layout shows a full-screen spinner while `useAuth()` resolves. For authenticated users this is imperceptible (~50ms for a cached session). For anonymous visitors on a public discovery page, this is 300–800ms of blank white screen before any content appears — because the auth check hits `/api/auth/me` which must complete before the page renders. This directly undercuts "public-first" positioning.
|
||||||
|
|
||||||
|
Additionally, `useOnboardingComplete()` fires for all users. For anonymous visitors it will hit an auth-required endpoint and produce a 401. Even though it is conditionally rendered, verify the hook itself does not fetch when `isAuthenticated` is false.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Single-user apps use simple unique constraints. Developers add `userId` to the table but forget to update the unique constraint from `unique(name)` to `unique(userId, name)`. The migration runs fine on an empty database but fails the moment a second user creates a category with a common name.
|
Login-first apps legitimately gate the entire UI on auth resolution — there is nothing useful to show an unauthenticated user. The same pattern applied to a public discovery page creates a perceived login wall.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
Audit every `.unique()` constraint in the schema during migration. `categories.name` must become a composite unique on `(userId, name)`. The `users.username` unique stays global (desired). No other tables currently have unique constraints, but new tables (reviews, products) should use composite uniqueness from the start.
|
Public routes must render immediately with unauthenticated defaults. Auth state loads in the background and hydrates progressive elements (nav user avatar, "Add to collection" CTAs) without blocking content. Use React Query's `enabled: isAuthenticated` on all hooks that call auth-required endpoints. The `useAuth()` query itself should never block page render — only auth-gated actions should wait on it.
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Database constraint errors when a second user creates categories.
|
- Full-screen spinner visible to anonymous visitors on the landing page
|
||||||
- Tests that only ever use one user.
|
- Lighthouse FCP score degrades after the public access change
|
||||||
|
- Network tab shows 401 on `/api/settings` or `/api/totals` for logged-out users
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Multi-user data model phase, during schema migration.
|
Public access auth model phase — same as Pitfall 1, tackled together.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 3: Drizzle Schema Rewrite Is a Replacement, Not a Migration
|
### Pitfall 3: Root-Level Components Fire Auth-Required Queries for Anonymous Users
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
Drizzle ORM schemas are dialect-specific. The current schema imports from `drizzle-orm/sqlite-core` and uses `sqliteTable`, `integer().primaryKey({ autoIncrement: true })`, and `real()`. The Postgres schema must import from `drizzle-orm/pg-core` and use `pgTable`, `serial()` or `integer().generatedAlwaysAsIdentity()`, and `doublePrecision()`. This is not a migration Drizzle can auto-generate -- it requires a full schema rewrite and a fresh migration history.
|
`TotalsBar` is rendered at the root layout level for all routes and calls `useTotals()` which hits `GET /api/totals`. The auth middleware does not skip `/api/totals` for GET requests (verified in `server/index.ts`) — it requires a `userId`. Anonymous visitors will receive a 401 on every public page load, and React Query will retry the failed query three times. `FabMenu`, `CatalogSearchOverlay`, `AddToCollectionModal`, and `AddToThreadModal` are similarly rendered at root level and may trigger auth-gated operations.
|
||||||
|
|
||||||
Specific differences that will cause bugs if missed:
|
|
||||||
- `integer("id").primaryKey({ autoIncrement: true })` becomes `serial("id").primaryKey()` or `integer("id").primaryKey().generatedAlwaysAsIdentity()`.
|
|
||||||
- `integer("created_at", { mode: "timestamp" })` -- SQLite stores timestamps as integers. Postgres has native `timestamp` type. Must decide: keep integer storage or switch to Postgres `timestamp()`.
|
|
||||||
- `real("weight_grams")` -- SQLite `REAL` is 8-byte float. Postgres `real` is 4-byte float (less precision). Use `doublePrecision()` for equivalent behavior.
|
|
||||||
- SQLite `text("status")` with string values works as pseudo-enum. Postgres has native `pgEnum` for type safety.
|
|
||||||
- The `Db` type alias (`typeof prodDb`) changes entirely -- every service file and MCP tool imports this type.
|
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Developers assume Drizzle abstracts away database differences. It does not at the schema layer. The query builder is mostly compatible, but schema definition is dialect-specific by design.
|
Root layout components were designed when every user was authenticated. Adding public routes does not automatically suppress these components' data fetches.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Write a new `schema.ts` from scratch using `pg-core`, not edit the existing one.
|
Audit every component rendered in the root layout. For each one: (1) does it make an API call? (2) does that endpoint require auth? If yes, add `enabled: isAuthenticated` to the query, or conditionally render the component itself behind `{isAuthenticated && <TotalsBar />}`. `TotalsBar` should not appear on the new public discovery landing page at all — it is a user-specific widget.
|
||||||
2. Start a fresh Drizzle migration history for Postgres. SQLite migrations are irrelevant.
|
|
||||||
3. Write a data migration script that reads from old SQLite and inserts into new Postgres.
|
|
||||||
4. Update the `Db` type alias in all service files.
|
|
||||||
5. Use `doublePrecision()` not `real()` for weight values to maintain precision parity with SQLite.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Weight values losing precision (245.5g becoming 245.49999...).
|
- Network tab shows 401 on `/api/totals` for anonymous users
|
||||||
- Timestamps behaving differently (integer epoch vs. native timestamp).
|
- React Query error boundaries firing on public pages for components that are not relevant to anonymous users
|
||||||
- drizzle-kit refusing to generate migrations against the wrong dialect.
|
- Console shows `[auth] OIDC auth failed` log spam from root-level queries
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Database migration phase. Must complete before any other v2.0 feature.
|
Public access auth model phase — audit and guard every root-level component before deploying the public landing page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 4: Test Infrastructure Collapses During Database Switch
|
### Pitfall 4: Discovery Feed Built as Per-Card Queries (N+1)
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The entire test infrastructure is built on SQLite. `createTestDb()` uses `bun:sqlite` with `Database(":memory:")` and `drizzle-orm/bun-sqlite`. E2E tests use a file-based SQLite (`e2e/test.db`). After switching to Postgres, every test needs a Postgres connection -- no more in-memory databases.
|
A discovery feed showing popular public setups or recently added catalog items typically starts as a list query followed by per-item detail fetches. For example: `getAllPublicSetups()` returns 20 setup IDs, then the frontend or backend fetches each setup's item count, owner display name, and total weight individually. At 20 items this is invisible; at 100+ items or with multiple feed sections it causes 2+ second response times and high DB connection pressure.
|
||||||
|
|
||||||
The MCP server hard-codes `db as prodDb` which is an SQLite Drizzle instance. The Hono context variable type for `db` changes. Every route handler that does `c.get("db")` gets a different type.
|
The existing `getPublicSetupWithItems()` service function is optimized for a single-setup detail view. Reusing it in a loop for a feed is the most common trap.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
In-memory SQLite is the best testing story in the Bun ecosystem -- fast, isolated, no external services. Postgres testing requires either: (a) a running Postgres instance, (b) testcontainers with Docker, or (c) PGlite (lightweight Postgres in WebAssembly). Developers delay updating tests and end up with a broken test suite for weeks.
|
Developers reach for familiar service functions. The function works. Performance issues only appear under real data volumes, not in development with 3 test setups.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Adopt PGlite (`@electric-sql/pglite`) for unit/integration tests. It provides in-memory Postgres without Docker. Drizzle supports PGlite via `drizzle-orm/pglite`.
|
Write dedicated feed query functions using Drizzle joins from day one. A single SQL query should return all feed cards with their aggregates (item count, total weight in grams, owner display name). Add PostgreSQL indexes on `setups.is_public`, `setups.created_at`, and `setups.updated_at` before building the feed query. Mirror the pattern already used for aggregate totals (computed via SQL on read, not stored).
|
||||||
2. Update `createTestDb()` to use PGlite instead of bun:sqlite.
|
|
||||||
3. For E2E tests, use Docker Compose with a test Postgres instance, or PGlite if performance is acceptable.
|
|
||||||
4. Update the Hono context variable type to the new Postgres Drizzle instance type.
|
|
||||||
5. Migrate test infrastructure in the same phase as the schema, not after.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- `bun test` fails across the board after schema change.
|
- Feed query time scales linearly with results count
|
||||||
- "Type 'BunSQLiteDatabase' is not assignable to type 'PgDatabase'" errors everywhere.
|
- `pg_stat_statements` shows repeated single-row lookups for users or items
|
||||||
- E2E tests silently skipped or disabled "temporarily."
|
- Adding a second feed section doubles total response time
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Database migration phase. Tests must migrate alongside the schema.
|
Discovery landing page phase — design feed queries as joins from the first implementation, not as a later optimization.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 5: Auth Provider Integration Breaks Existing Sessions, API Keys, and MCP
|
### Pitfall 5: Image Attribution Stored as Unstructured Text
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The current auth stores users, sessions, and API keys in the local database. Switching to an external auth provider means: (1) user identity moves external, (2) session management changes (JWT or OAuth flow vs. cookie sessions), (3) existing API keys become orphaned because they reference the old user table, (4) the MCP server authenticates via API keys stored locally, (5) E2E tests authenticate via `POST /api/auth/login` with a seeded user, (6) the onboarding flow (`POST /api/auth/setup`) creates the first user.
|
If image attribution for catalog items is stored as a single `attribution: text` field (the fastest approach), it becomes impossible to: programmatically render a copyright badge, distinguish manufacturer press images from community uploads from AI-generated placeholders, enforce a "no scraped retailer images" policy, or filter catalog items by image source type. Agent-seeded catalog items will have inconsistent attribution formats that are expensive to clean up retroactively.
|
||||||
|
|
||||||
|
The current `globalItems` schema has only `imageUrl: text`. There is no `imageSourceType` or structured attribution.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
Auth migration is treated as "swap the login page" when it touches the entire authentication surface: user identity, session lifecycle, API key management, MCP authentication, E2E test setup, and onboarding.
|
"We'll add a text note" is the zero-friction path. Attribution structure seems like a nice-to-have until you need to answer "how many catalog items have manufacturer-licensed images?" or build a compliance filter.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Keep API keys in the local database even after auth moves external. API keys are long-lived credentials managed by the application, not the auth provider.
|
Define a structured attribution model at schema design time before any seeding. Minimum: `imageSourceType: text` (enum: `manufacturer`, `community`, `agent_seeded`, `no_image`), `imageAttribution: text` (human-readable credit line), and `imageSourceUrl: text` (already exists on items but not on globalItems). This allows source-type-specific rendering and filtering without a schema migration mid-catalog-build.
|
||||||
2. Map external provider user IDs to a local `users` table. The external provider handles authentication; the local table handles application-level data (userId foreign keys, API keys, preferences). Foreign keys reference local `users.id`, not the provider's UUID.
|
|
||||||
3. Replace the onboarding flow: instead of "create admin account," it becomes "sign up via external provider, first user gets admin role."
|
|
||||||
4. Update E2E tests to either mock the auth provider or use API key authentication exclusively for E2E.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- MCP server stops working after auth migration.
|
- Seeding agent instructions say "put attribution in the description field"
|
||||||
- E2E tests that log in via `POST /api/auth/login` all fail.
|
- Catalog items display images without any credit indication
|
||||||
- API keys created before migration stop working.
|
- No way to query "show me only manufacturer-sourced images"
|
||||||
- No local `users` table -- everything delegated to external provider.
|
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Auth migration phase. Should be done early because user identity is the foundation.
|
Catalog enrichment infrastructure phase — schema changes must be in the migration before any seeding begins.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 6: Global Item Database Creates a Data Model Fork
|
### Pitfall 6: Agent Catalog Seeding Creates Duplicate Global Items
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
The current `items` table represents user-owned gear. The v2.0 vision includes a "global item database" with manufacturer specs. These are fundamentally different entities: a user's item has quantity, personal notes, setup associations, and belongs to a user. A global item is a product definition with canonical specs, owned by nobody. Conflating them in one table (via `isGlobal` flag or `NULL userId`) creates an unmaintainable mess. Separating them creates a sync problem.
|
Without a unique constraint on `(brand, model)` in the `globalItems` table (which currently has none), running an MCP agent seeding pass twice creates duplicate rows for the same product. Agents also retry on API errors, compounding the issue. The current `create_item` MCP tool creates a new row unconditionally — it was designed for personal collection management where duplicates are intentional (a user can own two of the same item). Reusing it for catalog seeding carries no deduplication.
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
It seems efficient to add an `isGlobal` flag. But then queries need to handle both cases, user items need to link to global items for spec inheritance, and the API surface doubles with different permission models.
|
The catalog seeding flow is built on top of existing personal item tools because they are already available via MCP. The semantic mismatch (user-owned vs. global reference item) is not obvious until duplicates appear.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Create a separate `products` table for the global database. A product has: name, manufacturer, canonical weight, canonical price, product URL, image, category.
|
Add a unique constraint on `globalItems(brand, model)` as part of the catalog enrichment schema migration. Create a dedicated `upsert_catalog_item` MCP tool or admin API endpoint that uses `ON CONFLICT (brand, model) DO UPDATE` semantics. This tool should be explicitly different from personal collection tools: no `userId`, upsert behavior, admin-scoped access.
|
||||||
2. User `items` gets a nullable `productId` foreign key. When set, the item inherits specs from the product but can override them (user's measured weight vs. manufacturer spec).
|
|
||||||
3. User items without a `productId` are standalone (backward-compatible with all existing items).
|
|
||||||
4. Reviews, owner counts, and setup appearances link to `products`, not user `items`.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- `items` table query complexity increases beyond what is reasonable.
|
- Catalog search returns two entries for the same product ("Apidura Backcountry Food Pouch")
|
||||||
- Ambiguity about whether an operation affects "my item" or "the global product."
|
- Owner count on a duplicate item is 0 because user-owned items link to the wrong copy
|
||||||
- Permission model becomes unclear (who can edit a global product?).
|
- Re-running a seed script doubles the catalog size
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Global item database phase. Must come after multi-user data model is stable.
|
Catalog enrichment infrastructure phase — unique constraint and upsert endpoint before any agent seeding run.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 7: Image Storage Migration Breaks Existing URLs and the MCP Tool
|
### Pitfall 7: Storing Third-Party Product Images in S3 Creates Legal and Cost Exposure
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
Images are stored in `./uploads/` on the filesystem, served via `app.use("/uploads/*", serveStatic({ root: "./" }))`, and referenced by `imageFilename` in the database. Moving to object storage changes URLs from `/uploads/uuid.jpg` to `https://bucket.s3.region.amazonaws.com/uuid.jpg`. Every existing `imageFilename` reference becomes a broken image.
|
The existing `upload_image_from_url` MCP tool fetches a URL and saves it to MinIO/S3. If an agent uses this to seed manufacturer product images from brand websites, retailer pages, or Amazon listings, those images are copyright-protected. Storing and publicly serving them creates: (1) legal liability for hosting images without a license — up to $150,000 per infringement in the US; (2) storage and egress costs that grow with public traffic; (3) dependency on external URLs that 404 silently when retailers change their CDN paths.
|
||||||
|
|
||||||
Both `items` and `threadCandidates` have `imageFilename` and `imageSourceUrl` fields. The MCP tool `upload_image_from_url` saves to the local filesystem. The image route `POST /api/images` saves to `./uploads/`.
|
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
The current design stores only the filename, not the full URL. The serving path is implicit (prepend `/uploads/`). When storage moves to S3, the "prepend `/uploads/`" pattern breaks.
|
"Just grab the product image from the brand website" produces accurate images immediately. It feels like fair use. It is not — attribution does not create a license, and copyright does not require a watermark or notice.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Add a reverse proxy route: keep `/uploads/*` working but proxy to S3 instead of local filesystem. This maintains backward compatibility during transition.
|
Define a clear image sourcing policy before seeding begins. Safest options in order: (1) store `imageUrl` as a reference to the external source without copying to S3; (2) use manufacturer-provided press/media kit images that explicitly grant redistribution; (3) use Creative Commons–licensed images from Wikimedia Commons or similar. Document which sources are permitted in the seeding agent's prompt. Do not hotlink to third-party URLs either — they create external dependencies. Distinguish permitted images from unverified ones using `imageSourceType`.
|
||||||
2. Or migrate `imageFilename` to store full URLs. Existing filenames get prefixed with the S3 URL during data migration.
|
|
||||||
3. Write a migration script that uploads all `./uploads/` files to S3 and updates database references.
|
|
||||||
4. Update `POST /api/images`, `POST /api/images/from-url`, and the MCP `upload_image_from_url` tool to write to S3.
|
|
||||||
5. Create an image storage abstraction layer so dev can use local filesystem and production uses S3.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Broken images after deployment.
|
- Seeding instructions tell the agent to call `upload_image_from_url` on Amazon product listing URLs
|
||||||
- Mixed URLs (some `/uploads/`, some `https://s3...`) in the database.
|
- All catalog items have `imageFilename` values from manufacturer/retailer URLs
|
||||||
- MCP tool `upload_image_from_url` silently failing.
|
- No documented image licensing policy before seeding starts
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Infrastructure phase. Should be done before discovery/public profiles (which serve images to many users).
|
Catalog enrichment infrastructure phase — establish policy and `imageSourceType` schema before any seeding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 8: Thread Resolution Creates Items Without Proper User Scoping
|
### Pitfall 8: MCP Catalog Tools Share the Seeding Agent's Personal userId
|
||||||
|
|
||||||
**What goes wrong:**
|
**What goes wrong:**
|
||||||
Thread resolution copies a candidate's data into a new item. In multi-user, the newly created item must inherit the thread owner's `userId`. If the resolution logic does not explicitly set `userId` on the new item, it either fails (NOT NULL constraint) or creates an orphaned item.
|
The MCP server binds every tool invocation to the `userId` of the authenticated API key or OAuth token. When an agent uses a regular user API key to create catalog items, those items are implicitly associated with that user's account context. This creates two problems: (1) catalog items appear in the seeding user's personal collection or produce permission collisions; (2) running the seeding agent as a specific user creates a "ghost user" with thousands of catalog entries that pollutes collection analytics and owner counts.
|
||||||
|
|
||||||
This is a specific instance of Pitfall 1 but deserves its own callout because resolution is a multi-step transaction: update thread status, set `resolvedCandidateId`, create new item. Any step that forgets `userId` breaks the chain.
|
|
||||||
|
|
||||||
**Why it happens:**
|
**Why it happens:**
|
||||||
The resolution logic is tested as a unit but the test does not set a `userId` because none existed. After adding `userId`, the test still passes if using a default/NULL value. The bug only surfaces with a second user.
|
There is no separation between personal collection MCP tools and catalog admin tools in the current implementation. The `userId` context flows through all tool handlers automatically.
|
||||||
|
|
||||||
**How to avoid:**
|
**How to avoid:**
|
||||||
1. Make `userId` NOT NULL on all entity tables from day one.
|
Catalog write operations must not carry a personal `userId`. Options: (1) create a separate admin-scoped API key that maps to a "system" user with no personal collection; (2) build dedicated catalog MCP tools that explicitly ignore `userId` for the globalItems table while still requiring authentication for authorization; (3) use a separate REST endpoint (`POST /api/admin/catalog-items`) with admin-only auth, bypassing the user-scoped MCP tools entirely.
|
||||||
2. Update `resolveThread` to accept and propagate `userId`.
|
|
||||||
3. Write a test: resolve thread as User A, verify created item belongs to User A.
|
|
||||||
|
|
||||||
**Warning signs:**
|
**Warning signs:**
|
||||||
- Items appearing in the wrong user's collection after resolution.
|
- Running the seeding agent creates items visible in someone's personal collection
|
||||||
- Thread resolution failing with constraint violations.
|
- Owner count on seeded global items starts at 1 (the seeding user's implicit ownership)
|
||||||
|
- Catalog items appear in the seeding user's dashboard totals
|
||||||
|
|
||||||
**Phase to address:**
|
**Phase to address:**
|
||||||
Multi-user data model phase.
|
Catalog enrichment infrastructure phase — design catalog write path before building seeding tooling.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 9: Public Content Without Explicit Privacy Controls
|
|
||||||
|
|
||||||
**What goes wrong:**
|
|
||||||
The v2.0 plan includes "public user profiles with shared setups" and a "discovery feed." Without explicit visibility controls, the default state is ambiguous: are new setups public? Are all items in a public setup visible? Can someone discover gear a user has not chosen to share? Users expecting a private gear tracker are surprised when their collection appears in search results.
|
|
||||||
|
|
||||||
**Why it happens:**
|
|
||||||
The developer defaults to "everything public" because it is simpler to build discovery features. Privacy controls are added as an afterthought, requiring a retroactive audit of all existing data.
|
|
||||||
|
|
||||||
**How to avoid:**
|
|
||||||
1. Default to private. Every entity (setup, profile) is private unless explicitly published.
|
|
||||||
2. Add a `visibility` column (`private` | `public`) to setups. Items are visible publicly only through public setups.
|
|
||||||
3. User profiles are private by default. Public profile is opt-in.
|
|
||||||
4. Public API endpoints (discovery, search) only query entities with `visibility = 'public'`.
|
|
||||||
5. Build the visibility model in the data layer before building any discovery UI.
|
|
||||||
|
|
||||||
**Warning signs:**
|
|
||||||
- No `visibility` or `isPublic` column in the schema.
|
|
||||||
- Discovery queries that do not filter by visibility.
|
|
||||||
- User complaints about unexpected data exposure.
|
|
||||||
|
|
||||||
**Phase to address:**
|
|
||||||
Multi-user data model phase (add visibility columns) and discovery phase (enforce in queries).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 10: SQLite-Specific Patterns That Silently Break on Postgres
|
|
||||||
|
|
||||||
**What goes wrong:**
|
|
||||||
The codebase has SQLite-specific patterns that will not error but will behave differently on Postgres:
|
|
||||||
- `src/db/index.ts` runs `PRAGMA journal_mode = WAL` and `PRAGMA foreign_keys = ON` -- Postgres has no PRAGMAs. Foreign keys are always enforced. WAL is always on.
|
|
||||||
- `bun:sqlite` is used as the driver. Postgres needs `postgres` (postgres.js) or `pg` (node-postgres) as the driver.
|
|
||||||
- The existing Drizzle migrator import is `drizzle-orm/bun-sqlite/migrator`. Postgres uses `drizzle-orm/node-postgres/migrator` or `drizzle-orm/postgres-js/migrator`.
|
|
||||||
- SQLite allows inserting strings into integer columns silently. Postgres will error.
|
|
||||||
- SQLite `AUTOINCREMENT` guarantees IDs never reuse. Postgres `serial` reuses IDs after deletions if the sequence is not explicitly configured.
|
|
||||||
- The test helper's `Database(":memory:")` has no Postgres equivalent without PGlite.
|
|
||||||
|
|
||||||
**Why it happens:**
|
|
||||||
These patterns are invisible in a working SQLite app. They only surface during or after the switch, often as runtime errors in production.
|
|
||||||
|
|
||||||
**How to avoid:**
|
|
||||||
1. Remove all PRAGMA statements when switching to Postgres.
|
|
||||||
2. Replace `bun:sqlite` driver with `postgres` (postgres.js is recommended for Bun compatibility).
|
|
||||||
3. Update all migrator imports.
|
|
||||||
4. Run the full test suite against Postgres to catch type strictness differences.
|
|
||||||
5. Use `serial` or `identity` columns for auto-increment; accept that IDs may be reused after deletion (this should not matter for a web app).
|
|
||||||
|
|
||||||
**Warning signs:**
|
|
||||||
- "PRAGMA" in the Postgres codebase.
|
|
||||||
- `bun:sqlite` imports anywhere in production code after migration.
|
|
||||||
- Tests passing against SQLite but failing against Postgres.
|
|
||||||
|
|
||||||
**Phase to address:**
|
|
||||||
Database migration phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 11: Setup-Item Delete-All-Reinsert Pattern Causes Phantom Reads
|
|
||||||
|
|
||||||
**What goes wrong:**
|
|
||||||
The current setup item sync uses delete-all-then-re-insert: `DELETE FROM setup_items WHERE setupId = X`, then re-insert all items. In single-user SQLite this is fine. In multi-user Postgres with concurrent writes: (a) race conditions if two users modify setups simultaneously, (b) brief windows where a public setup appears empty to concurrent readers.
|
|
||||||
|
|
||||||
**Why it happens:**
|
|
||||||
The pattern was chosen for simplicity (noted in CLAUDE.md: "Simpler than diffing, atomic in transaction"). "Atomic in transaction" only holds if the transaction isolation level prevents phantom reads, which is not the default in Postgres (`READ COMMITTED`).
|
|
||||||
|
|
||||||
**How to avoid:**
|
|
||||||
1. Wrap in an explicit transaction with `SERIALIZABLE` or `REPEATABLE READ` isolation for the sync operation.
|
|
||||||
2. Or switch to diff-based approach for public setups: compare existing vs. new list, delete removed, insert added.
|
|
||||||
3. For private setups, the delete-reinsert pattern with a basic transaction is acceptable.
|
|
||||||
|
|
||||||
**Warning signs:**
|
|
||||||
- Public setups briefly appearing empty.
|
|
||||||
- Foreign key violations in concurrent scenarios.
|
|
||||||
|
|
||||||
**Phase to address:**
|
|
||||||
Multi-user data model phase, when updating the setup service.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 12: Existing Data Has No Owner After Multi-User Migration
|
|
||||||
|
|
||||||
**What goes wrong:**
|
|
||||||
The existing SQLite database has items, categories, threads, setups -- all without a `userId` column. When the schema adds `userId NOT NULL`, the existing data needs an owner. If the migration script does not assign existing data to the original user, the data is either lost (NOT NULL violation prevents migration) or orphaned.
|
|
||||||
|
|
||||||
**Why it happens:**
|
|
||||||
The developer writes the new schema with `userId NOT NULL`, runs `db:push`, and the migration fails because existing rows have no `userId`. The "fix" is to make `userId` nullable, which undermines the entire data isolation model.
|
|
||||||
|
|
||||||
**How to avoid:**
|
|
||||||
1. The data migration script must: (a) create the original user in the new system, (b) assign all existing data to that user's ID, (c) then apply the NOT NULL constraint.
|
|
||||||
2. Migration order: create tables with `userId` nullable, insert data with the owner's userId, then ALTER to NOT NULL.
|
|
||||||
3. Verify row counts match before and after migration.
|
|
||||||
|
|
||||||
**Warning signs:**
|
|
||||||
- `userId` column is nullable in the final schema "because of migration."
|
|
||||||
- Existing data missing after migration.
|
|
||||||
- Migration script that only handles schema, not data.
|
|
||||||
|
|
||||||
**Phase to address:**
|
|
||||||
Database migration phase, specifically the data migration step.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -316,121 +189,116 @@ Database migration phase, specifically the data migration step.
|
|||||||
|
|
||||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||||
|----------|-------------------|----------------|-----------------|
|
|----------|-------------------|----------------|-----------------|
|
||||||
| Keeping SQLite test infrastructure while developing Postgres features | Tests keep passing during migration | Two database dialects to maintain, false confidence from tests that do not match production | Never -- migrate tests alongside schema |
|
| Single `isPublicRoute` allowlist in `__root.tsx` | Simple to reason about | Every new public route requires updating this list; lists drift | Never — use per-route `beforeLoad` guards on protected routes instead |
|
||||||
| Storing both old `/uploads/` paths and new S3 URLs | Avoid data migration script | Every image-rendering component handles both URL formats forever | Only as a 1-2 week transition |
|
| Reuse personal item MCP tools for catalog seeding | No new tools to build | Creates wrong userId semantics, no deduplication, wrong ownership | Never for bulk ops — build a dedicated catalog upsert tool |
|
||||||
| Using `userId` as nullable during migration | Existing data does not need backfilling | Every query must handle NULL userId, privacy bugs when userId is missing | Only during the migration transaction itself, then enforce NOT NULL |
|
| `attribution: text` free-form field for image credit | Zero schema change | Cannot programmatically distinguish source types, filter, or enforce licensing policy | Only for internal admin-only catalog; never for public content |
|
||||||
| Skipping RLS and relying only on app-level userId filtering | Faster to implement | Single missed WHERE clause = data leak | Never for multi-user platforms |
|
| Hotlink external product images without copying to S3 | Zero storage cost | Silent 404s when retailers change CDN URLs; external dependency | Only for dev/prototype with a clear plan to replace |
|
||||||
| Deferring visibility controls to "after discovery ships" | Ship discovery faster | Retroactive privacy audit, potential data exposure, user trust damage | Never |
|
| Discovery feed as multiple React Query calls per card | Familiar pattern | N+1 queries degrade at scale; visible at ~30 feed cards | Only for MVP with < 20 items and a committed optimization plan |
|
||||||
| Keeping the local `users` table password hash after external auth | Avoid migration complexity | Dead column confuses future developers, potential security liability | Never -- remove password hash column after auth migration |
|
| No unique constraint on `globalItems(brand, model)` | Faster initial schema | Duplicate catalog entries after every re-seed or agent retry | Never — add the constraint before any seeding |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Integration Gotchas
|
## Integration Gotchas
|
||||||
|
|
||||||
| Integration | Common Mistake | Correct Approach |
|
| Integration | Common Mistake | Correct Approach |
|
||||||
|-------------|----------------|------------------|
|
|-------------|----------------|------------------|
|
||||||
| External auth provider | Removing the local `users` table entirely | Keep a local `users` table with `externalId` (from auth provider) + local fields (preferences, API keys). Foreign keys reference local `users.id`, not the external provider's UUID. |
|
| Logto OIDC + public routes | `oidcAuthMiddleware()` throws or redirects when there is no session, breaking public routes | Use `getAuth(c)` which returns null gracefully for unauthenticated requests; only apply `oidcAuthMiddleware()` on login-gated routes |
|
||||||
| External auth provider | Storing user profile data in the auth provider and querying it at runtime | Store only identity in auth provider. Sync user profile to local `users` table on login. Application queries local table only. |
|
| MCP tools + catalog seeding | Using user-scoped tools (bound to API key owner's `userId`) to write global catalog entries | Build separate catalog admin tools or a REST endpoint that writes to `globalItems` without personal userId semantics |
|
||||||
| External auth provider | Using auth provider's session tokens directly as API authentication | Auth provider handles login/logout. Application mints its own session after verifying the auth provider's token. This decouples session lifecycle from the provider. |
|
| MinIO/S3 + public catalog | Using presigned URLs (which expire) for catalog image delivery | Catalog item images need stable public paths or a CDN URL; presigned URLs are for user-private content only |
|
||||||
| S3-compatible object storage | Using the S3 SDK directly in route handlers | Create an image storage abstraction (interface with `upload`, `getUrl`, `delete`). Swap implementations (local filesystem for dev, S3 for production) via environment config. |
|
| TanStack Router `beforeLoad` + auth check | `beforeLoad` that re-fetches auth on every navigation creates a waterfall | Read from React Query cache (already has 5-min `staleTime` in `useAuth`); `beforeLoad` should read cached auth state, not re-fetch |
|
||||||
| Postgres driver | Assuming `bun:sqlite` patterns work with Postgres | Postgres uses `postgres` (postgres.js) or `pg`. Connection pooling, async queries, and error handling differ. SQLite is synchronous; Postgres is async. Service functions may need to become async. |
|
| PostgreSQL + public feed queries | Missing indexes on `is_public`, `created_at` cause full-table scans | Add composite indexes on `(is_public, created_at)` on setups table before the feed goes live |
|
||||||
| Postgres | Assuming SQLite PRAGMA behaviors exist | Postgres has no PRAGMAs. Foreign keys are always on. WAL is always on. Remove all PRAGMA code. |
|
|
||||||
| Drizzle ORM Postgres driver | Using synchronous `.get()` and `.all()` query methods | SQLite Drizzle uses `.get()` (sync). Postgres Drizzle uses `.execute()` or `await` on queries. Every service function that calls `.get()` or `.all()` must be updated. |
|
---
|
||||||
|
|
||||||
## Performance Traps
|
## Performance Traps
|
||||||
|
|
||||||
| Trap | Symptoms | Prevention | When It Breaks |
|
| Trap | Symptoms | Prevention | When It Breaks |
|
||||||
|------|----------|------------|----------------|
|
|------|----------|------------|----------------|
|
||||||
| N+1 queries in discovery feed | Feed page takes 2+ seconds | Use joins or batch queries for setups with items and categories | 50+ setups in feed, each with 10+ items |
|
| Per-card queries in discovery feed | Feed loads in > 2s; each section multiplies DB time | Single JOIN query returning all feed card data with aggregates | At ~30 items in feed |
|
||||||
| Unindexed `userId` columns | All queries slow after adding userId filtering | Add indexes on `userId` for every table. Composite indexes for `(userId, categoryId)` on items. | 1000+ items across 50+ users |
|
| Auth check blocking public FCP | Blank + spinner visible on first load; LCP degraded | Render public content immediately; auth state hydrates progressively | Immediately on first deploy — visible in Lighthouse |
|
||||||
| Full-table scans for aggregates | Dashboard slow for large collections | Current aggregates are computed via SQL on read. Add materialized views or cache for public setup totals. | 100+ items per user, or public setups viewed by 100+ visitors |
|
| Full-table scan on `globalItems` text search | Search feels fine at 18 items; slows visibly at 500+ | Add `pg_trgm` trigram index or `tsvector` GIN index before catalog grows | At ~200 catalog items |
|
||||||
| Image serving from app server | Server CPU/bandwidth saturated | Serve images from S3/CDN. Current `serveStatic` for uploads hits the app server for every request. | 100+ concurrent users browsing image-heavy pages |
|
| Image egress costs without CDN | MinIO egress scales with public traffic | CDN in front of public catalog images, or store external `imageUrl` references | Once catalog is publicly discoverable |
|
||||||
| Global product search without full-text index | Product search slow or inaccurate | Use Postgres full-text search (`tsvector`/`tsquery`) or `pg_trgm` trigram index. | 10,000+ products |
|
| React Query refetching public feed on every window focus | Unnecessary server load for anonymous browsing | Set appropriate `staleTime` (5–10 min) on public catalog/feed queries | At moderate traffic |
|
||||||
| Synchronous service functions on Postgres | Request timeouts, connection pool exhaustion | SQLite Drizzle is sync. Postgres Drizzle is async. Service functions that were sync must become async. | Any usage under load |
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security Mistakes
|
## Security Mistakes
|
||||||
|
|
||||||
| Mistake | Risk | Prevention |
|
| Mistake | Risk | Prevention |
|
||||||
|---------|------|------------|
|
|---------|------|------------|
|
||||||
| No RLS, relying only on app-level userId filtering | Single missed WHERE clause exposes all user data | Enable Postgres RLS on all user-owned tables. App filtering is primary; RLS is safety net. |
|
| Regular user API key authorized to write global catalog items | Any user with an API key can pollute the shared catalog | Catalog write operations require admin scope or a designated system API key; regular user keys are read-only on globalItems |
|
||||||
| Public setup exposes private item details | Users share a setup but private notes/pricing leak | Public setup views project only public fields (name, weight, category). Define a "public item projection" and enforce it. |
|
| Public setup pages exposing private item fields | Public setup view leaks item notes, threads, or product URLs not intended for sharing | Audit `getPublicSetupWithItems` — return only explicitly public fields (name, weight, image); strip notes and thread data |
|
||||||
| API keys not scoped to users after auth migration | API key created by User A operates on User B's data | API keys must associate with a userId. After validation, the key's userId scopes all operations. |
|
| No rate limiting on public catalog search endpoint | `GET /api/global-items?q=...` is unauthenticated; bots can enumerate or abuse it | Add basic rate limiting middleware to unauthenticated GET endpoints before making them discoverable |
|
||||||
| Auth provider misconfigured for open self-registration | Random users create accounts without approval | Configure auth provider for admin-approval or invite-only registration. Test explicitly. |
|
| `imageSourceUrl` storing retailer order URLs with auth tokens in query params | Private session or order data in stored URLs | Normalize and validate `imageSourceUrl` before storage; strip query params that resemble auth or session tokens |
|
||||||
| Image upload accepts any file type | Stored XSS via SVG uploads, executable content | Validate MIME type on upload (JPEG, PNG, WebP only). Set `Content-Type` and `Content-Disposition` headers. Strip EXIF metadata. |
|
|
||||||
| External auth provider callback URL not validated | OAuth redirect attack | Whitelist exact callback URLs in auth provider config. Never use wildcard redirect URIs. |
|
---
|
||||||
|
|
||||||
## UX Pitfalls
|
## UX Pitfalls
|
||||||
|
|
||||||
| Pitfall | User Impact | Better Approach |
|
| Pitfall | User Impact | Better Approach |
|
||||||
|---------|-------------|-----------------|
|
|---------|-------------|-----------------|
|
||||||
| Forcing existing single user to re-register via external auth | User loses access to their own data until they figure out new login | Migration path: on first visit after upgrade, guide user to create auth provider account and automatically link to existing data. |
|
| Hard login wall immediately after discovery | Anonymous users discover value, click a setup, hit a login wall — they leave | Show full public setup/item detail to anonymous users; only prompt login at the point of a write action (add to collection) |
|
||||||
| Public profiles default to showing everything | Users surprised their gear list is public | Default profile to private. Public is opt-in with clear preview of what others see. |
|
| Empty state on catalog search with no query | Users expect to browse; zero results on open page is confusing | Return a curated/ranked set for empty queries (popular, recently added, or featured tags) |
|
||||||
| Review system with only star ratings | Ratings without context are useless for gear decisions | Structured reviews with predefined fields (durability, weight accuracy, value) per category. "Weight is 15g heavier than listed" is actionable; a 4-star rating is not. |
|
| Catalog feed with no images | Text-only cards look sparse and unfinished | Ensure most catalog items have images before the feed is public; add a styled placeholder with brand initial |
|
||||||
| Discovery feed dominated by one hobby | Users in other hobbies see irrelevant content | Category-based feed filtering. Show content relevant to user's categories. |
|
| Replacing dashboard for logged-in users | Existing users lose their familiar personal dashboard entry point | Discovery page is the anonymous entry point; authenticated users see a hybrid or a personal dashboard — do not remove the existing dashboard |
|
||||||
| No indication of data ownership when browsing others' setups | User tries to edit someone else's setup and gets error | Clear visual distinction between "my setup" and "someone else's setup." Read-only view with "copy to my setups" action. |
|
| Agent-seeded content displayed raw without quality review | Inconsistent formatting, wrong weights, or invalid product links visible publicly | Implement `status: draft | published` on catalog items; agents create drafts, a review step publishes them |
|
||||||
| Settings lost during migration | User's weight unit preference, onboarding state disappear | Migrate the `settings` table data alongside everything else. Map settings to the original user. |
|
|
||||||
|
---
|
||||||
|
|
||||||
## "Looks Done But Isn't" Checklist
|
## "Looks Done But Isn't" Checklist
|
||||||
|
|
||||||
- [ ] **Multi-user data model:** Often missing userId on the `settings` table -- verify settings are user-scoped (weight unit preference, onboarding state).
|
- [ ] **Public route guard:** Routes `/`, `/global-items/`, `/global-items/:id`, and `/users/:id` render without redirect in a private browser window with no session cookies — verify manually before shipping
|
||||||
- [ ] **Multi-user data model:** Often missing userId filter on `threadCandidates` queries that join through `threads` -- verify candidates are not directly queryable across users.
|
- [ ] **Root-level component suppression:** No 401 responses in the network tab when browsing public pages as an anonymous user — `TotalsBar`, `FabMenu`, and `OnboardingWizard` must not fire auth-required queries
|
||||||
- [ ] **Multi-user data model:** Often missing userId on thread resolution -- verify `resolveThread` propagates userId to the newly created item.
|
- [ ] **Catalog deduplication:** Running the agent seed script twice does not increase the row count in `globalItems` — verify unique constraint exists and upsert behavior works
|
||||||
- [ ] **Auth migration:** Often missing MCP server auth update -- verify MCP tools operate in context of the authenticated user, not as global admin.
|
- [ ] **Image attribution schema:** `globalItems` has `imageSourceType` column in the migration before any seeding starts — verify migration file exists and was applied
|
||||||
- [ ] **Auth migration:** Often missing E2E test auth update -- verify E2E tests authenticate against new auth system or use API keys.
|
- [ ] **Feed query efficiency:** Discovery feed data loads from a single JOIN query — verify using `EXPLAIN ANALYZE` or query logging, not by eyeballing response time
|
||||||
- [ ] **Auth migration:** Often missing API key userId association -- verify API keys created after migration are scoped to the creating user.
|
- [ ] **Public setup privacy:** `getPublicSetupWithItems` response does not include item `notes`, thread data, or private product URLs — verify the response shape manually
|
||||||
- [ ] **Database migration:** Often missing data migration script -- verify existing SQLite data is actually moved to Postgres, not just the schema.
|
- [ ] **Catalog write authorization:** A regular user's API key cannot create or modify `globalItems` — verify the catalog tool/endpoint requires admin scope
|
||||||
- [ ] **Database migration:** Often missing timestamp conversion -- verify SQLite integer timestamps are correctly handled in Postgres schema.
|
- [ ] **Image copyright policy:** Seeding instructions explicitly specify which image sources are permitted; no `upload_image_from_url` calls against brand/retailer URLs — verify in the agent prompt before any seeding run
|
||||||
- [ ] **Database migration:** Often missing weight precision check -- verify `real()` vs `doublePrecision()` does not lose decimal precision.
|
|
||||||
- [ ] **Database migration:** Often missing sync-to-async conversion -- verify all service functions are async after Postgres switch.
|
---
|
||||||
- [ ] **Image migration:** Often missing MCP tool update -- verify `upload_image_from_url` writes to S3, not local filesystem.
|
|
||||||
- [ ] **Image migration:** Often missing `imageSourceUrl` field -- verify source URL metadata is preserved during migration.
|
|
||||||
- [ ] **Public content:** Often missing visibility filtering on aggregate endpoints -- verify `/api/totals` only counts requesting user's items.
|
|
||||||
- [ ] **Reviews:** Often missing rate limiting -- verify a user cannot submit 100 reviews in a minute.
|
|
||||||
- [ ] **Discovery feed:** Often missing pagination -- verify feed does not load all public setups at once.
|
|
||||||
- [ ] **Global items:** Often missing product-vs-item distinction -- verify adding a product to global database does not add it to anyone's collection.
|
|
||||||
|
|
||||||
## Recovery Strategies
|
## Recovery Strategies
|
||||||
|
|
||||||
| Pitfall | Recovery Cost | Recovery Steps |
|
| Pitfall | Recovery Cost | Recovery Steps |
|
||||||
|---------|---------------|----------------|
|
|---------|---------------|----------------|
|
||||||
| Data leaked between users (missing userId filter) | HIGH | Audit all queries, add RLS immediately, notify affected users, review access logs. Reputation damage is the real cost. |
|
| Login redirect blocking public routes | LOW | Update `isPublicRoute` allowlist in `__root.tsx` and add server-side guard bypasses; redeploy; verify in incognito |
|
||||||
| Broken images after storage migration | MEDIUM | Keep old uploads directory as fallback. Re-upload missing images. Update database references. |
|
| Duplicate catalog items from agent seeding | MEDIUM | Write a deduplication migration to merge duplicates keeping owner links; add unique constraint post-merge; re-run seed in upsert mode |
|
||||||
| Test suite broken for weeks during DB migration | MEDIUM | Pause feature work. Set up PGlite test infrastructure. Port tests one file at a time. |
|
| Copyrighted images stored in S3 | HIGH | Identify affected items via `imageSourceType`; delete S3 objects; replace with permitted images or null `imageFilename`; legal review |
|
||||||
| Auth migration breaks MCP server | LOW | MCP server can fall back to API key auth (already implemented). Fix isolated to MCP auth middleware. |
|
| N+1 feed queries causing degraded response times | MEDIUM | Write optimized JOIN query; API response shape may change requiring frontend update; deploy together |
|
||||||
| Category unique constraint failures | LOW | Drop old unique constraint, add composite unique. Single transaction. |
|
| Auth-scoped queries firing for anonymous users | LOW | Add `enabled: isAuthenticated` to each affected query; guard root-level components with auth check |
|
||||||
| Weight precision loss (SQLite real to Postgres real) | LOW | Alter column to `doublePrecision`. One-time verification script. |
|
| Catalog items created with seeding user's userId | MEDIUM | Migration to null out `userId` on globalItems created during seeding; update catalog write path to not accept userId |
|
||||||
| Public data exposure before visibility controls | HIGH | Emergency: set all entities to private, deploy, then build visibility controls properly. Cannot undo exposure. |
|
|
||||||
| Existing data orphaned after migration | MEDIUM | Re-run data migration script with correct userId assignment. Verify row counts. |
|
---
|
||||||
| Service functions still sync after Postgres switch | MEDIUM | Systematic conversion of all service functions to async. Update all callers. TypeScript will catch most issues. |
|
|
||||||
|
|
||||||
## Pitfall-to-Phase Mapping
|
## Pitfall-to-Phase Mapping
|
||||||
|
|
||||||
| Pitfall | Prevention Phase | Verification |
|
| Pitfall | Prevention Phase | Verification |
|
||||||
|---------|------------------|--------------|
|
|---------|------------------|--------------|
|
||||||
| Missing userId filters (P1) | Multi-user data model | Integration tests: create as User A, query as User B, assert empty. RLS policies active. |
|
| Frontend auth guard blocks public routes (P1) | Public access auth model | Load `/global-items/` and `/` in private window — no redirect |
|
||||||
| Category uniqueness (P2) | Multi-user data model | Two users create identically-named categories without constraint violations. |
|
| `useAuth()` spinner blocks public FCP (P2) | Public access auth model | Lighthouse FCP on landing page with cold cache — no full-screen spinner |
|
||||||
| Drizzle schema rewrite (P3) | Database migration | Schema compiles with pg-core. drizzle-kit generates valid Postgres migrations. Weight values maintain precision. |
|
| Root-level components 401 for anonymous users (P3) | Public access auth model | Zero 401 responses in network tab on public pages |
|
||||||
| Test infrastructure collapse (P4) | Database migration | `bun test` passes with PGlite. E2E tests pass against Postgres. No SQLite imports in test code. |
|
| Discovery feed N+1 queries (P4) | Discovery landing page | `EXPLAIN ANALYZE` on feed endpoint confirms single query, no per-row loops |
|
||||||
| Auth provider breaks sessions/keys (P5) | Auth migration | Existing API keys work. MCP server authenticates. E2E tests pass. First-time setup works via external provider. |
|
| Image attribution stored as free text (P5) | Catalog enrichment infrastructure | Schema review — `imageSourceType` column exists on `globalItems` before seeding |
|
||||||
| Global item data model fork (P6) | Global item database | Separate `products` table exists. User items optionally reference a product. CRUD operations distinct. |
|
| Agent seeding creates duplicates (P6) | Catalog enrichment infrastructure | Run seed script twice — row count unchanged on second run |
|
||||||
| Image URL breakage (P7) | Infrastructure / Image storage | Existing images render. New uploads go to S3. MCP upload tool works. |
|
| Copyrighted images in S3 (P7) | Catalog enrichment infrastructure | Seeding instructions reviewed — no calls to `upload_image_from_url` on brand URLs |
|
||||||
| Thread resolution userId (P8) | Multi-user data model | Resolving a thread creates an item owned by the thread's owner. Tested with multiple users. |
|
| Agent catalog tools carry personal userId (P8) | Catalog enrichment infrastructure | Seeded items have null userId or system userId; not in any user's collection |
|
||||||
| Privacy/visibility (P9) | Multi-user data model + Discovery | Default is private. Public queries filter by visibility. No private data in discovery feed. |
|
|
||||||
| SQLite-specific patterns (P10) | Database migration | No PRAGMAs in codebase. No bun:sqlite imports. All queries async. |
|
---
|
||||||
| Setup sync race conditions (P11) | Multi-user data model | Concurrent setup modifications do not produce empty setups or constraint violations. |
|
|
||||||
| Existing data ownership (P12) | Database migration | All existing data assigned to original user. Row counts match. userId NOT NULL enforced. |
|
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- Direct codebase analysis of GearBox v1.4 (schema.ts, services, auth middleware, MCP server, test helpers, db/index.ts, E2E seed)
|
- GearBox codebase: `src/client/routes/__root.tsx` — root auth guard and `isPublicRoute` allowlist (direct inspection)
|
||||||
- [Drizzle ORM PostgreSQL documentation](https://orm.drizzle.team/docs/get-started/postgresql-new)
|
- GearBox codebase: `src/server/index.ts` — server-side public route bypass patterns (direct inspection)
|
||||||
- [Drizzle ORM SQLite column types](https://orm.drizzle.team/docs/column-types/sqlite)
|
- GearBox codebase: `src/db/schema.ts` — `globalItems` table confirming no unique constraint on brand/model, no `imageSourceType` (direct inspection)
|
||||||
- [Drizzle ORM migrations documentation](https://orm.drizzle.team/docs/migrations)
|
- GearBox codebase: `src/server/mcp/index.ts` — MCP userId binding per API key (direct inspection)
|
||||||
- [SQLite to PostgreSQL migration pitfalls (Open WebUI discussion)](https://github.com/open-webui/open-webui/discussions/21609)
|
- [TanStack Router: Auth performance issue with recommended patterns (GitHub #3997)](https://github.com/TanStack/router/issues/3997)
|
||||||
- [How to migrate from SQLite to PostgreSQL (Render)](https://render.com/articles/how-to-migrate-from-sqlite-to-postgresql)
|
- [TanStack Router: Authenticated Routes documentation](https://tanstack.com/router/v1/docs/guide/authenticated-routes)
|
||||||
- [Multi-tenant architecture guide (WorkOS)](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture)
|
- [Practical Ecommerce: Online Retailer's Guide to Photo Copyrights](https://www.practicalecommerce.com/Online-Retailers-Guide-to-Photo-Copyrights)
|
||||||
- [Multi-tenant vs single-tenant SaaS (Clerk)](https://clerk.com/blog/multi-tenant-vs-single-tenant)
|
- [MCP Idempotency: Best Practices 2025 (BytePlus)](https://www.byteplus.com/en/topic/542207)
|
||||||
- [Migrating file storage to Amazon S3 (DZone)](https://dzone.com/articles/migrating-file-storage-to-amazon-s3)
|
- [Six Fatal Flaws of MCP (Scalifiai, 2025)](https://www.scalifiai.com/blog/model-context-protocol-flaws-2025)
|
||||||
- [Drizzle ORM PostgreSQL best practices 2025 (GitHub Gist)](https://gist.github.com/productdevbook/7c9ce3bbeb96b3fabc3c7c2aa2abc717)
|
- [Hostwinds: Hotlinking Pitfalls and How to Protect Yourself](https://www.hostwinds.com/blog/hotlinking-pitfalls-and-how-to-protect-yourself)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Pitfalls research for: GearBox v2.0 -- Single-user to multi-user platform migration*
|
*Pitfalls research for: GearBox v2.1 — Public-first discovery platform with catalog enrichment*
|
||||||
*Researched: 2026-04-03*
|
*Researched: 2026-04-09*
|
||||||
|
|||||||
@@ -1,260 +1,333 @@
|
|||||||
# Stack Research
|
# Stack Research
|
||||||
|
|
||||||
**Domain:** Multi-user gear management platform (v2.0 platform additions)
|
**Domain:** Public-first gear discovery platform — catalog enrichment, discovery feed, agent-powered seeding (v2.1)
|
||||||
**Researched:** 2026-04-03
|
**Researched:** 2026-04-09
|
||||||
**Confidence:** MEDIUM-HIGH
|
**Confidence:** HIGH (existing stack verified against package.json; additions verified against npm/official docs)
|
||||||
|
|
||||||
This document covers ONLY the new stack additions for v2.0. The existing stack (React 19, Hono, Drizzle ORM, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, Zustand, Zod, Bun) is validated and unchanged.
|
---
|
||||||
|
|
||||||
## Recommended Stack
|
## Context: What Already Exists (Do Not Re-Research)
|
||||||
|
|
||||||
### Authentication -- Logto (Self-Hosted)
|
The following are validated and in production at v2.0. This file covers ADDITIONS AND CHANGES only.
|
||||||
|
|
||||||
| Technology | Version | Purpose | Why Recommended |
|
| Layer | Current |
|
||||||
|------------|---------|---------|-----------------|
|
|-------|---------|
|
||||||
| Logto OSS | v1.36+ | External OIDC/OAuth 2.1 auth provider | TypeScript-native, purpose-built for app auth (not enterprise IAM), requires Postgres (shared infra), beautiful pre-built sign-in UI, React SDK with hooks, lightweight JWT validation on backend. MIT-licensed core. |
|
| Runtime | Bun |
|
||||||
| @logto/react | ^4.0.13 | React SDK for auth flows | LogtoProvider wraps app, provides useLogto() hook for sign-in/sign-out/token access. Handles OIDC redirect flow, token refresh, and user info. |
|
| Frontend | React 19, TanStack Router/Query v5, Tailwind CSS v4, Zustand, Zod 4.x, framer-motion, Recharts, Lucide React |
|
||||||
| jose | ^6.2.2 | JWT validation on Hono backend | Zero-dependency, Bun-compatible, used to verify Logto-issued access tokens via JWKS. Recommended by Logto docs over heavier alternatives. |
|
| Backend | Hono 4.12.x, Drizzle ORM 0.45.x, PostgreSQL (postgres.js 3.4.x driver) |
|
||||||
|
| Auth | @hono/oidc-auth 1.8.x (Logto), API key auth, MCP OAuth 2.1 |
|
||||||
|
| Storage | @aws-sdk/client-s3 3.x (MinIO) |
|
||||||
|
| MCP | @modelcontextprotocol/sdk 1.29.x (19 tools) |
|
||||||
|
| Rate limiting | Custom in-process Map (auth endpoints only, 5 req/15 min per IP) |
|
||||||
|
|
||||||
**Why Logto over alternatives:**
|
---
|
||||||
|
|
||||||
| Provider | Why Not |
|
## New Capability Areas
|
||||||
|----------|---------|
|
|
||||||
| Authentik | Python-based, heavyweight (designed for enterprise proxy/SSO), overkill for app-level auth. No React SDK -- requires raw OIDC integration. Better for infra-level SSO (Portainer, Grafana). |
|
|
||||||
| Zitadel | Go-based, Kubernetes-first architecture, AGPL 3.0 license (copyleft since 2025). Stronger for multi-tenant B2B SaaS. Over-engineered for a single-product platform. |
|
|
||||||
| SuperTokens | Session-based by default (not OIDC), requires embedding their middleware into your backend. Tighter coupling than external provider model. |
|
|
||||||
| Keycloak | Java-based, heavy memory footprint (1-2GB RAM), complex admin UI. Industry standard but vastly over-scoped for this use case. |
|
|
||||||
|
|
||||||
**Integration pattern:** Logto runs as a separate Docker container alongside Postgres. React app redirects to Logto's hosted sign-in page for auth flows. Hono backend validates JWT access tokens from the Authorization header using `jose` JWKS verification -- no Logto SDK needed on the backend, just standard OIDC token validation. User identity is the Logto `sub` claim (a stable string ID), stored as `userId` on all user-owned records.
|
### 1. Public Access Auth Model
|
||||||
|
|
||||||
**Backend middleware pattern (Hono):**
|
**What's needed:** The `requireAuth` middleware in `src/server/middleware/auth.ts` already handles three auth paths (API key, OAuth Bearer, OIDC session). The skip-list pattern in `src/server/index.ts` already exempts public GETs on `/api/global-items`, `/api/tags`, `/api/users/:id/profile`, and `/api/setups/:id/public`.
|
||||||
|
|
||||||
|
**This milestone extends the skip-list** to cover new discovery endpoints (`/api/discovery/*`). Additionally, a new `tryAuth` middleware variant is needed for endpoints that work for both anonymous and authenticated users — it resolves `userId` if credentials are present but does NOT 401 on absence. This enables auth-aware responses (e.g., annotating feed items with "in your collection" for logged-in users).
|
||||||
|
|
||||||
|
**No new dependency.** Pure middleware logic — add `tryAuth` to `auth.ts`, update skip-list in `index.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Discovery Feed (Popular Setups, Trending Items)
|
||||||
|
|
||||||
|
The feed requires: ranked/scored queries, cursor-based pagination, and cheap repeated reads by anonymous users.
|
||||||
|
|
||||||
|
#### Trending Score
|
||||||
|
|
||||||
|
Use a hot-score computed in PostgreSQL SQL — no external search engine or materialized view needed at this scale.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Hacker News-style decay: engagement / time^gravity
|
||||||
|
SELECT id, brand, model,
|
||||||
|
(owner_count::float / power((extract(epoch from now()) - extract(epoch from created_at)) / 3600.0 + 2, 1.8)) AS hot_score
|
||||||
|
FROM global_items
|
||||||
|
ORDER BY hot_score DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires `ownerCount` as a real column (not a JOIN-time COUNT) on `globalItems`. The column already logically exists via join — promote it to a denormalized integer that the collection add/remove service path updates. No trigger needed; update it in the same database transaction as the collection operation.
|
||||||
|
|
||||||
|
**No new dependency.** Schema migration + service-layer update.
|
||||||
|
|
||||||
|
#### Cursor-Based Pagination
|
||||||
|
|
||||||
|
Drizzle ORM 0.45.x has documented cursor pagination support (two-column keyset). Use `(hotScore DESC, id DESC)` for the trending feed and `(createdAt DESC, id DESC)` for "recently added." Encode cursor as base64 JSON — opaque to the client.
|
||||||
|
|
||||||
|
The Hono + Drizzle cursor pattern is documented and actively used in the ecosystem. No pagination library needed.
|
||||||
|
|
||||||
|
**No new dependency.** Drizzle already supports this natively.
|
||||||
|
|
||||||
|
#### Full-Text Catalog Search
|
||||||
|
|
||||||
|
`globalItems` needs fast free-text search across `brand + model + description`. Use PostgreSQL native `tsvector` with a GIN index.
|
||||||
|
|
||||||
|
Drizzle 0.45.x does not generate `GENERATED ALWAYS AS ... STORED` syntax for tsvector columns in drizzle-kit. Add the `searchVector` column and GIN index via a raw SQL migration file (create via `drizzle-kit generate` then manually add the ALTER TABLE and CREATE INDEX statements to the generated file).
|
||||||
|
|
||||||
|
For the Hono route, use Drizzle's `sql` template tag with `to_tsquery`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
.where(sql`search_vector @@ plainto_tsquery('english', ${q})`)
|
||||||
|
.orderBy(sql`ts_rank(search_vector, plainto_tsquery('english', ${q})) DESC`)
|
||||||
|
```
|
||||||
|
|
||||||
const jwks = createRemoteJWKSet(
|
**No new dependency.** Schema migration + raw SQL in service layer.
|
||||||
new URL("https://logto.example.com/oidc/jwks")
|
|
||||||
);
|
|
||||||
|
|
||||||
const authMiddleware = createMiddleware(async (c, next) => {
|
#### Feed Client (TanStack Query + IntersectionObserver)
|
||||||
const token = c.req.header("Authorization")?.replace("Bearer ", "");
|
|
||||||
if (!token) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const { payload } = await jwtVerify(token, jwks, {
|
`useInfiniteQuery` from `@tanstack/react-query` (already at 5.90.x) handles cursor pagination natively via `getNextPageParam`. The scroll trigger uses the browser-native IntersectionObserver API — implement a `useIntersectionObserver(ref, callback)` hook (~12 lines) rather than adding a scroll library. This matches the existing GearBox pattern of minimal third-party UI dependencies.
|
||||||
issuer: "https://logto.example.com/oidc",
|
|
||||||
audience: "your-api-resource-indicator",
|
|
||||||
});
|
|
||||||
|
|
||||||
c.set("userId", payload.sub);
|
**No new dependency.**
|
||||||
await next();
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Catalog Enrichment Infrastructure
|
||||||
|
|
||||||
|
#### Schema Additions to `globalItems`
|
||||||
|
|
||||||
|
New fields for attribution, source tracking, and feed ranking:
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `sourceUrl` | `text` | Canonical product page (retailer or manufacturer) |
|
||||||
|
| `sourceAttribution` | `text` | Human-readable credit ("via REI", "via manufacturer") |
|
||||||
|
| `imageAttributionUrl` | `text` | URL where product image was originally sourced |
|
||||||
|
| `imageAttributionText` | `text` | License or credit line for the image |
|
||||||
|
| `submittedByUserId` | `integer FK → users` | Who submitted this catalog entry (null = seeded by admin/agent) |
|
||||||
|
| `verifiedAt` | `timestamp` | When an admin approved the entry (null = unverified) |
|
||||||
|
| `ownerCount` | `integer NOT NULL DEFAULT 0` | Denormalized count of collection items referencing this |
|
||||||
|
| `productUrl` | `text` | Retailer/manufacturer product link (duplicates item-level, but catalog-owned) |
|
||||||
|
|
||||||
|
These are Drizzle schema additions. **No new dependency.**
|
||||||
|
|
||||||
|
#### Zod Schemas for Enriched Catalog
|
||||||
|
|
||||||
|
Add `CreateCatalogItemSchema` in `src/shared/schemas.ts` with attribution fields. Zod 4.3.x handles this natively. The schema feeds the new `POST /api/global-items` route (currently only GET is public — writes will require auth but open to non-admins for catalog submissions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Agent-Powered Catalog Seeding via MCP
|
||||||
|
|
||||||
|
The existing MCP server (`@modelcontextprotocol/sdk` 1.29.x, 19 tools) already provides the infrastructure. The agent workflow:
|
||||||
|
|
||||||
|
1. Claude agent receives a category or brand as a prompt
|
||||||
|
2. Uses a new `create_catalog_item` MCP tool — purpose-built for `globalItems` insertion with full attribution fields
|
||||||
|
3. Server validates via Zod, inserts into `globalItems`, updates `ownerCount` denormalization
|
||||||
|
4. Agent uses the existing `upload_image_from_url` tool to fetch and store product images
|
||||||
|
|
||||||
|
The new tool registers identically to existing tools in `src/server/mcp/index.ts`. Batch seeding sessions: the agent runs N `create_catalog_item` calls in sequence within one MCP session — no parallel execution framework needed at catalog bootstrap scale.
|
||||||
|
|
||||||
|
For standalone seed scripts (`bun run src/db/dev-seed.ts` extensions), use the Drizzle db instance directly. No external seeding framework.
|
||||||
|
|
||||||
|
**No new dependency.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. HTTP Caching for Public Endpoints
|
||||||
|
|
||||||
|
Public GET endpoints (discovery feed, catalog detail pages) will be hit by anonymous users repeatedly. Add HTTP-level cache hints to reduce DB round-trips.
|
||||||
|
|
||||||
|
- **Catalog item detail pages** (`GET /api/global-items/:id`): Use Hono's built-in `etag()` middleware. Content-addressed — returns 304 Not Modified when item hasn't changed.
|
||||||
|
- **Discovery feed endpoints** (`GET /api/discovery/*`): Set `Cache-Control: public, max-age=60, stale-while-revalidate=300` manually in route handlers. Feed data tolerates 60s staleness.
|
||||||
|
|
||||||
|
**Do NOT use Hono's `cache()` middleware** — it is platform-specific to Cloudflare Workers and Deno, and silently does nothing on Bun. This is a documented limitation. Known issue #4401 in the Hono repo also shows the `etag()` middleware can generate inconsistent ETags when combining with other middleware — test in integration tests before shipping.
|
||||||
|
|
||||||
|
**No new dependency.** `etag` is built into Hono 4.12.x.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Rate Limiting for Public Traffic
|
||||||
|
|
||||||
|
The existing `rateLimit.ts` in-process Map handles auth endpoints correctly (5 req/15 min per IP). It is inappropriate for public discovery traffic because:
|
||||||
|
|
||||||
|
- 5 req/15 min is far too strict for anonymous browsing
|
||||||
|
- In-process state resets on server restart (tolerable for auth, wrong for general rate limiting)
|
||||||
|
- No way to differentiate authenticated vs anonymous callers in the current implementation
|
||||||
|
|
||||||
|
**Recommendation:** Keep the existing `rateLimit.ts` for auth endpoints only. Add `hono-rate-limiter` for discovery/catalog public endpoints with a permissive anonymous limit (e.g., 100 req/min per IP) and no limit for authenticated callers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
|
|
||||||
|
const discoveryLimiter = rateLimiter({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
limit: 100,
|
||||||
|
keyGenerator: (c) => c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use("/api/discovery/*", discoveryLimiter);
|
||||||
```
|
```
|
||||||
|
|
||||||
**React provider pattern:**
|
The in-process storage adapter (default in `hono-rate-limiter`) is sufficient for single-instance deployment. If the app scales horizontally, swap to `@hono-rate-limiter/redis` — but that is a future decision, not a v2.1 concern.
|
||||||
|
|
||||||
```typescript
|
**New dependency:**
|
||||||
import { LogtoProvider, LogtoConfig } from "@logto/react";
|
|
||||||
|
|
||||||
const config: LogtoConfig = {
|
| Library | Version | Purpose |
|
||||||
endpoint: "https://logto.example.com",
|
|---------|---------|---------|
|
||||||
appId: "<your-app-id>",
|
| `hono-rate-limiter` | `^0.5.3` | Per-route rate limiting with configurable windows for public endpoints |
|
||||||
resources: ["https://api.gearbox.example.com"],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap app root
|
|
||||||
<LogtoProvider config={config}>
|
|
||||||
<App />
|
|
||||||
</LogtoProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database -- PostgreSQL via Bun Native Driver
|
|
||||||
|
|
||||||
| Technology | Version | Purpose | Why Recommended |
|
|
||||||
|------------|---------|---------|-----------------|
|
|
||||||
| PostgreSQL | 16+ | Primary database | Required by Logto anyway, proper concurrent access for multi-user, JSONB for flexible spec fields, full-text search for discovery feed. |
|
|
||||||
| drizzle-orm | ^0.45.1 (existing) | Type-safe ORM | Already in use. Switch from `drizzle-orm/bun-sqlite` to `drizzle-orm/bun-sql` for Postgres. Schema definitions move from `sqlite-core` to `pg-core`. |
|
|
||||||
| Bun native SQL | built-in | Postgres driver | Zero additional dependencies. `import { SQL } from "bun"` provides native Postgres bindings. Drizzle ORM supports it via `drizzle-orm/bun-sql`. |
|
|
||||||
| postgres (postgres.js) | ^3.4.8 | Fallback Postgres driver | Only needed if Bun native SQL has issues with drizzle-kit CLI tooling (known issue #4122). More mature ecosystem, proven with Drizzle. Install as dev dependency for drizzle-kit. |
|
|
||||||
|
|
||||||
**Schema migration approach:**
|
|
||||||
|
|
||||||
1. Rewrite `src/db/schema.ts` imports from `drizzle-orm/sqlite-core` to `drizzle-orm/pg-core`
|
|
||||||
2. Replace `sqliteTable` with `pgTable`
|
|
||||||
3. Replace `integer().primaryKey({ autoIncrement: true })` with `integer().primaryKey().generatedAlwaysAsIdentity()` for PKs
|
|
||||||
4. Replace `integer("created_at", { mode: "timestamp" })` with `timestamp("created_at").defaultNow().notNull()`
|
|
||||||
5. Add `userId text("user_id").notNull()` to all user-owned tables (items, threads, setups, categories)
|
|
||||||
6. Add `visibility text("visibility").notNull().default("private")` to setups and profiles
|
|
||||||
7. Generate fresh Postgres migration with `drizzle-kit generate`
|
|
||||||
8. Write a one-time data migration script (SQLite read -> Postgres insert) for existing data
|
|
||||||
|
|
||||||
**drizzle.config.ts change:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
{ dialect: "sqlite", dbCredentials: { url: "./gearbox.db" } }
|
|
||||||
|
|
||||||
// After
|
|
||||||
{ dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL } }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Known issue:** drizzle-kit CLI does not use the Bun SQL driver for `push`/`generate` commands (GitHub issue #4122). Workaround: install `postgres` (postgres.js) as a dev dependency for drizzle-kit, while the app runtime uses Bun native SQL.
|
|
||||||
|
|
||||||
### Image Storage -- Bun Native S3 + MinIO
|
|
||||||
|
|
||||||
| Technology | Version | Purpose | Why Recommended |
|
|
||||||
|------------|---------|---------|-----------------|
|
|
||||||
| Bun S3Client | built-in | S3 API client | Zero dependencies, native Bun bindings, extends Blob interface. Supports presigned URLs, streaming uploads. Built-in MinIO compatibility. |
|
|
||||||
| MinIO | latest | Self-hosted S3-compatible object storage | Replaces local `./uploads/` directory. Single Go binary, Docker-friendly, S3 API compatible. Handles multi-user image scaling without cloud vendor lock-in. |
|
|
||||||
|
|
||||||
**Why Bun native S3 over @aws-sdk/client-s3:**
|
|
||||||
|
|
||||||
- Zero additional dependencies (Bun ships with it)
|
|
||||||
- Simpler API (extends Blob, web-standard patterns)
|
|
||||||
- Native performance bindings
|
|
||||||
- Full MinIO compatibility documented by Bun team
|
|
||||||
|
|
||||||
**Migration from ./uploads/:**
|
|
||||||
|
|
||||||
1. Deploy MinIO container alongside app
|
|
||||||
2. Create `gearbox-images` bucket
|
|
||||||
3. Write migration script to upload existing files from `./uploads/` to MinIO
|
|
||||||
4. Update image service to use S3Client for reads/writes
|
|
||||||
5. Serve images via presigned URLs or a proxy route on Hono
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { S3Client } from "bun";
|
|
||||||
|
|
||||||
const storage = new S3Client({
|
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
|
||||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
|
||||||
bucket: "gearbox-images",
|
|
||||||
endpoint: process.env.S3_ENDPOINT!, // e.g., http://minio:9000
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Supporting Libraries
|
|
||||||
|
|
||||||
| Library | Version | Purpose | When to Use |
|
|
||||||
|---------|---------|---------|-------------|
|
|
||||||
| jose | ^6.2.2 | JWKS-based JWT verification | Every authenticated API request -- validate Logto access tokens on Hono middleware |
|
|
||||||
| @logto/react | ^4.0.13 | React auth provider + hooks | Wrap app root, sign-in/sign-out flows, access token retrieval for API calls |
|
|
||||||
|
|
||||||
### Development / Infrastructure
|
|
||||||
|
|
||||||
| Tool | Purpose | Notes |
|
|
||||||
|------|---------|-------|
|
|
||||||
| Docker Compose | Local dev environment | Postgres + Logto + MinIO containers. App still runs on bare Bun for HMR. |
|
|
||||||
| drizzle-kit | Schema management | Same tool, different dialect config. `bun run db:generate` and `bun run db:push` still work. |
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# New production dependencies
|
bun add hono-rate-limiter
|
||||||
bun add @logto/react jose
|
|
||||||
|
|
||||||
# New dev dependencies (for drizzle-kit Postgres support)
|
|
||||||
bun add -D postgres
|
|
||||||
|
|
||||||
# No install needed for:
|
|
||||||
# - Bun native S3 (built-in)
|
|
||||||
# - Bun native SQL/Postgres (built-in)
|
|
||||||
# - drizzle-orm (already installed, just change imports)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Stack Additions Summary
|
||||||
|
|
||||||
|
### New Dependencies (v2.1 only)
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why |
|
||||||
|
|---------|---------|---------|-----|
|
||||||
|
| `hono-rate-limiter` | `^0.5.3` | Configurable rate limits for public discovery routes | Existing in-process limiter is auth-only with a 5-req cap; public browse traffic needs separate, permissive limits |
|
||||||
|
|
||||||
|
### No New Dependencies Needed For
|
||||||
|
|
||||||
|
| Capability | Existing Stack Component Used |
|
||||||
|
|------------|------------------------------|
|
||||||
|
| Public auth model (`tryAuth` variant) | Hono middleware — no library |
|
||||||
|
| Discovery feed cursor pagination | Drizzle 0.45.x cursor pagination docs |
|
||||||
|
| Full-text catalog search (tsvector GIN) | PostgreSQL native + Drizzle `sql` template |
|
||||||
|
| Trending score computation | PostgreSQL SQL expression — no extension |
|
||||||
|
| Infinite scroll client | TanStack Query `useInfiniteQuery` + native IntersectionObserver |
|
||||||
|
| Catalog attribution fields | Drizzle schema migration |
|
||||||
|
| Agent catalog seeding | Existing MCP SDK + new `create_catalog_item` tool |
|
||||||
|
| HTTP cache headers | Hono built-in `etag()` + manual `Cache-Control` |
|
||||||
|
| Feed ranking denormalization | Service-layer transaction update (no trigger, no cron) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Changes Required (Not Library Changes)
|
||||||
|
|
||||||
|
These are Drizzle schema additions generating migrations:
|
||||||
|
|
||||||
|
### `globalItems` additions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In src/db/schema.ts — globalItems table additions
|
||||||
|
sourceUrl: text("source_url"),
|
||||||
|
sourceAttribution: text("source_attribution"),
|
||||||
|
imageAttributionUrl: text("image_attribution_url"),
|
||||||
|
imageAttributionText: text("image_attribution_text"),
|
||||||
|
submittedByUserId: integer("submitted_by_user_id").references(() => users.id),
|
||||||
|
verifiedAt: timestamp("verified_at"),
|
||||||
|
ownerCount: integer("owner_count").notNull().default(0),
|
||||||
|
productUrl: text("product_url"),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raw SQL migration additions (cannot be expressed in Drizzle schema)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Add after Drizzle-generated migration runs:
|
||||||
|
|
||||||
|
-- Generated tsvector column for full-text search
|
||||||
|
ALTER TABLE global_items
|
||||||
|
ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('english',
|
||||||
|
coalesce(brand, '') || ' ' ||
|
||||||
|
coalesce(model, '') || ' ' ||
|
||||||
|
coalesce(description, '')
|
||||||
|
)
|
||||||
|
) STORED;
|
||||||
|
|
||||||
|
CREATE INDEX global_items_search_vector_idx ON global_items USING GIN(search_vector);
|
||||||
|
|
||||||
|
-- Partial index for public setup discovery feed
|
||||||
|
CREATE INDEX setups_public_updated_idx ON setups (updated_at DESC) WHERE is_public = true;
|
||||||
|
|
||||||
|
-- Trending feed index
|
||||||
|
CREATE INDEX global_items_owner_count_id_idx ON global_items (owner_count DESC, id DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Drizzle Kit does not generate `GENERATED ALWAYS AS ... STORED` for tsvector. Add these as a separate raw SQL file appended to the Drizzle migration or as a separate `customMigration` file in the migrations folder. Run via `bun run db:push` after the Drizzle migration applies.
|
||||||
|
|
||||||
|
### `setups` additions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In src/db/schema.ts — setups table additions
|
||||||
|
viewCount: integer("view_count").notNull().default(0),
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
### Authentication Provider
|
| Recommended | Alternative | Why Not |
|
||||||
|
|-------------|-------------|---------|
|
||||||
|
| PostgreSQL tsvector + GIN | Meilisearch / Typesense | Separate search service adds infra ops complexity; tsvector covers structured gear catalog search at GearBox scale without additional containers |
|
||||||
|
| PostgreSQL tsvector + GIN | pg_textsearch (BM25 extension) | Requires installing a PostgreSQL extension in production; BM25 ranking is unnecessary for a catalog of branded products where exact brand/model matches dominate |
|
||||||
|
| Denormalized `ownerCount` column | COUNT JOIN per feed request | Feed queries fire on every anonymous page load; a JOIN COUNT becomes a bottleneck before any other part of the stack does |
|
||||||
|
| Native IntersectionObserver hook | react-infinite-scroll-component | Zero-dependency — 12-line hook replaces an 8KB library; consistent with GearBox's minimal-external-dependency UI philosophy |
|
||||||
|
| Manual `Cache-Control` headers | Hono `cache()` middleware | Hono `cache()` is Cloudflare Workers/Deno only — silently does nothing on Bun |
|
||||||
|
| `hono-rate-limiter` in-process | Redis-backed rate limiter | Single-instance deployment — Redis adds an infra dependency not justified at current scale |
|
||||||
|
| Extend existing MCP toolset | Separate seeding CLI script | MCP agents already have auth and structured tool calling; a dedicated `create_catalog_item` tool is cleaner than a one-off script that bypasses the service layer |
|
||||||
|
| Service-layer `ownerCount` update | PostgreSQL trigger | Triggers are invisible to the TypeScript codebase, harder to test, and prone to silent failures in complex transactions |
|
||||||
|
|
||||||
| Recommended | Alternative | When to Use Alternative |
|
---
|
||||||
|-------------|-------------|-------------------------|
|
|
||||||
| Logto | Authentik | If you need proxy-mode SSO for non-OIDC apps (Portainer, legacy tools) |
|
|
||||||
| Logto | Zitadel | If building multi-tenant B2B SaaS with organization-level isolation |
|
|
||||||
| Logto | Keycloak | If enterprise LDAP/AD integration is mandatory |
|
|
||||||
|
|
||||||
### Database Driver
|
|
||||||
|
|
||||||
| Recommended | Alternative | When to Use Alternative |
|
|
||||||
|-------------|-------------|-------------------------|
|
|
||||||
| Bun native SQL (`bun:sql`) | postgres.js | If Bun native SQL has concurrency bugs (known issue in Bun 1.2.0 with concurrent statements) |
|
|
||||||
| Bun native SQL (`bun:sql`) | @neondatabase/serverless | If deploying to serverless/edge where persistent connections are not possible |
|
|
||||||
|
|
||||||
### Image Storage
|
|
||||||
|
|
||||||
| Recommended | Alternative | When to Use Alternative |
|
|
||||||
|-------------|-------------|-------------------------|
|
|
||||||
| MinIO (self-hosted) | Cloudflare R2 | If you want zero-ops storage with no egress fees and don't mind cloud dependency |
|
|
||||||
| MinIO (self-hosted) | Local filesystem (current) | For development/testing only. Not viable for multi-user at scale. |
|
|
||||||
|
|
||||||
## What NOT to Add
|
## What NOT to Add
|
||||||
|
|
||||||
| Avoid | Why | Use Instead |
|
| Avoid | Why | Use Instead |
|
||||||
|-------|-----|-------------|
|
|-------|-----|-------------|
|
||||||
| @aws-sdk/client-s3 | 60+ transitive dependencies, Bun has native S3 support | Bun built-in S3Client |
|
| Elasticsearch / OpenSearch | Separate cluster, ops overhead, overkill for a structured product catalog | PostgreSQL tsvector with GIN index |
|
||||||
| passport.js / express-session | Wrong paradigm -- we want external OIDC, not embedded session auth | Logto + jose JWT validation |
|
| pg_textsearch / VectorChord-BM25 | PostgreSQL extension install required in prod; BM25 precision unnecessary for brand+model search | PostgreSQL native `ts_rank` |
|
||||||
| next-auth / auth.js | Designed for Next.js, assumes framework integration we don't have | Logto (external provider) |
|
| Hono `cache()` middleware | Platform-specific to Cloudflare/Deno; does nothing on Bun | Manual `Cache-Control` headers in route handlers |
|
||||||
| better-auth | Embedded auth library, opposite of external provider model | Logto (external provider) |
|
| react-virtual / windowing | Feed is paginated, not a virtual list; items per page (~20) never hit DOM performance limits | Standard DOM list with cursor pagination |
|
||||||
| pg (node-postgres) | Callback-based API, Bun has native Postgres bindings | Bun native SQL or postgres.js |
|
| Prisma | Already using Drizzle ORM; two ORMs in one codebase is a maintenance trap | drizzle-orm (existing) |
|
||||||
| sharp / image processing libs | Premature optimization -- serve originals first, add resizing later if needed | Direct S3 storage of originals |
|
| Materialized views for feed caching | drizzle-kit does not fully support materialized view migrations; manual REFRESH logic is brittle | Denormalized score columns + partial indexes |
|
||||||
| Redis | Not needed at this scale. Postgres handles sessions (via Logto), caching is premature | Postgres for everything |
|
| Separate seeding framework (Faker, etc.) | Catalog data is real product data, not fake; agent seeding produces real structured records | MCP `create_catalog_item` tool |
|
||||||
| Prisma | Already using Drizzle ORM, no reason to add a second ORM | drizzle-orm (existing) |
|
|
||||||
| nanoid / cuid2 | Postgres `gen_random_uuid()` is built-in for public-facing IDs if needed | Postgres native UUID generation |
|
|
||||||
| TypeORM / Sequelize | Legacy ORMs with worse TypeScript support than Drizzle | drizzle-orm (existing) |
|
|
||||||
|
|
||||||
## Infrastructure Architecture
|
---
|
||||||
|
|
||||||
```
|
|
||||||
Docker Compose (dev) / Docker (prod)
|
|
||||||
+-- gearbox-app (Bun, port 3000)
|
|
||||||
+-- gearbox-postgres (PostgreSQL 16, port 5432)
|
|
||||||
| +-- gearbox DB (app data)
|
|
||||||
| +-- logto DB (Logto data, separate database same instance)
|
|
||||||
+-- gearbox-logto (Logto OSS, port 3001 app / 3002 admin)
|
|
||||||
+-- gearbox-minio (MinIO, port 9000 API / 9001 console)
|
|
||||||
```
|
|
||||||
|
|
||||||
Logto and the app share a single Postgres instance (different databases). This keeps infrastructure simple -- one Postgres to back up, one to monitor. Logto requires PostgreSQL 14+; using 16 covers both.
|
|
||||||
|
|
||||||
## Version Compatibility
|
## Version Compatibility
|
||||||
|
|
||||||
| Package | Compatible With | Notes |
|
| Package | Current Version | v2.1 Notes |
|
||||||
|---------|-----------------|-------|
|
|---------|----------------|------------|
|
||||||
| drizzle-orm@0.45.x | Bun native SQL | Supported via `drizzle-orm/bun-sql` driver |
|
| `hono` | 4.12.x (4.12.12 latest) | `etag()` built-in available; `cache()` is NOT compatible with Bun — do not use |
|
||||||
| drizzle-orm@0.45.x | postgres.js@3.4.x | Supported via `drizzle-orm/postgres-js` driver (fallback) |
|
| `drizzle-orm` | 0.45.x (0.45.2 latest stable) | Cursor pagination confirmed; generated tsvector column requires raw SQL migration appended to drizzle-kit output |
|
||||||
| drizzle-kit@0.31.x | PostgreSQL 16 | Generates Postgres-dialect migrations |
|
| `@tanstack/react-query` | 5.90.x | `useInfiniteQuery` with `getNextPageParam` fully supports cursor pattern natively |
|
||||||
| @logto/react@4.x | React 19 | Uses React context/hooks, compatible |
|
| `hono-rate-limiter` | 0.5.3 (latest, published ~16 days ago) | In-process storage adapter works on Bun; actively maintained |
|
||||||
| jose@6.x | Bun runtime | Explicitly lists Bun support in docs |
|
| `@modelcontextprotocol/sdk` | 1.29.x | Existing MCP tooling is sufficient for adding `create_catalog_item` |
|
||||||
| Logto OSS v1.36 | PostgreSQL 14+ | Logto requires PG 14 minimum; use PG 16 for both app and Logto |
|
| `zod` | 4.3.x | New catalog attribution schemas are straightforward additions to existing `schemas.ts` |
|
||||||
| Bun S3Client | MinIO latest | Documented compatibility with endpoint configuration |
|
| `@hono/zod-validator` | 0.7.x | Already used for all routes; covers new discovery/catalog endpoints |
|
||||||
|
|
||||||
## Migration Checklist (SQLite to Postgres)
|
---
|
||||||
|
|
||||||
1. **Schema rewrite**: `sqlite-core` -> `pg-core` imports, adjust column types
|
## Installation
|
||||||
2. **Driver swap**: `drizzle-orm/bun-sqlite` -> `drizzle-orm/bun-sql`
|
|
||||||
3. **Config update**: `drizzle.config.ts` dialect and credentials
|
```bash
|
||||||
4. **Fresh migrations**: Generate from scratch for Postgres (do not try to convert SQLite migrations)
|
# Only one new package for v2.1
|
||||||
5. **Data migration**: One-time script reads SQLite, writes to Postgres
|
bun add hono-rate-limiter
|
||||||
6. **Test infrastructure**: Update `createTestDb()` helper to use Postgres test database (or pg-mem for in-memory testing)
|
```
|
||||||
7. **CI pipeline**: Add Postgres service container for test runs
|
|
||||||
8. **Remove SQLite deps**: Remove `better-sqlite3` from devDependencies after migration confirmed
|
Everything else is schema migrations, new service/route/middleware code, and one new MCP tool — all on the existing stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- [Logto official docs -- React quickstart](https://docs.logto.io/quick-starts/react) -- SDK setup, LogtoProvider config (HIGH confidence)
|
- [Drizzle ORM cursor-based pagination](https://orm.drizzle.team/docs/guides/cursor-based-pagination) — two-column keyset pattern, v0.45.x confirmed (HIGH)
|
||||||
- [Logto API protection -- JWT validation](https://docs.logto.io/api-protection/nodejs/express) -- jose-based middleware pattern (HIGH confidence)
|
- [Drizzle ORM PostgreSQL full-text search](https://orm.drizzle.team/docs/guides/postgresql-full-text-search) — tsvector approach confirmed (HIGH)
|
||||||
- [Logto OSS getting started](https://docs.logto.io/logto-oss/get-started-with-oss) -- Docker deployment, Postgres requirements (HIGH confidence)
|
- [Drizzle ORM full-text search with generated columns](https://orm.drizzle.team/docs/guides/full-text-search-with-generated-columns) — generated column pattern for tsvector (HIGH)
|
||||||
- [Logto @logto/react npm](https://www.npmjs.com/package/@logto/react) -- Version 4.0.13 confirmed (HIGH confidence)
|
- [Hono ETag middleware](https://hono.dev/docs/middleware/builtin/etag) — built-in, no install required (HIGH)
|
||||||
- [Drizzle ORM -- Bun SQL driver](https://orm.drizzle.team/docs/connect-bun-sql) -- Native Postgres via Bun (HIGH confidence)
|
- [Hono Cache middleware](https://hono.dev/docs/middleware/builtin/cache) — explicitly listed as Cloudflare/Deno only, not Bun (HIGH)
|
||||||
- [Drizzle ORM -- PostgreSQL column types](https://orm.drizzle.team/docs/column-types/pg) -- pg-core schema definitions (HIGH confidence)
|
- [Hono ETag issue #4401](https://github.com/honojs/hono/issues/4401) — known inconsistency bug in etag middleware (MEDIUM)
|
||||||
- [drizzle-kit Bun SQL issue #4122](https://github.com/drizzle-team/drizzle-orm/issues/4122) -- Known CLI limitation with Bun driver (MEDIUM confidence)
|
- [hono-rate-limiter GitHub](https://github.com/rhinobase/hono-rate-limiter) — v0.5.3, active, Bun compatible (HIGH)
|
||||||
- [Bun S3 documentation](https://bun.com/docs/runtime/s3) -- Native S3 client, MinIO config (HIGH confidence)
|
- [hono-rate-limiter npm](https://www.npmjs.com/package/hono-rate-limiter) — version 0.5.3 confirmed (HIGH)
|
||||||
- [MinIO GitHub](https://github.com/minio/minio) -- S3-compatible self-hosted storage (HIGH confidence)
|
- [TanStack Query infinite queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries) — `useInfiniteQuery` cursor pattern (HIGH)
|
||||||
- [jose GitHub](https://github.com/panva/jose) -- JWT library v6.2.2, explicit Bun support (HIGH confidence)
|
- [Drizzle ORM materialized views issue #2653](https://github.com/drizzle-team/drizzle-orm/issues/2653) — confirmed drizzle-kit does not fully support materialized view migrations (MEDIUM)
|
||||||
- [Authentik vs Zitadel comparison](https://wz-it.com/en/blog/authentik-vs-zitadel-identity-provider-comparison/) -- Auth provider analysis (MEDIUM confidence)
|
- [Hono middleware docs](https://hono.dev/docs/guides/middleware) — selective auth middleware pattern (HIGH)
|
||||||
- [Keycloak vs Authentik vs Zitadel 2026](https://blog.houseoffoss.com/post/keycloak-vs-authentik-vs-zitadel-2026-which-open-source-login-tool-should-you-use) -- Ecosystem overview (MEDIUM confidence)
|
- GearBox `package.json` — all existing dependency versions verified directly (HIGH)
|
||||||
- [postgres.js npm](https://www.npmjs.com/package/postgres) -- Version 3.4.8, fallback driver (HIGH confidence)
|
- GearBox `src/server/index.ts` — existing skip-list pattern verified directly (HIGH)
|
||||||
|
- GearBox `src/server/middleware/auth.ts` — existing three-way auth verified directly (HIGH)
|
||||||
|
- GearBox `src/db/schema.ts` — existing `globalItems` table columns verified directly (HIGH)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Stack research for: GearBox v2.0 Platform Foundation*
|
|
||||||
*Researched: 2026-04-03*
|
*Stack research for: GearBox v2.1 Public Discovery milestone*
|
||||||
|
*Researched: 2026-04-09*
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
created: 2026-04-10T09:17:31.682Z
|
||||||
|
title: Add cursor pointer to all clickable links
|
||||||
|
area: ui
|
||||||
|
files: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Clickable links across multiple pages in the app do not consistently show `cursor: pointer` styling. Users expect the cursor to change to a pointer when hovering over clickable elements, but some links/buttons may use default cursor. This needs a sweep across all pages to ensure consistent hover behavior.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Audit all pages for clickable elements (links, buttons, interactive spans/divs) and ensure they have `cursor: pointer` via Tailwind's `cursor-pointer` class or appropriate element semantics (`<a>`, `<button>` naturally get pointer cursor). Focus on custom clickable elements that may be using `<div>` or `<span>` with onClick handlers.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
created: 2026-04-10T09:23:46.394Z
|
||||||
|
title: Add manufacturer entity with brand details
|
||||||
|
area: database
|
||||||
|
files:
|
||||||
|
- src/db/schema.ts
|
||||||
|
- src/server/services/global-item.service.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The manual item adding form doesn't include detailed manufacturer info. Currently `brand` is just a text field on items and globalItems. There's no structured manufacturer data (logo, website, country, description). Users can't select from existing manufacturers — they type freeform text which leads to inconsistencies ("Ortlieb" vs "ORTLIEB" vs "ortlieb").
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Create a `manufacturers` table with fields: `id`, `name` (unique), `logoUrl`, `websiteUrl`, `country`, `description`, `createdAt`. Add a `manufacturerId` FK on `globalItems` (and potentially `items`). Build a manufacturer picker component (like CategoryPicker) with search and inline create. Update the manual add form and catalog enrichment to use the manufacturer entity. This is a significant feature — likely needs its own phase.
|
||||||
|
|
||||||
|
Note: This would also improve the MCP agent seeding workflow — agents could create manufacturers first, then reference them when creating catalog items.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
created: 2026-04-10T09:23:46.394Z
|
||||||
|
title: Fix item image not showing on collection overview
|
||||||
|
area: ui
|
||||||
|
files:
|
||||||
|
- src/client/routes/collection.tsx
|
||||||
|
- src/client/components/ItemCard.tsx
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When adding an image to an item, the image is not displayed on the collection overview page (grid/list view). The image only appears when viewing the item's detail page. This suggests the collection overview query or component isn't fetching/passing the image URL, or the card component isn't rendering it.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Check the collection overview query — does it include `imageUrl`/`imageFilename` in the response? Check the ItemCard component — does it render the image when present? May need to ensure the presigned S3 URL is being generated for the collection list endpoint, not just the detail endpoint.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
created: 2026-04-10T09:23:46.394Z
|
||||||
|
title: Fix storage service tests
|
||||||
|
area: testing
|
||||||
|
files:
|
||||||
|
- tests/services/storage.service.test.ts
|
||||||
|
- src/server/services/storage.service.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
15 tests in the storage service test file are failing with `SyntaxError: Export named 'withImageUrl' not found in module 'src/server/services/storage.service.ts'`. This is a pre-existing issue that predates Phase 25. The `withImageUrl` export was likely renamed or removed but tests still reference it.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Investigate the `storage.service.ts` exports, find what `withImageUrl` was renamed to (possibly `withImageUrls` based on recent Phase 24 commit `e1afd54`), and update the test imports to match.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
created: 2026-04-10T09:23:46.394Z
|
||||||
|
title: Investigate slow image loading
|
||||||
|
area: ui
|
||||||
|
files:
|
||||||
|
- src/server/services/storage.service.ts
|
||||||
|
- src/client/components/ItemCard.tsx
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Images seem to load slowly across the app. This could be due to presigned URL generation happening on every request, large original images being served without resizing, or no caching headers on S3 responses.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Investigate: Are presigned URLs being cached or regenerated on every API call? Are images being served at original resolution? Consider: presigned URL caching with TTL, image thumbnails for list views, appropriate Cache-Control headers on S3 objects, lazy loading for off-screen images.
|
||||||
4
drizzle-pg/0003_loving_serpent_society.sql
Normal file
4
drizzle-pg/0003_loving_serpent_society.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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 "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model");
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1775413526643,
|
"when": 1775413526643,
|
||||||
"tag": "0002_wakeful_vermin",
|
"tag": "0002_wakeful_vermin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775811339957,
|
||||||
|
"tag": "0003_loving_serpent_society",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1
graphify-out/.graphify_python
Normal file
1
graphify-out/.graphify_python
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/home/jean-luc-makiola/.local/share/pipx/venvs/graphifyy/bin/python
|
||||||
1
graphify-out/needs_update
Normal file
1
graphify-out/needs_update
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
45
src/client/components/AuthPromptModal.tsx
Normal file
45
src/client/components/AuthPromptModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function AuthPromptModal() {
|
||||||
|
const showAuthPrompt = useUIStore((s) => s.showAuthPrompt);
|
||||||
|
const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt);
|
||||||
|
|
||||||
|
if (!showAuthPrompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/30"
|
||||||
|
onClick={closeAuthPrompt}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") closeAuthPrompt();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Join GearBox
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
To manage your own collection, sign in or sign up.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="w-full text-center px-4 py-2.5 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
onClick={closeAuthPrompt}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="w-full text-center px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
onClick={closeAuthPrompt}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ interface PublicSetupCardProps {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
itemCount?: number;
|
||||||
|
creatorName?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +24,24 @@ export function PublicSetupCard({ setup }: PublicSetupCardProps) {
|
|||||||
<Link
|
<Link
|
||||||
to="/setups/$setupId"
|
to="/setups/$setupId"
|
||||||
params={{ setupId: String(setup.id) }}
|
params={{ setupId: String(setup.id) }}
|
||||||
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4"
|
className="block bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-md transition-all p-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
||||||
{setup.name}
|
{setup.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-400 mt-1">{formattedDate}</p>
|
{setup.creatorName && (
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">by {setup.creatorName}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{setup.itemCount != null && setup.itemCount > 0 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
|
{setup.itemCount} {setup.itemCount === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">{formattedDate}</p>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/client/hooks/useDiscovery.ts
Normal file
65
src/client/hooks/useDiscovery.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
|
||||||
|
export interface DiscoverySetup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
itemCount: number;
|
||||||
|
creatorName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveryCategory {
|
||||||
|
name: string;
|
||||||
|
itemCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalItem {
|
||||||
|
id: number;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
category: string | null;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
imageCredit: string | null;
|
||||||
|
imageSourceUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CursorPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoverySetups(limit = 6) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["discovery", "setups", limit],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<CursorPage<DiscoverySetup>>(
|
||||||
|
`/api/discovery/setups?limit=${limit}`,
|
||||||
|
),
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryItems(limit = 8) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["discovery", "items", limit],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<CursorPage<GlobalItem>>(`/api/discovery/items?limit=${limit}`),
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscoveryCategories(limit = 12) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["discovery", "categories", limit],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<DiscoveryCategory[]>(`/api/discovery/categories?limit=${limit}`),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ export function useUpdateSetting() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOnboardingComplete() {
|
export function useOnboardingComplete(enabled = true) {
|
||||||
return useSetting("onboardingComplete");
|
return useQuery({
|
||||||
|
queryKey: ["settings", "onboardingComplete"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiGet<Setting>(
|
||||||
|
`/api/settings/onboardingComplete`,
|
||||||
|
);
|
||||||
|
return result.value;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.status === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export function useSetup(setupId: number | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePublicSetup(setupId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["setups", setupId, "public"],
|
||||||
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}/public`),
|
||||||
|
enabled: setupId != null,
|
||||||
|
retry: (count, error) =>
|
||||||
|
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateSetup() {
|
export function useCreateSetup() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Toaster } from "sonner";
|
|||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||||
import { AddToThreadModal } from "../components/AddToThreadModal";
|
import { AddToThreadModal } from "../components/AddToThreadModal";
|
||||||
|
import { AuthPromptModal } from "../components/AuthPromptModal";
|
||||||
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||||
@@ -94,7 +95,7 @@ function RootLayout() {
|
|||||||
|
|
||||||
// Onboarding — only check when authenticated (endpoint requires auth)
|
// Onboarding — only check when authenticated (endpoint requires auth)
|
||||||
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
||||||
useOnboardingComplete();
|
useOnboardingComplete(isAuthenticated);
|
||||||
const [wizardDismissed, setWizardDismissed] = useState(false);
|
const [wizardDismissed, setWizardDismissed] = useState(false);
|
||||||
|
|
||||||
// Don't show onboarding wizard until user has created an account
|
// Don't show onboarding wizard until user has created an account
|
||||||
@@ -117,40 +118,21 @@ function RootLayout() {
|
|||||||
|
|
||||||
const totalsBarProps = isDashboard ? {} : { linkTo: "/" };
|
const totalsBarProps = isDashboard ? {} : { linkTo: "/" };
|
||||||
|
|
||||||
// Show loading while checking auth
|
|
||||||
if (authLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect unauthenticated users to login (server-side OIDC route)
|
|
||||||
// Allow public routes through without auth
|
// Allow public routes through without auth
|
||||||
const isPublicRoute =
|
const isPublicRoute =
|
||||||
location.pathname.startsWith("/users/") || location.pathname === "/login";
|
location.pathname === "/" ||
|
||||||
|
location.pathname.startsWith("/users/") ||
|
||||||
|
location.pathname.startsWith("/global-items") ||
|
||||||
|
location.pathname.startsWith("/setups/") ||
|
||||||
|
location.pathname === "/login";
|
||||||
|
|
||||||
// FAB visibility: show on all authenticated, non-public routes
|
// FAB visibility: show on all authenticated, non-public routes
|
||||||
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
|
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
|
||||||
const showFab = isAuthenticated && !isPublicRoute;
|
const showFab = isAuthenticated && !isPublicRoute;
|
||||||
|
|
||||||
if (!isAuthenticated && !isPublicRoute) {
|
if (!isAuthenticated && !isPublicRoute && !authLoading) {
|
||||||
window.location.href = "/login";
|
navigate({ to: "/login" });
|
||||||
return (
|
return null;
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<p className="text-sm text-gray-500">Redirecting to login...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading while checking onboarding status
|
|
||||||
if (onboardingLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="w-6 h-6 border-2 border-gray-600 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,6 +180,9 @@ function RootLayout() {
|
|||||||
<AddToThreadModal />
|
<AddToThreadModal />
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
|
|
||||||
|
{/* Auth Prompt Modal */}
|
||||||
|
<AuthPromptModal />
|
||||||
|
|
||||||
{/* Onboarding Wizard */}
|
{/* Onboarding Wizard */}
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import { useGlobalItem } from "../../hooks/useGlobalItems";
|
import { useGlobalItem } from "../../hooks/useGlobalItems";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
@@ -11,8 +12,11 @@ function GlobalItemDetail() {
|
|||||||
const { globalItemId } = Route.useParams();
|
const { globalItemId } = Route.useParams();
|
||||||
const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId));
|
const { data: item, isLoading, error } = useGlobalItem(Number(globalItemId));
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
|
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
|
||||||
const openAddToThread = useUIStore((s) => s.openAddToThread);
|
const openAddToThread = useUIStore((s) => s.openAddToThread);
|
||||||
|
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -58,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}
|
||||||
@@ -80,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">
|
||||||
@@ -133,18 +156,26 @@ function GlobalItemDetail() {
|
|||||||
<div className="flex gap-3 mb-6">
|
<div className="flex gap-3 mb-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
openAddToCollection(item.id, `${item.brand} ${item.model}`)
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
openAddToCollection(item.id, `${item.brand} ${item.model}`);
|
||||||
|
}}
|
||||||
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
|
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
Add to Collection
|
Add to Collection
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
openAddToThread(item.id, `${item.brand} ${item.model}`)
|
if (!isAuthenticated) {
|
||||||
|
openAuthPrompt();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
openAddToThread(item.id, `${item.brand} ${item.model}`);
|
||||||
|
}}
|
||||||
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
|
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
Add to Thread
|
Add to Thread
|
||||||
@@ -157,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 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,191 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { DashboardCard } from "../components/DashboardCard";
|
import { Search } from "lucide-react";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { GlobalItemCard } from "../components/GlobalItemCard";
|
||||||
import { useSetups } from "../hooks/useSetups";
|
import { PublicSetupCard } from "../components/PublicSetupCard";
|
||||||
import { useThreads } from "../hooks/useThreads";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import {
|
||||||
|
useDiscoveryCategories,
|
||||||
|
useDiscoveryItems,
|
||||||
|
useDiscoverySetups,
|
||||||
|
} from "../hooks/useDiscovery";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: DashboardPage,
|
component: LandingPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function DashboardPage() {
|
function LandingPage() {
|
||||||
const { data: totals } = useTotals();
|
const { data: auth } = useAuth();
|
||||||
const { data: threads } = useThreads(false);
|
const isAuthenticated = !!auth?.user;
|
||||||
const { data: setups } = useSetups();
|
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||||
const { weight, price } = useFormatters();
|
|
||||||
|
|
||||||
const global = totals?.global;
|
|
||||||
const activeThreadCount = threads?.length ?? 0;
|
|
||||||
const setupCount = setups?.length ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<HeroSection
|
||||||
<DashboardCard
|
isAuthenticated={isAuthenticated}
|
||||||
to="/collection"
|
onSearchFocus={() => openCatalogSearch("collection")}
|
||||||
title="Collection"
|
|
||||||
icon="backpack"
|
|
||||||
stats={[
|
|
||||||
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
|
||||||
{
|
|
||||||
label: "Weight",
|
|
||||||
value: weight(global?.totalWeight ?? null),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cost",
|
|
||||||
value: price(global?.totalCost ?? null),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
emptyText="Get started"
|
|
||||||
/>
|
/>
|
||||||
<DashboardCard
|
<PopularSetupsSection />
|
||||||
to="/collection"
|
<RecentItemsSection />
|
||||||
search={{ tab: "planning" }}
|
<TrendingCategoriesSection />
|
||||||
title="Planning"
|
</div>
|
||||||
icon="search"
|
);
|
||||||
stats={[
|
}
|
||||||
{ label: "Active threads", value: String(activeThreadCount) },
|
|
||||||
]}
|
function HeroSection({
|
||||||
/>
|
isAuthenticated,
|
||||||
<DashboardCard
|
onSearchFocus,
|
||||||
to="/collection"
|
}: {
|
||||||
search={{ tab: "setups" }}
|
isAuthenticated: boolean;
|
||||||
title="Setups"
|
onSearchFocus: () => void;
|
||||||
icon="tent"
|
}) {
|
||||||
stats={[{ label: "Setups", value: String(setupCount) }]}
|
return (
|
||||||
/>
|
<div className="text-center mb-16">
|
||||||
</div>
|
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
|
||||||
|
Discover Gear
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mb-8">Browse what other people carry</p>
|
||||||
|
<div
|
||||||
|
onClick={onSearchFocus}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSearchFocus()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="max-w-xl mx-auto flex items-center gap-3 px-4 py-3 bg-white rounded-xl border border-gray-200 hover:border-gray-300 cursor-pointer shadow-sm transition-all"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5 text-gray-400 shrink-0" />
|
||||||
|
<span className="text-sm text-gray-400 flex-1 text-left">
|
||||||
|
Search the catalog...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link
|
||||||
|
to="/collection"
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 underline underline-offset-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
Go to Collection
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopularSetupsSection() {
|
||||||
|
const { data, isLoading } = useDiscoverySetups(6);
|
||||||
|
const setups = data?.items ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && setups.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Popular Setups</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<SectionSkeleton count={6} aspect="none" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<PublicSetupCard key={setup.id} setup={setup} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentItemsSection() {
|
||||||
|
const { data, isLoading } = useDiscoveryItems(8);
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Recently Added</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<SectionSkeleton count={8} aspect="[4/3]" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<GlobalItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
brand={item.brand}
|
||||||
|
model={item.model}
|
||||||
|
category={item.category}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
imageUrl={item.imageUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendingCategoriesSection() {
|
||||||
|
const { data, isLoading } = useDiscoveryCategories(12);
|
||||||
|
const categories = data ?? [];
|
||||||
|
|
||||||
|
if (!isLoading && categories.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Trending Categories
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-8 w-24 bg-gray-100 rounded-full animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<span
|
||||||
|
key={cat.name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-gray-50 text-gray-700 border border-gray-100 hover:border-gray-200 hover:bg-gray-100 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
<span className="text-xs text-gray-400">{cat.itemCount}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionSkeleton({ count, aspect }: { count: number; aspect: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid ${aspect === "none" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"} gap-4`}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-white rounded-xl border border-gray-100 overflow-hidden animate-pulse"
|
||||||
|
>
|
||||||
|
{aspect !== "none" && (
|
||||||
|
<div className={`aspect-${aspect} bg-gray-100`} />
|
||||||
|
)}
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-100 rounded w-16" />
|
||||||
|
<div className="h-4 bg-gray-100 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
|||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import {
|
import {
|
||||||
useDeleteSetup,
|
useDeleteSetup,
|
||||||
|
usePublicSetup,
|
||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
useUpdateItemClassification,
|
useUpdateItemClassification,
|
||||||
@@ -23,7 +25,16 @@ function SetupDetailPage() {
|
|||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const numericId = Number(setupId);
|
const numericId = Number(setupId);
|
||||||
const { data: setup, isLoading } = useSetup(numericId);
|
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
|
||||||
|
const privateSetup = useSetup(isAuthenticated ? numericId : null);
|
||||||
|
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
|
||||||
|
const { data: setup, isLoading } = isAuthenticated
|
||||||
|
? privateSetup
|
||||||
|
: publicSetup;
|
||||||
|
|
||||||
const deleteSetup = useDeleteSetup();
|
const deleteSetup = useDeleteSetup();
|
||||||
const updateSetup = useUpdateSetup(numericId);
|
const updateSetup = useUpdateSetup(numericId);
|
||||||
const removeItem = useRemoveSetupItem(numericId);
|
const removeItem = useRemoveSetupItem(numericId);
|
||||||
@@ -140,7 +151,8 @@ function SetupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — only visible to authenticated users */}
|
||||||
|
{isAuthenticated && (
|
||||||
<div className="flex items-center gap-3 py-4">
|
<div className="flex items-center gap-3 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -196,6 +208,7 @@ function SetupDetailPage() {
|
|||||||
Delete Setup
|
Delete Setup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{itemCount === 0 && (
|
{itemCount === 0 && (
|
||||||
@@ -214,6 +227,7 @@ function SetupDetailPage() {
|
|||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
Add items from your collection to build this loadout.
|
Add items from your collection to build this loadout.
|
||||||
</p>
|
</p>
|
||||||
|
{isAuthenticated && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
@@ -221,6 +235,7 @@ function SetupDetailPage() {
|
|||||||
>
|
>
|
||||||
Add Items
|
Add Items
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -268,15 +283,22 @@ function SetupDetailPage() {
|
|||||||
imageFilename={item.imageFilename}
|
imageFilename={item.imageFilename}
|
||||||
imageUrl={item.imageUrl}
|
imageUrl={item.imageUrl}
|
||||||
productUrl={item.productUrl}
|
productUrl={item.productUrl}
|
||||||
onRemove={() => removeItem.mutate(item.id)}
|
onRemove={
|
||||||
|
isAuthenticated
|
||||||
|
? () => removeItem.mutate(item.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
classification={item.classification}
|
classification={item.classification}
|
||||||
onClassificationCycle={() =>
|
onClassificationCycle={
|
||||||
|
isAuthenticated
|
||||||
|
? () =>
|
||||||
updateClassification.mutate({
|
updateClassification.mutate({
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
classification: nextClassification(
|
classification: nextClassification(
|
||||||
item.classification,
|
item.classification,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -288,16 +310,18 @@ function SetupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Item Picker */}
|
{/* Item Picker — only for authenticated users */}
|
||||||
|
{isAuthenticated && (
|
||||||
<ItemPicker
|
<ItemPicker
|
||||||
setupId={numericId}
|
setupId={numericId}
|
||||||
currentItemIds={currentItemIds}
|
currentItemIds={currentItemIds}
|
||||||
isOpen={pickerOpen}
|
isOpen={pickerOpen}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog — only for authenticated users */}
|
||||||
{confirmDelete && (
|
{isAuthenticated && confirmDelete && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/30"
|
className="absolute inset-0 bg-black/30"
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ interface UIState {
|
|||||||
// Session thread tracking
|
// Session thread tracking
|
||||||
catalogSessionThreadId: number | null;
|
catalogSessionThreadId: number | null;
|
||||||
setCatalogSessionThreadId: (id: number | null) => void;
|
setCatalogSessionThreadId: (id: number | null) => void;
|
||||||
|
|
||||||
|
// Auth prompt modal
|
||||||
|
showAuthPrompt: boolean;
|
||||||
|
openAuthPrompt: () => void;
|
||||||
|
closeAuthPrompt: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -184,4 +189,9 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
// Session thread tracking
|
// Session thread tracking
|
||||||
catalogSessionThreadId: null,
|
catalogSessionThreadId: null,
|
||||||
setCatalogSessionThreadId: (id) => set({ catalogSessionThreadId: id }),
|
setCatalogSessionThreadId: (id) => set({ catalogSessionThreadId: id }),
|
||||||
|
|
||||||
|
// Auth prompt modal
|
||||||
|
showAuthPrompt: false,
|
||||||
|
openAuthPrompt: () => set({ showAuthPrompt: true }),
|
||||||
|
closeAuthPrompt: () => set({ showAuthPrompt: false }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ export const setupItems = pgTable("setup_items", {
|
|||||||
|
|
||||||
// ── Global Items ────────────────────────────────────────────────────
|
// ── Global Items ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const globalItems = pgTable("global_items", {
|
export const globalItems = pgTable(
|
||||||
|
"global_items",
|
||||||
|
{
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
brand: text("brand").notNull(),
|
brand: text("brand").notNull(),
|
||||||
model: text("model").notNull(),
|
model: text("model").notNull(),
|
||||||
@@ -142,8 +144,13 @@ export const globalItems = pgTable("global_items", {
|
|||||||
priceCents: integer("price_cents"),
|
priceCents: integer("price_cents"),
|
||||||
imageUrl: text("image_url"),
|
imageUrl: text("image_url"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
sourceUrl: text("source_url"),
|
||||||
|
imageCredit: text("image_credit"),
|
||||||
|
imageSourceUrl: text("image_source_url"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
},
|
||||||
|
(table) => [unique().on(table.brand, table.model)],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Tags ───────────────────────────────────────────────────────────
|
// ── Tags ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { db as prodDb } from "../db/index.ts";
|
|||||||
import { seedDefaults } from "../db/seed.ts";
|
import { seedDefaults } from "../db/seed.ts";
|
||||||
import { mcpRoutes } from "./mcp/index.ts";
|
import { mcpRoutes } from "./mcp/index.ts";
|
||||||
import { requireAuth } from "./middleware/auth.ts";
|
import { requireAuth } from "./middleware/auth.ts";
|
||||||
|
import { createRateLimit } from "./middleware/rateLimit.ts";
|
||||||
import { authRoutes } from "./routes/auth.ts";
|
import { authRoutes } from "./routes/auth.ts";
|
||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
|
import { discoveryRoutes } from "./routes/discovery.ts";
|
||||||
import { globalItemRoutes } from "./routes/global-items.ts";
|
import { globalItemRoutes } from "./routes/global-items.ts";
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
import { itemRoutes } from "./routes/items.ts";
|
import { itemRoutes } from "./routes/items.ts";
|
||||||
@@ -117,6 +119,39 @@ app.use("/api/*", async (c, next) => {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rate limiting for public endpoints (per D-07, D-08)
|
||||||
|
const browseTier = createRateLimit(120, 60_000);
|
||||||
|
const detailTier = createRateLimit(60, 60_000);
|
||||||
|
|
||||||
|
// Browse endpoints — higher limit for list/search
|
||||||
|
app.use("/api/discovery/*", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/global-items", async (c, next) => {
|
||||||
|
if (c.req.method === "GET" && !c.req.path.match(/^\/api\/global-items\/\d+$/))
|
||||||
|
return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/tags", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return browseTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail endpoints — moderate limit for individual resources
|
||||||
|
app.use("/api/global-items/:id", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/setups/:id/public", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use("/api/users/:id/profile", async (c, next) => {
|
||||||
|
if (c.req.method === "GET") return detailTier(c, next);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
// Auth middleware for all data routes (userId must be available for per-user scoping)
|
// Auth middleware for all data routes (userId must be available for per-user scoping)
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
// Skip auth routes — they handle their own auth
|
// Skip auth routes — they handle their own auth
|
||||||
@@ -132,6 +167,9 @@ app.use("/api/*", async (c, next) => {
|
|||||||
// Skip public tags endpoint (GET /api/tags)
|
// Skip public tags endpoint (GET /api/tags)
|
||||||
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
|
if (c.req.path.startsWith("/api/tags") && c.req.method === "GET")
|
||||||
return next();
|
return next();
|
||||||
|
// Skip public discovery endpoints (GET /api/discovery/*)
|
||||||
|
if (c.req.path.startsWith("/api/discovery") && c.req.method === "GET")
|
||||||
|
return next();
|
||||||
// Skip public global-items endpoint (GET /api/global-items)
|
// Skip public global-items endpoint (GET /api/global-items)
|
||||||
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
|
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
|
||||||
return next();
|
return next();
|
||||||
@@ -149,6 +187,7 @@ app.route("/api/settings", settingsRoutes);
|
|||||||
app.route("/api/threads", threadRoutes);
|
app.route("/api/threads", threadRoutes);
|
||||||
app.route("/api/users", profileRoutes);
|
app.route("/api/users", profileRoutes);
|
||||||
app.route("/api/setups", setupRoutes);
|
app.route("/api/setups", setupRoutes);
|
||||||
|
app.route("/api/discovery", discoveryRoutes);
|
||||||
app.route("/api/global-items", globalItemRoutes);
|
app.route("/api/global-items", globalItemRoutes);
|
||||||
app.route("/api/tags", tagRoutes);
|
app.route("/api/tags", tagRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,9 +7,6 @@ interface RateLimitEntry {
|
|||||||
|
|
||||||
const store = new Map<string, RateLimitEntry>();
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 5;
|
|
||||||
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
||||||
|
|
||||||
function getClientIp(c: Context): string {
|
function getClientIp(c: Context): string {
|
||||||
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
}
|
}
|
||||||
@@ -23,30 +20,29 @@ function cleanup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rateLimit(c: Context, next: Next) {
|
export function createRateLimit(maxAttempts: number, windowMs: number) {
|
||||||
|
return async function rateLimitMiddleware(c: Context, next: Next) {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
const ip = getClientIp(c);
|
const ip = getClientIp(c);
|
||||||
const key = `${ip}:${c.req.path}`;
|
const key = `${ip}:${c.req.path}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const entry = store.get(key);
|
const entry = store.get(key);
|
||||||
|
|
||||||
if (!entry || now >= entry.resetAt) {
|
if (!entry || now >= entry.resetAt) {
|
||||||
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
if (entry.count >= maxAttempts) {
|
||||||
if (entry.count >= MAX_ATTEMPTS) {
|
|
||||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
c.header("Retry-After", String(retryAfter));
|
c.header("Retry-After", String(retryAfter));
|
||||||
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.count++;
|
entry.count++;
|
||||||
return next();
|
return next();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const rateLimit = createRateLimit(5, 15 * 60 * 1000);
|
||||||
|
|
||||||
/** @internal — only for testing */
|
/** @internal — only for testing */
|
||||||
export function _resetForTesting() {
|
export function _resetForTesting() {
|
||||||
store.clear();
|
store.clear();
|
||||||
|
|||||||
38
src/server/routes/discovery.ts
Normal file
38
src/server/routes/discovery.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import {
|
||||||
|
getPopularSetups,
|
||||||
|
getRecentGlobalItems,
|
||||||
|
getTrendingCategories,
|
||||||
|
} from "../services/discovery.service.ts";
|
||||||
|
|
||||||
|
type Env = { Variables: { db?: any } };
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.get("/setups", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const limitParam = c.req.query("limit");
|
||||||
|
const cursor = c.req.query("cursor");
|
||||||
|
const limit = Math.min(limitParam ? parseInt(limitParam, 10) || 6 : 6, 50);
|
||||||
|
const result = await getPopularSetups(db, limit, cursor);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/items", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const limitParam = c.req.query("limit");
|
||||||
|
const cursor = c.req.query("cursor");
|
||||||
|
const limit = Math.min(limitParam ? parseInt(limitParam, 10) || 8 : 8, 50);
|
||||||
|
const result = await getRecentGlobalItems(db, limit, cursor);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/categories", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const limitParam = c.req.query("limit");
|
||||||
|
const limit = Math.min(limitParam ? parseInt(limitParam, 10) || 12 : 12, 50);
|
||||||
|
const result = await getTrendingCategories(db, limit);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app as discoveryRoutes };
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ app.get("/:id/public", async (c) => {
|
|||||||
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
const setup = await getPublicSetupWithItems(db, id);
|
const setup = await getPublicSetupWithItems(db, id);
|
||||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||||
return c.json(setup);
|
const enrichedItems = await withImageUrls(setup.items);
|
||||||
|
return c.json({ ...setup, items: enrichedItems });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/:id", async (c) => {
|
app.get("/:id", async (c) => {
|
||||||
|
|||||||
130
src/server/services/discovery.service.ts
Normal file
130
src/server/services/discovery.service.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { and, count, desc, eq, isNotNull, lt, sql } from "drizzle-orm";
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { globalItems, setups, setupItems, users } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
interface CursorPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get popular public setups ordered by item count descending.
|
||||||
|
* Cursor format: "{itemCount}_{id}" for stable composite pagination.
|
||||||
|
* Only public setups (isPublic=true) are returned.
|
||||||
|
*/
|
||||||
|
export async function getPopularSetups(
|
||||||
|
db: Db = prodDb,
|
||||||
|
limit = 6,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<
|
||||||
|
CursorPage<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: Date;
|
||||||
|
itemCount: number;
|
||||||
|
creatorName: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
// Fetch more rows when cursor provided to ensure we can filter and still fill the page
|
||||||
|
const fetchLimit = cursor ? limit * 2 + limit + 1 : limit + 1;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: setups.id,
|
||||||
|
name: setups.name,
|
||||||
|
createdAt: setups.createdAt,
|
||||||
|
itemCount: sql<number>`CAST(COUNT(${setupItems.id}) AS INT)`,
|
||||||
|
creatorName: users.displayName,
|
||||||
|
})
|
||||||
|
.from(setups)
|
||||||
|
.leftJoin(setupItems, eq(setupItems.setupId, setups.id))
|
||||||
|
.leftJoin(users, eq(users.id, setups.userId))
|
||||||
|
.where(eq(setups.isPublic, true))
|
||||||
|
.groupBy(setups.id, setups.name, setups.createdAt, users.displayName)
|
||||||
|
.orderBy(
|
||||||
|
desc(sql<number>`COUNT(${setupItems.id})`),
|
||||||
|
desc(setups.id),
|
||||||
|
)
|
||||||
|
.limit(fetchLimit);
|
||||||
|
|
||||||
|
// Apply cursor filter in JS (composite cursor: itemCount_id)
|
||||||
|
let filtered = rows;
|
||||||
|
if (cursor) {
|
||||||
|
const parts = cursor.split("_");
|
||||||
|
const cursorCount = Number(parts[0]);
|
||||||
|
const cursorId = Number(parts[1]);
|
||||||
|
filtered = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.itemCount < cursorCount ||
|
||||||
|
(r.itemCount === cursorCount && r.id < cursorId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = filtered.length > limit;
|
||||||
|
const items = hasMore ? filtered.slice(0, limit) : filtered;
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && items.length > 0
|
||||||
|
? `${items[items.length - 1].itemCount}_${items[items.length - 1].id}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { items, nextCursor, hasMore };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recently added global catalog items ordered by createdAt descending.
|
||||||
|
* Cursor is an ISO timestamp string — returns items created before that time.
|
||||||
|
*/
|
||||||
|
export async function getRecentGlobalItems(
|
||||||
|
db: Db = prodDb,
|
||||||
|
limit = 8,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<CursorPage<typeof globalItems.$inferSelect>> {
|
||||||
|
const conditions = cursor
|
||||||
|
? [lt(globalItems.createdAt, new Date(cursor))]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(globalItems)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(globalItems.createdAt))
|
||||||
|
.limit(limit + 1);
|
||||||
|
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && items.length > 0
|
||||||
|
? items[items.length - 1].createdAt.toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { items, nextCursor, hasMore };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trending categories ordered by global item count descending.
|
||||||
|
* Excludes items where category IS NULL.
|
||||||
|
* Simple limit pagination — no cursor (categories are a bounded list).
|
||||||
|
*/
|
||||||
|
export async function getTrendingCategories(
|
||||||
|
db: Db = prodDb,
|
||||||
|
limit = 12,
|
||||||
|
): Promise<Array<{ name: string; itemCount: number }>> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
name: globalItems.category,
|
||||||
|
itemCount: count(globalItems.id),
|
||||||
|
})
|
||||||
|
.from(globalItems)
|
||||||
|
.where(isNotNull(globalItems.category))
|
||||||
|
.groupBy(globalItems.category)
|
||||||
|
.orderBy(desc(count(globalItems.id)))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
name: r.name as string,
|
||||||
|
itemCount: r.itemCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
_resetForTesting,
|
_resetForTesting,
|
||||||
|
createRateLimit,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
} from "../../src/server/middleware/rateLimit";
|
} from "../../src/server/middleware/rateLimit";
|
||||||
|
|
||||||
@@ -19,6 +20,13 @@ function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeGetRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
||||||
|
return app.request(path, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "x-forwarded-for": ip },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("rateLimit middleware", () => {
|
describe("rateLimit middleware", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetForTesting();
|
_resetForTesting();
|
||||||
@@ -83,3 +91,90 @@ describe("rateLimit middleware", () => {
|
|||||||
expect(allowedSetup.status).toBe(200);
|
expect(allowedSetup.status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createRateLimit factory", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks on 4th request when limit is 3", async () => {
|
||||||
|
const limit3 = createRateLimit(3, 60_000);
|
||||||
|
const app = new Hono();
|
||||||
|
app.get("/items", limit3, (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const res = await makeGetRequest(app, "/items");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
const res = await makeGetRequest(app, "/items");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows exactly 10 requests then blocks on 11th", async () => {
|
||||||
|
const limit10 = createRateLimit(10, 60_000);
|
||||||
|
const app = new Hono();
|
||||||
|
app.get("/catalog", limit10, (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await makeGetRequest(app, "/catalog");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
const res = await makeGetRequest(app, "/catalog");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different IPs independently", async () => {
|
||||||
|
const limit3 = createRateLimit(3, 60_000);
|
||||||
|
const app = new Hono();
|
||||||
|
app.get("/items", limit3, (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await makeGetRequest(app, "/items", "192.168.1.1");
|
||||||
|
}
|
||||||
|
const blocked = await makeGetRequest(app, "/items", "192.168.1.1");
|
||||||
|
expect(blocked.status).toBe(429);
|
||||||
|
|
||||||
|
// Different IP should still be allowed
|
||||||
|
const allowed = await makeGetRequest(app, "/items", "192.168.1.2");
|
||||||
|
expect(allowed.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Retry-After header on 429 response", async () => {
|
||||||
|
const limit2 = createRateLimit(2, 60_000);
|
||||||
|
const app = new Hono();
|
||||||
|
app.get("/tags", limit2, (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
await makeGetRequest(app, "/tags");
|
||||||
|
await makeGetRequest(app, "/tags");
|
||||||
|
const res = await makeGetRequest(app, "/tags");
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
|
expect(retryAfter).toBeTruthy();
|
||||||
|
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two instances with different limits operate independently", async () => {
|
||||||
|
const limit3 = createRateLimit(3, 60_000);
|
||||||
|
const limit5 = createRateLimit(5, 60_000);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.get("/browse", limit3, (c) => c.json({ ok: true }));
|
||||||
|
app.get("/detail", limit5, (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
// Exhaust browse limit (3)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await makeGetRequest(app, "/browse");
|
||||||
|
}
|
||||||
|
const blockedBrowse = await makeGetRequest(app, "/browse");
|
||||||
|
expect(blockedBrowse.status).toBe(429);
|
||||||
|
|
||||||
|
// Detail endpoint still has its own limit (5) and should work
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await makeGetRequest(app, "/detail");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
const blockedDetail = await makeGetRequest(app, "/detail");
|
||||||
|
expect(blockedDetail.status).toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
240
tests/routes/discovery.test.ts
Normal file
240
tests/routes/discovery.test.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { globalItems, items, setups, setupItems } from "../../src/db/schema.ts";
|
||||||
|
import { discoveryRoutes } from "../../src/server/routes/discovery.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
|
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
||||||
|
|
||||||
|
async function createTestApp() {
|
||||||
|
const { db, userId } = await createTestDb();
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
// Note: NO userId set — discovery endpoints don't need auth
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route("/api/discovery", discoveryRoutes);
|
||||||
|
return { app, db, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertGlobalItem(
|
||||||
|
db: TestDb["db"],
|
||||||
|
brand: string,
|
||||||
|
model: string,
|
||||||
|
category?: string,
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(globalItems)
|
||||||
|
.values({ brand, model, category: category ?? "bags" })
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertPublicSetup(
|
||||||
|
db: TestDb["db"],
|
||||||
|
userId: number,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(setups)
|
||||||
|
.values({ name, userId, isPublic: true })
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertItem(
|
||||||
|
db: TestDb["db"],
|
||||||
|
userId: number,
|
||||||
|
name: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(items)
|
||||||
|
.values({ name, categoryId: 1, userId })
|
||||||
|
.returning();
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addItemToSetup(
|
||||||
|
db: TestDb["db"],
|
||||||
|
setupId: number,
|
||||||
|
itemId: number,
|
||||||
|
) {
|
||||||
|
await db.insert(setupItems).values({ setupId, itemId });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Discovery Routes", () => {
|
||||||
|
let app: Hono;
|
||||||
|
let db: TestDb["db"];
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const testApp = await createTestApp();
|
||||||
|
app = testApp.app;
|
||||||
|
db = testApp.db;
|
||||||
|
userId = testApp.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/discovery/setups", () => {
|
||||||
|
it("returns 200 with { items, nextCursor, hasMore } shape", async () => {
|
||||||
|
await insertPublicSetup(db, userId, "My Bikepacking Setup");
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/setups");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("items");
|
||||||
|
expect(body).toHaveProperty("nextCursor");
|
||||||
|
expect(body).toHaveProperty("hasMore");
|
||||||
|
expect(Array.isArray(body.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only public setups", async () => {
|
||||||
|
await insertPublicSetup(db, userId, "Public Setup");
|
||||||
|
// Insert a private setup
|
||||||
|
await db
|
||||||
|
.insert(setups)
|
||||||
|
.values({ name: "Private Setup", userId, isPublic: false });
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/setups");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.items).toHaveLength(1);
|
||||||
|
expect(body.items[0].name).toBe("Public Setup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects limit query param", async () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await insertPublicSetup(db, userId, `Setup ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/setups?limit=2");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.items).toHaveLength(2);
|
||||||
|
expect(body.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/discovery/items", () => {
|
||||||
|
it("returns 200 with { items, nextCursor, hasMore } shape", async () => {
|
||||||
|
await insertGlobalItem(db, "MSR", "PocketRocket 2");
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/items");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("items");
|
||||||
|
expect(body).toHaveProperty("nextCursor");
|
||||||
|
expect(body).toHaveProperty("hasMore");
|
||||||
|
expect(Array.isArray(body.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with empty items when catalog is empty", async () => {
|
||||||
|
const res = await app.request("/api/discovery/items");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.items).toHaveLength(0);
|
||||||
|
expect(body.hasMore).toBe(false);
|
||||||
|
expect(body.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects limit query param", async () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await insertGlobalItem(db, `Brand${i}`, `Model${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/items?limit=2");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.items).toHaveLength(2);
|
||||||
|
expect(body.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pagination with cursor returns items before cursor timestamp", async () => {
|
||||||
|
// Insert items with explicit timestamps so they are definitively ordered
|
||||||
|
const olderTime = new Date("2024-01-01T00:00:00Z");
|
||||||
|
const newerTime = new Date("2024-06-01T00:00:00Z");
|
||||||
|
|
||||||
|
await db.insert(globalItems).values({
|
||||||
|
brand: "Brand A",
|
||||||
|
model: "Model A",
|
||||||
|
category: "bags",
|
||||||
|
createdAt: olderTime,
|
||||||
|
});
|
||||||
|
await db.insert(globalItems).values({
|
||||||
|
brand: "Brand B",
|
||||||
|
model: "Model B",
|
||||||
|
category: "bags",
|
||||||
|
createdAt: newerTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First page (newest first: Brand B)
|
||||||
|
const res1 = await app.request("/api/discovery/items?limit=1");
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
const page1 = await res1.json();
|
||||||
|
expect(page1.items).toHaveLength(1);
|
||||||
|
expect(page1.items[0].brand).toBe("Brand B");
|
||||||
|
expect(page1.hasMore).toBe(true);
|
||||||
|
expect(page1.nextCursor).not.toBeNull();
|
||||||
|
|
||||||
|
// Second page using cursor (should return Brand A)
|
||||||
|
const cursor = encodeURIComponent(page1.nextCursor);
|
||||||
|
const res2 = await app.request(
|
||||||
|
`/api/discovery/items?limit=1&cursor=${cursor}`,
|
||||||
|
);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
const page2 = await res2.json();
|
||||||
|
expect(page2.items).toHaveLength(1);
|
||||||
|
expect(page2.items[0].brand).toBe("Brand A");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/discovery/categories", () => {
|
||||||
|
it("returns 200 with array shape", async () => {
|
||||||
|
await insertGlobalItem(db, "MSR", "PocketRocket", "cooking");
|
||||||
|
await insertGlobalItem(db, "Revelate", "Terrapin", "bags");
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/categories");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns categories with name and itemCount fields", async () => {
|
||||||
|
await insertGlobalItem(db, "MSR", "PocketRocket", "cooking");
|
||||||
|
await insertGlobalItem(db, "Jetboil", "Flash", "cooking");
|
||||||
|
await insertGlobalItem(db, "Revelate", "Terrapin", "bags");
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/categories");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.length).toBeGreaterThan(0);
|
||||||
|
expect(body[0]).toHaveProperty("name");
|
||||||
|
expect(body[0]).toHaveProperty("itemCount");
|
||||||
|
// cooking has most items (2), should be first
|
||||||
|
expect(body[0].name).toBe("cooking");
|
||||||
|
expect(body[0].itemCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects limit query param", async () => {
|
||||||
|
await insertGlobalItem(db, "Brand A", "Model A", "category-a");
|
||||||
|
await insertGlobalItem(db, "Brand B", "Model B", "category-b");
|
||||||
|
await insertGlobalItem(db, "Brand C", "Model C", "category-c");
|
||||||
|
|
||||||
|
const res = await app.request("/api/discovery/categories?limit=2");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
246
tests/services/discovery.service.test.ts
Normal file
246
tests/services/discovery.service.test.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
globalItems,
|
||||||
|
items,
|
||||||
|
setups,
|
||||||
|
setupItems,
|
||||||
|
users,
|
||||||
|
} from "../../src/db/schema.ts";
|
||||||
|
import {
|
||||||
|
getPopularSetups,
|
||||||
|
getRecentGlobalItems,
|
||||||
|
getTrendingCategories,
|
||||||
|
} from "../../src/server/services/discovery.service.ts";
|
||||||
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
|
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
||||||
|
|
||||||
|
async function insertGlobalItem(
|
||||||
|
db: TestDb["db"],
|
||||||
|
data: { brand: string; model: string; category?: string },
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(globalItems)
|
||||||
|
.values({
|
||||||
|
brand: data.brand,
|
||||||
|
model: data.model,
|
||||||
|
category: data.category ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertItem(
|
||||||
|
db: TestDb["db"],
|
||||||
|
userId: number,
|
||||||
|
categoryId = 1,
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(items)
|
||||||
|
.values({ name: "Test Item", categoryId, userId })
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertPublicSetup(
|
||||||
|
db: TestDb["db"],
|
||||||
|
userId: number,
|
||||||
|
name: string,
|
||||||
|
itemIds: number[],
|
||||||
|
) {
|
||||||
|
const [setup] = await db
|
||||||
|
.insert(setups)
|
||||||
|
.values({ name, userId, isPublic: true })
|
||||||
|
.returning();
|
||||||
|
for (const itemId of itemIds) {
|
||||||
|
await db.insert(setupItems).values({ setupId: setup.id, itemId });
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertPrivateSetup(
|
||||||
|
db: TestDb["db"],
|
||||||
|
userId: number,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
const [setup] = await db
|
||||||
|
.insert(setups)
|
||||||
|
.values({ name, userId, isPublic: false })
|
||||||
|
.returning();
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Discovery Service", () => {
|
||||||
|
let db: TestDb["db"];
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const testDb = await createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
userId = testDb.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPopularSetups", () => {
|
||||||
|
it("returns public setups ordered by item count descending", async () => {
|
||||||
|
const item1 = await insertItem(db, userId);
|
||||||
|
const item2 = await insertItem(db, userId);
|
||||||
|
const item3 = await insertItem(db, userId);
|
||||||
|
|
||||||
|
// Setup with 1 item
|
||||||
|
await insertPublicSetup(db, userId, "Solo Setup", [item1.id]);
|
||||||
|
// Setup with 2 items
|
||||||
|
await insertPublicSetup(db, userId, "Dual Setup", [item2.id, item3.id]);
|
||||||
|
|
||||||
|
const result = await getPopularSetups(db);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items[0].name).toBe("Dual Setup");
|
||||||
|
expect(result.items[0].itemCount).toBe(2);
|
||||||
|
expect(result.items[1].name).toBe("Solo Setup");
|
||||||
|
expect(result.items[1].itemCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes private setups", async () => {
|
||||||
|
const item1 = await insertItem(db, userId);
|
||||||
|
await insertPublicSetup(db, userId, "Public Setup", [item1.id]);
|
||||||
|
await insertPrivateSetup(db, userId, "Private Setup");
|
||||||
|
|
||||||
|
const result = await getPopularSetups(db);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0].name).toBe("Public Setup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasMore=true and nextCursor when more results exist", async () => {
|
||||||
|
const item1 = await insertItem(db, userId);
|
||||||
|
const item2 = await insertItem(db, userId);
|
||||||
|
const item3 = await insertItem(db, userId);
|
||||||
|
|
||||||
|
await insertPublicSetup(db, userId, "Setup A", [item1.id, item2.id, item3.id]);
|
||||||
|
await insertPublicSetup(db, userId, "Setup B", [item1.id, item2.id]);
|
||||||
|
await insertPublicSetup(db, userId, "Setup C", [item1.id]);
|
||||||
|
|
||||||
|
const result = await getPopularSetups(db, 1);
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
expect(result.nextCursor).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns second page without duplicates when cursor provided", async () => {
|
||||||
|
const item1 = await insertItem(db, userId);
|
||||||
|
const item2 = await insertItem(db, userId);
|
||||||
|
const item3 = await insertItem(db, userId);
|
||||||
|
|
||||||
|
await insertPublicSetup(db, userId, "Setup A", [item1.id, item2.id, item3.id]);
|
||||||
|
await insertPublicSetup(db, userId, "Setup B", [item1.id, item2.id]);
|
||||||
|
await insertPublicSetup(db, userId, "Setup C", [item1.id]);
|
||||||
|
|
||||||
|
const page1 = await getPopularSetups(db, 1);
|
||||||
|
expect(page1.items).toHaveLength(1);
|
||||||
|
expect(page1.nextCursor).not.toBeNull();
|
||||||
|
|
||||||
|
const page2 = await getPopularSetups(db, 1, page1.nextCursor!);
|
||||||
|
expect(page2.items).toHaveLength(1);
|
||||||
|
expect(page2.items[0].id).not.toBe(page1.items[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes creatorName from users.displayName", async () => {
|
||||||
|
// Update user display name
|
||||||
|
await db.update(users)
|
||||||
|
.set({ displayName: "Jean-Luc" })
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
const item1 = await insertItem(db, userId);
|
||||||
|
await insertPublicSetup(db, userId, "My Setup", [item1.id]);
|
||||||
|
|
||||||
|
const result = await getPopularSetups(db);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0].creatorName).toBe("Jean-Luc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRecentGlobalItems", () => {
|
||||||
|
it("returns items ordered by createdAt descending", async () => {
|
||||||
|
// Insert items with slight delay to get different timestamps
|
||||||
|
const item1 = await insertGlobalItem(db, { brand: "BrandA", model: "Model1" });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
const item2 = await insertGlobalItem(db, { brand: "BrandB", model: "Model2" });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
const item3 = await insertGlobalItem(db, { brand: "BrandC", model: "Model3" });
|
||||||
|
|
||||||
|
const result = await getRecentGlobalItems(db);
|
||||||
|
expect(result.items).toHaveLength(3);
|
||||||
|
// Most recent first
|
||||||
|
expect(result.items[0].id).toBe(item3.id);
|
||||||
|
expect(result.items[1].id).toBe(item2.id);
|
||||||
|
expect(result.items[2].id).toBe(item1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasMore=true and nextCursor when more results exist", async () => {
|
||||||
|
await insertGlobalItem(db, { brand: "BrandA", model: "Model1" });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
await insertGlobalItem(db, { brand: "BrandB", model: "Model2" });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
await insertGlobalItem(db, { brand: "BrandC", model: "Model3" });
|
||||||
|
|
||||||
|
const result = await getRecentGlobalItems(db, 2);
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
expect(result.nextCursor).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns second page without duplicates when cursor provided", async () => {
|
||||||
|
await insertGlobalItem(db, { brand: "BrandA", model: "Model1" });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
await insertGlobalItem(db, { brand: "BrandB", model: "Model2" });
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
await insertGlobalItem(db, { brand: "BrandC", model: "Model3" });
|
||||||
|
|
||||||
|
const page1 = await getRecentGlobalItems(db, 2);
|
||||||
|
expect(page1.items).toHaveLength(2);
|
||||||
|
expect(page1.nextCursor).not.toBeNull();
|
||||||
|
|
||||||
|
const page2 = await getRecentGlobalItems(db, 2, page1.nextCursor!);
|
||||||
|
expect(page2.items).toHaveLength(1);
|
||||||
|
// Page 2 item should not appear in page 1
|
||||||
|
const page1Ids = page1.items.map((i) => i.id);
|
||||||
|
expect(page1Ids).not.toContain(page2.items[0].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTrendingCategories", () => {
|
||||||
|
it("returns categories ordered by item count descending", async () => {
|
||||||
|
// 3 items in Tents, 1 in Bags, 2 in Stoves
|
||||||
|
await insertGlobalItem(db, { brand: "BrandA", model: "Tent1", category: "Tents" });
|
||||||
|
await insertGlobalItem(db, { brand: "BrandB", model: "Tent2", category: "Tents" });
|
||||||
|
await insertGlobalItem(db, { brand: "BrandC", model: "Tent3", category: "Tents" });
|
||||||
|
await insertGlobalItem(db, { brand: "BrandD", model: "Bag1", category: "Bags" });
|
||||||
|
await insertGlobalItem(db, { brand: "BrandE", model: "Stove1", category: "Stoves" });
|
||||||
|
await insertGlobalItem(db, { brand: "BrandF", model: "Stove2", category: "Stoves" });
|
||||||
|
|
||||||
|
const result = await getTrendingCategories(db);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].name).toBe("Tents");
|
||||||
|
expect(result[0].itemCount).toBe(3);
|
||||||
|
expect(result[1].name).toBe("Stoves");
|
||||||
|
expect(result[1].itemCount).toBe(2);
|
||||||
|
expect(result[2].name).toBe("Bags");
|
||||||
|
expect(result[2].itemCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes items with null category", async () => {
|
||||||
|
await insertGlobalItem(db, { brand: "BrandA", model: "Tent1", category: "Tents" });
|
||||||
|
// No category — should be excluded
|
||||||
|
await insertGlobalItem(db, { brand: "BrandB", model: "NoCategory" });
|
||||||
|
|
||||||
|
const result = await getTrendingCategories(db);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe("Tents");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no items have a category set", async () => {
|
||||||
|
await insertGlobalItem(db, { brand: "BrandA", model: "Model1" });
|
||||||
|
await insertGlobalItem(db, { brand: "BrandB", model: "Model2" });
|
||||||
|
|
||||||
|
const result = await getTrendingCategories(db);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
globalItems,
|
globalItems,
|
||||||
globalItemTags,
|
globalItemTags,
|
||||||
@@ -7,8 +8,10 @@ import {
|
|||||||
} from "../../src/db/schema.ts";
|
} from "../../src/db/schema.ts";
|
||||||
import { seedGlobalItems } from "../../src/db/seed-global-items.ts";
|
import { seedGlobalItems } from "../../src/db/seed-global-items.ts";
|
||||||
import {
|
import {
|
||||||
|
bulkUpsertGlobalItems,
|
||||||
getGlobalItemWithOwnerCount,
|
getGlobalItemWithOwnerCount,
|
||||||
searchGlobalItems,
|
searchGlobalItems,
|
||||||
|
upsertGlobalItem,
|
||||||
} from "../../src/server/services/global-item.service.ts";
|
} from "../../src/server/services/global-item.service.ts";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
@@ -263,4 +266,157 @@ describe("Global Item Service", () => {
|
|||||||
expect(countAfterSecond).toBe(countAfterFirst);
|
expect(countAfterSecond).toBe(countAfterFirst);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("upsert operations", () => {
|
||||||
|
it("upsertGlobalItem creates new item and returns { item, created: true }", async () => {
|
||||||
|
const result = await upsertGlobalItem(db, {
|
||||||
|
brand: "Revelate Designs",
|
||||||
|
model: "Terrapin System",
|
||||||
|
category: "Bags",
|
||||||
|
weightGrams: 210,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.created).toBe(true);
|
||||||
|
expect(result.item.id).toBeDefined();
|
||||||
|
expect(result.item.brand).toBe("Revelate Designs");
|
||||||
|
expect(result.item.model).toBe("Terrapin System");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertGlobalItem updates existing item on (brand, model) conflict and returns { item, created: false }", async () => {
|
||||||
|
await upsertGlobalItem(db, {
|
||||||
|
brand: "MSR",
|
||||||
|
model: "PocketRocket 2",
|
||||||
|
weightGrams: 83,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = await upsertGlobalItem(db, {
|
||||||
|
brand: "MSR",
|
||||||
|
model: "PocketRocket 2",
|
||||||
|
weightGrams: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.item.weightGrams).toBe(90);
|
||||||
|
|
||||||
|
// Only one row should exist
|
||||||
|
const all = await db.select().from(globalItems);
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => {
|
||||||
|
const result = await upsertGlobalItem(db, {
|
||||||
|
brand: "Apidura",
|
||||||
|
model: "Handlebar Pack",
|
||||||
|
sourceUrl: "https://apidura.com/shop/handlebar-pack/",
|
||||||
|
imageCredit: "Apidura Ltd",
|
||||||
|
imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertGlobalItem with tags creates tags and links them", async () => {
|
||||||
|
const result = await upsertGlobalItem(db, {
|
||||||
|
brand: "Therm-a-Rest",
|
||||||
|
model: "NeoAir XLite",
|
||||||
|
tags: ["sleeping-pad", "ultralight"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.created).toBe(true);
|
||||||
|
|
||||||
|
const linkedTags = await db
|
||||||
|
.select({ name: tags.name })
|
||||||
|
.from(globalItemTags)
|
||||||
|
.innerJoin(tags, eq(globalItemTags.tagId, tags.id))
|
||||||
|
.where(eq(globalItemTags.globalItemId, result.item.id));
|
||||||
|
|
||||||
|
expect(linkedTags).toHaveLength(2);
|
||||||
|
const tagNames = linkedTags.map((t) => t.name).sort();
|
||||||
|
expect(tagNames).toEqual(["sleeping-pad", "ultralight"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertGlobalItem without tags leaves existing tags untouched", async () => {
|
||||||
|
// Create item with tags
|
||||||
|
const first = await upsertGlobalItem(db, {
|
||||||
|
brand: "Sea to Summit",
|
||||||
|
model: "Spark III",
|
||||||
|
tags: ["sleeping-bag"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert without tags
|
||||||
|
await upsertGlobalItem(db, {
|
||||||
|
brand: "Sea to Summit",
|
||||||
|
model: "Spark III",
|
||||||
|
weightGrams: 450,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags should remain
|
||||||
|
const linkedTags = await db
|
||||||
|
.select()
|
||||||
|
.from(globalItemTags)
|
||||||
|
.where(eq(globalItemTags.globalItemId, first.item.id));
|
||||||
|
|
||||||
|
expect(linkedTags).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertGlobalItem with empty tags array clears existing tags", async () => {
|
||||||
|
// Create item with tags
|
||||||
|
const first = await upsertGlobalItem(db, {
|
||||||
|
brand: "Big Agnes",
|
||||||
|
model: "Copper Spur HV UL2",
|
||||||
|
tags: ["tent", "ultralight"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert with empty tags
|
||||||
|
await upsertGlobalItem(db, {
|
||||||
|
brand: "Big Agnes",
|
||||||
|
model: "Copper Spur HV UL2",
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags should be cleared
|
||||||
|
const linkedTags = await db
|
||||||
|
.select()
|
||||||
|
.from(globalItemTags)
|
||||||
|
.where(eq(globalItemTags.globalItemId, first.item.id));
|
||||||
|
|
||||||
|
expect(linkedTags).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => {
|
||||||
|
const result = await bulkUpsertGlobalItems(db, [
|
||||||
|
{ brand: "Petzl", model: "Actik Core", weightGrams: 87 },
|
||||||
|
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 },
|
||||||
|
{ brand: "Black Diamond", model: "Spot 350", weightGrams: 90 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.created).toBe(3);
|
||||||
|
expect(result.updated).toBe(0);
|
||||||
|
expect(result.items).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulkUpsertGlobalItems handles mix of new and existing items", async () => {
|
||||||
|
// Pre-insert one item
|
||||||
|
await upsertGlobalItem(db, {
|
||||||
|
brand: "Petzl",
|
||||||
|
model: "Actik Core",
|
||||||
|
weightGrams: 87,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await bulkUpsertGlobalItems(db, [
|
||||||
|
{ brand: "Petzl", model: "Actik Core", weightGrams: 90 }, // existing
|
||||||
|
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, // new
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.created).toBe(1);
|
||||||
|
expect(result.updated).toBe(1);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user