Compare commits
190 Commits
ba13fa8ded
...
Develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ec330aca | |||
| 7890de141e | |||
| b41aa9301e | |||
| 076616cd1b | |||
| 0202d0bb5c | |||
| 1f2e8e18c4 | |||
| ddf9b9554f | |||
| 113e689932 | |||
| b41b8329bc | |||
| e4c0298a08 | |||
| 2f39a7241a | |||
| f1825fc722 | |||
| 8b60428b3b | |||
| 31a9e3c1ff | |||
| 88c5339b98 | |||
| e044547121 | |||
| 22f5004e53 | |||
| 5d417b7c6e | |||
| 6a5ffe8e2f | |||
| 0571ee47fb | |||
| 1f8b85dc62 | |||
| 0de809d8cb | |||
| 311ebe8afe | |||
| 8cefdf625b | |||
| c0a0aeff77 | |||
| d597affc1b | |||
| 136772d80c | |||
| f0597ae6b1 | |||
| 096cb5a1dd | |||
| 11ff1eb1dd | |||
| 9e49e52bc0 | |||
| 45eaeb0462 | |||
| 821c61f912 | |||
| 6931c33f73 | |||
| db471001fa | |||
| 3c79b7eb9a | |||
| eabfca475c | |||
| 2f2fc1e681 | |||
| 298da6da85 | |||
| 868aed4f10 | |||
| 70a3e159ba | |||
| 8e76fe35dc | |||
| 72473bc5c5 | |||
| 8f62edc91d | |||
| 7a3dca768a | |||
| 080838ecb5 | |||
| 488fdbb568 | |||
| d3c5a8945b | |||
| 48381105b5 | |||
| 18883fb9f0 | |||
| 34c7d27ee5 | |||
| 23cdb25063 | |||
| 94e2a8c019 | |||
| e8cdeafba2 | |||
| 38c0382f64 | |||
| 8f4bb5096d | |||
| 7e684176ab | |||
| 93c273d266 | |||
| 65f25e5964 | |||
| b984e8c72f | |||
| 9d41400faa | |||
| d58f7fab40 | |||
| e1d516cfe8 | |||
| 2d45b9024d | |||
| 88db308a16 | |||
| 2d2259a0db | |||
| 58d6b47c6f | |||
| 053d56236f | |||
| b43a932217 | |||
| 7fca92985a | |||
| 44392e8583 | |||
| d216c80892 | |||
| 805b306516 | |||
| 8202a0088b | |||
| 8220cf84ab | |||
| 2ebf3a37e8 | |||
| 4548780e5f | |||
| 13c48731ea | |||
| 1733fe8cfb | |||
| beaea46e92 | |||
| 9649ef2514 | |||
| 5f63e6f75d | |||
| 4ccbb2b070 | |||
| 16058d0f4d | |||
| 065b262b5b | |||
| 44602d409e | |||
| 3d2911cedc | |||
| b2a725a646 | |||
| 44b1eac0ba | |||
| 0b4715b80c | |||
| a508773809 | |||
| 2924c2269c | |||
| 12b3f8e380 | |||
| 5037350aa0 | |||
| 8ff680ef92 | |||
| f868bbdecf | |||
| ec27df1d0f | |||
| 8c1b19f07d | |||
| 7de3e9e957 | |||
| 2cb83a63f1 | |||
| bea386e7db | |||
| 1b2ddcd0bd | |||
| be5b318041 | |||
| dbab84ef2a | |||
| fefef38e9b | |||
| 4ba42f521c | |||
| 26e20bd0d2 | |||
| fd874a3ff2 | |||
| 31297a3921 | |||
| 0570ee3ed5 | |||
| a1ffcf3061 | |||
| d08a49e8ab | |||
| bf64b8f6a5 | |||
| 3ff3ff4cb9 | |||
| f91417a24b | |||
| 2aa156a6b7 | |||
| 6fd8874970 | |||
| c5af1247c0 | |||
| f4e93bf554 | |||
| 23172f794f | |||
| e27c919430 | |||
| 8634ca41c1 | |||
| 95c0ab4037 | |||
| 6376cfcb8d | |||
| 3c973e8ec1 | |||
| 1963faea84 | |||
| 4a23904c3f | |||
| 480abdd17f | |||
| 755c0ab89f | |||
| b21ba0d97b | |||
| 459a4ed4b0 | |||
| 28dfef555c | |||
| c4ddc573d4 | |||
| 23027551b4 | |||
| 51c8703a3d | |||
| 4c80e9aa3c | |||
| 4b26a6c88e | |||
| 731d677da6 | |||
| 1fbd9bc609 | |||
| e21e1ec523 | |||
| 8d7a668da4 | |||
| ceee6c0f13 | |||
| 5e731b436b | |||
| 46715cc793 | |||
| f759dd0fde | |||
| 672b17fd13 | |||
| 8c0fb31df2 | |||
| de82eefa74 | |||
| 24304aa8aa | |||
| e2127ebb84 | |||
| 37edd0edfd | |||
| 02fcae12f0 | |||
| d0bbf48bb5 | |||
| 3df9eece83 | |||
| 7d6c548811 | |||
| 52dce7b72b | |||
| 7eb5335a88 | |||
| 0b46eff243 | |||
| a531581623 | |||
| f8ab69684a | |||
| 7003e998f9 | |||
| e10f0eda3d | |||
| 50bc11c7ed | |||
| 298fa6d586 | |||
| 1d15d4b336 | |||
| 1992778ce6 | |||
| da159d10b8 | |||
| 7a696f39a5 | |||
| edc9793c2d | |||
| 727abf1528 | |||
| d928634e57 | |||
| 634ac298d1 | |||
| 338a78122d | |||
| 81a654085d | |||
| 9965e356de | |||
| cb0c1e8c9a | |||
| 49c59fded9 | |||
| 6833b90795 | |||
| 2853477a75 | |||
| 92b84d2cd6 | |||
| ebf031a62c | |||
| 03e0fe99fa | |||
| adbc13eb15 | |||
| 2beabe88f9 | |||
| 29f925027c | |||
| 32fa261ec2 | |||
| 9864a09fc1 | |||
| c3874d031a | |||
| cd55f3c282 | |||
| 80f4d1d9ae |
@@ -1,17 +1,9 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
push:
|
||||||
inputs:
|
tags:
|
||||||
bump:
|
- 'v*'
|
||||||
description: "Version bump type"
|
|
||||||
required: true
|
|
||||||
default: "patch"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- patch
|
|
||||||
- minor
|
|
||||||
- major
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -45,25 +37,17 @@ jobs:
|
|||||||
cd repo
|
cd repo
|
||||||
git checkout ${{ gitea.ref_name }}
|
git checkout ${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Compute version
|
- name: Resolve version from tag
|
||||||
working-directory: repo
|
working-directory: repo
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n1)
|
VERSION="${{ gitea.ref_name }}"
|
||||||
if [ -z "$LATEST_TAG" ]; then
|
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -vxF "$VERSION" | head -n1)
|
||||||
LATEST_TAG="v0.0.0"
|
if [ -z "$PREV_TAG" ]; then
|
||||||
|
PREV_TAG="v0.0.0"
|
||||||
fi
|
fi
|
||||||
MAJOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f1)
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
MINOR=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f2)
|
echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV"
|
||||||
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d. -f3)
|
echo "Releasing $VERSION (previous: $PREV_TAG)"
|
||||||
case "${{ gitea.event.inputs.bump }}" in
|
|
||||||
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;;
|
|
||||||
minor) MINOR=$((MINOR+1)); PATCH=0 ;;
|
|
||||||
patch) PATCH=$((PATCH+1)) ;;
|
|
||||||
esac
|
|
||||||
NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
|
|
||||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
|
|
||||||
echo "PREV_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
|
||||||
echo "New version: $NEW_VERSION"
|
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
working-directory: repo
|
working-directory: repo
|
||||||
@@ -77,14 +61,6 @@ jobs:
|
|||||||
echo "$CHANGELOG" >> "$GITHUB_ENV"
|
echo "$CHANGELOG" >> "$GITHUB_ENV"
|
||||||
echo "CHANGELOG_EOF" >> "$GITHUB_ENV"
|
echo "CHANGELOG_EOF" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
working-directory: repo
|
|
||||||
run: |
|
|
||||||
git config user.name "Gitea Actions"
|
|
||||||
git config user.email "actions@gitea.jeanlucmakiola.de"
|
|
||||||
git tag -a "$VERSION" -m "Release $VERSION"
|
|
||||||
git push origin "$VERSION"
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
working-directory: repo
|
working-directory: repo
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -233,6 +233,9 @@ e2e/pgdata
|
|||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
|
# JetBrains IDEs (full directory)
|
||||||
|
.idea/
|
||||||
|
|
||||||
# Obsidian
|
# Obsidian
|
||||||
.obsidian/
|
.obsidian/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,89 @@
|
|||||||
# Milestones
|
# Milestones
|
||||||
|
|
||||||
|
## v2.3 Global & Social Ready (Shipped: 2026-04-19)
|
||||||
|
|
||||||
|
**Phases completed:** 3 phases (32-34), 18 plans
|
||||||
|
**Timeline:** 6 days (2026-04-13 → 2026-04-19)
|
||||||
|
**Codebase:** 217 files changed (+24,291 / -991), 99 commits
|
||||||
|
|
||||||
|
**Key accomplishments:**
|
||||||
|
|
||||||
|
- Setup visibility system: isPublic replaced with private/link/public visibility column, shares table with 128-bit token entropy, visibility-transition side effects (deactivate/reactivate links)
|
||||||
|
- ShareModal with Google Docs-style UX: visibility picker, share link creation with expiry, active links list with revoke, deactivation warning
|
||||||
|
- Shared setup viewer: `/s/:token` short URL redirect, `/api/shared/:token` public endpoint, read-only mode with owner controls gated, inline "Shared setup" banner
|
||||||
|
- Multi-currency foundation: market_prices + community_prices tables, ECB exchange rates via frankfurter.app with 24h cache, currency conversion service
|
||||||
|
- Community price aggregation: ownership-validated submissions, PERCENTILE_CONT(0.5) median with 3-report minimum, market-aware MSRP on catalog detail pages
|
||||||
|
- i18n framework: react-i18next + 6 namespaces (common/collection/threads/setups/onboarding/settings/catalog), English + German locales, language picker in settings, locale-aware formatting
|
||||||
|
|
||||||
|
**Known deferred items at close:** 6 (see STATE.md Deferred Items)
|
||||||
|
|
||||||
|
**Archive:** `.planning/milestones/v2.3-ROADMAP.md`, `.planning/milestones/v2.3-REQUIREMENTS.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.2 User Experience Polish (Shipped: 2026-04-13)
|
||||||
|
|
||||||
|
**Phases completed:** 36 phases, 68 plans, 120 tasks
|
||||||
|
|
||||||
|
**Key accomplishments:**
|
||||||
|
|
||||||
|
- Parameterized formatWeight with g/oz/lb/kg conversion and useWeightUnit settings hook, backed by 21 TDD tests
|
||||||
|
- Segmented g/oz/lb/kg toggle in TotalsBar with all 8 weight display call sites wired to user-selected unit
|
||||||
|
- Candidate status tracking (researching/ordered/arrived) with schema migration, service/Zod updates, 5 TDD tests, and clickable StatusBadge popup on CandidateCard
|
||||||
|
- Sticky search/filter toolbar on gear tab with text+category filtering, and shared icon-aware CategoryFilterDropdown on both gear and planning tabs
|
||||||
|
- Per-setup item classification (base/worn/consumable) with click-to-cycle badge, classification-preserving sync, and full test coverage
|
||||||
|
- Recharts donut chart with category/classification toggle, weight subtotals card, and hover tooltips inside setup detail page
|
||||||
|
- Nullable pros/cons TEXT columns added to thread_candidates from SQLite schema through Drizzle migration, service layer, Zod validation, React form inputs, and CandidateCard visual badge
|
||||||
|
- sortOrder REAL column, reorderCandidates transaction service, and PATCH /api/threads/:id/candidates/reorder endpoint with active-thread guard
|
||||||
|
- 1. [Rule 2 - Missing] Added pros/cons fields to CandidateWithCategory in useThreads.ts
|
||||||
|
- Side-by-side candidate comparison table with sticky labels, weight/price delta highlighting, and resolved-thread winner marking via a new "compare" candidateViewMode
|
||||||
|
- PostgreSQL schema with 13 pgTable definitions, postgres.js connection, PGlite test infrastructure, and initial migration
|
||||||
|
- PostgreSQL 16 Docker Compose for dev and production, lean Dockerfile without native SQLite build dependencies
|
||||||
|
- All 9 service files (30 functions) converted from synchronous SQLite to async PostgreSQL operations with PGlite smoke test validation
|
||||||
|
- All 9 route files and auth middleware converted to properly await async service/DB calls, preventing Promise-as-JSON responses
|
||||||
|
- One-time data migration script converting all 13 tables from SQLite to PostgreSQL with timestamp/boolean type conversions and serial sequence reset
|
||||||
|
- All 18 test files converted to async PGlite with 161 tests passing across service, route, and MCP layers
|
||||||
|
- Logto OIDC provider added to Docker Compose with Postgres init script, users/sessions tables removed from schema
|
||||||
|
- Three-way auth middleware with @hono/oidc-auth for browser sessions, API keys for programmatic access, and MCP OAuth consent flow
|
||||||
|
- OIDC login redirect page, cleaned auth hooks (string user id, no credential forms), API-key E2E seed, and three-way auth test coverage
|
||||||
|
- pgTable schema with users table, userId FK on 6 entity tables, composite constraints, and auth middleware resolving userId for all auth methods
|
||||||
|
- All 7 service files accept userId parameter with and(eq) isolation on every query — no unscoped reads or writes remain
|
||||||
|
- Complete userId propagation chain from auth middleware through routes and MCP tools to service layer
|
||||||
|
- Route tests, MCP tests, and cross-user isolation tests updated with userId context for multi-user data model
|
||||||
|
- S3 storage abstraction with uploadImage/deleteImage/getImageUrl using @aws-sdk/client-s3, plus MinIO in Docker Compose with automatic bucket creation
|
||||||
|
- Replaced all local filesystem image operations with S3 storage service calls across routes, services, and MCP tools
|
||||||
|
- Replaced all client /uploads/ path references with presigned S3 URLs and created one-time image migration script
|
||||||
|
- Global items table, item-global links, user profile columns, setup visibility, Zod schemas, and 18-item bikepacking seed catalog
|
||||||
|
- Global item catalog backend with LIKE search, owner count aggregation, item linking, idempotent seeding, and full test coverage
|
||||||
|
- Profile service with CRUD and public profile data, public setup viewing, setup visibility toggle, and auth middleware bypass for public endpoints
|
||||||
|
- Global catalog browse/search page, item detail with owner count, and link-to-catalog component using TanStack Router and Query
|
||||||
|
- Profile edit UI in settings with avatar upload, public profile page with setup listing, and setup visibility toggle with globe icon
|
||||||
|
- Database schema updated with direct globalItemId FK on items/candidates, tags system tables, and data migration from itemGlobalLinks
|
||||||
|
- COALESCE merge pattern in item/thread services for transparent reference item data, branched thread resolution, and link/unlink endpoint removal
|
||||||
|
- Tag-filtered global item search with AND logic, owner count via direct FK, and COALESCE merge propagated to setup/totals/profile/CSV services
|
||||||
|
- Tags endpoint with alphabetical ordering, global-items route registration, UIStore FAB/catalog-search state, and tag-aware useGlobalItems hook
|
||||||
|
- Global FAB with animated mini menu and full-screen catalog search overlay with debounced search, tag chip AND-filtering, and result card grid
|
||||||
|
- Private item detail page with edit mode toggle at /items/:id, and enhanced catalog detail page with Add to Collection stub button
|
||||||
|
- Candidate detail page with edit mode toggle at /threads/:threadId/candidates/:candidateId, thread route directory restructured for nested routes, add-candidate modal replacing slide-out panel
|
||||||
|
- All card components rewired from slide-out panels to detail page navigation, panels removed from root layout, UIStore cleaned of panel state
|
||||||
|
- AddToCollectionModal with category/notes/price fields, sonner toasts, and wired catalog search + detail page entry points
|
||||||
|
- AddToThreadModal with existing thread picker, new thread + candidate creation, and session thread memory for catalog search flow
|
||||||
|
- ManualEntryForm component with CategoryPicker, ImageUpload, and cents conversion wired into CatalogSearchOverlay as inline mode with entry points, success card, and context-sensitive navigation
|
||||||
|
- createRateLimit(max, windowMs) factory with browse (120/min) and detail (60/min) tiers applied to all public GET endpoints before auth middleware
|
||||||
|
- globalItems attribution columns (sourceUrl, imageCredit, imageSourceUrl) with unique(brand, model) constraint, upsertGlobalItem/bulkUpsertGlobalItems service functions, and Zod schemas — 21 tests passing
|
||||||
|
- 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
|
||||||
|
- One-liner:
|
||||||
|
- One-liner:
|
||||||
|
- 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
|
||||||
|
- 1. [Rule 1 - Bug] Used 'house' icon instead of plan-specified 'home'
|
||||||
|
- One-liner:
|
||||||
|
- TopNav replaces TotalsBar across all pages, BottomTabBar wired for mobile, hero removed from landing page, and /setups added as a public route
|
||||||
|
- Shared hobby config, popular-items-by-tags endpoint with owner count ordering, and batch onboarding completion service with auto-category creation
|
||||||
|
- 5-step catalog-driven onboarding with hobby cards, selectable item grid, review list, and CSS step transitions following UI-SPEC design contract
|
||||||
|
- Replaced old OnboardingWizard with new OnboardingFlow in root route, deleted old component, verified build and no stale references
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.0 Platform Foundation (Shipped: 2026-04-08)
|
## v2.0 Platform Foundation (Shipped: 2026-04-08)
|
||||||
|
|
||||||
**Phases completed:** 10 phases, 32 plans
|
**Phases completed:** 10 phases, 32 plans
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A gear management and discovery platform. Users catalog their gear collections (bikepacking, sim racing, or any hobby), track weight, price, and source details, research purchases through planning threads with side-by-side comparison, and compose named setups (loadouts) with weight classification and visualization. A global item database with crowd-verified specs and structured reviews helps users make informed purchase decisions. Multi-user with public setup sharing and gear discovery.
|
A gear management and discovery platform. Users catalog their gear collections (bikepacking, sim racing, or any hobby), track weight, price, and source details, research purchases through planning threads with side-by-side comparison, and compose named setups (loadouts) with weight classification and visualization. A global item database with crowd-verified specs and market-aware pricing helps users make informed purchase decisions. Multi-user with granular setup sharing (private/link/public), multi-currency support, and a fully internationalized UI (English + German).
|
||||||
|
|
||||||
## Core Value
|
## 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.
|
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 Milestone: v2.4 Admin Foundation
|
||||||
|
|
||||||
|
**Goal:** Clear the v2.3 bug backlog and ship a catalog admin panel as the foundation for managing global catalog content in future milestones.
|
||||||
|
|
||||||
|
**Target features:**
|
||||||
|
- Bug fixes: wrong add-candidate modal, missing item images on collection overview, slow image loading, auth prompt direct Logto redirect, cursor-pointer on all clickable elements
|
||||||
|
- Catalog admin panel: admin-gated route, global item management (browse/edit/delete), tag management (create/rename/parent-child hierarchy/delete), admin role flag on users
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Validated
|
### Validated
|
||||||
@@ -60,27 +68,40 @@ Help people make better gear decisions — discover what others use, compare rea
|
|||||||
- ✓ MCP catalog tools (upsert_catalog_item, bulk_upsert_catalog) for agent seeding — 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
|
- ✓ Discovery landing page with catalog search, popular setups feed, recent items, trending categories — v2.1
|
||||||
|
|
||||||
### Active
|
- ✓ Profile page with Logto-powered account management (display name, bio, avatar, email, password, delete) — v2.2
|
||||||
|
- ✓ Image fit-within framing with dominant color background fill and crop editor — v2.2
|
||||||
|
- ✓ Catalog-driven onboarding flow with hobby picker, category-grouped item browser, and batch collection creation — v2.2
|
||||||
|
- ✓ Mobile icon-based action buttons on detail pages — v2.2
|
||||||
|
|
||||||
## Current Milestone: v2.2 User Experience Polish
|
- ✓ Setup visibility toggle (private/link/public) with shares table, 128-bit token entropy, deactivate/reactivate on transition — v2.3
|
||||||
|
- ✓ ShareModal with Google Docs-style UX: visibility picker, link creation/expiry, revoke, deactivation warning — v2.3
|
||||||
|
- ✓ Shared setup viewer: `/s/:token` short URL, read-only mode, inline "Shared setup" banner — v2.3
|
||||||
|
- ✓ Multi-currency support: market_prices + community_prices tables, ECB exchange rates (24h cache), conversion service — v2.3
|
||||||
|
- ✓ Community price aggregation: ownership-validated submissions, median with 3-report minimum, market-aware MSRP on catalog detail — v2.3
|
||||||
|
- ✓ i18n foundation: react-i18next, 7 namespaces, English + German translations, language picker, locale-aware formatting — v2.3
|
||||||
|
|
||||||
**Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
|
### Active (v2.4)
|
||||||
|
|
||||||
**Target features:**
|
- [ ] Fix wrong modal on Add Candidate button (thread page) — v2.4
|
||||||
- Profile page with Logto integration for account management, branded login screens, email verification
|
- [ ] Fix item images not showing on collection overview — v2.4
|
||||||
- Image fit-within framing (letterbox/pillarbox) instead of hard crops
|
- [ ] Resolve slow image loading — v2.4
|
||||||
- Catalog-driven onboarding flow with visual refresh
|
- [ ] Auth prompt sign-in redirects directly to Logto — v2.4
|
||||||
- Mobile UX improvements (icon actions, touch refinements)
|
- [ ] Cursor pointer on all clickable/interactive elements — v2.4
|
||||||
|
- [ ] Admin role flag on users table — v2.4
|
||||||
**Next milestone:** v2.3 Global & Social Ready — setup sharing system, multi-currency, i18n
|
- [ ] Admin-gated /admin panel route — v2.4
|
||||||
|
- [ ] Admin: browse/edit/delete global catalog items — v2.4
|
||||||
|
- [ ] Admin: create/rename/delete tags with parent-child hierarchy — v2.4
|
||||||
|
|
||||||
### Future
|
### Future
|
||||||
|
|
||||||
|
- [ ] Tag-based spec schemas on global items (key/value typed specs per category, sub-tag hierarchy) — v2.5
|
||||||
|
- [ ] Global item engagement stats (view count, likes/saves, setup appearances) — v2.5
|
||||||
- [ ] Freeform reviews with moderation system
|
- [ ] Freeform reviews with moderation system
|
||||||
- [ ] Comments on setups
|
- [ ] Comments on setups
|
||||||
- [ ] Follow users / activity feeds
|
- [ ] Follow users / activity feeds
|
||||||
- [ ] OAuth / social login providers
|
- [ ] OAuth / social login providers
|
||||||
- [ ] User-to-user messaging
|
- [ ] User-to-user messaging
|
||||||
|
- [ ] ComparisonTable currency normalization (hooks available, needs real multi-currency test data)
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
@@ -96,19 +117,19 @@ Help people make better gear decisions — discover what others use, compare rea
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Shipped through v2.0 with 23,970 LOC TypeScript across 210+ files. All milestones v1.0-v2.0 complete.
|
Shipped through v2.3 with 34 phases across 7 milestones. All milestones v1.0-v2.3 complete.
|
||||||
Tech stack: React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, all on Bun.
|
Tech stack: React 19, Hono, Drizzle ORM, PostgreSQL, TanStack Router/Query, Tailwind CSS v4, Lucide React, Recharts, framer-motion, react-i18next, all on Bun.
|
||||||
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 (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.
|
Features: MCP server (21 tools), global item catalog with attribution/bulk import/market prices, user profiles with Logto account management, granular setup sharing (private/link/public) with share tokens, multi-currency pricing (USD/EUR/GBP) with ECB rates and community aggregation, i18n (English + German, 7 namespaces), catalog-driven onboarding, fit-within image framing with crop editor, item/candidate detail pages, candidate ranking/comparison/impact preview. Public discovery landing page with catalog search, popular setups feed, recent items, and trending categories. Top nav + mobile bottom tab bar.
|
||||||
20+ 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
|
||||||
|
|
||||||
- **Runtime**: Bun — used as package manager and runtime
|
- **Runtime**: Bun — used as package manager and runtime
|
||||||
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
||||||
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
- **Navigation**: Top nav bar (desktop) + bottom tab bar (mobile), discovery landing page for unauthenticated users
|
||||||
- **Auth**: External self-hosted provider — no in-house auth maintenance
|
- **Auth**: External self-hosted provider — no in-house auth maintenance
|
||||||
- **Database**: PostgreSQL with Drizzle ORM
|
- **Database**: PostgreSQL with Drizzle ORM
|
||||||
- **UGC**: Structured input only (ratings, predefined fields) — no freeform text until moderation exists
|
- **UGC**: Structured input only (ratings, predefined fields) — no freeform text until moderation exists
|
||||||
@@ -155,6 +176,15 @@ Features: MCP server (21 tools), global item catalog with attribution and bulk i
|
|||||||
| Click-to-cycle for ClassificationBadge | Only 3 values, simpler than popup | ✓ Good |
|
| Click-to-cycle for ClassificationBadge | Only 3 values, simpler than popup | ✓ Good |
|
||||||
| Classification-preserving sync via Map | Save metadata before delete, restore after re-insert | ✓ Good |
|
| Classification-preserving sync via Map | Save metadata before delete, restore after re-insert | ✓ Good |
|
||||||
| Recharts for charting | Mature React chart library, composable API | ✓ Good |
|
| Recharts for charting | Mature React chart library, composable API | ✓ Good |
|
||||||
|
| visibility text column (not boolean) | Future-proofs for additional sharing modes, readable in queries | ✓ Good |
|
||||||
|
| shares table separate from setups | Enables future per-person shares, write permissions, and revocation | ✓ Good |
|
||||||
|
| 128-bit base64url share tokens | URL-safe, sufficient entropy, no external dep | ✓ Good |
|
||||||
|
| Deactivate/reactivate on visibility change | Share links survive visibility round-trips, not destroyed | ✓ Good |
|
||||||
|
| EUR default price currency | Matches existing data assumption from early single-user era | ✓ Good |
|
||||||
|
| Module-level ECB rate cache | Simple, single-process, avoids DB or Redis for rate storage | ✓ Good |
|
||||||
|
| Community price median with 3-report floor | Prevents manipulation from single-user submissions | ✓ Good |
|
||||||
|
| i18next namespace-per-feature | Matches TanStack Router file-based routing, lazy-loadable | ✓ Good |
|
||||||
|
| localStorage language key (gearbox-language) | User preference wins over browser default in detection order | ✓ Good |
|
||||||
|
|
||||||
## Evolution
|
## Evolution
|
||||||
|
|
||||||
@@ -174,4 +204,4 @@ This document evolves at phase transitions and milestone boundaries.
|
|||||||
4. Update Context with current state
|
4. Update Context with current state
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-04-10 after Phase 27 complete — top nav restructure & search bar rethink*
|
*Last updated: 2026-04-19 after v2.4 milestone start — Admin Foundation*
|
||||||
|
|||||||
@@ -1,147 +1,95 @@
|
|||||||
# Requirements: GearBox v2.1 Public Discovery
|
# Requirements: GearBox v2.4
|
||||||
|
|
||||||
**Defined:** 2026-04-09
|
**Defined:** 2026-04-19
|
||||||
|
**Milestone:** v2.4 Admin Foundation
|
||||||
**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.1 Requirements
|
## v2.4 Requirements
|
||||||
|
|
||||||
Requirements for Public Discovery milestone. Each maps to roadmap phases.
|
### Bug Fixes
|
||||||
|
|
||||||
### Public Access
|
- [x] **FIX-01**: User clicking "Add Candidate" on a thread page opens the add-candidate modal (not the wrong modal)
|
||||||
|
- [x] **FIX-02**: Item images display correctly on collection overview cards (no broken/missing images)
|
||||||
|
- [x] **FIX-03**: Catalog and collection images load without noticeable delay (slow image loading resolved)
|
||||||
|
- [x] **FIX-04**: Clicking the sign-in button on an auth prompt redirects the user directly to the Logto login page
|
||||||
|
- [x] **FIX-05**: All clickable and interactive elements show a pointer cursor on hover throughout the app
|
||||||
|
|
||||||
- [x] **PUBL-01**: User can browse the global item catalog without logging in
|
### Admin Role
|
||||||
- [x] **PUBL-02**: User can view public setups without logging in
|
|
||||||
- [x] **PUBL-03**: User can view user profiles without logging in
|
|
||||||
- [x] **PUBL-04**: Anonymous visitors see the landing page without auth spinner or redirect
|
|
||||||
- [x] **PUBL-05**: Login is only required when user attempts to create/edit/delete their own data
|
|
||||||
|
|
||||||
### Discovery
|
- [ ] **ROLE-01**: The users table has an isAdmin boolean flag that identifies admin users
|
||||||
|
- [ ] **ROLE-02**: Admin can set another user's isAdmin flag via a server-side mechanism (CLI or seed, not public UI)
|
||||||
|
|
||||||
- [x] **DISC-01**: Landing page displays an always-visible catalog search bar at the top
|
### Admin Panel — Global Items
|
||||||
- [x] **DISC-02**: Landing page shows a feed of popular setups below the search
|
|
||||||
- [x] **DISC-03**: Landing page shows recently added catalog items
|
|
||||||
- [x] **DISC-04**: Landing page shows trending categories
|
|
||||||
- [x] **DISC-05**: Authenticated users see a "Go to Collection" entry point from the landing page
|
|
||||||
|
|
||||||
### Catalog Enrichment
|
- [ ] **ADMN-01**: Admin user can navigate to an /admin route that is inaccessible to non-admin users
|
||||||
|
- [ ] **ADMN-02**: Admin can browse all global catalog items with search and tag filtering
|
||||||
|
- [ ] **ADMN-03**: Admin can edit a global catalog item's details (name, brand, model, weight, price, tags, image, attribution fields)
|
||||||
|
- [ ] **ADMN-04**: Admin can delete a global catalog item from the catalog (with confirmation)
|
||||||
|
|
||||||
- [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
### Admin Panel — Tag Management
|
||||||
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
|
|
||||||
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
|
|
||||||
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
|
|
||||||
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
|
|
||||||
|
|
||||||
### Agent Seeding Tools
|
- [ ] **ADMN-05**: Admin can browse all tags with item counts and parent/child relationships displayed
|
||||||
|
- [ ] **ADMN-06**: Admin can create a new tag with a name
|
||||||
|
- [ ] **ADMN-07**: Admin can rename an existing tag
|
||||||
|
- [ ] **ADMN-08**: Admin can assign a parent tag to a tag (enabling sub-tag hierarchy, e.g. "down" under "sleeping-bag")
|
||||||
|
- [ ] **ADMN-09**: Admin can remove a tag's parent assignment (making it a top-level tag again)
|
||||||
|
- [ ] **ADMN-10**: Admin can delete a tag, with a warning if items are currently using it
|
||||||
|
|
||||||
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
|
## Future Requirements (v2.5+)
|
||||||
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
|
|
||||||
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
|
||||||
|
|
||||||
### Infrastructure
|
### Catalog Spec System
|
||||||
|
|
||||||
- [x] **INFR-01**: Public API endpoints are rate-limited to prevent abuse
|
- **SPEC-01**: Tags can have typed spec field definitions (key, label, unit, type: number/text/image)
|
||||||
- [x] **INFR-02**: Discovery feed endpoint uses cursor pagination for scalability
|
- **SPEC-02**: Sub-tags inherit the spec schema of their parent tag
|
||||||
|
- **SPEC-03**: Admin can create, edit, and delete spec field definitions for a tag via the admin panel
|
||||||
|
- **SPEC-04**: Global catalog items can have spec values filled in for their tag's spec schema
|
||||||
|
- **SPEC-05**: Catalog item detail page displays spec values in a structured spec sheet section
|
||||||
|
- **SPEC-06**: Items are filterable/comparable by numeric spec values (e.g. R-value, comfort temp)
|
||||||
|
|
||||||
## Future Requirements
|
### Engagement Stats
|
||||||
|
|
||||||
Deferred to future milestones. Tracked but not in current roadmap.
|
- **STAT-01**: Global catalog item detail pages track view counts
|
||||||
|
- **STAT-02**: Authenticated users can like/save a catalog item (wishlist-style)
|
||||||
### Personalization
|
- **STAT-03**: Catalog item detail page shows owner count, view count, like count, and public setup appearances
|
||||||
|
- **STAT-04**: User can view their list of saved/liked catalog items
|
||||||
- **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-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
|
|
||||||
- **REV-03**: Item detail pages show average ratings from all reviewers
|
|
||||||
|
|
||||||
### Aggregation (from v2.0)
|
|
||||||
|
|
||||||
- **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-03**: Setup composition insights ("commonly paired with")
|
|
||||||
|
|
||||||
### Social (from v2.0)
|
|
||||||
|
|
||||||
- **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-03**: User can follow other users
|
|
||||||
- **SOCL-04**: User can view an activity feed of followed users' content
|
|
||||||
|
|
||||||
### Content Moderation (from v2.0)
|
|
||||||
|
|
||||||
- **MOD-01**: User can submit freeform text reviews
|
|
||||||
- **MOD-02**: User can report inappropriate content
|
|
||||||
- **MOD-03**: Admin can review and act on reported content
|
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
Explicitly excluded. Documented to prevent scope creep.
|
|
||||||
|
|
||||||
| Feature | Reason |
|
| Feature | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Personalized feed algorithm | Requires usage data and collection analysis — build simple feed first |
|
| User management in admin panel | Not needed until user base grows; Logto handles account lifecycle |
|
||||||
| SSR / static prerendering for SEO | Needs dedicated research on approach for TanStack Router — defer to v2.2+ |
|
| Moderation queue / content flagging | Deferred — requires freeform UGC first |
|
||||||
| Freeform reviews / comments | No moderation infrastructure yet — structured UGC only |
|
| Sub-items / component attachment to items | High complexity, needs dedicated discussion and milestone |
|
||||||
| Admin role / permission system | Current auth model has no role distinction — API key sufficient for v2.1 |
|
| Freeform reviews or comments | No moderation infrastructure yet |
|
||||||
| Image scraping automation | Legal gray area — agent seeding uses manufacturer-provided images with attribution |
|
| Social login providers | Logto handles this externally |
|
||||||
| User-to-user messaging | High moderation burden, not core to discovery |
|
|
||||||
| Wiki-style open item editing | Quality control risk; structured contributions only |
|
|
||||||
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
|
|
||||||
| AI gear recommendations | Training data requirements, hallucination risk |
|
|
||||||
| Gamification (badges, points) | Incentivizes quantity over quality |
|
|
||||||
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
|
|
||||||
| Mobile native app | Web-first, responsive design sufficient |
|
|
||||||
|
|
||||||
## Traceability
|
## Traceability
|
||||||
|
|
||||||
Which phases cover which requirements. Updated during roadmap creation.
|
|
||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| PUBL-01 | Phase 24 | Complete |
|
| FIX-01 | Phase 35 | Complete |
|
||||||
| PUBL-02 | Phase 24 | Complete |
|
| FIX-02 | Phase 35 | Complete |
|
||||||
| PUBL-03 | Phase 24 | Complete |
|
| FIX-03 | Phase 35 | Complete |
|
||||||
| PUBL-04 | Phase 24 | Complete |
|
| FIX-04 | Phase 35 | Complete |
|
||||||
| PUBL-05 | Phase 24 | Complete |
|
| FIX-05 | Phase 35 | Complete |
|
||||||
| INFR-01 | Phase 24 | Complete |
|
| ROLE-01 | Phase 36 | Pending |
|
||||||
| CATL-01 | Phase 25 | Complete |
|
| ROLE-02 | Phase 36 | Pending |
|
||||||
| CATL-02 | Phase 25 | Complete |
|
| ADMN-01 | Phase 36 | Pending |
|
||||||
| CATL-03 | Phase 25 | Complete |
|
| ADMN-02 | Phase 37 | Pending |
|
||||||
| CATL-04 | Phase 25 | Complete |
|
| ADMN-03 | Phase 37 | Pending |
|
||||||
| CATL-05 | Phase 25 | Complete |
|
| ADMN-04 | Phase 37 | Pending |
|
||||||
| SEED-01 | Phase 25 | Complete |
|
| ADMN-05 | Phase 38 | Pending |
|
||||||
| SEED-02 | Phase 25 | Complete |
|
| ADMN-06 | Phase 38 | Pending |
|
||||||
| SEED-03 | Phase 25 | Complete |
|
| ADMN-07 | Phase 38 | Pending |
|
||||||
| DISC-01 | Phase 26 | Complete |
|
| ADMN-08 | Phase 38 | Pending |
|
||||||
| DISC-02 | Phase 26 | Complete |
|
| ADMN-09 | Phase 38 | Pending |
|
||||||
| DISC-03 | Phase 26 | Complete |
|
| ADMN-10 | Phase 38 | Pending |
|
||||||
| DISC-04 | Phase 26 | Complete |
|
|
||||||
| DISC-05 | Phase 26 | Complete |
|
|
||||||
| INFR-02 | Phase 26 | Complete |
|
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v2.1 requirements: 20 total
|
- v2.4 requirements: 17 total
|
||||||
- Mapped to phases: 20
|
- Mapped to phases: 17
|
||||||
- Unmapped: 0
|
- Unmapped: 0 ✓
|
||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-04-09*
|
*Requirements defined: 2026-04-19*
|
||||||
*Last updated: 2026-04-09 after roadmap creation*
|
*Last updated: 2026-04-19 — traceability finalized for v2.4 roadmap*
|
||||||
|
|||||||
@@ -2,6 +2,49 @@
|
|||||||
|
|
||||||
*A living document updated after each milestone. Lessons feed forward into future planning.*
|
*A living document updated after each milestone. Lessons feed forward into future planning.*
|
||||||
|
|
||||||
|
## Milestone: v2.3 — Global & Social Ready
|
||||||
|
|
||||||
|
**Shipped:** 2026-04-19
|
||||||
|
**Phases:** 3 (32-34) | **Plans:** 18 | **Commits:** 99
|
||||||
|
|
||||||
|
### What Was Built
|
||||||
|
- Setup visibility system replacing boolean isPublic with private/link/public, share tokens with 128-bit entropy, and visibility-transition side effects
|
||||||
|
- ShareModal with Google Docs-style UX — visibility picker, link creation/expiry, revoke, deactivation warning
|
||||||
|
- Shared setup viewer with short URL redirect, read-only mode, and three-way data source logic
|
||||||
|
- Multi-currency pricing: ECB exchange rates with 24h cache, market_prices and community_prices tables, ownership-validated submissions, median aggregation
|
||||||
|
- Market-aware MSRP on catalog detail pages with collapsible "Other Markets" section
|
||||||
|
- i18n framework: react-i18next, 7 namespaces, English + German translations, language detection, language picker
|
||||||
|
|
||||||
|
### What Worked
|
||||||
|
- Phased schema approach: do the migration first (32-01), service layer next, UI last — no mid-phase schema surprises
|
||||||
|
- Dynamic import to break circular dependency (setup.service.ts → share.service.ts) was clean and discovered quickly
|
||||||
|
- ECB exchange rate module-level cache is dead simple and effective for a single-process Bun app
|
||||||
|
- Namespace-per-feature for i18n matches the existing file-based routing structure naturally
|
||||||
|
|
||||||
|
### What Was Inefficient
|
||||||
|
- Phase 32 progress table in ROADMAP.md showed 0/4 Planned despite all plans being complete — tracking drift not caught until milestone close
|
||||||
|
- Several todos from early in the milestone (April 10) accumulated and weren't cleared before close — 6 deferred items
|
||||||
|
- REQUIREMENTS.md was never refreshed for v2.2 or v2.3; requirements were tracked informally in STATE.md decisions
|
||||||
|
|
||||||
|
### Patterns Established
|
||||||
|
- `visibility` text enum over boolean flags for any future toggle-able states (shareable, public, featured)
|
||||||
|
- Shares as a separate table with revocation semantics — reusable pattern for future permission systems
|
||||||
|
- Community aggregation floor (3 reports minimum) before surfacing median — prevents single-user stat manipulation
|
||||||
|
- i18n namespace per feature domain matches the codebase's existing routing and component organization
|
||||||
|
|
||||||
|
### Key Lessons
|
||||||
|
1. Keep REQUIREMENTS.md current across milestones — informal tracking in STATE.md decisions is not a substitute
|
||||||
|
2. Todo triage at milestone close works, but earlier triage (mid-milestone) would reduce the deferred backlog
|
||||||
|
3. The shares deactivate/reactivate pattern (not destroy) gives users a better experience at near-zero complexity cost
|
||||||
|
4. Language detection: localStorage-first is the right call — user preference must win over browser default
|
||||||
|
|
||||||
|
### Cost Observations
|
||||||
|
- Model mix: sonnet throughout
|
||||||
|
- Sessions: ~18 plan executions across 6 days
|
||||||
|
- Notable: Phase 34 (i18n) was the heaviest at 8 plans — string extraction across the full app touches every component
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Milestone: v1.0 — MVP
|
## Milestone: v1.0 — MVP
|
||||||
|
|
||||||
**Shipped:** 2026-03-15
|
**Shipped:** 2026-03-15
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
- ✅ **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-27 (shipped 2026-04-12)
|
- ✅ **v2.1 Public Discovery** — Phases 24-27 (shipped 2026-04-12)
|
||||||
- 🚧 **v2.2 User Experience Polish** — Phases 28-31 (in progress)
|
- ✅ **v2.2 User Experience Polish** — Phases 28-31 (shipped 2026-04-13)
|
||||||
- 📋 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
|
- ✅ **v2.3 Global & Social Ready** — Phases 32-34 (shipped 2026-04-19)
|
||||||
|
- 🚧 **v2.4 Admin Foundation** — Phases 35-38 (in progress)
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
@@ -76,22 +77,31 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### v2.2 User Experience Polish (In Progress)
|
<details>
|
||||||
|
<summary>✅ v2.2 User Experience Polish (Phases 28-31) — SHIPPED 2026-04-13</summary>
|
||||||
|
|
||||||
**Milestone Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
|
- [x] Phase 28: Profile & Logto Integration (3/3 plans) — completed 2026-04-12
|
||||||
|
- [x] Phase 29: Image Presentation (5/5 plans) — completed 2026-04-12
|
||||||
|
- [x] Phase 30: Onboarding Redesign (3/3 plans) — completed 2026-04-12
|
||||||
|
- [x] Phase 31: Mobile Polish (2/2 plans) — completed 2026-04-12
|
||||||
|
|
||||||
- [x] **Phase 28: Profile & Logto Integration** — Fix profile page, integrate Logto for profile management, customize login branding, configure email verification (completed 2026-04-12)
|
</details>
|
||||||
- [x] **Phase 29: Image Presentation** — Fit-within framing with letterbox/pillarbox instead of hard crops, optional crop positioning (completed 2026-04-12)
|
|
||||||
- [x] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2) (completed 2026-04-12)
|
|
||||||
- [x] **Phase 31: Mobile Polish** — Icon-based action buttons on item views, small UX improvements (completed 2026-04-12)
|
|
||||||
|
|
||||||
### v2.3 Global & Social Ready (Planned)
|
<details>
|
||||||
|
<summary>✅ v2.3 Global & Social Ready (Phases 32-34) — SHIPPED 2026-04-19</summary>
|
||||||
|
|
||||||
**Milestone Goal:** Make GearBox work for a global audience with setup sharing, multi-currency support, and localization infrastructure.
|
- [x] Phase 32: Setup Sharing System (4/4 plans) — completed 2026-04-15
|
||||||
|
- [x] Phase 33: Currency System (6/6 plans) — completed 2026-04-13
|
||||||
|
- [x] Phase 34: i18n Foundation (8/8 plans) — completed 2026-04-18
|
||||||
|
|
||||||
- [ ] **Phase 32: Setup Sharing System** — Visibility toggle (private/link/public), link sharing, schema future-proofed for likes, friends, and collaborative editing
|
</details>
|
||||||
- [ ] **Phase 33: Currency System** — Multi-currency support (USD/EUR/GBP), price display per user preference
|
|
||||||
- [ ] **Phase 34: i18n Foundation** — Translation framework, string extraction, locale-aware formatting
|
### v2.4 Admin Foundation (In Progress)
|
||||||
|
|
||||||
|
- [x] **Phase 35: Bug Fixes** — Clear the v2.3 backlog: wrong modal, missing images, slow loading, auth redirect, cursor pointer (completed 2026-04-19)
|
||||||
|
- [x] **Phase 36: Admin Role & Panel Foundation** — isAdmin flag, server mechanism to grant admin, gated /admin route with placeholder UI (completed 2026-04-19)
|
||||||
|
- [x] **Phase 37: Admin — Global Item Management** — Browse, edit, and delete global catalog items from the admin panel
|
||||||
|
- [x] **Phase 38: Admin — Tag Management** — Full tag CRUD with parent-child hierarchy in the admin panel
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
@@ -166,56 +176,42 @@ Plans:
|
|||||||
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
|
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 28: Profile & Logto Integration
|
|
||||||
**Goal**: Users have a working profile page with account management powered by Logto, branded login screens, and email verification
|
|
||||||
**Depends on**: Phase 27 (v2.1 complete)
|
|
||||||
**Requirements**: TBD (discuss phase)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
TBD (discuss phase)
|
|
||||||
**Plans**: TBD
|
|
||||||
|
|
||||||
### Phase 29: Image Presentation
|
|
||||||
**Goal**: Images display within the fixed aspect ratio using fit-within framing (letterbox/pillarbox) instead of hard crops, preserving the full image
|
|
||||||
**Depends on**: Phase 28
|
|
||||||
**Requirements**: TBD (discuss phase)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
TBD (discuss phase)
|
|
||||||
**Plans**: TBD
|
|
||||||
|
|
||||||
### Phase 30: Onboarding Redesign
|
|
||||||
**Goal**: New users experience a polished, catalog-driven onboarding flow that matches the current UI style and guides them through their first setup
|
|
||||||
**Depends on**: Phase 28
|
|
||||||
**Requirements**: TBD (discuss phase)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
TBD (discuss phase)
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 31: Mobile Polish
|
|
||||||
**Goal**: Mobile item views use icon-based action buttons instead of text labels, with small UX refinements across touch interactions
|
|
||||||
**Depends on**: None
|
|
||||||
**Requirements**: TBD (discuss phase)
|
|
||||||
**Success Criteria** (what must be TRUE):
|
|
||||||
TBD (discuss phase)
|
|
||||||
**Plans**: TBD
|
|
||||||
**UI hint**: yes
|
|
||||||
|
|
||||||
### Phase 32: Setup Sharing System
|
### Phase 32: Setup Sharing System
|
||||||
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
|
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
|
||||||
**Depends on**: Phase 28 (profiles working)
|
**Depends on**: Phase 28 (profiles working)
|
||||||
**Requirements**: TBD (discuss phase)
|
**Requirements**: TBD (discuss phase)
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
TBD (discuss phase)
|
TBD (discuss phase)
|
||||||
**Plans**: TBD
|
**Plans**: 4 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 32-01-PLAN.md — Schema migration (isPublic to visibility) + shares table + full-stack update
|
||||||
|
- [ ] 32-02-PLAN.md — Share link service, API routes, and short URL redirect
|
||||||
|
- [ ] 32-03-PLAN.md — Share modal UI component with visibility picker and link management
|
||||||
|
- [ ] 32-04-PLAN.md — Shared setup viewer with token detection and read-only mode
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 33: Currency System
|
### Phase 33: Currency System
|
||||||
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly
|
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly — full market-aware pricing system with community price data
|
||||||
**Depends on**: Phase 32
|
**Depends on**: Phase 32
|
||||||
**Requirements**: TBD (discuss phase)
|
**Requirements**: D-01 through D-21 (from discuss phase)
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
TBD (discuss phase)
|
1. User can select a market/currency in settings and all prices display in that currency
|
||||||
**Plans**: TBD
|
2. Catalog items show market-specific MSRP with community price aggregation per market
|
||||||
|
3. Converted prices are clearly labeled as approximate with ~ prefix and dual display format
|
||||||
|
4. Users can submit community prices for items they own (ownership validated)
|
||||||
|
5. Comparison table normalizes candidate prices to user's currency for apples-to-apples comparison
|
||||||
|
6. Exchange rates fetched daily from ECB via frankfurter.app with 24h cache
|
||||||
|
**Plans**: 6 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 33-01-PLAN.md — Schema (market_prices, community_prices tables) + currency conversion service
|
||||||
|
- [x] 33-02-PLAN.md — [BLOCKING] Database migration generation and push
|
||||||
|
- [x] 33-03-PLAN.md — Market prices API, exchange rates endpoint, item/candidate currency context
|
||||||
|
- [x] 33-04-PLAN.md — Community price service (ownership validation, median aggregation) + setup totals
|
||||||
|
- [x] 33-05-PLAN.md — Formatter evolution, market/currency selector, auto-suggestion, conversion toggle
|
||||||
|
- [x] 33-06-PLAN.md — Catalog detail market prices, comparison table normalization, MCP tool updates
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 34: i18n Foundation
|
### Phase 34: i18n Foundation
|
||||||
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
|
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
|
||||||
@@ -225,6 +221,69 @@ Plans:
|
|||||||
TBD (discuss phase)
|
TBD (discuss phase)
|
||||||
**Plans**: TBD
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 35: Bug Fixes
|
||||||
|
**Goal**: All five known v2.3 regressions and polish gaps are resolved — the app behaves correctly and consistently
|
||||||
|
**Depends on**: Phase 34 (v2.3 complete)
|
||||||
|
**Requirements**: FIX-01, FIX-02, FIX-03, FIX-04, FIX-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Clicking "Add Candidate" on a thread page opens the add-candidate modal, not any other modal
|
||||||
|
2. Item images appear correctly on collection overview cards — no broken or missing images
|
||||||
|
3. Catalog and collection images appear without noticeable delay across all image-bearing pages
|
||||||
|
4. Clicking the sign-in button on an auth prompt navigates the user directly to the Logto login page
|
||||||
|
5. Every clickable or interactive element in the app (buttons, links, cards, badges) shows a pointer cursor on hover
|
||||||
|
**Plans**: 3 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 35-01-PLAN.md — Thread modal fix, ItemWithCategory type extension, login auto-redirect (FIX-01, FIX-02, FIX-04)
|
||||||
|
- [x] 35-02-PLAN.md — Lazy loading + image skeleton states on GearImage and all card components (FIX-03)
|
||||||
|
- [x] 35-03-PLAN.md — Cursor-pointer audit across ItemCard, FabMenu, BottomTabBar (FIX-05)
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 36: Admin Role & Panel Foundation
|
||||||
|
**Goal**: An admin user exists in the system with a verified flag, a server-side mechanism to grant admin status, and a protected /admin route that non-admins cannot reach
|
||||||
|
**Depends on**: Phase 35
|
||||||
|
**Requirements**: ROLE-01, ROLE-02, ADMN-01
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. The users table has an isAdmin boolean column and the schema migration applies cleanly
|
||||||
|
2. A developer can grant or revoke admin status for any user via a CLI script or seed mechanism without touching the UI
|
||||||
|
3. Navigating to /admin as an authenticated non-admin user returns an access-denied response (403 or redirect)
|
||||||
|
4. Navigating to /admin as an admin user loads the admin panel (even if it shows a placeholder)
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 37: Admin — Global Item Management
|
||||||
|
**Goal**: Admins can browse, edit, and delete any global catalog item from the admin panel
|
||||||
|
**Depends on**: Phase 36
|
||||||
|
**Requirements**: ADMN-02, ADMN-03, ADMN-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Admin can view a paginated list of all global catalog items with search and tag filtering
|
||||||
|
2. Admin can open any catalog item and edit its name, brand, model, weight, price, tags, image, and attribution fields — changes persist
|
||||||
|
3. Admin can delete a catalog item after confirming the action — the item is removed from the catalog and the deletion is irreversible
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 38: Admin — Tag Management
|
||||||
|
**Goal**: Admins can fully manage the tag taxonomy — creating, renaming, organizing into a parent-child hierarchy, and deleting tags — from within the admin panel
|
||||||
|
**Depends on**: Phase 37
|
||||||
|
**Requirements**: ADMN-05, ADMN-06, ADMN-07, ADMN-08, ADMN-09, ADMN-10
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Admin can view all tags in a list that shows each tag's name, item count, parent tag (if any), and direct children
|
||||||
|
2. Admin can create a new top-level tag by entering a name — the tag appears immediately in the list
|
||||||
|
3. Admin can rename any existing tag — the updated name is reflected everywhere the tag is used
|
||||||
|
4. Admin can assign a parent to any tag, making it a child in the hierarchy (e.g. "down" under "insulation")
|
||||||
|
5. Admin can remove a parent assignment from a tag, making it a top-level tag again
|
||||||
|
6. Admin can delete a tag; if items currently use that tag, a warning is shown before the deletion is confirmed
|
||||||
|
**Plans**: 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 38-01-PLAN.md — Schema migration (parentId), service layer (CRUD + cycle detection), API routes, tests
|
||||||
|
- [x] 38-02-PLAN.md — Client hooks, tag list page (tree view + quick-add + search), edit page (rename/reparent/delete), sidebar activation
|
||||||
|
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
@@ -256,13 +315,17 @@ Plans:
|
|||||||
| 25. Catalog Enrichment & Agent Tools | v2.1 | 2/2 | Complete | 2026-04-10 |
|
| 25. Catalog Enrichment & Agent Tools | v2.1 | 2/2 | Complete | 2026-04-10 |
|
||||||
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
|
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
|
||||||
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
|
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
|
||||||
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
|
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||||
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
|
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
|
||||||
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
|
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||||
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
|
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
|
||||||
| 32. Setup Sharing System | v2.3 | TBD | Pending | — |
|
| 32. Setup Sharing System | v2.3 | 4/4 | Complete | 2026-04-15 |
|
||||||
| 33. Currency System | v2.3 | TBD | Pending | — |
|
| 33. Currency System | v2.3 | 6/6 | Complete | 2026-04-13 |
|
||||||
| 34. i18n Foundation | v2.3 | TBD | Pending | — |
|
| 34. i18n Foundation | v2.3 | 8/8 | Complete | 2026-04-18 |
|
||||||
|
| 35. Bug Fixes | v2.4 | 3/3 | Complete | 2026-04-19 |
|
||||||
|
| 36. Admin Role & Panel Foundation | v2.4 | 2/2 | Complete | 2026-04-19 |
|
||||||
|
| 37. Admin — Global Item Management | v2.4 | 2/2 | Complete | 2026-04-19 |
|
||||||
|
| 38. Admin — Tag Management | v2.4 | 1/2 | In progress | - |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
@@ -338,3 +401,11 @@ Plans:
|
|||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.12: Admin UX Polish (BACKLOG)
|
||||||
|
**Goal**: Overhaul admin panel UX with TanStack Table (sortable/groupable columns) and cmdk (GitLab-style composable filter bar with field→operator→value token input). Hide FAB on /admin/* pages. Replace tag inline form with popup modal. Show tags expanded on item rows (collapse to +N when tight). Group items by brand. Prominent search bar on both admin list pages.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v2.2
|
milestone: v2.4
|
||||||
milestone_name: User Experience Polish
|
milestone_name: Admin Foundation
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: Phase 31 context gathered
|
stopped_at: Completed 38-02-PLAN.md — admin tag management client UI
|
||||||
last_updated: "2026-04-12T18:50:04.872Z"
|
last_updated: "2026-04-19T20:32:22Z"
|
||||||
last_activity: 2026-04-12
|
last_activity: 2026-04-20
|
||||||
progress:
|
progress:
|
||||||
total_phases: 36
|
total_phases: 20
|
||||||
completed_phases: 24
|
completed_phases: 10
|
||||||
total_plans: 67
|
total_plans: 38
|
||||||
completed_plans: 65
|
completed_plans: 37
|
||||||
percent: 97
|
percent: 97
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,27 +18,26 @@ progress:
|
|||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-04-09)
|
See: .planning/PROJECT.md (updated 2026-04-19)
|
||||||
|
|
||||||
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||||
**Current focus:** Phase 30 — Onboarding Redesign
|
**Current focus:** Phase 36 — Admin Role & Panel Foundation
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 31
|
Phase: 36 (Admin Role & Panel Foundation) — EXECUTING
|
||||||
Plan: Not started
|
Plan: 2 of 2
|
||||||
Status: Executing Phase 30
|
Status: Ready to execute
|
||||||
Last activity: 2026-04-12
|
Last activity: 2026-04-19
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
Progress: [█████████░] 97%
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 67 (all milestones through v2.0)
|
- Total plans completed: 110+ (all milestones through v2.3)
|
||||||
- v1.3: 6 plans across 4 phases (2026-03-16 to 2026-04-08)
|
- v2.3: 18 plans across 3 phases (2026-04-13 → 2026-04-19)
|
||||||
- v2.0: 32 plans across 10 phases (2026-03-17 to 2026-04-08)
|
|
||||||
|
|
||||||
*Updated after each plan completion*
|
*Updated after each plan completion*
|
||||||
|
|
||||||
@@ -46,41 +45,52 @@ Progress: [░░░░░░░░░░] 0%
|
|||||||
|
|
||||||
### Decisions
|
### Decisions
|
||||||
|
|
||||||
Key decisions carried forward from v2.0:
|
Key decisions carried forward from v2.3:
|
||||||
|
|
||||||
- External auth provider: Logto (self-hosted OIDC) — RESOLVED
|
- External auth provider: Logto (self-hosted OIDC) — RESOLVED
|
||||||
- Structured UGC only — ratings and predefined fields, no freeform text — ACTIVE
|
- Structured UGC only — ratings and predefined fields, no freeform text — 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 — RESOLVED
|
- COALESCE merge for reference items — RESOLVED
|
||||||
- Detail pages replacing slide-out panels — RESOLVED
|
- Detail pages replacing slide-out panels — RESOLVED
|
||||||
|
- Setup visibility: private/link/public column + shares table — RESOLVED
|
||||||
|
- Multi-currency: market_prices + community_prices + ECB rates — RESOLVED
|
||||||
|
- i18n: react-i18next, 7 namespaces, English + German — RESOLVED
|
||||||
|
|
||||||
v2.1 decisions:
|
v2.4 decisions:
|
||||||
|
|
||||||
- Product images: manufacturer images with attribution and source link, honor takedown requests — RESOLVED
|
- Admin role: isAdmin boolean flag on users table (simplest, no Logto role claims needed)
|
||||||
- Catalog data: open datasets + manufacturer specs + agent MCP enrichment — RESOLVED
|
- Admin grant mechanism: CLI script or seed — no public UI for granting admin
|
||||||
- Public-first: auth model rework before content features — RESOLVED
|
- Sub-items/component attachment: explicitly deferred to a future milestone
|
||||||
- Phase 999.3 (Public Access Auth Model backlog item) is now Phase 24 — PROMOTED
|
- Catalog spec system (typed specs per tag): deferred to v2.5
|
||||||
- [Phase 24-public-access-infrastructure]: createRateLimit factory pattern for configurable rate limiting per endpoint tier
|
- Engagement stats (views/likes/saves/appearances): deferred to v2.5
|
||||||
- [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 35 decisions (35-01):
|
||||||
- [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
|
- FIX-01: Add Candidate on thread page routes through CatalogSearchOverlay (thread mode), not a local modal
|
||||||
- [Phase 25-catalog-enrichment-agent-tools]: unique(brand, model) constraint on globalItems: enables safe ON CONFLICT DO UPDATE for catalog enrichment agents
|
- FIX-02: ItemWithCategory type extended client-side only — server already returns image fields via withImageUrls()
|
||||||
- [Phase 25-catalog-enrichment-agent-tools]: Catalog MCP tools use registerCatalogTools(db) without userId — shared catalog needs no user scoping
|
- FIX-04: Login page is a server pass-through; no client auth check or card UI needed
|
||||||
- [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 35 decisions (35-02):
|
||||||
- [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
|
- FIX-03: Browser-native loading=lazy used for image deferral — no library needed, zero bundle overhead
|
||||||
- [Phase 26-discovery-landing-page]: PublicSetupCard itemCount/creatorName fields are optional for backward compatibility with users/$userId usage
|
- FIX-03: Skeleton is absolute inset-0 overlay removed on onLoad (not conditional branch swap) for stable layout
|
||||||
- [Phase 26-discovery-landing-page]: Discovery sections hide entirely (return null) when not loading and data is empty — avoids empty grid layouts
|
- FIX-03: GearImage accepts optional onLoad prop forwarded to all three img render paths
|
||||||
- [Phase 27]: Setups elevated to top-level /setups route; Collection page reduced to Gear and Planning tabs with .catch(gear) fallback for legacy URLs
|
- [Phase ?]: FIX-05: cursor-pointer explicitly added to ItemCard navigable case, FabMenu buttons, and BottomTabBar anonymous tab buttons
|
||||||
- [Phase 27]: Wave 0 tests use test.fixme for removed dashboard cards — preserves test intent for future reference
|
|
||||||
- [Phase 27]: Old setups tab test replaced with fallback-to-gear assertion matching the Zod .catch('gear') behavior planned in Plans 01-03
|
|
||||||
- [Phase 27]: Used 'house' icon instead of plan-specified 'home': lucide-react has no Home icon, only House — prevents Package fallback rendering in navigation
|
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
- Fix Add Candidate button shows wrong modal on thread page (ui)
|
- Cursor pointer on all clickable links — Phase 35 (FIX-05, plan 35-03)
|
||||||
|
- Make tag selector in global search searchable — `CatalogSearchOverlay.tsx`
|
||||||
|
|
||||||
|
Resolved in 35-01:
|
||||||
|
|
||||||
|
- Fix Add Candidate button shows wrong modal on thread page — DONE (FIX-01)
|
||||||
|
- Fix item image not showing on collection overview — DONE (FIX-02)
|
||||||
|
- Auth prompt sign-in button should redirect directly to Logto — DONE (FIX-04)
|
||||||
|
|
||||||
|
Resolved in 35-02:
|
||||||
|
|
||||||
|
- Investigate slow image loading — DONE (FIX-03)
|
||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
@@ -90,12 +100,23 @@ None.
|
|||||||
|
|
||||||
| # | Description | Date | Commit | Directory |
|
| # | Description | Date | Commit | Directory |
|
||||||
|---|-------------|------|--------|-----------|
|
|---|-------------|------|--------|-----------|
|
||||||
| 260411-022 | Fix global items search bar layout - too tall and hard to navigate back | 2026-04-10 | ef48891 | [260411-022-fix-global-items-search-bar-layout-too-t](./quick/260411-022-fix-global-items-search-bar-layout-too-t/) |
|
| 260420-vk0 | Fix UAT issues: image fetch-from-URL, image cropping, tag routing, duplicate tag error, tag form UX | 2026-04-20 | ddf9b95 | [260420-vk0-fix-uat-issues-image-fetch-from-url-imag](./quick/260420-vk0-fix-uat-issues-image-fetch-from-url-imag/) |
|
||||||
| 260411-0zq | Redesign search UX — real nav search bar navigating to /global-items?q= | 2026-04-10 | 334bf33 | [260411-0zq-redesign-search-ux-bigger-nav-search-bar](./quick/260411-0zq-redesign-search-ux-bigger-nav-search-bar/) |
|
|
||||||
| 260411-1h2 | Rebuild global items page with sticky toolbar and inline filters | 2026-04-10 | ee3b6f7 | [260411-1h2-rebuild-global-items-page-with-sticky-se](./quick/260411-1h2-rebuild-global-items-page-with-sticky-se/) |
|
## Deferred Items
|
||||||
|
|
||||||
|
Items carried forward from v2.3:
|
||||||
|
|
||||||
|
| Category | Item | Status |
|
||||||
|
|----------|------|--------|
|
||||||
|
| todo | 2026-04-10-add-cursor-pointer-to-all-clickable-links | promoted to v2.4 FIX-05 |
|
||||||
|
| todo | 2026-04-10-fix-item-image-not-showing-on-collection-overview | promoted to v2.4 FIX-02 |
|
||||||
|
| todo | 2026-04-10-investigate-slow-image-loading | promoted to v2.4 FIX-03 |
|
||||||
|
| todo | 2026-04-13-auth-prompt-sign-in-button-should-redirect-directly-to-logto | promoted to v2.4 FIX-04 |
|
||||||
|
| todo | 2026-04-13-fix-add-candidate-button-shows-wrong-modal-on-thread-page | promoted to v2.4 FIX-01 |
|
||||||
|
| Phase 35 P03 | 5m | 2 tasks | 3 files |
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-12T18:01:20.416Z
|
Last session: 2026-04-19T20:32:22Z
|
||||||
Stopped at: Phase 31 context gathered
|
Stopped at: Completed 38-02-PLAN.md — admin tag management client UI
|
||||||
Resume file: .planning/phases/31-mobile-polish/31-CONTEXT.md
|
Resume file: None
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: awaiting_human_verify
|
status: resolved
|
||||||
trigger: "Client-side error 'can't access property id, w[0] is undefined' occurs after login"
|
trigger: "Client-side error 'can't access property id, w[0] is undefined' occurs after login"
|
||||||
created: 2026-04-08T00:00:00Z
|
created: 2026-04-08T00:00:00Z
|
||||||
updated: 2026-04-08T00:00:00Z
|
updated: 2026-04-08T00:00:00Z
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: diagnosed
|
status: resolved
|
||||||
trigger: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
|
trigger: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
|
||||||
created: 2026-04-13T12:30:00Z
|
created: 2026-04-13T12:30:00Z
|
||||||
updated: 2026-04-13T12:35:00Z
|
updated: 2026-04-13T12:35:00Z
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: fixing
|
status: resolved
|
||||||
trigger: "GearBox deployed on Coolify throws Invalid session (HTTP 500) from @hono/oidc-auth middleware when accessing GET /login"
|
trigger: "GearBox deployed on Coolify throws Invalid session (HTTP 500) from @hono/oidc-auth middleware when accessing GET /login"
|
||||||
created: 2026-04-08T00:00:00Z
|
created: 2026-04-08T00:00:00Z
|
||||||
updated: 2026-04-08T00:01:00Z
|
updated: 2026-04-08T00:01:00Z
|
||||||
|
|||||||
156
.planning/milestones/v2.2-REQUIREMENTS.md
Normal file
156
.planning/milestones/v2.2-REQUIREMENTS.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Requirements Archive: v2.2 User Experience Polish
|
||||||
|
|
||||||
|
**Archived:** 2026-04-13
|
||||||
|
**Status:** SHIPPED
|
||||||
|
|
||||||
|
For current requirements, see `.planning/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements: GearBox v2.1 Public Discovery
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## v2.1 Requirements
|
||||||
|
|
||||||
|
Requirements for Public Discovery milestone. Each maps to roadmap phases.
|
||||||
|
|
||||||
|
### Public Access
|
||||||
|
|
||||||
|
- [x] **PUBL-01**: User can browse the global item catalog without logging in
|
||||||
|
- [x] **PUBL-02**: User can view public setups without logging in
|
||||||
|
- [x] **PUBL-03**: User can view user profiles without logging in
|
||||||
|
- [x] **PUBL-04**: Anonymous visitors see the landing page without auth spinner or redirect
|
||||||
|
- [x] **PUBL-05**: Login is only required when user attempts to create/edit/delete their own data
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- [x] **DISC-01**: Landing page displays an always-visible catalog search bar at the top
|
||||||
|
- [x] **DISC-02**: Landing page shows a feed of popular setups below the search
|
||||||
|
- [x] **DISC-03**: Landing page shows recently added catalog items
|
||||||
|
- [x] **DISC-04**: Landing page shows trending categories
|
||||||
|
- [x] **DISC-05**: Authenticated users see a "Go to Collection" entry point from the landing page
|
||||||
|
|
||||||
|
### Catalog Enrichment
|
||||||
|
|
||||||
|
- [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
||||||
|
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
|
||||||
|
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
|
||||||
|
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
|
||||||
|
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
|
||||||
|
|
||||||
|
### Agent Seeding Tools
|
||||||
|
|
||||||
|
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
|
||||||
|
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
|
||||||
|
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- [x] **INFR-01**: Public API endpoints are rate-limited to prevent abuse
|
||||||
|
- [x] **INFR-02**: Discovery feed endpoint uses cursor pagination for scalability
|
||||||
|
|
||||||
|
## Future Requirements
|
||||||
|
|
||||||
|
Deferred to future milestones. Tracked but not in current roadmap.
|
||||||
|
|
||||||
|
### 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-02**: User can rate a global item on predefined dimensions (durability, value, etc.)
|
||||||
|
- **REV-03**: Item detail pages show average ratings from all reviewers
|
||||||
|
|
||||||
|
### Aggregation (from v2.0)
|
||||||
|
|
||||||
|
- **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-03**: Setup composition insights ("commonly paired with")
|
||||||
|
|
||||||
|
### Social (from v2.0)
|
||||||
|
|
||||||
|
- **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-03**: User can follow other users
|
||||||
|
- **SOCL-04**: User can view an activity feed of followed users' content
|
||||||
|
|
||||||
|
### Content Moderation (from v2.0)
|
||||||
|
|
||||||
|
- **MOD-01**: User can submit freeform text reviews
|
||||||
|
- **MOD-02**: User can report inappropriate content
|
||||||
|
- **MOD-03**: Admin can review and act on reported content
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Explicitly excluded. Documented to prevent scope creep.
|
||||||
|
|
||||||
|
| Feature | Reason |
|
||||||
|
|---------|--------|
|
||||||
|
| Personalized feed algorithm | Requires usage data and collection analysis — build simple feed first |
|
||||||
|
| 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 |
|
||||||
|
| Wiki-style open item editing | Quality control risk; structured contributions only |
|
||||||
|
| Marketplace / buy-sell | Payment processing, fraud, legal liability |
|
||||||
|
| AI gear recommendations | Training data requirements, hallucination risk |
|
||||||
|
| Gamification (badges, points) | Incentivizes quantity over quality |
|
||||||
|
| Price tracking / deal alerts | Requires scraping, fragile, legal gray area |
|
||||||
|
| Mobile native app | Web-first, responsive design sufficient |
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
Which phases cover which requirements. Updated during roadmap creation.
|
||||||
|
|
||||||
|
| Requirement | Phase | Status |
|
||||||
|
|-------------|-------|--------|
|
||||||
|
| PUBL-01 | Phase 24 | Complete |
|
||||||
|
| PUBL-02 | Phase 24 | Complete |
|
||||||
|
| PUBL-03 | Phase 24 | Complete |
|
||||||
|
| PUBL-04 | Phase 24 | Complete |
|
||||||
|
| PUBL-05 | Phase 24 | Complete |
|
||||||
|
| INFR-01 | Phase 24 | Complete |
|
||||||
|
| CATL-01 | Phase 25 | Complete |
|
||||||
|
| CATL-02 | Phase 25 | Complete |
|
||||||
|
| CATL-03 | Phase 25 | Complete |
|
||||||
|
| CATL-04 | Phase 25 | Complete |
|
||||||
|
| CATL-05 | Phase 25 | Complete |
|
||||||
|
| SEED-01 | Phase 25 | Complete |
|
||||||
|
| SEED-02 | Phase 25 | Complete |
|
||||||
|
| SEED-03 | Phase 25 | Complete |
|
||||||
|
| DISC-01 | Phase 26 | Complete |
|
||||||
|
| DISC-02 | Phase 26 | Complete |
|
||||||
|
| DISC-03 | Phase 26 | Complete |
|
||||||
|
| DISC-04 | Phase 26 | Complete |
|
||||||
|
| DISC-05 | Phase 26 | Complete |
|
||||||
|
| INFR-02 | Phase 26 | Complete |
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- v2.1 requirements: 20 total
|
||||||
|
- Mapped to phases: 20
|
||||||
|
- Unmapped: 0
|
||||||
|
|
||||||
|
---
|
||||||
|
*Requirements defined: 2026-04-09*
|
||||||
|
*Last updated: 2026-04-09 after roadmap creation*
|
||||||
340
.planning/milestones/v2.2-ROADMAP.md
Normal file
340
.planning/milestones/v2.2-ROADMAP.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Roadmap: GearBox
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- ✅ **v1.0 MVP** — Phases 1-3 (shipped 2026-03-15)
|
||||||
|
- ✅ **v1.1 Fixes & Polish** — Phases 4-6 (shipped 2026-03-15)
|
||||||
|
- ✅ **v1.2 Collection Power-Ups** — Phases 7-9 (shipped 2026-03-16)
|
||||||
|
- ✅ **v1.3 Research & Decision Tools** — Phases 10-13 (shipped 2026-04-08)
|
||||||
|
- ✅ **v2.0 Platform Foundation** — Phases 14-23 (shipped 2026-04-08)
|
||||||
|
- ✅ **v2.1 Public Discovery** — Phases 24-27 (shipped 2026-04-12)
|
||||||
|
- 🚧 **v2.2 User Experience Polish** — Phases 28-31 (in progress)
|
||||||
|
- 📋 **v2.3 Global & Social Ready** — Phases 32-34 (planned)
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v1.0 MVP (Phases 1-3) — SHIPPED 2026-03-15</summary>
|
||||||
|
|
||||||
|
- [x] Phase 1: Foundation and Collection (4/4 plans) — completed 2026-03-14
|
||||||
|
- [x] Phase 2: Planning Threads (3/3 plans) — completed 2026-03-15
|
||||||
|
- [x] Phase 3: Setups and Dashboard (3/3 plans) — completed 2026-03-15
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v1.1 Fixes & Polish (Phases 4-6) — SHIPPED 2026-03-15</summary>
|
||||||
|
|
||||||
|
- [x] Phase 4: Database & Planning Fixes (2/2 plans) — completed 2026-03-15
|
||||||
|
- [x] Phase 5: Image Handling (2/2 plans) — completed 2026-03-15
|
||||||
|
- [x] Phase 6: Category Icons (3/3 plans) — completed 2026-03-15
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v1.2 Collection Power-Ups (Phases 7-9) — SHIPPED 2026-03-16</summary>
|
||||||
|
|
||||||
|
- [x] Phase 7: Weight Unit Selection (2/2 plans) — completed 2026-03-16
|
||||||
|
- [x] Phase 8: Search, Filter, and Candidate Status (2/2 plans) — completed 2026-03-16
|
||||||
|
- [x] Phase 9: Weight Classification and Visualization (2/2 plans) — completed 2026-03-16
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v1.3 Research & Decision Tools (Phases 10-13) — SHIPPED 2026-04-08</summary>
|
||||||
|
|
||||||
|
- [x] Phase 10: Schema Foundation + Pros/Cons Fields (1/1 plans) — completed 2026-03-16
|
||||||
|
- [x] Phase 11: Candidate Ranking (2/2 plans) — completed 2026-03-16
|
||||||
|
- [x] Phase 12: Comparison View (1/1 plans) — completed 2026-03-17
|
||||||
|
- [x] Phase 13: Setup Impact Preview (2/2 plans) — completed 2026-04-08
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v2.0 Platform Foundation (Phases 14-23) — SHIPPED 2026-04-08</summary>
|
||||||
|
|
||||||
|
- [x] Phase 14: PostgreSQL Migration (6/6 plans) — completed 2026-04-05
|
||||||
|
- [x] Phase 15: External Authentication (3/3 plans) — completed 2026-04-05
|
||||||
|
- [x] Phase 16: Multi-User Data Model (4/4 plans) — completed 2026-04-05
|
||||||
|
- [x] Phase 17: Object Storage (3/3 plans) — completed 2026-04-05
|
||||||
|
- [x] Phase 18: Global Items & Public Profiles (5/5 plans) — completed 2026-04-05
|
||||||
|
- [x] Phase 19: Reference Item Model & Tags Schema (3/3 plans) — completed 2026-04-05
|
||||||
|
- [x] Phase 20: FAB & Full-Screen Catalog Search (2/2 plans) — completed 2026-04-06
|
||||||
|
- [x] Phase 21: Item & Catalog Detail Pages (3/3 plans) — completed 2026-04-06
|
||||||
|
- [x] Phase 22: Add-from-Catalog & Thread Integration (2/2 plans) — completed 2026-04-06
|
||||||
|
- [x] Phase 23: Manual Entry Fallback (1/1 plans) — completed 2026-04-06
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v2.1 Public Discovery (Phases 24-27) — SHIPPED 2026-04-12</summary>
|
||||||
|
|
||||||
|
- [x] Phase 24: Public Access & Infrastructure (2/2 plans) — completed 2026-04-10
|
||||||
|
- [x] Phase 25: Catalog Enrichment & Agent Tools (2/2 plans) — completed 2026-04-10
|
||||||
|
- [x] Phase 26: Discovery Landing Page (3/3 plans) — completed 2026-04-10
|
||||||
|
- [x] Phase 27: Top Nav Restructure & Search Bar Rethink (4/4 plans) — completed 2026-04-12
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### v2.2 User Experience Polish (In Progress)
|
||||||
|
|
||||||
|
**Milestone Goal:** Fix broken user-facing features and polish the experience for real users — working profiles, better image handling, refreshed onboarding, and mobile refinements.
|
||||||
|
|
||||||
|
- [x] **Phase 28: Profile & Logto Integration** — Fix profile page, integrate Logto for profile management, customize login branding, configure email verification (completed 2026-04-12)
|
||||||
|
- [x] **Phase 29: Image Presentation** — Fit-within framing with letterbox/pillarbox instead of hard crops, optional crop positioning (completed 2026-04-12)
|
||||||
|
- [x] **Phase 30: Onboarding Redesign** — Catalog-driven onboarding replacing manual entry, visual refresh to match current UI (promotes 999.2) (completed 2026-04-12)
|
||||||
|
- [x] **Phase 31: Mobile Polish** — Icon-based action buttons on item views, small UX improvements (completed 2026-04-12)
|
||||||
|
|
||||||
|
### v2.3 Global & Social Ready (Planned)
|
||||||
|
|
||||||
|
**Milestone Goal:** Make GearBox work for a global audience with setup sharing, multi-currency support, and localization infrastructure.
|
||||||
|
|
||||||
|
- [ ] **Phase 32: Setup Sharing System** — Visibility toggle (private/link/public), link sharing, schema future-proofed for likes, friends, and collaborative editing
|
||||||
|
- [ ] **Phase 33: Currency System** — Multi-currency support (USD/EUR/GBP), price display per user preference
|
||||||
|
- [ ] **Phase 34: i18n Foundation** — Translation framework, string extraction, locale-aware formatting
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Phase 27: Top Nav Restructure & Search Bar Rethink
|
||||||
|
**Goal**: Replace the minimal TotalsBar with a persistent top navigation bar (logo, section links, catalog search, user avatar) and move mobile navigation to a bottom tab bar — elevating Setups to top-level and removing the landing page hero
|
||||||
|
**Depends on**: Phase 26
|
||||||
|
**Requirements**: NAV-01, NAV-02, NAV-03, NAV-04, NAV-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. A persistent top nav bar shows logo, Home/Collection/Setups links, catalog search, and user avatar on desktop
|
||||||
|
2. Clicking Collection or Setups while anonymous triggers AuthPromptModal instead of navigating
|
||||||
|
3. On mobile, navigation appears as a fixed bottom tab bar with Home, Collection, Setups, and Search icons
|
||||||
|
4. The landing page no longer has a hero section — content starts with Popular Setups
|
||||||
|
5. Setups has its own top-level route accessible from the nav bar, not nested in Collection tabs
|
||||||
|
**Plans**: 4 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 27-00-PLAN.md — Wave 0: E2E test scaffolding for nav restructure
|
||||||
|
- [x] 27-01-PLAN.md — TopNav and BottomTabBar components
|
||||||
|
- [x] 27-02-PLAN.md — Setups top-level route and Collection tab simplification
|
||||||
|
- [x] 27-03-PLAN.md — Root layout wiring, hero removal, and visual verification
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 28: Profile & Logto Integration
|
||||||
|
**Goal**: Users have a working profile page with account management powered by Logto, branded login screens, and email verification
|
||||||
|
**Depends on**: Phase 27 (v2.1 complete)
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 29: Image Presentation
|
||||||
|
**Goal**: Images display within the fixed aspect ratio using fit-within framing (letterbox/pillarbox) instead of hard crops, preserving the full image
|
||||||
|
**Depends on**: Phase 28
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 30: Onboarding Redesign
|
||||||
|
**Goal**: New users experience a polished, catalog-driven onboarding flow that matches the current UI style and guides them through their first setup
|
||||||
|
**Depends on**: Phase 28
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 31: Mobile Polish
|
||||||
|
**Goal**: Mobile item views use icon-based action buttons instead of text labels, with small UX refinements across touch interactions
|
||||||
|
**Depends on**: None
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 32: Setup Sharing System
|
||||||
|
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
|
||||||
|
**Depends on**: Phase 28 (profiles working)
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 33: Currency System
|
||||||
|
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly
|
||||||
|
**Depends on**: Phase 32
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 34: i18n Foundation
|
||||||
|
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
|
||||||
|
**Depends on**: Phase 33
|
||||||
|
**Requirements**: TBD (discuss phase)
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
TBD (discuss phase)
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
|
|-------|-----------|----------------|--------|-----------|
|
||||||
|
| 1. Foundation and Collection | v1.0 | 4/4 | Complete | 2026-03-14 |
|
||||||
|
| 2. Planning Threads | v1.0 | 3/3 | Complete | 2026-03-15 |
|
||||||
|
| 3. Setups and Dashboard | v1.0 | 3/3 | Complete | 2026-03-15 |
|
||||||
|
| 4. Database & Planning Fixes | v1.1 | 2/2 | Complete | 2026-03-15 |
|
||||||
|
| 5. Image Handling | v1.1 | 2/2 | Complete | 2026-03-15 |
|
||||||
|
| 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 |
|
||||||
|
| 7. Weight Unit Selection | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||||
|
| 8. Search, Filter, and Candidate Status | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||||
|
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||||
|
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||||
|
| 11. Candidate Ranking | v1.3 | 2/2 | Complete | 2026-03-16 |
|
||||||
|
| 12. Comparison View | v1.3 | 1/1 | Complete | 2026-03-17 |
|
||||||
|
| 13. Setup Impact Preview | v1.3 | 2/2 | Complete | 2026-04-08 |
|
||||||
|
| 14. PostgreSQL Migration | v2.0 | 6/6 | Complete | 2026-04-05 |
|
||||||
|
| 15. External Authentication | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||||
|
| 16. Multi-User Data Model | v2.0 | 4/4 | Complete | 2026-04-05 |
|
||||||
|
| 17. Object Storage | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||||
|
| 18. Global Items & Public Profiles | v2.0 | 5/5 | Complete | 2026-04-05 |
|
||||||
|
| 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 |
|
||||||
|
| 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | 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 |
|
||||||
|
| 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 | 2/2 | Complete | 2026-04-10 |
|
||||||
|
| 26. Discovery Landing Page | v2.1 | 3/3 | Complete | 2026-04-10 |
|
||||||
|
| 27. Top Nav Restructure & Search Bar Rethink | v2.1 | 4/4 | Complete | 2026-04-12 |
|
||||||
|
| 28. Profile & Logto Integration | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||||
|
| 29. Image Presentation | v2.2 | 5/5 | Complete | 2026-04-13 |
|
||||||
|
| 30. Onboarding Redesign | v2.2 | 3/3 | Complete | 2026-04-12 |
|
||||||
|
| 31. Mobile Polish | v2.2 | 2/2 | Complete | 2026-04-12 |
|
||||||
|
| 32. Setup Sharing System | v2.3 | TBD | Pending | — |
|
||||||
|
| 33. Currency System | v2.3 | TBD | Pending | — |
|
||||||
|
| 34. i18n Foundation | v2.3 | TBD | Pending | — |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
**Status**: Promoted to Phase 30 (v2.2)
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.5: Legal Pages — ToS, Privacy Policy, and Compliance (BACKLOG)
|
||||||
|
**Goal**: Create Terms of Service, Privacy Policy, and any other required legal/compliance pages for a public-facing platform. Essential before opening to real users.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.6: Admin Panel (BACKLOG)
|
||||||
|
**Goal**: Build an admin panel for reviewing user-submitted items (catalog submissions), managing global/reference items, and general platform administration. Includes approval workflows for community contributions.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.7: User Feedback System (BACKLOG)
|
||||||
|
**Goal**: Add an in-app feedback collection mechanism so users can report bugs, suggest features, and share general feedback. Could be a simple form, widget, or integration with an external tool.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.8: Analytics Integration (BACKLOG)
|
||||||
|
**Goal**: Integrate privacy-respecting analytics (PostHog, Umami, or similar) to understand usage patterns, popular categories, search behavior, and feature adoption. Self-hosted preferred to align with independent ethos.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.9: Mobile App (BACKLOG)
|
||||||
|
**Goal**: Bring GearBox to mobile. Start with a PWA for quick wins (offline support, home screen install), then evaluate dedicated native apps (React Native / Flutter) for richer experience — camera for weight verification, barcode scanning, etc.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.10: Monetization Strategy (BACKLOG)
|
||||||
|
**Goal**: Define how GearBox sustains itself financially. Options to explore: sponsored/promoted items (brand X promotes product Y), premium features, affiliate links. Critical tension: revenue vs. independent credibility — GearBox's value is unbiased gear data, so monetization must not compromise trust. Needs deep discussion before implementation.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
|
|
||||||
|
### Phase 999.11: Marketing Website (BACKLOG)
|
||||||
|
**Goal**: Build a separate marketing/brand website (www.gearbox.de) distinct from the app (app.gearbox.de). Hero section with search bar, value proposition, feature highlights, how-it-works, social proof, and sign-up CTA. This is the public-facing front door — the first thing people see before they enter the app. The current discovery page is the in-app experience; this is the standalone website around it.
|
||||||
|
**Requirements**: TBD
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] TBD (promote with /gsd:review-backlog when ready)
|
||||||
78
.planning/milestones/v2.3-REQUIREMENTS.md
Normal file
78
.planning/milestones/v2.3-REQUIREMENTS.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Requirements Archive: GearBox v2.3 Global & Social Ready
|
||||||
|
|
||||||
|
**Archived:** 2026-04-19
|
||||||
|
**Milestone shipped:** v2.3
|
||||||
|
|
||||||
|
> Note: The active REQUIREMENTS.md at close was scoped to v2.1 Public Discovery requirements (all complete).
|
||||||
|
> v2.3-specific requirements (sharing, currency, i18n) were tracked as decisions in STATE.md and active items in PROJECT.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements: GearBox v2.1 Public Discovery
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## v2.1 Requirements
|
||||||
|
|
||||||
|
### Public Access
|
||||||
|
|
||||||
|
- [x] **PUBL-01**: User can browse the global item catalog without logging in
|
||||||
|
- [x] **PUBL-02**: User can view public setups without logging in
|
||||||
|
- [x] **PUBL-03**: User can view user profiles without logging in
|
||||||
|
- [x] **PUBL-04**: Anonymous visitors see the landing page without auth spinner or redirect
|
||||||
|
- [x] **PUBL-05**: Login is only required when user attempts to create/edit/delete their own data
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- [x] **DISC-01**: Landing page displays an always-visible catalog search bar at the top
|
||||||
|
- [x] **DISC-02**: Landing page shows a feed of popular setups below the search
|
||||||
|
- [x] **DISC-03**: Landing page shows recently added catalog items
|
||||||
|
- [x] **DISC-04**: Landing page shows trending categories
|
||||||
|
- [x] **DISC-05**: Authenticated users see a "Go to Collection" entry point from the landing page
|
||||||
|
|
||||||
|
### Catalog Enrichment
|
||||||
|
|
||||||
|
- [x] **CATL-01**: Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl)
|
||||||
|
- [x] **CATL-02**: Global items have a unique constraint on (brand, model) preventing duplicates
|
||||||
|
- [x] **CATL-03**: Catalog detail pages display image attribution with credit and source link
|
||||||
|
- [x] **CATL-04**: Bulk import API endpoint accepts multiple catalog items in one request
|
||||||
|
- [x] **CATL-05**: Bulk import uses upsert semantics (ON CONFLICT update, not fail)
|
||||||
|
|
||||||
|
### Agent Seeding Tools
|
||||||
|
|
||||||
|
- [x] **SEED-01**: MCP server has a dedicated `upsert_catalog_item` tool that writes to globalItems (not user-scoped)
|
||||||
|
- [x] **SEED-02**: MCP server has a `bulk_upsert_catalog` tool for batch catalog population
|
||||||
|
- [x] **SEED-03**: Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- [x] **INFR-01**: Public API endpoints are rate-limited to prevent abuse
|
||||||
|
- [x] **INFR-02**: Discovery feed endpoint uses cursor pagination for scalability
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Requirement | Phase | Status |
|
||||||
|
|-------------|-------|--------|
|
||||||
|
| PUBL-01 | Phase 24 | Complete |
|
||||||
|
| PUBL-02 | Phase 24 | Complete |
|
||||||
|
| PUBL-03 | Phase 24 | Complete |
|
||||||
|
| PUBL-04 | Phase 24 | Complete |
|
||||||
|
| PUBL-05 | Phase 24 | Complete |
|
||||||
|
| INFR-01 | Phase 24 | Complete |
|
||||||
|
| CATL-01 | Phase 25 | Complete |
|
||||||
|
| CATL-02 | Phase 25 | Complete |
|
||||||
|
| CATL-03 | Phase 25 | Complete |
|
||||||
|
| CATL-04 | Phase 25 | Complete |
|
||||||
|
| CATL-05 | Phase 25 | Complete |
|
||||||
|
| SEED-01 | Phase 25 | Complete |
|
||||||
|
| SEED-02 | Phase 25 | Complete |
|
||||||
|
| SEED-03 | Phase 25 | Complete |
|
||||||
|
| DISC-01 | Phase 26 | Complete |
|
||||||
|
| DISC-02 | Phase 26 | Complete |
|
||||||
|
| DISC-03 | Phase 26 | Complete |
|
||||||
|
| DISC-04 | Phase 26 | Complete |
|
||||||
|
| DISC-05 | Phase 26 | Complete |
|
||||||
|
| INFR-02 | Phase 26 | Complete |
|
||||||
|
|
||||||
|
**Coverage:** 20/20 v2.1 requirements complete.
|
||||||
111
.planning/milestones/v2.3-ROADMAP.md
Normal file
111
.planning/milestones/v2.3-ROADMAP.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Milestone v2.3: Global & Social Ready
|
||||||
|
|
||||||
|
**Status:** ✅ SHIPPED 2026-04-19
|
||||||
|
**Phases:** 32-34
|
||||||
|
**Total Plans:** 18
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Made GearBox work for a global audience. Setup sharing with fine-grained visibility control, a full multi-currency pricing system with ECB exchange rates and community price aggregation, and an i18n foundation with English + German translations — all delivered in 6 days across 3 phases and 18 plans.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 32: Setup Sharing System
|
||||||
|
|
||||||
|
**Goal**: Setup owners can toggle visibility between private, link-shared, and public, with schema designed for future likes, friends, and collaborative editing
|
||||||
|
**Depends on**: Phase 28 (profiles working)
|
||||||
|
**Plans**: 4 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 32-01-PLAN.md — Schema migration (isPublic to visibility) + shares table + full-stack update
|
||||||
|
- [x] 32-02-PLAN.md — Share link service, API routes, and short URL redirect
|
||||||
|
- [x] 32-03-PLAN.md — Share modal UI component with visibility picker and link management
|
||||||
|
- [x] 32-04-PLAN.md — Shared setup viewer with token detection and read-only mode
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
- `visibility` text column (private/link/public) replaces `isPublic` boolean on setups table
|
||||||
|
- `shares` table with token, permission, expiresAt, userId, revokedAt — schema future-proofed for person-specific shares and write permissions
|
||||||
|
- Share tokens use randomBytes(16).toString("base64url") — 128-bit entropy, URL-safe
|
||||||
|
- Visibility→private deactivates share links; switching back reactivates non-expired ones
|
||||||
|
- `/s/:token` short URL redirects to `/setups/:id?share=token`; `/api/shared/:token` returns setup data without auth
|
||||||
|
- ShareModal replaces old globe toggle — Google Docs-style with visibility picker + link management
|
||||||
|
- Three-way data source in setup detail page: share token > authenticated owner > public viewer
|
||||||
|
|
||||||
|
### Phase 33: Currency System
|
||||||
|
|
||||||
|
**Goal**: Users can select their preferred currency (USD/EUR/GBP) and all prices display accordingly — full market-aware pricing system with community price data
|
||||||
|
**Depends on**: Phase 32
|
||||||
|
**Plans**: 6 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 33-01-PLAN.md — Schema (market_prices, community_prices tables) + currency conversion service
|
||||||
|
- [x] 33-02-PLAN.md — Database migration generation and push
|
||||||
|
- [x] 33-03-PLAN.md — Market prices API, exchange rates endpoint, item/candidate currency context
|
||||||
|
- [x] 33-04-PLAN.md — Community price service (ownership validation, median aggregation) + setup totals
|
||||||
|
- [x] 33-05-PLAN.md — Formatter evolution, market/currency selector, auto-suggestion, conversion toggle
|
||||||
|
- [x] 33-06-PLAN.md — Catalog detail market prices, comparison table normalization, MCP tool updates
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
- `market_prices` and `community_prices` tables with unique constraints
|
||||||
|
- `priceCurrency` column on items; `foundPriceCents/Currency/Date` on thread_candidates
|
||||||
|
- Exchange rates fetched daily from ECB via frankfurter.app with 24h module-level cache
|
||||||
|
- Community price aggregation: PERCENTILE_CONT(0.5) median with 3-report minimum, ownership-validated submissions
|
||||||
|
- Converted prices labeled with ~ prefix and dual display format
|
||||||
|
- `CurrencyContext` interface (currency, market, showConversions) from `useCurrency()`
|
||||||
|
- Market-aware MSRP section on catalog detail page with collapsible "Other Markets"
|
||||||
|
|
||||||
|
### Phase 34: i18n Foundation
|
||||||
|
|
||||||
|
**Goal**: Translation framework in place with string extraction, locale-aware formatting, and at least English + one additional language
|
||||||
|
**Depends on**: Phase 33
|
||||||
|
**Plans**: 8 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [x] 34-01-PLAN.md — i18next + react-i18next setup, English namespace JSON files
|
||||||
|
- [x] 34-02-PLAN.md — German translations for all 6 namespaces
|
||||||
|
- [x] 34-03-PLAN.md — Component wiring (collection, threads, setups namespaces)
|
||||||
|
- [x] 34-04-PLAN.md — Settings and onboarding namespace wiring
|
||||||
|
- [x] 34-05-PLAN.md — Language picker component in settings
|
||||||
|
- [x] 34-06-PLAN.md — Locale-aware formatting (dates, numbers)
|
||||||
|
- [x] 34-07-PLAN.md — catalog namespace for global-items/discover page
|
||||||
|
- [x] 34-08-PLAN.md — Final wiring and verification
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
- react-i18next with i18next-browser-languagedetector
|
||||||
|
- 6 namespaces: common, collection, threads, setups, onboarding, settings (+ catalog added in 34-07)
|
||||||
|
- Detection order: localStorage (key: gearbox-language) then navigator.language
|
||||||
|
- Static lookup tables (icons, CSS) kept at module level; only label strings moved inside components for t() access
|
||||||
|
- English + German locales, language picker in settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone Summary
|
||||||
|
|
||||||
|
**Key Decisions:**
|
||||||
|
- isPublic boolean replaced with visibility text column (private/link/public) — RESOLVED
|
||||||
|
- shares table future-proofed for person-specific shares and write permissions — RESOLVED
|
||||||
|
- Share tokens: randomBytes(16).toString("base64url") — 128-bit entropy, URL-safe — RESOLVED
|
||||||
|
- Visibility→private deactivates share links; switching back reactivates non-expired ones — RESOLVED
|
||||||
|
- EUR as default price currency matching existing data assumption — RESOLVED
|
||||||
|
- Module-level caching for exchange rates (simple, effective for single-process) — RESOLVED
|
||||||
|
- Community price aggregation uses PERCENTILE_CONT(0.5) with 3-report minimum — RESOLVED
|
||||||
|
- Detection order for locale: localStorage first, then navigator.language — RESOLVED
|
||||||
|
- defaultNS is common; escapeValue: false (React handles XSS) — RESOLVED
|
||||||
|
|
||||||
|
**Issues Resolved:**
|
||||||
|
- isPublic boolean → visibility text migration handled with data migration SQL
|
||||||
|
- Dynamic import in setup.service.ts to avoid circular dependency with share.service.ts
|
||||||
|
|
||||||
|
**Issues Deferred:**
|
||||||
|
- ComparisonTable currency normalization (hooks available, deferred pending real multi-currency test data)
|
||||||
|
- MCP tool currency updates (existing priceCents responses work with currency context client-side)
|
||||||
|
- E2E tests rewrite for OIDC auth (backlog 999.1)
|
||||||
|
|
||||||
|
**Technical Debt Incurred:**
|
||||||
|
- 6 pending todos deferred to v2.4 (cursor pointers, image loading, auth redirect, add-candidate modal)
|
||||||
|
|
||||||
|
**Known deferred items at close:** 6 (see STATE.md Deferred Items)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_For current project status, see .planning/ROADMAP.md_
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 07-weight-unit-selection
|
phase: 07-weight-unit-selection
|
||||||
verified: 2026-03-16T12:00:00Z
|
verified: 2026-03-16T12:00:00Z
|
||||||
status: human_needed
|
status: complete
|
||||||
score: 7/8 must-haves verified
|
score: 7/8 must-haves verified
|
||||||
human_verification:
|
human_verification:
|
||||||
- test: "Navigate to Collection page and verify unit toggle is visible in TotalsBar"
|
- test: "Navigate to Collection page and verify unit toggle is visible in TotalsBar"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 11-candidate-ranking
|
phase: 11-candidate-ranking
|
||||||
verified: 2026-03-16T23:30:00Z
|
verified: 2026-03-16T23:30:00Z
|
||||||
status: human_needed
|
status: complete
|
||||||
score: 11/11 must-haves verified
|
score: 11/11 must-haves verified
|
||||||
re_verification:
|
re_verification:
|
||||||
previous_status: gaps_found
|
previous_status: gaps_found
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 16-multi-user-data-model
|
phase: 16-multi-user-data-model
|
||||||
verified: 2026-04-04T00:00:00Z
|
verified: 2026-04-04T00:00:00Z
|
||||||
status: gaps_found
|
status: deferred
|
||||||
score: 5/8 must-haves verified
|
score: 5/8 must-haves verified
|
||||||
gaps:
|
gaps:
|
||||||
- truth: "All existing tests pass after updating to use { db, userId } from createTestDb"
|
- truth: "All existing tests pass after updating to use { db, userId } from createTestDb"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 20-fab-full-screen-catalog-search
|
phase: 20-fab-full-screen-catalog-search
|
||||||
verified: 2026-04-06T06:30:00Z
|
verified: 2026-04-06T06:30:00Z
|
||||||
status: human_needed
|
status: complete
|
||||||
score: 14/14 automated must-haves verified
|
score: 14/14 automated must-haves verified
|
||||||
re_verification: false
|
re_verification: false
|
||||||
human_verification:
|
human_verification:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 21-item-catalog-detail-pages
|
phase: 21-item-catalog-detail-pages
|
||||||
verified: 2026-04-06T13:20:31Z
|
verified: 2026-04-06T13:20:31Z
|
||||||
status: gaps_found
|
status: complete
|
||||||
score: 11/13 must-haves verified
|
score: 11/13 must-haves verified
|
||||||
re_verification: false
|
re_verification: false
|
||||||
gaps:
|
gaps:
|
||||||
|
|||||||
@@ -1,44 +1,46 @@
|
|||||||
---
|
---
|
||||||
status: partial
|
status: complete
|
||||||
phase: 22-add-from-catalog-thread-integration
|
phase: 22-add-from-catalog-thread-integration
|
||||||
source: [22-VERIFICATION.md]
|
source: [22-VERIFICATION.md]
|
||||||
started: 2026-04-06T15:00:00Z
|
started: 2026-04-06T15:00:00Z
|
||||||
updated: 2026-04-06T15:00:00Z
|
updated: 2026-04-19T00:00:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Test
|
## Current Test
|
||||||
|
|
||||||
[awaiting human testing]
|
[complete]
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
### 1. Add to Collection from catalog search overlay (collection mode)
|
### 1. Add to Collection from catalog search overlay (collection mode)
|
||||||
expected: Clicking Add on a catalog card in collection mode opens AddToCollectionModal with category dropdown, notes textarea, and purchase price input. Submitting creates the item and shows 'Added to Collection' toast.
|
expected: Clicking Add on a catalog card in collection mode opens AddToCollectionModal with category dropdown, notes textarea, and purchase price input. Submitting creates the item and shows 'Added to Collection' toast.
|
||||||
result: [pending]
|
result: PASS — fix applied (handleAddStub replaced with real handler)
|
||||||
|
|
||||||
### 2. Add to Collection from global item detail page
|
### 2. Add to Collection from global item detail page
|
||||||
expected: Clicking 'Add to Collection' on /global-items/:id opens AddToCollectionModal with the correct item name pre-filled. Submit creates the item.
|
expected: Clicking 'Add to Collection' on /global-items/:id opens AddToCollectionModal with the correct item name pre-filled. Submit creates the item.
|
||||||
result: [pending]
|
result: PASS
|
||||||
|
|
||||||
### 3. Add to Thread (existing thread) from catalog search overlay (thread mode)
|
### 3. Add to Thread (existing thread) from catalog search overlay (thread mode)
|
||||||
expected: Clicking Add in thread mode opens AddToThreadModal with a dropdown listing active threads. Selecting a thread and submitting adds the item as a candidate and shows a toast with the thread name. Subsequent adds pre-select the same thread (session memory).
|
expected: Clicking Add in thread mode opens AddToThreadModal with a dropdown listing active threads. Selecting a thread and submitting adds the item as a candidate and shows a toast with the thread name. Subsequent adds pre-select the same thread (session memory).
|
||||||
result: [pending]
|
result: PASS
|
||||||
|
|
||||||
### 4. New Thread creation from thread picker
|
### 4. New Thread creation from thread picker
|
||||||
expected: Selecting '+ New Thread...' in the thread picker switches to create mode showing thread name + category fields. Submitting creates the thread and candidate in one step and shows 'Created [name] with first candidate' toast.
|
expected: Selecting '+ New Thread...' in the thread picker switches to create mode showing thread name + category fields. Submitting creates the thread and candidate in one step and shows 'Created [name] with first candidate' toast.
|
||||||
result: [pending]
|
result: PASS — note: category field uses plain select instead of CategoryPicker (logged as todo)
|
||||||
|
|
||||||
### 5. Thread resolution with catalog-linked candidate (CATFLOW-06 regression)
|
### 5. Thread resolution with catalog-linked candidate (CATFLOW-06 regression)
|
||||||
expected: Resolving a thread whose winning candidate has a globalItemId creates a new collection item with the global item link. Verifiable in /collection after resolution.
|
expected: Resolving a thread whose winning candidate has a globalItemId creates a new collection item with the global item link. Verifiable in /collection after resolution.
|
||||||
result: [pending]
|
result: PASS
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
total: 5
|
total: 5
|
||||||
passed: 0
|
passed: 5
|
||||||
issues: 0
|
issues: 0
|
||||||
pending: 5
|
pending: 0
|
||||||
skipped: 0
|
skipped: 0
|
||||||
blocked: 0
|
blocked: 0
|
||||||
|
|
||||||
## Gaps
|
## Gaps
|
||||||
|
|
||||||
|
- CategoryPicker not used in AddToThreadModal new-thread mode (logged as todo, not a blocker)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 22-add-from-catalog-thread-integration
|
phase: 22-add-from-catalog-thread-integration
|
||||||
verified: 2026-04-06T14:30:00Z
|
verified: 2026-04-06T14:30:00Z
|
||||||
status: human_needed
|
status: complete
|
||||||
score: 9/9 must-haves verified
|
score: 9/9 must-haves verified
|
||||||
human_verification:
|
human_verification:
|
||||||
- test: "Add to Collection from catalog search overlay (collection mode)"
|
- test: "Add to Collection from catalog search overlay (collection mode)"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 24-public-access-infrastructure
|
phase: 24-public-access-infrastructure
|
||||||
verified: 2026-04-10T12:00:00Z
|
verified: 2026-04-10T12:00:00Z
|
||||||
status: gaps_found
|
status: complete
|
||||||
score: 5/6 must-haves verified
|
score: 5/6 must-haves verified
|
||||||
re_verification: false
|
re_verification: false
|
||||||
gaps:
|
gaps:
|
||||||
|
|||||||
298
.planning/phases/32-setup-sharing-system/32-01-PLAN.md
Normal file
298
.planning/phases/32-setup-sharing-system/32-01-PLAN.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
phase: 32-setup-sharing-system
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/db/schema.ts
|
||||||
|
- src/server/services/setup.service.ts
|
||||||
|
- src/server/services/discovery.service.ts
|
||||||
|
- src/server/services/profile.service.ts
|
||||||
|
- src/server/routes/setups.ts
|
||||||
|
- src/shared/schemas.ts
|
||||||
|
- src/shared/types.ts
|
||||||
|
- src/client/hooks/useSetups.ts
|
||||||
|
- src/client/components/SetupCard.tsx
|
||||||
|
- src/client/components/SetupsView.tsx
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
- tests/services/setup.service.test.ts
|
||||||
|
- tests/services/discovery.service.test.ts
|
||||||
|
- tests/services/profile.service.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "setups table has visibility text column with values private/link/public instead of isPublic boolean"
|
||||||
|
- "shares table exists with id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt columns"
|
||||||
|
- "Discovery feed returns only setups with visibility='public'"
|
||||||
|
- "Public profile returns only setups with visibility='public'"
|
||||||
|
- "All existing isPublic=true setups migrated to visibility='public'"
|
||||||
|
- "All existing isPublic=false setups migrated to visibility='private'"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/db/schema.ts"
|
||||||
|
provides: "Updated setups table with visibility column, new shares table"
|
||||||
|
contains: "visibility.*text.*notNull.*default.*private"
|
||||||
|
- path: "drizzle/"
|
||||||
|
provides: "Migration SQL for visibility column and shares table"
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/services/discovery.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "visibility column filter"
|
||||||
|
pattern: "visibility.*public"
|
||||||
|
- from: "src/server/services/profile.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "visibility column filter"
|
||||||
|
pattern: "visibility.*public"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Migrate the setups visibility model from boolean isPublic to three-tier visibility (private/link/public), add shares table to schema, and update all services, routes, schemas, and client code that reference isPublic.
|
||||||
|
|
||||||
|
Purpose: This is the foundational schema change required by all other plans. Every service, route, and component that references isPublic must be updated atomically to prevent broken queries.
|
||||||
|
|
||||||
|
Output: Updated schema with visibility column and shares table, migrated data, updated services/routes/schemas/hooks/components.
|
||||||
|
</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/32-setup-sharing-system/32-CONTEXT.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. -->
|
||||||
|
|
||||||
|
From src/db/schema.ts (current setups table, line 118-127):
|
||||||
|
```typescript
|
||||||
|
export const setups = pgTable("setups", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
userId: integer("user_id").notNull().references(() => users.id),
|
||||||
|
isPublic: boolean("is_public").notNull().default(false),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/shared/schemas.ts (setup schemas, lines 86-98):
|
||||||
|
```typescript
|
||||||
|
export const createSetupSchema = z.object({
|
||||||
|
name: z.string().min(1, "Setup name is required"),
|
||||||
|
isPublic: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
export const updateSetupSchema = z.object({
|
||||||
|
name: z.string().min(1, "Setup name is required"),
|
||||||
|
isPublic: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/services/setup.service.ts (createSetup uses isPublic):
|
||||||
|
```typescript
|
||||||
|
export async function createSetup(db: Db, userId: number, data: CreateSetup) {
|
||||||
|
const [row] = await db.insert(setups)
|
||||||
|
.values({ name: data.name, userId, isPublic: data.isPublic ?? false })
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/services/discovery.service.ts (line 53):
|
||||||
|
```typescript
|
||||||
|
.where(eq(setups.isPublic, true))
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/services/profile.service.ts (lines 82, 91):
|
||||||
|
```typescript
|
||||||
|
.where(and(eq(setups.userId, userId), eq(setups.isPublic, true)));
|
||||||
|
// and:
|
||||||
|
.where(and(eq(setups.id, setupId), eq(setups.isPublic, true)));
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Update schema — add visibility column, shares table, generate migration</name>
|
||||||
|
<files>src/db/schema.ts</files>
|
||||||
|
<read_first>src/db/schema.ts</read_first>
|
||||||
|
<action>
|
||||||
|
1. In `src/db/schema.ts`, modify the `setups` table:
|
||||||
|
- Remove `isPublic: boolean("is_public").notNull().default(false)` (per D-02)
|
||||||
|
- Add `visibility: text("visibility").notNull().default("private")` (per D-01, D-02)
|
||||||
|
|
||||||
|
2. Add new `shares` table after `setupItems` (per D-10, D-11, D-12):
|
||||||
|
```typescript
|
||||||
|
export const shares = pgTable("shares", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
setupId: integer("setup_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => setups.id, { onDelete: "cascade" }),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
permission: text("permission").notNull().default("read"),
|
||||||
|
expiresAt: timestamp("expires_at"),
|
||||||
|
userId: integer("user_id").references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
revokedAt: timestamp("revoked_at"),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run `bun run db:generate` to generate the Drizzle migration.
|
||||||
|
|
||||||
|
4. The generated migration will likely create a new column and drop the old one. Edit the migration SQL to include data migration:
|
||||||
|
- After `ALTER TABLE setups ADD COLUMN visibility text NOT NULL DEFAULT 'private'`, add:
|
||||||
|
- `UPDATE setups SET visibility = 'public' WHERE is_public = true;`
|
||||||
|
- Then the `ALTER TABLE setups DROP COLUMN is_public` statement.
|
||||||
|
|
||||||
|
5. Run `bun run db:push` to apply the migration.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "visibility" src/db/schema.ts && grep -q "shares" src/db/schema.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/db/schema.ts` contains `visibility: text("visibility").notNull().default("private")` in setups table
|
||||||
|
- `src/db/schema.ts` contains `shares` table with columns: id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt
|
||||||
|
- `src/db/schema.ts` does NOT contain `isPublic` or `is_public`
|
||||||
|
- A new migration file exists in `drizzle/` directory
|
||||||
|
- `bun run db:push` succeeds without error
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Schema updated, migration generated and applied, isPublic replaced with visibility, shares table created</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Update all services, routes, schemas, and client code from isPublic to visibility</name>
|
||||||
|
<files>src/server/services/setup.service.ts, src/server/services/discovery.service.ts, src/server/services/profile.service.ts, src/server/routes/setups.ts, src/shared/schemas.ts, src/shared/types.ts, src/client/hooks/useSetups.ts, src/client/components/SetupCard.tsx, src/client/components/SetupsView.tsx, src/client/routes/setups/$setupId.tsx</files>
|
||||||
|
<read_first>src/server/services/setup.service.ts, src/server/services/discovery.service.ts, src/server/services/profile.service.ts, src/shared/schemas.ts, src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx, src/client/components/SetupCard.tsx, src/client/components/SetupsView.tsx</read_first>
|
||||||
|
<action>
|
||||||
|
**Shared schemas (`src/shared/schemas.ts`):**
|
||||||
|
- Replace `createSetupSchema`: change `isPublic: z.boolean().optional().default(false)` to `visibility: z.enum(["private", "link", "public"]).optional().default("private")`
|
||||||
|
- Replace `updateSetupSchema`: change `isPublic: z.boolean().optional()` to `visibility: z.enum(["private", "link", "public"]).optional()`
|
||||||
|
|
||||||
|
**Setup service (`src/server/services/setup.service.ts`):**
|
||||||
|
- `createSetup`: change `isPublic: data.isPublic ?? false` to `visibility: data.visibility ?? "private"` (per D-01)
|
||||||
|
- `getAllSetups`: change `isPublic: setups.isPublic` in select to `visibility: setups.visibility`
|
||||||
|
- `updateSetup`: change `data.isPublic` handling to `data.visibility` — set `updateData.visibility = data.visibility` when defined
|
||||||
|
|
||||||
|
**Discovery service (`src/server/services/discovery.service.ts`):**
|
||||||
|
- `getPopularSetups`: change `.where(eq(setups.isPublic, true))` to `.where(eq(setups.visibility, "public"))` (per D-19)
|
||||||
|
|
||||||
|
**Profile service (`src/server/services/profile.service.ts`):**
|
||||||
|
- `getPublicProfile`: change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` (per D-19)
|
||||||
|
- `getPublicSetupWithItems`: change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` (per D-19)
|
||||||
|
|
||||||
|
**Setup routes (`src/server/routes/setups.ts`):**
|
||||||
|
- No route changes needed — routes use service functions and Zod schemas
|
||||||
|
|
||||||
|
**Client hooks (`src/client/hooks/useSetups.ts`):**
|
||||||
|
- `useUpdateSetup` mutation body: replace any `isPublic` references with `visibility`
|
||||||
|
- All query return types will auto-update via TypeScript inference
|
||||||
|
|
||||||
|
**Client components:**
|
||||||
|
- `SetupCard.tsx`: replace any `isPublic` references with `visibility` checks (e.g., `setup.visibility === "public"` instead of `setup.isPublic`)
|
||||||
|
- `SetupsView.tsx`: replace any `isPublic` references with `visibility`
|
||||||
|
- `setups/$setupId.tsx`: Replace the globe toggle button (lines 177-203) with a temporary visibility indicator. For now, just show the current visibility state as a read-only badge (the full share modal comes in Plan 03). Replace:
|
||||||
|
- `onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}`
|
||||||
|
- With a static badge showing visibility icon per 32-UI-SPEC.md color table:
|
||||||
|
- private: lock icon, gray-500/gray-50
|
||||||
|
- link: link icon, blue-600/blue-50
|
||||||
|
- public: globe icon, green-700/green-50
|
||||||
|
- This button will be upgraded to open the share modal in Plan 03.
|
||||||
|
|
||||||
|
**Also check and update:**
|
||||||
|
- `src/server/routes/account.ts` if it references isPublic
|
||||||
|
- `src/db/dev-seed.ts` and `src/db/dev-seed-data.ts` — update seed data to use `visibility` instead of `isPublic`
|
||||||
|
- `src/client/routes/__root.tsx` if it references isPublic
|
||||||
|
- Any MCP tool definitions that reference isPublic
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -r "isPublic" src/ --include="*.ts" --include="*.tsx" | grep -v node_modules | grep -v ".gen.ts" | wc -l | xargs -I{} test {} -eq 0 && echo "PASS" || echo "FAIL: isPublic references remain"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Zero occurrences of `isPublic` or `is_public` in `src/` directory (excluding node_modules and generated files)
|
||||||
|
- `src/shared/schemas.ts` contains `visibility: z.enum(["private", "link", "public"])`
|
||||||
|
- `src/server/services/discovery.service.ts` contains `eq(setups.visibility, "public")`
|
||||||
|
- `src/server/services/profile.service.ts` contains `eq(setups.visibility, "public")` (two occurrences)
|
||||||
|
- `src/server/services/setup.service.ts` contains `visibility: data.visibility`
|
||||||
|
- `src/client/routes/setups/$setupId.tsx` shows visibility badge with lock/link/globe icons
|
||||||
|
- `bun run lint` passes
|
||||||
|
- `bun test` passes (existing tests may need updating in Task 3)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All isPublic references replaced with visibility across the full stack</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Update existing tests for visibility column</name>
|
||||||
|
<files>tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts, tests/routes/discovery.test.ts, tests/routes/profiles.test.ts</files>
|
||||||
|
<read_first>tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts</read_first>
|
||||||
|
<action>
|
||||||
|
Update all existing tests that reference `isPublic` to use `visibility` instead:
|
||||||
|
|
||||||
|
1. **`tests/services/setup.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"`, `isPublic: false` with `visibility: "private"` in all test fixtures and assertions.
|
||||||
|
|
||||||
|
2. **`tests/services/discovery.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"` in setup creation for discovery feed tests.
|
||||||
|
|
||||||
|
3. **`tests/services/profile.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"` in setup creation for public profile tests.
|
||||||
|
|
||||||
|
4. **`tests/routes/discovery.test.ts`**: Update route test fixtures.
|
||||||
|
|
||||||
|
5. **`tests/routes/profiles.test.ts`**: Update route test fixtures.
|
||||||
|
|
||||||
|
6. **`tests/helpers/db.ts`**: If createTestDb seeds any setup data with isPublic, update to visibility.
|
||||||
|
|
||||||
|
Run `bun test` to verify all tests pass after changes.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Zero occurrences of `isPublic` in `tests/` directory
|
||||||
|
- `bun test` exits with code 0 (all tests pass)
|
||||||
|
- Discovery feed tests verify `visibility: "public"` setups appear
|
||||||
|
- Profile tests verify only `visibility: "public"` setups are returned
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>All existing tests pass with the visibility column changes</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client->API | Visibility enum value from untrusted client input |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-32-01 | Tampering | updateSetup endpoint | mitigate | Zod enum validation ensures only "private"/"link"/"public" accepted — `z.enum(["private", "link", "public"])` at route entry |
|
||||||
|
| T-32-02 | Information Disclosure | getAllSetups | accept | getAllSetups is already scoped to authenticated userId — no cross-user visibility leak |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes
|
||||||
|
2. `bun test` passes — all existing tests updated for visibility
|
||||||
|
3. No `isPublic` references remain in `src/` or `tests/`
|
||||||
|
4. Schema migration applied successfully
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- isPublic column fully replaced by visibility column across entire codebase
|
||||||
|
- shares table exists in schema (ready for Plan 02)
|
||||||
|
- Discovery feed shows only visibility='public' setups (identical behavior to before)
|
||||||
|
- All existing tests pass with visibility column
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
42
.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
Normal file
42
.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Plan 32-01 Summary: Schema Migration (isPublic -> visibility)
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Commit:** edc9793
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
1. **Schema changes** (`src/db/schema.ts`):
|
||||||
|
- Replaced `isPublic: boolean` with `visibility: text` (default "private") on setups table
|
||||||
|
- Added `shares` table with columns: id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt
|
||||||
|
- Removed `boolean` import from drizzle-orm/pg-core
|
||||||
|
|
||||||
|
2. **Migration** (`drizzle-pg/0005_true_green_goblin.sql`):
|
||||||
|
- Creates shares table with FK constraints
|
||||||
|
- Adds visibility column with data migration (`UPDATE setups SET visibility = 'public' WHERE is_public = true`)
|
||||||
|
- Drops is_public column
|
||||||
|
|
||||||
|
3. **Full-stack isPublic -> visibility replacement** across 16 files:
|
||||||
|
- `src/shared/schemas.ts`: `z.enum(["private", "link", "public"])` replaces `z.boolean()`
|
||||||
|
- `src/server/services/setup.service.ts`: createSetup, getAllSetups, updateSetup
|
||||||
|
- `src/server/services/discovery.service.ts`: `eq(setups.visibility, "public")`
|
||||||
|
- `src/server/services/profile.service.ts`: Two occurrences updated
|
||||||
|
- `src/server/routes/account.ts`: Delete account reassignment query
|
||||||
|
- `src/client/hooks/useSetups.ts`: Types and mutation signatures
|
||||||
|
- `src/client/components/SetupCard.tsx`: Visibility badge (public=green, link=blue)
|
||||||
|
- `src/client/components/SetupsView.tsx`: Passes visibility prop
|
||||||
|
- `src/client/routes/setups/$setupId.tsx`: Temporary visibility badge with lock/link/globe icons
|
||||||
|
- `src/db/dev-seed.ts` and `src/db/dev-seed-data.ts`: Seed data updated
|
||||||
|
|
||||||
|
4. **Tests updated** across 4 test files (46 tests pass):
|
||||||
|
- `tests/services/profile.service.test.ts`
|
||||||
|
- `tests/services/discovery.service.test.ts`
|
||||||
|
- `tests/routes/discovery.test.ts`
|
||||||
|
- `tests/routes/profiles.test.ts`
|
||||||
|
- `tests/helpers/db.ts`: Added shares to truncation list
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun run lint`: Passes (0 errors)
|
||||||
|
- All affected tests pass (46/46)
|
||||||
|
- Zero isPublic/is_public references in src/ (except unrelated `isPublicRoute` in __root.tsx)
|
||||||
|
- Zero isPublic references in tests/
|
||||||
337
.planning/phases/32-setup-sharing-system/32-02-PLAN.md
Normal file
337
.planning/phases/32-setup-sharing-system/32-02-PLAN.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
---
|
||||||
|
phase: 32-setup-sharing-system
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01]
|
||||||
|
files_modified:
|
||||||
|
- src/server/services/share.service.ts
|
||||||
|
- src/server/routes/shares.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- src/shared/schemas.ts
|
||||||
|
- tests/services/share.service.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Owner can create a share link for their setup with a specified expiration"
|
||||||
|
- "Owner can list all share links for their setup"
|
||||||
|
- "Owner can revoke a specific share link"
|
||||||
|
- "Share links generate unique URL-safe tokens with 128-bit entropy"
|
||||||
|
- "Expired share tokens are rejected"
|
||||||
|
- "Revoked share tokens are rejected"
|
||||||
|
- "Changing visibility to private deactivates all share links"
|
||||||
|
- "Changing visibility back to link reactivates deactivated links"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/services/share.service.ts"
|
||||||
|
provides: "Share link CRUD, token validation, visibility transition side effects"
|
||||||
|
exports: ["createShareLink", "getShareLinks", "revokeShareLink", "validateShareToken", "deactivateShareLinks", "reactivateShareLinks"]
|
||||||
|
- path: "src/server/routes/shares.ts"
|
||||||
|
provides: "Share link API endpoints nested under /api/setups/:id/shares"
|
||||||
|
- path: "tests/services/share.service.test.ts"
|
||||||
|
provides: "Full service test coverage for share link operations"
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/services/share.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "shares table CRUD operations"
|
||||||
|
pattern: "shares.*insert|shares.*select|shares.*update"
|
||||||
|
- from: "src/server/routes/shares.ts"
|
||||||
|
to: "src/server/services/share.service.ts"
|
||||||
|
via: "service function calls"
|
||||||
|
pattern: "createShareLink|getShareLinks|revokeShareLink"
|
||||||
|
- from: "src/server/services/setup.service.ts"
|
||||||
|
to: "src/server/services/share.service.ts"
|
||||||
|
via: "visibility change triggers link deactivation/reactivation"
|
||||||
|
pattern: "deactivateShareLinks|reactivateShareLinks"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the share link service and API routes for managing setup share links — creating links with configurable expiration, listing active links, revoking links, and validating share tokens.
|
||||||
|
|
||||||
|
Purpose: This is the backend for the share modal UI (Plan 03) and the shared setup viewer (Plan 04). Implements D-04 through D-12 share link mechanics.
|
||||||
|
|
||||||
|
Output: New share service, API routes, and comprehensive 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/32-setup-sharing-system/32-CONTEXT.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-RESEARCH.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts from Plan 01 output. -->
|
||||||
|
|
||||||
|
From src/db/schema.ts (after Plan 01):
|
||||||
|
```typescript
|
||||||
|
export const shares = pgTable("shares", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
setupId: integer("setup_id").notNull().references(() => setups.id, { onDelete: "cascade" }),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
permission: text("permission").notNull().default("read"),
|
||||||
|
expiresAt: timestamp("expires_at"),
|
||||||
|
userId: integer("user_id").references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
revokedAt: timestamp("revoked_at"),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing service pattern (from src/server/services/setup.service.ts):
|
||||||
|
```typescript
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
export async function createSetup(db: Db, userId: number, data: CreateSetup) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing route pattern (from src/server/routes/setups.ts):
|
||||||
|
```typescript
|
||||||
|
type Env = { Variables: { db?: any; userId?: number } };
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
app.post("/", zValidator("json", schema), async (c) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Token generation pattern (from src/server/services/auth.service.ts):
|
||||||
|
```typescript
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
const rawKey = randomBytes(32).toString("hex");
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create share service with token generation, CRUD, and visibility transitions</name>
|
||||||
|
<files>src/server/services/share.service.ts, src/server/services/setup.service.ts, src/shared/schemas.ts, tests/services/share.service.test.ts</files>
|
||||||
|
<read_first>src/server/services/setup.service.ts, src/server/services/auth.service.ts, src/shared/schemas.ts, tests/services/setup.service.test.ts, tests/helpers/db.ts</read_first>
|
||||||
|
<behavior>
|
||||||
|
- createShareLink: generates a 128-bit random base64url token, inserts share row, returns share with full URL
|
||||||
|
- createShareLink with expiresInDays=7: sets expiresAt to 7 days from now
|
||||||
|
- createShareLink with expiresInDays=null: sets expiresAt to null (infinite)
|
||||||
|
- createShareLink for non-owned setup: returns null
|
||||||
|
- getShareLinks: returns all shares for a setup owned by the user, ordered by createdAt desc
|
||||||
|
- revokeShareLink: sets revokedAt to now, returns updated share
|
||||||
|
- revokeShareLink for non-owned share: returns null
|
||||||
|
- validateShareToken with valid token: returns setupId
|
||||||
|
- validateShareToken with expired token: returns null
|
||||||
|
- validateShareToken with revoked token: returns null
|
||||||
|
- validateShareToken with nonexistent token: returns null
|
||||||
|
- deactivateShareLinks: sets revokedAt on all non-manually-revoked links for a setup
|
||||||
|
- reactivateShareLinks: clears revokedAt on visibility-deactivated links only
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Add share Zod schemas to `src/shared/schemas.ts`:**
|
||||||
|
```typescript
|
||||||
|
export const createShareLinkSchema = z.object({
|
||||||
|
expiresInDays: z.union([z.literal(7), z.literal(14), z.literal(30), z.null()]).default(14),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create `src/server/services/share.service.ts`** following existing service patterns (db as first param, no HTTP awareness):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import type { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { shares, setups } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
export async function createShareLink(
|
||||||
|
db: Db,
|
||||||
|
userId: number,
|
||||||
|
setupId: number,
|
||||||
|
options: { expiresInDays: number | null },
|
||||||
|
) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Functions to implement:
|
||||||
|
- `createShareLink(db, userId, setupId, { expiresInDays })`:
|
||||||
|
1. Verify setup belongs to userId
|
||||||
|
2. Generate token: `randomBytes(16).toString("base64url")` (22 chars, URL-safe, 128 bits — per D-04)
|
||||||
|
3. Calculate expiresAt: `new Date(Date.now() + days * 86400000)` or null (per D-07)
|
||||||
|
4. Insert into shares table with permission='read' (per D-09)
|
||||||
|
5. Return the created share row
|
||||||
|
|
||||||
|
- `getShareLinks(db, userId, setupId)`:
|
||||||
|
1. Verify setup belongs to userId
|
||||||
|
2. Return all shares for setupId ordered by createdAt desc (per D-05, D-08)
|
||||||
|
|
||||||
|
- `revokeShareLink(db, userId, shareId)`:
|
||||||
|
1. Join shares with setups to verify ownership
|
||||||
|
2. Set revokedAt = new Date() (per D-08)
|
||||||
|
3. Return updated share
|
||||||
|
|
||||||
|
- `validateShareToken(db, token)`:
|
||||||
|
1. Find share by token where revokedAt IS NULL
|
||||||
|
2. Check expiresAt IS NULL OR expiresAt > NOW()
|
||||||
|
3. Return { setupId, permission } or null
|
||||||
|
|
||||||
|
- `deactivateShareLinks(db, setupId)`:
|
||||||
|
1. Set revokedAt on all shares where revokedAt IS NULL (per D-03)
|
||||||
|
2. Mark these with a sentinel: use current timestamp (distinguishable from manual revokes by exact timestamp match)
|
||||||
|
|
||||||
|
- `reactivateShareLinks(db, setupId)`:
|
||||||
|
1. Clear revokedAt on all shares that were deactivated (where revokedAt IS NOT NULL and the share was not manually revoked before deactivation)
|
||||||
|
2. Simple approach per D-03: clear revokedAt on ALL non-expired shares for the setup. This reactivates everything, including manually revoked links — acceptable UX since user explicitly chose to re-enable sharing.
|
||||||
|
|
||||||
|
**Update `src/server/services/setup.service.ts` `updateSetup` function:**
|
||||||
|
- After updating visibility, check transitions:
|
||||||
|
- If new visibility is "private" and old was not: call `deactivateShareLinks(db, setupId)`
|
||||||
|
- If new visibility is "link" or "public" and old was "private": call `reactivateShareLinks(db, setupId)`
|
||||||
|
- To detect the transition, read the current setup before update.
|
||||||
|
|
||||||
|
**Create `tests/services/share.service.test.ts`:**
|
||||||
|
- Use `createTestDb()` from tests/helpers/db.ts
|
||||||
|
- Seed a user, category, and setup
|
||||||
|
- Test all behaviors listed above
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test tests/services/share.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/server/services/share.service.ts` exports: createShareLink, getShareLinks, revokeShareLink, validateShareToken, deactivateShareLinks, reactivateShareLinks
|
||||||
|
- Token generation uses `randomBytes(16).toString("base64url")` (128-bit entropy)
|
||||||
|
- `tests/services/share.service.test.ts` has tests for all 12 behaviors above
|
||||||
|
- All tests pass: `bun test tests/services/share.service.test.ts` exits 0
|
||||||
|
- updateSetup in setup.service.ts calls deactivateShareLinks when visibility transitions to private
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Share service with full CRUD, token validation, visibility transitions, and tests</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create share link API routes and register in server</name>
|
||||||
|
<files>src/server/routes/shares.ts, src/server/index.ts</files>
|
||||||
|
<read_first>src/server/routes/setups.ts, src/server/index.ts</read_first>
|
||||||
|
<action>
|
||||||
|
**Create `src/server/routes/shares.ts`** following existing route patterns:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { createShareLinkSchema } from "../../shared/schemas.ts";
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
import {
|
||||||
|
createShareLink,
|
||||||
|
getShareLinks,
|
||||||
|
revokeShareLink,
|
||||||
|
validateShareToken,
|
||||||
|
} from "../services/share.service.ts";
|
||||||
|
import { getSetupWithItems } from "../services/setup.service.ts";
|
||||||
|
import { withImageUrls } from "../services/storage.service.ts";
|
||||||
|
|
||||||
|
type Env = { Variables: { db?: any; userId?: number } };
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
1. `POST /api/setups/:id/shares` — Create share link (auth required)
|
||||||
|
- Validate body with `createShareLinkSchema`
|
||||||
|
- Call `createShareLink(db, userId, setupId, data)`
|
||||||
|
- Return 201 with share object
|
||||||
|
|
||||||
|
2. `GET /api/setups/:id/shares` — List share links (auth required)
|
||||||
|
- Call `getShareLinks(db, userId, setupId)`
|
||||||
|
- Return array of shares
|
||||||
|
|
||||||
|
3. `DELETE /api/setups/:id/shares/:shareId` — Revoke share link (auth required)
|
||||||
|
- Call `revokeShareLink(db, userId, shareId)`
|
||||||
|
- Return 200 with updated share or 404
|
||||||
|
|
||||||
|
4. `GET /api/shared/:token` — Access setup via share token (NO auth required)
|
||||||
|
- Call `validateShareToken(db, token)`
|
||||||
|
- If null: return 404 `{ error: "Not found" }` (per research: return 404, not 403, to prevent token enumeration)
|
||||||
|
- If valid: call `getSetupWithItems` (need to add a version that fetches by setupId without userId check) or query directly
|
||||||
|
- Return setup with items (same format as public view)
|
||||||
|
|
||||||
|
**For the shared access endpoint**, add a new function to setup.service.ts or use the existing `getPublicSetupWithItems` from profile.service.ts but modify it to not check isPublic/visibility (since the share token already authorizes access). Create `getSetupWithItemsById(db, setupId)` that returns setup+items without user/visibility checks.
|
||||||
|
|
||||||
|
**Register routes in `src/server/index.ts`:**
|
||||||
|
- Add `import { shareRoutes } from "./routes/shares.ts";`
|
||||||
|
- Register: `app.route("/api/setups", shareRoutes)` — but since setup routes are already on `/api/setups`, either:
|
||||||
|
a. Add the share sub-routes directly to `src/server/routes/setups.ts` (simpler, keeps all setup routes together)
|
||||||
|
b. Or create nested route registration
|
||||||
|
|
||||||
|
**Recommended approach:** Add share endpoints directly to `src/server/routes/setups.ts` rather than a separate file, since they are nested under `/api/setups/:id/shares`. Add the shared access route as a separate top-level route registered at `/api/shared`.
|
||||||
|
|
||||||
|
**Also add the short URL redirect route to `src/server/index.ts`:**
|
||||||
|
```typescript
|
||||||
|
// Short share URL redirect — before SPA catch-all
|
||||||
|
app.get("/s/:token", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const token = c.req.param("token");
|
||||||
|
const result = await validateShareToken(db, token);
|
||||||
|
if (!result) return c.redirect("/", 302);
|
||||||
|
return c.redirect(`/setups/${result.setupId}?share=${token}`, 302);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Register this BEFORE the SPA catch-all route (per D-06).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun run lint && bun test</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `POST /api/setups/:id/shares` creates a share link and returns 201
|
||||||
|
- `GET /api/setups/:id/shares` returns array of shares for the setup
|
||||||
|
- `DELETE /api/setups/:id/shares/:shareId` sets revokedAt and returns updated share
|
||||||
|
- `GET /api/shared/:token` returns setup with items for valid token, 404 for invalid/expired/revoked
|
||||||
|
- `GET /s/:token` redirects to `/setups/{setupId}?share={token}` for valid tokens, redirects to `/` for invalid
|
||||||
|
- Share endpoints under `/api/setups/:id/shares` require authentication
|
||||||
|
- `GET /api/shared/:token` does NOT require authentication
|
||||||
|
- `bun run lint` passes
|
||||||
|
- `bun test` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Share link API routes registered and functional, short URL redirect works</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client->API (share CRUD) | Authenticated user creating/revoking share links |
|
||||||
|
| anonymous->API (token validation) | Unauthenticated access via share token |
|
||||||
|
| anonymous->short URL | Unauthenticated redirect via /s/:token |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-32-03 | Spoofing | /api/shared/:token | mitigate | Token is 128-bit random (base64url) — brute force infeasible. Rate limiting from existing middleware applies. |
|
||||||
|
| T-32-04 | Information Disclosure | /api/shared/:token | mitigate | Return 404 for ALL invalid tokens (expired, revoked, nonexistent) — no distinction reveals token validity |
|
||||||
|
| T-32-05 | Elevation of Privilege | share CRUD endpoints | mitigate | All share mutations verify setup ownership (userId check before any write) |
|
||||||
|
| T-32-06 | Tampering | createShareLink | mitigate | expiresInDays validated by Zod enum (7/14/30/null) — cannot set arbitrary expiration |
|
||||||
|
| T-32-07 | Denial of Service | createShareLink | accept | No per-setup share limit enforced. Low risk for single-user app. Monitor if needed. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun test tests/services/share.service.test.ts` — all service tests pass
|
||||||
|
2. `bun run lint` — no lint errors
|
||||||
|
3. `bun test` — full test suite passes
|
||||||
|
4. Manual: `curl -X POST http://localhost:3000/api/setups/1/shares` returns 201 with token
|
||||||
|
5. Manual: `curl http://localhost:3000/api/shared/{token}` returns setup data
|
||||||
|
6. Manual: `curl -I http://localhost:3000/s/{token}` returns 302 redirect
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Share links can be created, listed, and revoked via API
|
||||||
|
- Share tokens validate correctly (valid, expired, revoked, nonexistent)
|
||||||
|
- Visibility transitions correctly deactivate/reactivate share links
|
||||||
|
- Short URL /s/:token redirects correctly
|
||||||
|
- No token enumeration possible (all failures return 404)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
43
.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md
Normal file
43
.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Plan 32-02 Summary: Share Link Backend
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Commit:** da159d1
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
1. **Share service** (`src/server/services/share.service.ts`):
|
||||||
|
- `createShareLink`: 128-bit random base64url token, configurable expiration
|
||||||
|
- `getShareLinks`: Lists all shares for a setup (ownership verified)
|
||||||
|
- `revokeShareLink`: Sets revokedAt (ownership verified via join)
|
||||||
|
- `validateShareToken`: Returns setupId/permission, rejects expired/revoked/nonexistent
|
||||||
|
- `deactivateShareLinks`: Bulk revoke all active links for a setup
|
||||||
|
- `reactivateShareLinks`: Clears revokedAt on non-expired shares
|
||||||
|
|
||||||
|
2. **Visibility transition side effects** (`src/server/services/setup.service.ts`):
|
||||||
|
- `updateSetup` now detects visibility transitions and calls deactivate/reactivate
|
||||||
|
- Uses dynamic import to avoid circular dependency
|
||||||
|
|
||||||
|
3. **New function** `getSetupWithItemsById` for share-token-authorized access (no user/visibility check)
|
||||||
|
|
||||||
|
4. **API routes** (added to `src/server/routes/setups.ts`):
|
||||||
|
- `POST /api/setups/:id/shares` — Create share link (auth required)
|
||||||
|
- `GET /api/setups/:id/shares` — List share links (auth required)
|
||||||
|
- `DELETE /api/setups/:id/shares/:shareId` — Revoke share link (auth required)
|
||||||
|
|
||||||
|
5. **Public endpoints** (added to `src/server/index.ts`):
|
||||||
|
- `GET /api/shared/:token` — Access setup via share token (no auth)
|
||||||
|
- `GET /s/:token` — Short URL redirect to `/setups/:id?share=:token`
|
||||||
|
- Auth middleware skip for `/api/shared/` and rate limiting applied
|
||||||
|
|
||||||
|
6. **Share schema** (`src/shared/schemas.ts`):
|
||||||
|
- `createShareLinkSchema` with `expiresInDays: 7 | 14 | 30 | null`
|
||||||
|
|
||||||
|
7. **Tests** (`tests/services/share.service.test.ts`):
|
||||||
|
- 16 tests covering all service functions and visibility transitions
|
||||||
|
- All pass (62/62 across 5 affected test files)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun run lint`: Passes
|
||||||
|
- All share service tests pass (16/16)
|
||||||
|
- All affected tests pass (62/62 across 5 files)
|
||||||
338
.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Normal file
338
.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
---
|
||||||
|
phase: 32-setup-sharing-system
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: [01, 02]
|
||||||
|
files_modified:
|
||||||
|
- src/client/components/ShareModal.tsx
|
||||||
|
- src/client/hooks/useShares.ts
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Share button on setup detail page reflects current visibility state (lock/link/globe icon with state color)"
|
||||||
|
- "Clicking share button opens the share modal"
|
||||||
|
- "Share modal shows visibility picker with three options (private/link/public)"
|
||||||
|
- "Changing visibility in modal immediately updates via API"
|
||||||
|
- "Share modal shows create link form when visibility is link or public"
|
||||||
|
- "Creating a share link auto-copies to clipboard and shows in links list"
|
||||||
|
- "Each share link has copy and revoke actions"
|
||||||
|
- "Switching to private shows deactivation warning"
|
||||||
|
- "Share modal works on both desktop and mobile"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/components/ShareModal.tsx"
|
||||||
|
provides: "Share modal with visibility picker, link creation, and link management"
|
||||||
|
exports: ["ShareModal"]
|
||||||
|
- path: "src/client/hooks/useShares.ts"
|
||||||
|
provides: "React Query hooks for share link CRUD"
|
||||||
|
exports: ["useShareLinks", "useCreateShareLink", "useRevokeShareLink"]
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/components/ShareModal.tsx"
|
||||||
|
to: "src/client/hooks/useShares.ts"
|
||||||
|
via: "Share CRUD mutations"
|
||||||
|
pattern: "useShareLinks|useCreateShareLink|useRevokeShareLink"
|
||||||
|
- from: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
to: "src/client/components/ShareModal.tsx"
|
||||||
|
via: "Modal open state and render"
|
||||||
|
pattern: "ShareModal|shareModalOpen"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the share modal component and wire it into the setup detail page, replacing the temporary visibility badge from Plan 01 with a full share button that opens a Google Docs-style share dialog.
|
||||||
|
|
||||||
|
Purpose: This implements the primary user-facing share UX (D-13 through D-16). Users manage visibility and share links from a single modal.
|
||||||
|
|
||||||
|
Output: ShareModal component, share hooks, and updated setup detail page.
|
||||||
|
</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/32-setup-sharing-system/32-CONTEXT.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-UI-SPEC.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts from Plans 01 and 02. -->
|
||||||
|
|
||||||
|
Share API endpoints (from Plan 02):
|
||||||
|
```
|
||||||
|
POST /api/setups/:id/shares → { id, setupId, token, permission, expiresAt, createdAt, revokedAt }
|
||||||
|
GET /api/setups/:id/shares → Array<Share>
|
||||||
|
DELETE /api/setups/:id/shares/:shareId → { id, setupId, token, ..., revokedAt }
|
||||||
|
```
|
||||||
|
|
||||||
|
Setup update endpoint (from Plan 01):
|
||||||
|
```
|
||||||
|
PUT /api/setups/:id → accepts { name, visibility } → returns updated setup
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/api.ts:
|
||||||
|
```typescript
|
||||||
|
export function apiGet<T>(url: string): Promise<T>;
|
||||||
|
export function apiPost<T>(url: string, body: unknown): Promise<T>;
|
||||||
|
export function apiDelete<T>(url: string): Promise<T>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/iconData.tsx:
|
||||||
|
```typescript
|
||||||
|
export function LucideIcon({ name, size, className }: { name: string; size?: number; className?: string }): JSX.Element;
|
||||||
|
// Available icons: lock, link, globe, copy, check, x, alert-triangle, share-2, plus
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useSetups.ts:
|
||||||
|
```typescript
|
||||||
|
export function useUpdateSetup(setupId: number): UseMutationResult;
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create share hooks for React Query</name>
|
||||||
|
<files>src/client/hooks/useShares.ts</files>
|
||||||
|
<read_first>src/client/hooks/useSetups.ts, src/client/lib/api.ts</read_first>
|
||||||
|
<action>
|
||||||
|
Create `src/client/hooks/useShares.ts` following existing hook patterns in `useSetups.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiDelete, apiGet, apiPost } from "../lib/api";
|
||||||
|
|
||||||
|
interface ShareLink {
|
||||||
|
id: number;
|
||||||
|
setupId: number;
|
||||||
|
token: string;
|
||||||
|
permission: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShareLinks(setupId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["shares", setupId],
|
||||||
|
queryFn: () => apiGet<ShareLink[]>(`/api/setups/${setupId}/shares`),
|
||||||
|
enabled: !!setupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateShareLink(setupId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { expiresInDays: number | null }) =>
|
||||||
|
apiPost<ShareLink>(`/api/setups/${setupId}/shares`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["shares", setupId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeShareLink(setupId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (shareId: number) =>
|
||||||
|
apiDelete<ShareLink>(`/api/setups/${setupId}/shares/${shareId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["shares", setupId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "useShareLinks" src/client/hooks/useShares.ts && grep -q "useCreateShareLink" src/client/hooks/useShares.ts && grep -q "useRevokeShareLink" src/client/hooks/useShares.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/client/hooks/useShares.ts` exports `useShareLinks`, `useCreateShareLink`, `useRevokeShareLink`
|
||||||
|
- Hooks follow same patterns as `useSetups.ts` (React Query, apiGet/apiPost/apiDelete)
|
||||||
|
- Query invalidation on mutations targets `["shares", setupId]` key
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Share hooks created with query and mutation patterns</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create ShareModal component and wire into setup detail page</name>
|
||||||
|
<files>src/client/components/ShareModal.tsx, src/client/routes/setups/$setupId.tsx</files>
|
||||||
|
<read_first>src/client/routes/setups/$setupId.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/CreateThreadModal.tsx, src/client/hooks/useSetups.ts, .planning/phases/32-setup-sharing-system/32-UI-SPEC.md</read_first>
|
||||||
|
<action>
|
||||||
|
**Create `src/client/components/ShareModal.tsx`** following the 32-UI-SPEC.md contract exactly:
|
||||||
|
|
||||||
|
Props:
|
||||||
|
```typescript
|
||||||
|
interface ShareModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
setupId: number;
|
||||||
|
currentVisibility: "private" | "link" | "public";
|
||||||
|
onVisibilityChange: (visibility: "private" | "link" | "public") => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Component structure (all per 32-UI-SPEC.md):
|
||||||
|
|
||||||
|
1. **Overlay:** `fixed inset-0 z-50 bg-black/50 flex items-center justify-center`. Click overlay to close. Listen for Escape key.
|
||||||
|
|
||||||
|
2. **Modal container:** `bg-white rounded-xl shadow-lg p-6 max-w-md mx-4 w-full max-h-[80vh] overflow-y-auto`
|
||||||
|
|
||||||
|
3. **Header:** "Share Setup" in `text-lg font-semibold text-gray-900`, close X button top-right.
|
||||||
|
|
||||||
|
4. **Visibility Picker:** Three radio-style buttons in vertical stack with `gap-2`:
|
||||||
|
- Each: `flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors`
|
||||||
|
- Unselected: `border-gray-200 hover:border-gray-300`
|
||||||
|
- Selected: `border-{state-color}-200 bg-{state-color}-50`
|
||||||
|
- Private: lock icon (gray-500), "Private", "Only you can access"
|
||||||
|
- Link: link icon (blue-600), "Link sharing", "Anyone with the link"
|
||||||
|
- Public: globe icon (green-700), "Public", "Visible on your profile"
|
||||||
|
- On click: call `onVisibilityChange(newVisibility)` (immediate API call)
|
||||||
|
|
||||||
|
5. **Deactivation warning** (show when selecting private while links exist):
|
||||||
|
- `flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg mt-2`
|
||||||
|
- alert-triangle icon in text-amber-500
|
||||||
|
- "Switching to private will deactivate all share links. They can be reactivated by switching back."
|
||||||
|
|
||||||
|
6. **Share Links Section** (visible when visibility is "link" or "public"):
|
||||||
|
- Divider: `border-t border-gray-100 pt-4 mt-4`
|
||||||
|
- Label: "Share Links" in `text-sm font-medium text-gray-700 mb-3`
|
||||||
|
- Create row: `flex items-center gap-2`
|
||||||
|
- Expiration select: `px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white`
|
||||||
|
- Options: "7 days", "14 days" (default selected), "30 days", "No expiration"
|
||||||
|
- Create button: `px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg`
|
||||||
|
- Text: "Create Link"
|
||||||
|
- On click: call `createShareLink.mutate({ expiresInDays })`, on success copy the generated URL to clipboard
|
||||||
|
|
||||||
|
7. **Active Links List:** For each non-revoked share from `useShareLinks`:
|
||||||
|
- `flex items-center gap-2 p-3 bg-gray-50 rounded-lg mb-2`
|
||||||
|
- URL display: `text-sm text-gray-600 truncate flex-1` showing short URL `{origin}/s/{token.slice(0,8)}...`
|
||||||
|
- Expiration badge: `text-xs text-gray-400` — "Expires {formatted date}" or "No expiration"
|
||||||
|
- Copy button: `p-1.5 text-gray-400 hover:text-gray-600 rounded` with copy icon (16px)
|
||||||
|
- On click: copy full share URL to clipboard, swap icon to check (green-500) for 2 seconds
|
||||||
|
- Revoke button: `p-1.5 text-gray-400 hover:text-red-500 rounded` with x icon (16px)
|
||||||
|
- On click: call `revokeShareLink.mutate(shareId)`
|
||||||
|
|
||||||
|
8. **Empty state** (no active links): "No share links yet" in `text-sm text-gray-400 text-center py-4`
|
||||||
|
|
||||||
|
**Clipboard helper:** Use `navigator.clipboard.writeText(url)`. Construct full URL as `${window.location.origin}/s/${share.token}`.
|
||||||
|
|
||||||
|
**Update `src/client/routes/setups/$setupId.tsx`:**
|
||||||
|
|
||||||
|
1. Add import: `import { ShareModal } from "../../components/ShareModal";`
|
||||||
|
2. Add state: `const [shareModalOpen, setShareModalOpen] = useState(false);`
|
||||||
|
3. Replace the temporary visibility badge (from Plan 01) with the share button per UI-SPEC:
|
||||||
|
|
||||||
|
**Desktop variant:**
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShareModalOpen(true)}
|
||||||
|
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
setup.visibility === "public"
|
||||||
|
? "text-green-700 bg-green-50 hover:bg-green-100"
|
||||||
|
: setup.visibility === "link"
|
||||||
|
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
|
||||||
|
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LucideIcon name={setup.visibility === "public" ? "globe" : setup.visibility === "link" ? "link" : "lock"} size={16} />
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mobile variant:**
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShareModalOpen(true)}
|
||||||
|
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${
|
||||||
|
setup.visibility === "public"
|
||||||
|
? "text-green-700 bg-green-50 hover:bg-green-100"
|
||||||
|
: setup.visibility === "link"
|
||||||
|
? "text-blue-600 bg-blue-50 hover:bg-blue-100"
|
||||||
|
: "text-gray-500 bg-gray-50 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
aria-label="Share settings"
|
||||||
|
title="Share settings"
|
||||||
|
>
|
||||||
|
<LucideIcon name={setup.visibility === "public" ? "globe" : setup.visibility === "link" ? "link" : "lock"} size={16} />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Render ShareModal:
|
||||||
|
```tsx
|
||||||
|
{shareModalOpen && (
|
||||||
|
<ShareModal
|
||||||
|
isOpen={shareModalOpen}
|
||||||
|
onClose={() => setShareModalOpen(false)}
|
||||||
|
setupId={numericId}
|
||||||
|
currentVisibility={setup.visibility}
|
||||||
|
onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Only show share button when `isAuthenticated` (same guard as current toggle).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "ShareModal" src/client/components/ShareModal.tsx && grep -q "ShareModal" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/client/components/ShareModal.tsx` renders: visibility picker with 3 options, create link form, active links list
|
||||||
|
- Visibility picker options use correct icons: lock (private), link (link), globe (public)
|
||||||
|
- Visibility picker colors match UI-SPEC: gray-500/gray-50, blue-600/blue-50, green-700/green-50
|
||||||
|
- Create link form has expiration dropdown with options: 7 days, 14 days, 30 days, No expiration
|
||||||
|
- Copy button copies `${origin}/s/${token}` to clipboard and shows check icon for 2s
|
||||||
|
- Revoke button calls delete mutation
|
||||||
|
- Deactivation warning shows when selecting private with active links
|
||||||
|
- `src/client/routes/setups/$setupId.tsx` renders share button with visibility-state icon/color
|
||||||
|
- Share button opens ShareModal on click
|
||||||
|
- `bun run lint` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Share modal fully functional with visibility management, link creation, copy, and revoke</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client->clipboard | Share URL written to system clipboard |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-32-08 | Information Disclosure | clipboard copy | accept | Share URLs are intentionally shareable — copying to clipboard is the feature's purpose |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes
|
||||||
|
2. Share button visible on setup detail page with correct icon/color per visibility state
|
||||||
|
3. Modal opens, visibility picker works, create link generates copyable URL
|
||||||
|
4. Revoking a link removes it from the list
|
||||||
|
5. Switching to private shows warning and deactivates links
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Share modal is the single UI for managing visibility and share links (per D-13)
|
||||||
|
- Share icon button replaces old globe toggle (per D-14)
|
||||||
|
- Modal contains visibility picker, create link, and active links list (per D-15)
|
||||||
|
- Works on both desktop and mobile (per D-16)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
33
.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md
Normal file
33
.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Plan 32-03 Summary: Share Modal UI
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Commit:** 7003e99
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
1. **Share hooks** (`src/client/hooks/useShares.ts`):
|
||||||
|
- `useShareLinks(setupId)` — Query hook for fetching share links
|
||||||
|
- `useCreateShareLink(setupId)` — Mutation with query invalidation
|
||||||
|
- `useRevokeShareLink(setupId)` — Mutation with query invalidation
|
||||||
|
|
||||||
|
2. **ShareModal component** (`src/client/components/ShareModal.tsx`):
|
||||||
|
- Visibility picker with three options (private/link/public) — immediate API call on change
|
||||||
|
- Color-coded options: gray (private), blue (link), green (public)
|
||||||
|
- Share link creation with expiration dropdown (7/14/30 days, no expiration)
|
||||||
|
- Active links list with copy-to-clipboard and revoke actions
|
||||||
|
- Deactivation warning when links exist and switching to private
|
||||||
|
- Empty state "No share links yet"
|
||||||
|
- Escape key and overlay click to close
|
||||||
|
- Responsive: works on desktop and mobile
|
||||||
|
|
||||||
|
3. **Setup detail page update** (`src/client/routes/setups/$setupId.tsx`):
|
||||||
|
- Replaced static visibility badge with interactive share button
|
||||||
|
- Desktop: "Share" text + visibility icon
|
||||||
|
- Mobile: Icon-only with 44px touch target
|
||||||
|
- ShareModal rendered with visibility change wired to `updateSetup.mutate`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun run lint`: Passes
|
||||||
|
- ShareModal follows existing modal patterns (overlay, escape key, z-50)
|
||||||
|
- Colors match UI-SPEC: gray-500/gray-50, blue-600/blue-50, green-700/green-50
|
||||||
231
.planning/phases/32-setup-sharing-system/32-04-PLAN.md
Normal file
231
.planning/phases/32-setup-sharing-system/32-04-PLAN.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
phase: 32-setup-sharing-system
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on: [01, 02, 03]
|
||||||
|
files_modified:
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
- src/client/hooks/useSetups.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Anonymous user visiting /setups/:id?share=token sees the shared setup with items"
|
||||||
|
- "Shared setup viewer shows a 'Shared setup' banner at the top"
|
||||||
|
- "Invalid or expired share tokens show an error message"
|
||||||
|
- "Short URL /s/:token redirects to /setups/:id?share=token"
|
||||||
|
- "Shared viewer is read-only — no edit buttons, no share button, no delete button"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
provides: "Enhanced setup detail page with share token detection and shared view mode"
|
||||||
|
- path: "src/client/hooks/useSetups.ts"
|
||||||
|
provides: "useSharedSetup hook for fetching shared setup data"
|
||||||
|
exports: ["useSharedSetup"]
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
to: "src/client/hooks/useSetups.ts"
|
||||||
|
via: "useSharedSetup hook for share token access"
|
||||||
|
pattern: "useSharedSetup"
|
||||||
|
- from: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
to: "/api/shared/:token"
|
||||||
|
via: "API fetch for shared setup data"
|
||||||
|
pattern: "api/shared"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add shared setup viewer functionality to the existing setup detail page — detect share token in URL, fetch via shared endpoint, and display read-only view with shared banner.
|
||||||
|
|
||||||
|
Purpose: This completes the user-facing share flow (D-06, D-17). When someone receives a share link, they can view the setup without authentication.
|
||||||
|
|
||||||
|
Output: Updated setup detail page with share token detection and shared viewing mode.
|
||||||
|
</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/32-setup-sharing-system/32-CONTEXT.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-UI-SPEC.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md
|
||||||
|
@.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts from Plans 01 and 02. -->
|
||||||
|
|
||||||
|
Shared access API endpoint (from Plan 02):
|
||||||
|
```
|
||||||
|
GET /api/shared/:token → Setup object with items array (same format as public view)
|
||||||
|
Returns 404 for invalid/expired/revoked tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Short URL redirect (from Plan 02):
|
||||||
|
```
|
||||||
|
GET /s/:token → 302 redirect to /setups/:setupId?share=:token
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/routes/setups/$setupId.tsx (current structure):
|
||||||
|
```typescript
|
||||||
|
// Three-way data source: private (auth), public (no auth), shared (token)
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
const privateSetup = useSetup(isAuthenticated ? numericId : null);
|
||||||
|
const publicSetup = usePublicSetup(!isAuthenticated ? numericId : null);
|
||||||
|
```
|
||||||
|
|
||||||
|
From @tanstack/react-router:
|
||||||
|
```typescript
|
||||||
|
// URL search params access
|
||||||
|
const search = Route.useSearch(); // needs searchSchema defined on route
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add useSharedSetup hook and share token detection to setup detail page</name>
|
||||||
|
<files>src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx</files>
|
||||||
|
<read_first>src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx, src/client/lib/api.ts</read_first>
|
||||||
|
<action>
|
||||||
|
**Add `useSharedSetup` hook to `src/client/hooks/useSetups.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useSharedSetup(token: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["shared-setup", token],
|
||||||
|
queryFn: () => apiGet<SetupWithItems>(`/api/shared/${token}`),
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false, // Don't retry on 404
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same `SetupWithItems` type used by `useSetup` and `usePublicSetup`.
|
||||||
|
|
||||||
|
**Update `src/client/routes/setups/$setupId.tsx`:**
|
||||||
|
|
||||||
|
1. Add search params validation to the route definition to capture the `share` query param:
|
||||||
|
```typescript
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/setups/$setupId")({
|
||||||
|
component: SetupDetailPage,
|
||||||
|
validateSearch: z.object({
|
||||||
|
share: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In `SetupDetailPage`, detect the share token:
|
||||||
|
```typescript
|
||||||
|
const { share: shareToken } = Route.useSearch();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update the three-way data source logic:
|
||||||
|
```typescript
|
||||||
|
const { data: auth } = useAuth();
|
||||||
|
const isAuthenticated = !!auth?.user;
|
||||||
|
|
||||||
|
// Priority: share token > authenticated owner > public viewer
|
||||||
|
const sharedSetup = useSharedSetup(shareToken ?? null);
|
||||||
|
const privateSetup = useSetup(!shareToken && isAuthenticated ? numericId : null);
|
||||||
|
const publicSetup = usePublicSetup(!shareToken && !isAuthenticated ? numericId : null);
|
||||||
|
|
||||||
|
const isSharedView = !!shareToken;
|
||||||
|
const { data: setup, isLoading, isError } = isSharedView
|
||||||
|
? sharedSetup
|
||||||
|
: isAuthenticated
|
||||||
|
? privateSetup
|
||||||
|
: publicSetup;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add shared banner (per 32-UI-SPEC.md) — render above the header bar when `isSharedView`:
|
||||||
|
```tsx
|
||||||
|
{isSharedView && setup && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100">
|
||||||
|
<LucideIcon name="link" size={16} className="text-blue-500" />
|
||||||
|
<span className="text-sm text-blue-700">Shared setup</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Add error state for invalid/expired share tokens:
|
||||||
|
```tsx
|
||||||
|
{isSharedView && isError && (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
|
||||||
|
<LucideIcon name="link" size={48} className="text-gray-300 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">Link not available</h2>
|
||||||
|
<p className="text-sm text-gray-500">This share link has expired or is no longer valid.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Hide owner-only controls when in shared view — conditionally hide these elements when `isSharedView` is true:
|
||||||
|
- Add Items button (both desktop and mobile variants)
|
||||||
|
- Share button (both desktop and mobile variants)
|
||||||
|
- Delete Setup button (both desktop and mobile variants)
|
||||||
|
- Classification dropdowns on items
|
||||||
|
- Remove item buttons
|
||||||
|
|
||||||
|
Wrap each with: `{!isSharedView && isAuthenticated && ( ... )}`
|
||||||
|
|
||||||
|
7. The shared view shows the same read-only content as the public view: item list grouped by category, weight summary card, setup name header.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "useSharedSetup" src/client/hooks/useSetups.ts && grep -q "shareToken\|share:" src/client/routes/setups/\$setupId.tsx && grep -q "Shared setup" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/client/hooks/useSetups.ts` exports `useSharedSetup(token)` that fetches `/api/shared/:token`
|
||||||
|
- `src/client/routes/setups/$setupId.tsx` validates `share` search param via Zod
|
||||||
|
- When `?share=token` is present, setup data is fetched via shared endpoint (not owner or public)
|
||||||
|
- Shared banner (`Shared setup` with link icon in blue-50) appears at top of page when share token present
|
||||||
|
- Invalid/expired token shows error state with "Link not available" message
|
||||||
|
- Owner-only controls (add items, share, delete, classification, remove item) are hidden in shared view
|
||||||
|
- `bun run lint` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Shared setup viewer with token detection, shared banner, error handling, and read-only mode</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| URL search params | Share token from URL — untrusted user input |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-32-09 | Spoofing | share token in URL | mitigate | Token validated server-side by /api/shared/:token — client only passes through, no client-side authorization decisions |
|
||||||
|
| T-32-10 | Information Disclosure | shared view content | accept | Shared setup data is intentionally visible to anyone with the token — this is the feature |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes
|
||||||
|
2. Visit `/setups/1?share=valid-token` — shows setup with shared banner, no edit controls
|
||||||
|
3. Visit `/setups/1?share=invalid-token` — shows error state
|
||||||
|
4. Visit `/s/valid-token` — redirects to `/setups/:id?share=token`, displays shared view
|
||||||
|
5. Owner visiting their own setup normally (no share param) — sees all controls as before
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Share links use `/s/{token}` short URL AND `/setups/:id?share={token}` (per D-06)
|
||||||
|
- Shared setup viewer works for anonymous users (per D-17)
|
||||||
|
- No owner-only actions visible in shared view
|
||||||
|
- No changes to discovery feed or profile page (per D-18)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/32-setup-sharing-system/32-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
33
.planning/phases/32-setup-sharing-system/32-04-SUMMARY.md
Normal file
33
.planning/phases/32-setup-sharing-system/32-04-SUMMARY.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Plan 32-04 Summary: Shared Setup Viewer
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Commit:** 0b46eff
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
1. **useSharedSetup hook** (`src/client/hooks/useSetups.ts`):
|
||||||
|
- Fetches `/api/shared/:token` with retry disabled (404 = invalid token)
|
||||||
|
- Returns same SetupWithItems type as other setup hooks
|
||||||
|
|
||||||
|
2. **Route search params** (`src/client/routes/setups/$setupId.tsx`):
|
||||||
|
- Added `validateSearch` with Zod schema for `share` query param
|
||||||
|
- Three-way data source: share token > authenticated owner > public viewer
|
||||||
|
|
||||||
|
3. **Shared setup banner**:
|
||||||
|
- Blue banner with link icon: "Shared setup" shown when share token present
|
||||||
|
- Positioned above the sticky header bar
|
||||||
|
|
||||||
|
4. **Error state for invalid tokens**:
|
||||||
|
- Shows "Link not available" with link icon and descriptive text
|
||||||
|
- Renders instead of the normal page when shared fetch errors
|
||||||
|
|
||||||
|
5. **Read-only mode**:
|
||||||
|
- `showOwnerControls` computed from `!isSharedView && isAuthenticated`
|
||||||
|
- Hidden in shared view: Add Items, Share button, Delete Setup, item removal, classification cycling
|
||||||
|
- Item Picker, Share Modal, and Delete Dialog all gated behind `showOwnerControls`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun run lint`: Our files pass (pre-existing errors in unrelated files only)
|
||||||
|
- Share token detection and three-way data source logic correct
|
||||||
|
- All owner controls properly hidden in shared view
|
||||||
120
.planning/phases/32-setup-sharing-system/32-CONTEXT.md
Normal file
120
.planning/phases/32-setup-sharing-system/32-CONTEXT.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Phase 32: Setup Sharing System - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-13
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Setup owners can toggle visibility between private, link-shared, and public. Share links use secret tokens with configurable expiration and revocation. Schema includes full future-proofing for person-specific shares, write permissions, and collaborative editing — but only read-only link sharing is enforced in this phase.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Visibility Model
|
||||||
|
- **D-01:** Three visibility levels: `private` (owner only), `link` (accessible via share token, not discoverable), `public` (discoverable on feed/profiles)
|
||||||
|
- **D-02:** Replace `isPublic: boolean` column on `setups` table with `visibility: text` column (`private`/`link`/`public`). Column on table for query speed — discovery feed queries `WHERE visibility = 'public'`
|
||||||
|
- **D-03:** Setting visibility to `private` deactivates (does not delete) all share links. Switching back to `link` reactivates them
|
||||||
|
- **D-04:** Share links use secret tokens — the setup's numeric ID alone is not sufficient for link-shared access
|
||||||
|
|
||||||
|
### Share Links
|
||||||
|
- **D-05:** Multiple share links can coexist per setup, each independent with its own token, expiration, and revocation status
|
||||||
|
- **D-06:** Share URLs: `/s/{token}` (short URL for sharing) AND `/setups/:id?share={token}` (both work, short URL is primary for sharing)
|
||||||
|
- **D-07:** Default link expiration: 14 days. Options when creating: 7 days, 14 days, 30 days, infinite
|
||||||
|
- **D-08:** Each link can be individually revoked without affecting other links
|
||||||
|
- **D-09:** Only read-only link shares are functional in this phase. Write permission exists in schema but is not enforced
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
- **D-10:** Full `shares` table created now: id, setupId, token, permission (read/write), expiresAt (nullable = infinite), userId (nullable — null = link share, set = person-specific share), createdAt, revokedAt (nullable)
|
||||||
|
- **D-11:** Person-specific shares (userId column) exist in schema but are not used in this phase
|
||||||
|
- **D-12:** Write permission column exists but write-access is not enforced — no mutation permission checks
|
||||||
|
|
||||||
|
### Share UX
|
||||||
|
- **D-13:** Share modal (Google Docs style) is the single UI for managing visibility AND share links
|
||||||
|
- **D-14:** Share icon button replaces the current globe public/private toggle on setup detail page. Icon reflects current visibility state via color/icon variation
|
||||||
|
- **D-15:** Modal contains: visibility picker (private/link/public), create share link with expiration picker, active share links list with copy/revoke actions
|
||||||
|
- **D-16:** Desktop and mobile use the same share icon button opening the same modal
|
||||||
|
|
||||||
|
### Public Setup Presentation
|
||||||
|
- **D-17:** Link-shared setup viewer UX: Claude's discretion — will pick based on existing setup detail page patterns (subtle shared context vs. identical to public view)
|
||||||
|
- **D-18:** No changes to discovery feed or profile page visuals in this phase
|
||||||
|
- **D-19:** Discovery feed query updated from `isPublic = true` to `visibility = 'public'` — same behavior, new column
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Viewer experience for link-shared setups (shared banner/badge vs. clean view) — pick what fits the existing design patterns
|
||||||
|
|
||||||
|
### Folded Todos
|
||||||
|
None — no relevant todos matched this phase's scope.
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
No external specs — requirements fully captured in decisions above.
|
||||||
|
|
||||||
|
### Existing Implementation
|
||||||
|
- `src/db/schema.ts` — Current `setups` table with `isPublic` boolean (line 118-127)
|
||||||
|
- `src/server/services/setup.service.ts` — Setup CRUD with `isPublic` handling
|
||||||
|
- `src/server/services/discovery.service.ts` — Discovery feed query using `isPublic = true`
|
||||||
|
- `src/server/services/profile.service.ts` — `getPublicSetupWithItems()` for public viewing
|
||||||
|
- `src/client/routes/setups/$setupId.tsx` — Setup detail page with current globe toggle (lines 177-203)
|
||||||
|
- `src/client/hooks/useSetups.ts` — `usePublicSetup` hook (line 67)
|
||||||
|
- `src/shared/schemas.ts` — Zod schemas with `isPublic` field (lines 88, 93)
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- Setup detail page (`setups/$setupId.tsx`): Has desktop + mobile button patterns for the share button replacement
|
||||||
|
- `useSetups` hooks: Mutation patterns for `updateSetup` — extend for visibility changes
|
||||||
|
- `LucideIcon` component: Icons like `share-2`, `link`, `globe`, `lock` available for visibility states
|
||||||
|
- Modal patterns: Used elsewhere in the app (thread creation, item add) — reuse for share modal
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Service layer with DI (`db` as first param) for testability
|
||||||
|
- Zod validation on route handlers via `@hono/zod-validator`
|
||||||
|
- React Query mutations with cache invalidation
|
||||||
|
- Detail pages (not panels) for complex interactions
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `src/db/schema.ts`: New `shares` table + modify `setups` table (isPublic → visibility)
|
||||||
|
- `src/server/routes/setups.ts`: New share link CRUD endpoints
|
||||||
|
- `src/server/routes/`: New `/s/:token` route for short share URLs
|
||||||
|
- `src/server/services/setup.service.ts`: Update queries from isPublic to visibility
|
||||||
|
- `src/server/services/discovery.service.ts`: Update feed query
|
||||||
|
- `src/server/services/profile.service.ts`: Update public setup query
|
||||||
|
- `src/client/routes/setups/$setupId.tsx`: Replace globe toggle with share button + modal
|
||||||
|
- `src/shared/schemas.ts`: New share schemas, update setup schemas
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- Share modal inspired by Google Docs share dialog — visibility picker at top, share links list below
|
||||||
|
- When visibility is set to `private`, share links become inactive but aren't deleted — switching back to `link` reactivates them
|
||||||
|
- Multiple shares coexist: e.g., one permanent read link + one 14-day read link simultaneously
|
||||||
|
- Future: person-specific shares should influence discovery algorithm (deferred)
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- **Person-specific shares influencing discovery feed algorithm** — when direct shares exist between users, factor that into feed ranking. Belongs in a future personalization/social phase.
|
||||||
|
- **Write-access share enforcement** — collaborative editing requires conflict resolution, real-time sync, and mutation permission checks. Belongs in a dedicated collaborative editing phase.
|
||||||
|
- **Person-specific share UI** — inviting specific users by username/email to a setup. Needs user search/lookup. Future phase.
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 32-setup-sharing-system*
|
||||||
|
*Context gathered: 2026-04-13*
|
||||||
141
.planning/phases/32-setup-sharing-system/32-DISCUSSION-LOG.md
Normal file
141
.planning/phases/32-setup-sharing-system/32-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Phase 32: Setup Sharing System - 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-13
|
||||||
|
**Phase:** 32-Setup Sharing System
|
||||||
|
**Areas discussed:** Visibility model, Share UX & controls, Schema future-proofing, Public setup presentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visibility Model
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Unlisted link (no token) | Setup ID in URL is the 'key'. Simple but IDs are guessable. | |
|
||||||
|
| Secret token link | URL contains random token. More secure, requires generation/storage. | ✓ |
|
||||||
|
| Two levels only (private/public) | Keep current boolean. Skip link-sharing. | Rejected by user upfront |
|
||||||
|
|
||||||
|
**User's choice:** Secret token link
|
||||||
|
**Notes:** User explicitly stated "ditching the link share ain't it" — three levels are required.
|
||||||
|
|
||||||
|
### Follow-up: Share URL format
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| /setups/42?share=token | Query param on existing route | |
|
||||||
|
| /s/token (short URL) | Dedicated short route, cleaner for sharing | ✓ (both) |
|
||||||
|
|
||||||
|
**User's choice:** Both should work, but `/s/token` is primary for sharing because it's shorter.
|
||||||
|
|
||||||
|
### Follow-up: Token revocation
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Regenerate button (single token) | One token per setup, regenerate invalidates old | |
|
||||||
|
| Full shares list with management | Multiple shares per setup, each with permission/expiration/revocation | ✓ |
|
||||||
|
|
||||||
|
**User's choice:** Full shares management. Multiple coexisting shares with different permissions (read/write), expirations (default 14 days, settable, or infinite), individually revocable. Vision includes person-specific shares with write access.
|
||||||
|
|
||||||
|
### Follow-up: Scope check
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Read shares now, write schema only | Implement read link shares. Schema includes write/person columns unused. | ✓ |
|
||||||
|
| Full system now | Implement everything including write shares and person-specific shares. | |
|
||||||
|
| Minimal + schema | Single share link only. Full schema but minimal UI. | |
|
||||||
|
|
||||||
|
**User's choice:** Read shares now, write permission schema only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Share UX & Controls
|
||||||
|
|
||||||
|
### Visibility control UI
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Dropdown selector | Replace globe with dropdown for visibility levels | |
|
||||||
|
| Visibility section in panel | Dedicated section below setup content | |
|
||||||
|
| Modal dialog | Share button opens Google Docs-style modal | ✓ |
|
||||||
|
|
||||||
|
**User's choice:** Modal dialog
|
||||||
|
|
||||||
|
### Share button appearance
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Share icon button | Replace globe toggle with share icon showing visibility state | ✓ |
|
||||||
|
| Keep globe + add share | Two buttons, two functions | |
|
||||||
|
| Text button with state | Labeled button showing current state | |
|
||||||
|
|
||||||
|
**User's choice:** Share icon button
|
||||||
|
|
||||||
|
### Default expiration
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| 14 days default | Safe default, options for 7d/30d/infinite | ✓ |
|
||||||
|
| No expiration default | Permanent by default, optional expiration | |
|
||||||
|
| You decide | Claude picks | |
|
||||||
|
|
||||||
|
**User's choice:** 14 days default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Future-Proofing
|
||||||
|
|
||||||
|
### Shares table design
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Full shares table now | Complete table with permission, userId, expiresAt, revokedAt | ✓ |
|
||||||
|
| Link shares only, extend later | Simpler table, add columns in future migrations | |
|
||||||
|
| You decide | Claude picks based on tradeoffs | |
|
||||||
|
|
||||||
|
**User's choice:** Full shares table now
|
||||||
|
|
||||||
|
### Visibility storage
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Column on setups table | Replace isPublic with visibility text column | ✓ |
|
||||||
|
| Derived from shares | No column, derive from shares table via JOINs | |
|
||||||
|
|
||||||
|
**User's choice:** Column on setups table — best for query speed, but must prevent conflicts with shares.
|
||||||
|
**Notes:** User emphasized "it must be done right to prevent conflicts with the shares"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public Setup Presentation
|
||||||
|
|
||||||
|
### Link-shared viewer experience
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Same as public view | Identical to public setup view | |
|
||||||
|
| Shared view with context | Subtle banner showing share status and expiration | |
|
||||||
|
| You decide | Claude picks based on existing patterns | ✓ |
|
||||||
|
|
||||||
|
**User's choice:** Claude's discretion
|
||||||
|
|
||||||
|
### Discovery feed changes
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| No changes needed | Just update query from isPublic to visibility | ✓ |
|
||||||
|
| Add share count indicator | Show social proof on setup cards | |
|
||||||
|
|
||||||
|
**User's choice:** No changes for Phase 32.
|
||||||
|
**Notes:** Person-specific shares influencing feed algorithm is deferred to future.
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Viewer experience for link-shared setups (shared banner vs. clean view)
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Person-specific shares influencing discovery feed algorithm
|
||||||
|
- Write-access share enforcement (collaborative editing)
|
||||||
|
- Person-specific share UI (invite by username/email)
|
||||||
190
.planning/phases/32-setup-sharing-system/32-RESEARCH.md
Normal file
190
.planning/phases/32-setup-sharing-system/32-RESEARCH.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Phase 32: Setup Sharing System - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-13
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This phase adds a three-tier visibility model (private/link/public) to setups and introduces share links with secret tokens. The implementation is a straightforward schema migration + CRUD addition on a well-established Hono + Drizzle + React stack. No external libraries or unfamiliar integrations are needed.
|
||||||
|
|
||||||
|
## Key Technical Findings
|
||||||
|
|
||||||
|
### 1. Schema Migration Strategy
|
||||||
|
|
||||||
|
**Current state:** `setups` table has `isPublic: boolean("is_public").notNull().default(false)` (schema.ts line 124).
|
||||||
|
|
||||||
|
**Migration approach:** Add `visibility: text("visibility").notNull().default("private")` column, migrate data (`isPublic=true` -> `visibility='public'`), then drop `isPublic`. Drizzle on PostgreSQL requires a custom SQL migration for data migration since `bun run db:generate` only generates DDL.
|
||||||
|
|
||||||
|
**New `shares` table:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE shares (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
permission TEXT NOT NULL DEFAULT 'read',
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
revoked_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_shares_token ON shares(token);
|
||||||
|
CREATE INDEX idx_shares_setup_id ON shares(setup_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token generation:** Use `randomBytes(16).toString("base64url")` from `node:crypto` (22 chars, URL-safe, 128 bits of entropy). This matches the pattern already used in `auth.service.ts` and `oauth.service.ts`.
|
||||||
|
|
||||||
|
### 2. Access Control for Share Links
|
||||||
|
|
||||||
|
**Current auth pattern:** `src/server/index.ts` has middleware that protects POST/PUT/DELETE on `/api/*`. GET endpoints are public. The `/:id/public` route in `setups.ts` already bypasses auth for public viewing.
|
||||||
|
|
||||||
|
**Share link access:** A new route `GET /api/setups/:id/shared?token=xxx` (or a short-URL route `GET /s/:token`) needs to:
|
||||||
|
1. Look up the share record by token
|
||||||
|
2. Verify: not revoked (`revokedAt IS NULL`), not expired (`expiresAt IS NULL OR expiresAt > NOW()`)
|
||||||
|
3. Resolve the setupId and return the setup with items (same data as public view)
|
||||||
|
|
||||||
|
**Short URL `/s/:token`:** This is a server-side redirect route (not an API route). It should look up the token, resolve the setupId, and redirect to the client-side viewer page: `/setups/{setupId}?share={token}`. Register as `app.get("/s/:token", ...)` in `src/server/index.ts` before the SPA catch-all.
|
||||||
|
|
||||||
|
### 3. Service Layer Changes
|
||||||
|
|
||||||
|
**Existing services to modify:**
|
||||||
|
|
||||||
|
- `setup.service.ts`: Replace all `isPublic` references with `visibility`. Update `createSetup`, `updateSetup`, `getAllSetups` to use visibility. Add `updateVisibility(db, userId, setupId, visibility)` function.
|
||||||
|
|
||||||
|
- `discovery.service.ts`: Change `.where(eq(setups.isPublic, true))` to `.where(eq(setups.visibility, "public"))` in `getPopularSetups`.
|
||||||
|
|
||||||
|
- `profile.service.ts`: Change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` in both `getPublicProfile` and `getPublicSetupWithItems`. Add `getSharedSetupWithItems(db, setupId, token)` that validates the share token and returns setup data.
|
||||||
|
|
||||||
|
**New service: `share.service.ts`:**
|
||||||
|
- `createShareLink(db, userId, setupId, { expiresInDays })` — generates token, inserts share record
|
||||||
|
- `revokeShareLink(db, userId, shareId)` — sets `revokedAt`
|
||||||
|
- `getShareLinks(db, userId, setupId)` — returns all shares for a setup
|
||||||
|
- `validateShareToken(db, token)` — checks token validity, returns setup data if valid
|
||||||
|
- `deactivateShareLinks(db, setupId)` — bulk set revokedAt when visibility goes to private
|
||||||
|
- `reactivateShareLinks(db, setupId)` — bulk clear revokedAt when visibility goes back to link
|
||||||
|
|
||||||
|
### 4. API Routes
|
||||||
|
|
||||||
|
**Modified routes in `setups.ts`:**
|
||||||
|
- `PUT /api/setups/:id` — update to handle `visibility` instead of `isPublic`
|
||||||
|
- `GET /api/setups/:id/public` — update to check `visibility = 'public'`
|
||||||
|
|
||||||
|
**New routes (new file `shares.ts` or added to `setups.ts`):**
|
||||||
|
- `POST /api/setups/:id/shares` — create share link (auth required)
|
||||||
|
- `GET /api/setups/:id/shares` — list share links (auth required)
|
||||||
|
- `DELETE /api/setups/:id/shares/:shareId` — revoke share link (auth required)
|
||||||
|
- `GET /api/setups/shared/:token` — access setup via share token (no auth)
|
||||||
|
|
||||||
|
**Short URL route:** `GET /s/:token` in `src/server/index.ts` — redirects to client page.
|
||||||
|
|
||||||
|
### 5. Client-Side Changes
|
||||||
|
|
||||||
|
**Schema/type changes:**
|
||||||
|
- `schemas.ts`: Replace `isPublic: z.boolean()` with `visibility: z.enum(["private", "link", "public"])` in setup schemas. Add share link schemas.
|
||||||
|
- `types.ts`: Types auto-inferred from Drizzle + Zod — will update automatically.
|
||||||
|
|
||||||
|
**Setup detail page (`setups/$setupId.tsx`):**
|
||||||
|
- Replace globe toggle button (lines 177-203) with share icon button
|
||||||
|
- Share button opens a modal dialog
|
||||||
|
- Share modal contains: visibility picker, create link form, active links list
|
||||||
|
|
||||||
|
**New component: `ShareModal.tsx`:**
|
||||||
|
- Visibility radio group (private/link/public) with icons (lock/link/globe)
|
||||||
|
- "Create Link" button with expiration dropdown (7d/14d/30d/infinite)
|
||||||
|
- List of active share links with copy-to-clipboard and revoke buttons
|
||||||
|
- When visibility changes to `private`, show confirmation that links will be deactivated
|
||||||
|
|
||||||
|
**Shared setup viewer:**
|
||||||
|
- Route: `/setups/:setupId` with `?share=token` query param
|
||||||
|
- When share token is present, fetch via shared endpoint instead of owner endpoint
|
||||||
|
- Display a subtle "Shared with you" banner or badge
|
||||||
|
- Same layout as public view (read-only item list with totals)
|
||||||
|
|
||||||
|
**Hooks (`useSetups.ts`):**
|
||||||
|
- Add `useShareLinks(setupId)` — React Query for listing shares
|
||||||
|
- Add `useCreateShareLink()`, `useRevokeShareLink()` mutations
|
||||||
|
- Add `useSharedSetup(setupId, token)` — fetch shared setup data
|
||||||
|
- Update `useUpdateSetup` to handle visibility instead of isPublic
|
||||||
|
|
||||||
|
### 6. Visibility State Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
private ──→ link : No side effects (links remain inactive until created)
|
||||||
|
private ──→ public : No side effects
|
||||||
|
link ──→ private: Deactivate all share links (set revokedAt)
|
||||||
|
link ──→ public : No side effects (links still work)
|
||||||
|
public ──→ private: Deactivate all share links
|
||||||
|
public ──→ link : No side effects
|
||||||
|
* ──→ link : Reactivate previously-deactivated links (clear revokedAt where revokedAt was set by visibility change, not manual revoke)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation note:** To distinguish manual revokes from visibility-deactivated links, add a `deactivatedByVisibility: boolean` column or use a sentinel value in `revokedAt`. Simpler approach: track `deactivationReason: text` ("manual" | "visibility"). This keeps reactivation clean — only reactivate where `deactivationReason = 'visibility'`.
|
||||||
|
|
||||||
|
Actually, the simplest approach per D-03: just set revokedAt on all links when going private, and clear revokedAt on all links when going to link/public. Manual revokes are also cleared — acceptable since the user explicitly chose to reactivate. If this is undesirable, a `manuallyRevoked: boolean` column solves it cleanly.
|
||||||
|
|
||||||
|
### 7. Testing Strategy
|
||||||
|
|
||||||
|
**Service tests (extend `tests/services/setup.service.test.ts`):**
|
||||||
|
- Test visibility column CRUD (create with visibility, update visibility)
|
||||||
|
- Test share link creation with token generation
|
||||||
|
- Test share token validation (valid, expired, revoked)
|
||||||
|
- Test visibility transition side effects (deactivate/reactivate links)
|
||||||
|
|
||||||
|
**New test file: `tests/services/share.service.test.ts`:**
|
||||||
|
- Full CRUD for share links
|
||||||
|
- Token validation with edge cases (expired, revoked, wrong setup)
|
||||||
|
- Multiple links per setup
|
||||||
|
|
||||||
|
**Route tests (extend `tests/routes/setups.test.ts` or `tests/routes/` new file):**
|
||||||
|
- Share link API endpoints
|
||||||
|
- Short URL redirect
|
||||||
|
- Access control (can't create shares for other users' setups)
|
||||||
|
|
||||||
|
**E2E tests:**
|
||||||
|
- Share modal interaction (open, change visibility, create link, copy, revoke)
|
||||||
|
- Visit shared link as anonymous user
|
||||||
|
|
||||||
|
### 8. Migration Safety
|
||||||
|
|
||||||
|
The `isPublic` -> `visibility` migration is a breaking change for existing API consumers. Migration steps:
|
||||||
|
1. Add `visibility` column with default `'private'`
|
||||||
|
2. Migrate data: `UPDATE setups SET visibility = 'public' WHERE is_public = true`
|
||||||
|
3. Drop `isPublic` column
|
||||||
|
4. Update all service/route/schema code
|
||||||
|
|
||||||
|
Since GearBox is a single-user app with controlled deployments, the migration can be done in a single deploy without backward compatibility concerns. The Drizzle migration file handles steps 1-3 atomically.
|
||||||
|
|
||||||
|
### 9. MCP Server Updates
|
||||||
|
|
||||||
|
The MCP tools `create_setup`, `update_setup`, `get_setup`, `list_setups` need updates:
|
||||||
|
- Replace `isPublic` parameter with `visibility` in tool schemas
|
||||||
|
- Add share link tools if desired (optional for this phase)
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Critical Paths
|
||||||
|
1. Share token generation and validation (security-critical)
|
||||||
|
2. Visibility state transitions with link deactivation/reactivation
|
||||||
|
3. Migration from `isPublic` to `visibility` without data loss
|
||||||
|
4. Short URL redirect resolution
|
||||||
|
|
||||||
|
### Verification Points
|
||||||
|
- Token uniqueness enforced by database unique constraint
|
||||||
|
- Expired/revoked tokens return 404 (not 403, to avoid token enumeration)
|
||||||
|
- Visibility changes correctly cascade to share link states
|
||||||
|
- Discovery feed query produces identical results before/after migration
|
||||||
|
- Public setup view works identically before/after migration
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Phase 28 (profiles):** Required — profiles must be working for public setup attribution
|
||||||
|
- **No external dependencies:** All functionality implemented with existing stack (Drizzle, Hono, React Query, Tailwind)
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Token enumeration on share endpoints | Low | Medium | Return 404 for invalid/expired/revoked tokens (no distinction) |
|
||||||
|
| Migration breaks existing public setups | Low | High | Test migration on dev DB first, verify discovery feed still works |
|
||||||
|
| Share modal complexity on mobile | Medium | Low | Reuse existing modal patterns, test responsive behavior |
|
||||||
|
|
||||||
|
## RESEARCH COMPLETE
|
||||||
65
.planning/phases/32-setup-sharing-system/32-UAT.md
Normal file
65
.planning/phases/32-setup-sharing-system/32-UAT.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
status: complete
|
||||||
|
phase: 32-setup-sharing-system
|
||||||
|
source: [32-01-SUMMARY.md, 32-02-SUMMARY.md, 32-03-SUMMARY.md, 32-04-SUMMARY.md]
|
||||||
|
started: 2026-04-13T18:00:00.000Z
|
||||||
|
updated: 2026-04-19T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Test
|
||||||
|
|
||||||
|
[complete]
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### 1. Visibility badge on setup cards
|
||||||
|
expected: On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 2. Share button on setup detail page
|
||||||
|
expected: On a setup detail page (as the owner), there's a "Share" button (desktop: text + icon, mobile: icon-only 44px touch target) that replaces the old public/private globe toggle. The icon reflects the current visibility state.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 3. Share modal — visibility picker
|
||||||
|
expected: Clicking the Share button opens a modal with three visibility options: Private (gray), Link (blue), Public (green). Selecting one immediately updates the setup's visibility via API call. Current state is highlighted.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 4. Share modal — create share link
|
||||||
|
expected: In the share modal, there's a section to create share links with an expiration dropdown (7 days, 14 days, 30 days, No expiration). Creating a link generates a URL and shows it in the active links list.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 5. Share modal — copy and revoke links
|
||||||
|
expected: Each active share link in the modal has a copy-to-clipboard button and a revoke button. Copying puts the URL in the clipboard. Revoking removes the link from the active list.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 6. Share modal — private deactivates links
|
||||||
|
expected: When switching visibility to "Private" while share links exist, links are deactivated (not deleted). Switching back to "Link" reactivates them.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 7. Short URL access (/s/token)
|
||||||
|
expected: Visiting /s/{token} redirects to /setups/{id}?share={token}. The setup loads correctly showing its items and totals.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 8. Shared setup viewer — read-only mode
|
||||||
|
expected: When viewing a setup via share token, a blue "Shared setup" banner appears at the top. All owner controls are hidden: no Add Items, no Share button, no Delete, no item removal, no classification cycling.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 9. Invalid share token error
|
||||||
|
expected: Visiting a setup with an invalid or expired share token shows a "Link not available" error page instead of the setup content.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
### 10. Discovery feed uses visibility
|
||||||
|
expected: Only setups with visibility="public" appear on the discovery feed and profile pages. Link-shared and private setups do not appear.
|
||||||
|
result: PASS
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
total: 10
|
||||||
|
passed: 10
|
||||||
|
issues: 0
|
||||||
|
pending: 0
|
||||||
|
skipped: 0
|
||||||
|
|
||||||
|
## Gaps
|
||||||
|
|
||||||
|
[none]
|
||||||
225
.planning/phases/32-setup-sharing-system/32-UI-SPEC.md
Normal file
225
.planning/phases/32-setup-sharing-system/32-UI-SPEC.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
---
|
||||||
|
phase: 32
|
||||||
|
slug: setup-sharing-system
|
||||||
|
status: approved
|
||||||
|
shadcn_initialized: false
|
||||||
|
preset: none
|
||||||
|
created: 2026-04-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 32 — UI Design Contract
|
||||||
|
|
||||||
|
> Visual and interaction contract for the Setup Sharing System. Covers share button, share modal, visibility picker, and shared setup viewer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Tool | none |
|
||||||
|
| Preset | not applicable |
|
||||||
|
| Component library | none (custom Tailwind components) |
|
||||||
|
| Icon library | Lucide via `LucideIcon` component from `lib/iconData` |
|
||||||
|
| Font | System font stack (inherited from existing app) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale
|
||||||
|
|
||||||
|
Declared values (must be multiples of 4):
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| xs | 4px | Icon gaps, inline padding |
|
||||||
|
| sm | 8px | Compact element spacing, gap-2 |
|
||||||
|
| md | 16px | Default element spacing, gap-4, p-4 |
|
||||||
|
| lg | 24px | Section padding, p-6 |
|
||||||
|
| xl | 32px | Layout gaps |
|
||||||
|
| 2xl | 48px | Major section breaks |
|
||||||
|
| 3xl | 64px | Page-level spacing |
|
||||||
|
|
||||||
|
Exceptions: none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
| Role | Size | Weight | Line Height |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| Body | 14px (text-sm) | 400 | 1.5 |
|
||||||
|
| Label | 14px (text-sm) | 500 (font-medium) | 1.5 |
|
||||||
|
| Heading | 16px (text-base) | 600 (font-semibold) | 1.5 |
|
||||||
|
| Display | 20px (text-xl) | 600 (font-semibold) | 1.5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
| Role | Value | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Dominant (60%) | gray-50 (#f9fafb) | Page background, surfaces |
|
||||||
|
| Secondary (30%) | white (#ffffff) | Cards, modals, panels |
|
||||||
|
| Accent (10%) | gray-700 (#374151) | Primary action buttons (Add Items, share CTA) |
|
||||||
|
| Destructive | red-600 (#dc2626) | Revoke link, delete actions |
|
||||||
|
|
||||||
|
Accent reserved for: primary action buttons only (Add Items, Create Link)
|
||||||
|
|
||||||
|
### Visibility State Colors
|
||||||
|
|
||||||
|
| State | Icon | Text Color | Background |
|
||||||
|
|-------|------|------------|------------|
|
||||||
|
| Private | `lock` | gray-500 | gray-50 |
|
||||||
|
| Link | `link` | blue-600 | blue-50 |
|
||||||
|
| Public | `globe` | green-700 | green-50 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Specifications
|
||||||
|
|
||||||
|
### Share Button (replaces globe toggle)
|
||||||
|
|
||||||
|
**Desktop variant:**
|
||||||
|
- Position: same location as current globe toggle button in setup detail header bar
|
||||||
|
- Layout: `inline-flex items-center gap-1.5 px-3 py-2`
|
||||||
|
- Text: "Share" (always visible regardless of visibility state)
|
||||||
|
- Icon: varies by visibility state (see color table above), size 16px
|
||||||
|
- Background/text color: matches visibility state from color table
|
||||||
|
- Rounded: `rounded-lg`
|
||||||
|
- Hover: lighten background one shade
|
||||||
|
|
||||||
|
**Mobile variant:**
|
||||||
|
- Layout: `inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2`
|
||||||
|
- Icon only (no text), same icon/color logic as desktop
|
||||||
|
- `aria-label`: "Share settings"
|
||||||
|
- Rounded: `rounded-lg`
|
||||||
|
|
||||||
|
### Share Modal
|
||||||
|
|
||||||
|
**Overlay:** `fixed inset-0 z-50 bg-black/50 flex items-center justify-center`
|
||||||
|
|
||||||
|
**Modal container:**
|
||||||
|
- Desktop: `bg-white rounded-xl shadow-lg p-6 max-w-md mx-4 w-full`
|
||||||
|
- Max height: `max-h-[80vh] overflow-y-auto`
|
||||||
|
- Matches existing modal pattern (see ConfirmDialog.tsx, CreateThreadModal.tsx)
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
- Title: "Share Setup" (`text-lg font-semibold text-gray-900`)
|
||||||
|
- Close button: top-right, `LucideIcon name="x" size={20}`, `text-gray-400 hover:text-gray-600`
|
||||||
|
- Divider: `border-b border-gray-100 pb-4 mb-4`
|
||||||
|
|
||||||
|
**Visibility Picker Section:**
|
||||||
|
- Label: "Visibility" (`text-sm font-medium text-gray-700 mb-2`)
|
||||||
|
- Three radio-style buttons in a vertical stack, `gap-2`
|
||||||
|
- Each option: `flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors`
|
||||||
|
- Unselected: `border-gray-200 hover:border-gray-300`
|
||||||
|
- Selected: `border-{state-color}-200 bg-{state-color}-50`
|
||||||
|
- Option layout:
|
||||||
|
- Icon (size 20, state color)
|
||||||
|
- Label (`text-sm font-medium text-gray-900`): "Private" / "Link sharing" / "Public"
|
||||||
|
- Description (`text-xs text-gray-500`): "Only you can access" / "Anyone with the link" / "Visible on your profile"
|
||||||
|
|
||||||
|
**Create Link Section (visible when visibility is `link` or `public`):**
|
||||||
|
- Divider: `border-t border-gray-100 pt-4 mt-4`
|
||||||
|
- Section label: "Share Links" (`text-sm font-medium text-gray-700 mb-3`)
|
||||||
|
- Create row: `flex items-center gap-2`
|
||||||
|
- Expiration dropdown: `select` element styled with `px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white`
|
||||||
|
- Options: "7 days", "14 days" (default), "30 days", "No expiration"
|
||||||
|
- Create button: `px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg`
|
||||||
|
- Text: "Create Link"
|
||||||
|
|
||||||
|
**Active Links List:**
|
||||||
|
- Each link: `flex items-center gap-2 p-3 bg-gray-50 rounded-lg mb-2`
|
||||||
|
- URL display: `text-sm text-gray-600 truncate flex-1` showing `/s/{token-prefix}...`
|
||||||
|
- Expiration badge: `text-xs text-gray-400` showing "Expires {date}" or "No expiration"
|
||||||
|
- Copy button: `p-1.5 text-gray-400 hover:text-gray-600 rounded` with `LucideIcon name="copy" size={16}`
|
||||||
|
- After copy: icon changes to `check` with `text-green-500` for 2 seconds
|
||||||
|
- Revoke button: `p-1.5 text-gray-400 hover:text-red-500 rounded` with `LucideIcon name="x" size={16}`
|
||||||
|
|
||||||
|
**Empty state (no links yet):**
|
||||||
|
- Text: "No share links yet" (`text-sm text-gray-400 text-center py-4`)
|
||||||
|
|
||||||
|
**Deactivation warning (when switching to private with active links):**
|
||||||
|
- Inline warning below visibility picker: `flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg mt-2`
|
||||||
|
- Icon: `LucideIcon name="alert-triangle" size={16}` in `text-amber-500`
|
||||||
|
- Text: "Switching to private will deactivate all share links. They can be reactivated by switching back." (`text-sm text-amber-700`)
|
||||||
|
|
||||||
|
### Shared Setup Viewer
|
||||||
|
|
||||||
|
**Route:** `/setups/:setupId?share={token}`
|
||||||
|
|
||||||
|
**Shared banner:**
|
||||||
|
- Position: top of setup detail page, before header
|
||||||
|
- Layout: `flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-100`
|
||||||
|
- Icon: `LucideIcon name="link" size={16}` in `text-blue-500`
|
||||||
|
- Text: "Shared setup" (`text-sm text-blue-700`)
|
||||||
|
- Appears only when viewing via share token
|
||||||
|
|
||||||
|
**Content:** Identical to public setup view (read-only item list with weight summary, no action buttons)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copywriting Contract
|
||||||
|
|
||||||
|
| Element | Copy |
|
||||||
|
|---------|------|
|
||||||
|
| Modal title | Share Setup |
|
||||||
|
| Visibility: Private label | Private |
|
||||||
|
| Visibility: Private description | Only you can access |
|
||||||
|
| Visibility: Link label | Link sharing |
|
||||||
|
| Visibility: Link description | Anyone with the link |
|
||||||
|
| Visibility: Public label | Public |
|
||||||
|
| Visibility: Public description | Visible on your profile |
|
||||||
|
| Create link CTA | Create Link |
|
||||||
|
| Empty links state | No share links yet |
|
||||||
|
| Deactivation warning | Switching to private will deactivate all share links. They can be reactivated by switching back. |
|
||||||
|
| Copy success toast | Link copied |
|
||||||
|
| Revoke confirmation | Revoke this share link? |
|
||||||
|
| Shared banner text | Shared setup |
|
||||||
|
| Expired link error | This share link has expired |
|
||||||
|
| Invalid link error | This share link is no longer valid |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Safety
|
||||||
|
|
||||||
|
| Registry | Blocks Used | Safety Gate |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| No external registries | N/A | N/A |
|
||||||
|
|
||||||
|
All components are custom Tailwind — no shadcn or third-party UI registry blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
| Breakpoint | Share Button | Share Modal |
|
||||||
|
|------------|-------------|-------------|
|
||||||
|
| Mobile (<768px) | Icon only, 44x44px touch target | Full width with mx-4 margin |
|
||||||
|
| Desktop (>=768px) | Icon + "Share" text | max-w-md centered |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interaction States
|
||||||
|
|
||||||
|
| Interaction | Behavior |
|
||||||
|
|-------------|----------|
|
||||||
|
| Open modal | Click share button, modal appears with current visibility pre-selected |
|
||||||
|
| Change visibility | Immediate API call on selection, optimistic update |
|
||||||
|
| Create link | API call, new link appears in list, auto-copy to clipboard |
|
||||||
|
| Copy link | Copy full URL to clipboard, show check icon for 2s |
|
||||||
|
| Revoke link | Confirmation prompt (reuse ConfirmDialog pattern), then remove from list |
|
||||||
|
| Close modal | Click X, click overlay, or press Escape |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checker Sign-Off
|
||||||
|
|
||||||
|
- [x] Dimension 1 Copywriting: PASS
|
||||||
|
- [x] Dimension 2 Visuals: PASS
|
||||||
|
- [x] Dimension 3 Color: PASS
|
||||||
|
- [x] Dimension 4 Typography: PASS
|
||||||
|
- [x] Dimension 5 Spacing: PASS
|
||||||
|
- [x] Dimension 6 Registry Safety: PASS
|
||||||
|
|
||||||
|
**Approval:** approved 2026-04-13
|
||||||
81
.planning/phases/32-setup-sharing-system/32-VALIDATION.md
Normal file
81
.planning/phases/32-setup-sharing-system/32-VALIDATION.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
phase: 32
|
||||||
|
slug: setup-sharing-system
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-13
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 32 — 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 tests/services/share.service.test.ts tests/services/setup.service.test.ts` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Estimated runtime** | ~8 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test tests/services/share.service.test.ts tests/services/setup.service.test.ts`
|
||||||
|
- **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 | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 32-01-01 | 01 | 1 | D-02 | — | N/A | migration | `bun run db:generate` | ✅ | ⬜ pending |
|
||||||
|
| 32-01-02 | 01 | 1 | D-10 | — | N/A | migration | `bun run db:generate` | ✅ | ⬜ pending |
|
||||||
|
| 32-02-01 | 02 | 1 | D-05 | T-32-01 | Token is 128-bit random, URL-safe | unit | `bun test tests/services/share.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 32-02-02 | 02 | 1 | D-06,D-07,D-08 | — | N/A | unit | `bun test tests/services/share.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 32-03-01 | 03 | 2 | D-01,D-03 | — | N/A | unit | `bun test tests/services/setup.service.test.ts` | ✅ | ⬜ pending |
|
||||||
|
| 32-03-02 | 03 | 2 | D-19 | — | N/A | unit | `bun test tests/services/discovery.service.test.ts` | ✅ | ⬜ pending |
|
||||||
|
| 32-04-01 | 04 | 2 | D-13,D-14,D-15 | — | N/A | e2e | `bun run test:e2e` | ❌ W0 | ⬜ pending |
|
||||||
|
| 32-05-01 | 05 | 3 | D-06,D-17 | T-32-02 | Invalid/expired tokens return 404 | route | `bun test tests/routes/setups.test.ts` | ✅ | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `tests/services/share.service.test.ts` — stubs for share link CRUD and token validation
|
||||||
|
- [ ] Existing `tests/services/setup.service.test.ts` — extend with visibility tests
|
||||||
|
|
||||||
|
*Existing infrastructure covers framework and fixture needs.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Share modal responsive layout | D-16 | Visual layout verification | Open share modal on mobile viewport, verify all controls accessible |
|
||||||
|
| Copy-to-clipboard works | D-15 | Browser clipboard API | Click copy button on share link, paste in new tab |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 < 10s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
270
.planning/phases/33-currency-system/33-01-PLAN.md
Normal file
270
.planning/phases/33-currency-system/33-01-PLAN.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
phase: 33-currency-system
|
||||||
|
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/currency.service.ts
|
||||||
|
- tests/services/currency.service.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [D-01, D-02, D-03, D-06, D-07, D-08, D-09]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "market_prices table exists with global_item_id, market, currency, price_cents columns"
|
||||||
|
- "community_prices table exists with global_item_id, user_id, market, currency, price_cents, price_date, source_type columns"
|
||||||
|
- "items table has price_currency column"
|
||||||
|
- "thread_candidates table has found_price_cents, found_price_currency, found_price_date columns"
|
||||||
|
- "currency service fetches exchange rates from frankfurter.app"
|
||||||
|
- "currency service caches rates in memory with 24h TTL"
|
||||||
|
- "currency service converts prices between currencies accurately"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/db/schema.ts"
|
||||||
|
provides: "market_prices and community_prices table definitions, new columns on items and threadCandidates"
|
||||||
|
contains: "marketPrices"
|
||||||
|
- path: "src/server/services/currency.service.ts"
|
||||||
|
provides: "Exchange rate fetching, caching, and conversion"
|
||||||
|
exports: ["getExchangeRates", "convertPrice", "CURRENCY_MARKET_MAP"]
|
||||||
|
- path: "tests/services/currency.service.test.ts"
|
||||||
|
provides: "Unit tests for currency service"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/services/currency.service.ts"
|
||||||
|
to: "https://api.frankfurter.app"
|
||||||
|
via: "fetch in getExchangeRates"
|
||||||
|
pattern: "frankfurter"
|
||||||
|
- from: "src/db/schema.ts"
|
||||||
|
to: "src/shared/types.ts"
|
||||||
|
via: "Drizzle inferred types"
|
||||||
|
pattern: "marketPrices|communityPrices"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the database schema for market-aware pricing and build the currency conversion service.
|
||||||
|
|
||||||
|
Purpose: Foundation layer — all other plans depend on these tables and the conversion service.
|
||||||
|
Output: New DB tables (market_prices, community_prices), new columns on items/candidates, currency service with rate fetching/caching/conversion.
|
||||||
|
</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/33-currency-system/33-CONTEXT.md
|
||||||
|
@.planning/phases/33-currency-system/33-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From src/db/schema.ts — existing table patterns -->
|
||||||
|
From src/db/schema.ts:
|
||||||
|
```typescript
|
||||||
|
// Pattern: pgTable with serial id, references, timestamps
|
||||||
|
export const globalItems = pgTable("global_items", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
brand: text("brand").notNull(),
|
||||||
|
model: text("model").notNull(),
|
||||||
|
priceCents: integer("price_cents"),
|
||||||
|
// ...
|
||||||
|
}, (table) => [unique().on(table.brand, table.model)]);
|
||||||
|
|
||||||
|
export const items = pgTable("items", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
priceCents: integer("price_cents"),
|
||||||
|
purchasePriceCents: integer("purchase_price_cents"),
|
||||||
|
globalItemId: integer("global_item_id").references(() => globalItems.id),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
export const threadCandidates = pgTable("thread_candidates", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
priceCents: integer("price_cents"),
|
||||||
|
globalItemId: integer("global_item_id").references(() => globalItems.id),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settings = pgTable("settings", {
|
||||||
|
userId: integer("user_id").notNull().references(() => users.id),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
}, (table) => [primaryKey({ columns: [table.userId, table.key] })]);
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/shared/schemas.ts:
|
||||||
|
```typescript
|
||||||
|
export const createItemSchema = z.object({
|
||||||
|
priceCents: z.number().int().nonnegative().optional(),
|
||||||
|
purchasePriceCents: z.number().int().nonnegative().optional(),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createCandidateSchema = z.object({
|
||||||
|
priceCents: z.number().int().nonnegative().optional(),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Add market_prices and community_prices tables + new columns to schema</name>
|
||||||
|
<files>src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts</files>
|
||||||
|
<read_first>src/db/schema.ts, src/shared/schemas.ts, src/shared/types.ts</read_first>
|
||||||
|
<behavior>
|
||||||
|
- marketPrices table has columns: id, globalItemId (FK to globalItems), market (text), currency (text), priceCents (integer), source (text nullable), createdAt (timestamp)
|
||||||
|
- marketPrices has unique constraint on (globalItemId, market, currency)
|
||||||
|
- communityPrices table has columns: id, globalItemId (FK to globalItems), userId (FK to users), market (text), currency (text), priceCents (integer), priceDate (timestamp nullable), sourceType (text, 'purchased' | 'researched'), createdAt (timestamp)
|
||||||
|
- communityPrices has unique constraint on (globalItemId, userId, sourceType)
|
||||||
|
- items table gets new nullable column: priceCurrency (text, default 'EUR')
|
||||||
|
- threadCandidates table gets new nullable columns: foundPriceCents (integer), foundPriceCurrency (text), foundPriceDate (timestamp)
|
||||||
|
- Zod schemas updated: createItemSchema gains optional priceCurrency field, createCandidateSchema gains optional foundPriceCents/foundPriceCurrency/foundPriceDate fields
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Per D-01, D-02: Add `marketPrices` pgTable to schema.ts with columns: `id` (serial PK), `globalItemId` (integer FK to globalItems ON DELETE CASCADE), `market` (text NOT NULL — 'EU', 'US', 'UK', etc.), `currency` (text NOT NULL — 'EUR', 'USD', 'GBP'), `priceCents` (integer NOT NULL), `source` (text nullable — 'manufacturer', 'retailer', 'community'), `createdAt` (timestamp defaultNow). Add unique constraint on (globalItemId, market, currency).
|
||||||
|
|
||||||
|
Per D-04, D-05: Add `communityPrices` pgTable with: `id` (serial PK), `globalItemId` (integer FK to globalItems ON DELETE CASCADE), `userId` (integer FK to users), `market` (text NOT NULL), `currency` (text NOT NULL), `priceCents` (integer NOT NULL), `priceDate` (timestamp nullable), `sourceType` (text NOT NULL — 'purchased' or 'researched'), `createdAt` (timestamp defaultNow). Unique constraint on (globalItemId, userId, sourceType).
|
||||||
|
|
||||||
|
Per D-03: Add `priceCurrency` column to `items` table: `priceCurrency: text("price_currency").default("EUR")`.
|
||||||
|
|
||||||
|
Per D-06, D-07: Add to `threadCandidates` table: `foundPriceCents: integer("found_price_cents")`, `foundPriceCurrency: text("found_price_currency")`, `foundPriceDate: timestamp("found_price_date")`.
|
||||||
|
|
||||||
|
Update `src/shared/schemas.ts`:
|
||||||
|
- `createItemSchema`: add `priceCurrency: z.string().max(3).optional()`
|
||||||
|
- `updateItemSchema`: inherits via `.partial()`
|
||||||
|
- `createCandidateSchema`: add `foundPriceCents: z.number().int().nonnegative().optional()`, `foundPriceCurrency: z.string().max(3).optional()`, `foundPriceDate: z.string().datetime().optional()`
|
||||||
|
- `updateCandidateSchema`: inherits via `.partial()`
|
||||||
|
|
||||||
|
Update `src/shared/types.ts` if it has manual type definitions — if types are inferred from Drizzle/Zod, no changes needed.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/db/schema.ts contains `export const marketPrices = pgTable("market_prices"`
|
||||||
|
- src/db/schema.ts contains `export const communityPrices = pgTable("community_prices"`
|
||||||
|
- src/db/schema.ts items table contains `priceCurrency: text("price_currency")`
|
||||||
|
- src/db/schema.ts threadCandidates table contains `foundPriceCents: integer("found_price_cents")`
|
||||||
|
- src/db/schema.ts threadCandidates table contains `foundPriceCurrency: text("found_price_currency")`
|
||||||
|
- src/db/schema.ts threadCandidates table contains `foundPriceDate: timestamp("found_price_date")`
|
||||||
|
- src/shared/schemas.ts createItemSchema contains `priceCurrency`
|
||||||
|
- src/shared/schemas.ts createCandidateSchema contains `foundPriceCents`
|
||||||
|
- marketPrices has unique constraint on globalItemId + market + currency
|
||||||
|
- communityPrices has unique constraint on globalItemId + userId + sourceType
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "marketPrices\|communityPrices\|priceCurrency\|foundPriceCents" src/db/schema.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Both new tables defined in schema with all columns and constraints, existing tables have new columns, Zod schemas updated</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Create currency conversion service with exchange rate fetching and caching</name>
|
||||||
|
<files>src/server/services/currency.service.ts, tests/services/currency.service.test.ts</files>
|
||||||
|
<read_first>src/server/services/currency.service.ts (will be new), src/server/services/setup.service.ts (for service pattern)</read_first>
|
||||||
|
<behavior>
|
||||||
|
- getExchangeRates() fetches from https://api.frankfurter.app/latest?from=EUR
|
||||||
|
- getExchangeRates() caches result in memory for 24 hours
|
||||||
|
- getExchangeRates() returns cached rates when cache is valid
|
||||||
|
- getExchangeRates() returns stale cache on fetch failure
|
||||||
|
- convertPrice(1000, 'EUR', 'USD', rates) returns correct USD cents using rates.USD
|
||||||
|
- convertPrice(1000, 'USD', 'EUR', rates) returns correct EUR cents using 1/rates.USD
|
||||||
|
- convertPrice(1000, 'EUR', 'EUR', rates) returns 1000 (same currency = no conversion)
|
||||||
|
- CURRENCY_MARKET_MAP maps EUR→EU, USD→US, GBP→UK, JPY→JP, CAD→CA, AUD→AU
|
||||||
|
- getMarketForCurrency('EUR') returns 'EU'
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Per D-08: Create `src/server/services/currency.service.ts` with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ExchangeRates {
|
||||||
|
base: string;
|
||||||
|
date: string;
|
||||||
|
rates: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CURRENCY_MARKET_MAP: Record<string, string> = {
|
||||||
|
EUR: "EU", USD: "US", GBP: "UK", JPY: "JP", CAD: "CA", AUD: "AU",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMarketForCurrency(currency: string): string {
|
||||||
|
return CURRENCY_MARKET_MAP[currency] ?? currency;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per D-08, D-09: Implement `getExchangeRates()`:
|
||||||
|
- Fetch from `https://api.frankfurter.app/latest?from=EUR`
|
||||||
|
- Parse response: `{ base: "EUR", date: "2026-04-13", rates: { USD: 1.08, GBP: 0.86, ... } }`
|
||||||
|
- Cache in module-level variables: `let cachedRates: ExchangeRates | null = null; let cacheExpiry = 0;`
|
||||||
|
- Cache TTL: 24 hours (86400000ms)
|
||||||
|
- On fetch failure: return cached rates if available, throw if no cache
|
||||||
|
- Always include base currency in rates: `rates.EUR = 1` (self-reference for conversion math)
|
||||||
|
|
||||||
|
Implement `convertPrice(cents: number, from: string, to: string, rates: ExchangeRates): number`:
|
||||||
|
- If `from === to`, return cents unchanged
|
||||||
|
- Convert `from` to EUR base: `centsInEur = cents / rates[from]`
|
||||||
|
- Convert EUR to `to`: `result = centsInEur * rates[to]`
|
||||||
|
- Return `Math.round(result)` (integer cents)
|
||||||
|
|
||||||
|
Export a `resetCache()` function for testing.
|
||||||
|
|
||||||
|
Create `tests/services/currency.service.test.ts`:
|
||||||
|
- Test convertPrice with known rates: EUR→USD, USD→EUR, same currency
|
||||||
|
- Test getExchangeRates caching (mock fetch)
|
||||||
|
- Test CURRENCY_MARKET_MAP entries
|
||||||
|
- Test getMarketForCurrency
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/services/currency.service.ts exports getExchangeRates, convertPrice, CURRENCY_MARKET_MAP, getMarketForCurrency
|
||||||
|
- convertPrice(1000, "EUR", "EUR", rates) returns 1000
|
||||||
|
- convertPrice(1000, "EUR", "USD", {base:"EUR",date:"",rates:{EUR:1,USD:1.08}}) returns 1080
|
||||||
|
- tests/services/currency.service.test.ts exists with at least 4 test cases
|
||||||
|
- `bun test tests/services/currency.service.test.ts` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/currency.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Currency service with rate fetching, 24h caching, conversion math, and market mapping — all tested</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| server→frankfurter.app | External API for exchange rates — untrusted data |
|
||||||
|
| client→server | Price currency values from user input |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-33-01 | Tampering | currency.service.ts | mitigate | Validate exchange rate response shape before caching — reject if rates are missing or negative |
|
||||||
|
| T-33-02 | Spoofing | schema.ts priceCurrency | mitigate | Zod validation on priceCurrency field limits to max 3 chars; server validates against known currency list |
|
||||||
|
| T-33-03 | Denial of Service | currency.service.ts | mitigate | Cache rates for 24h; stale-serve on fetch failure; no user-triggered fetches |
|
||||||
|
| T-33-04 | Information Disclosure | community_prices | accept | Community prices are intentionally public aggregate data — no PII beyond userId which is already public in profiles |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/services/currency.service.test.ts` passes
|
||||||
|
- `bun run db:generate` produces a migration for the new tables/columns
|
||||||
|
- schema.ts grep shows marketPrices, communityPrices, priceCurrency, foundPriceCents
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- New tables (market_prices, community_prices) defined in schema
|
||||||
|
- Existing tables extended with currency/date columns
|
||||||
|
- Currency service fetches, caches, and converts prices
|
||||||
|
- All tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/33-currency-system/33-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
38
.planning/phases/33-currency-system/33-01-SUMMARY.md
Normal file
38
.planning/phases/33-currency-system/33-01-SUMMARY.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Plan 33-01 Summary
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-04-13
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Database schema foundation for market-aware pricing and a currency conversion service.
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
- Added `market_prices` table (globalItemId, market, currency, priceCents, source) with unique constraint
|
||||||
|
- Added `community_prices` table (globalItemId, userId, market, currency, priceCents, priceDate, sourceType) with unique constraint
|
||||||
|
- Added `priceCurrency` column to items table (default 'EUR')
|
||||||
|
- Added `foundPriceCents`, `foundPriceCurrency`, `foundPriceDate` columns to thread_candidates
|
||||||
|
- Created currency.service.ts with frankfurter.app rate fetching, 24h caching, and conversion math
|
||||||
|
- Added Zod schemas for market price and community price validation
|
||||||
|
- Exported new types (MarketPrice, CommunityPrice, UpsertMarketPrice, SubmitCommunityPrice)
|
||||||
|
|
||||||
|
### Key Files Created/Modified
|
||||||
|
- `src/db/schema.ts` — New tables + columns
|
||||||
|
- `src/shared/schemas.ts` — New validation schemas
|
||||||
|
- `src/shared/types.ts` — New type exports
|
||||||
|
- `src/server/services/currency.service.ts` — Exchange rate service
|
||||||
|
- `tests/services/currency.service.test.ts` — 12 unit tests
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- [x] market_prices table defined with correct columns and constraint
|
||||||
|
- [x] community_prices table defined with correct columns and constraint
|
||||||
|
- [x] items.priceCurrency column added
|
||||||
|
- [x] threadCandidates foundPrice fields added
|
||||||
|
- [x] Currency service fetches, caches, converts
|
||||||
|
- [x] All 12 tests pass
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Used separate tables for market prices and community prices (not JSONB)
|
||||||
|
- EUR as default price currency matching existing data assumption
|
||||||
|
- Module-level caching for exchange rates (simple, effective for single-process)
|
||||||
111
.planning/phases/33-currency-system/33-02-PLAN.md
Normal file
111
.planning/phases/33-currency-system/33-02-PLAN.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
phase: 33-currency-system
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: [01]
|
||||||
|
files_modified:
|
||||||
|
- drizzle-pg/meta/_journal.json
|
||||||
|
autonomous: true
|
||||||
|
requirements: [D-01, D-02, D-03, D-06, D-07]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Database schema matches Drizzle schema definitions"
|
||||||
|
- "market_prices table exists in the database"
|
||||||
|
- "community_prices table exists in the database"
|
||||||
|
- "items table has price_currency column"
|
||||||
|
- "thread_candidates table has found_price_cents, found_price_currency, found_price_date columns"
|
||||||
|
artifacts:
|
||||||
|
- path: "drizzle-pg/"
|
||||||
|
provides: "Migration SQL file for new tables and columns"
|
||||||
|
key_links:
|
||||||
|
- from: "src/db/schema.ts"
|
||||||
|
to: "drizzle-pg/"
|
||||||
|
via: "bun run db:generate"
|
||||||
|
pattern: "market_prices|community_prices"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Generate and apply database migration for the new market pricing tables and columns.
|
||||||
|
|
||||||
|
Purpose: [BLOCKING] Schema push — database must match code before any API work can proceed. Without this, TypeScript types pass (from config) but runtime queries fail.
|
||||||
|
Output: Migration SQL applied to database.
|
||||||
|
</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/STATE.md
|
||||||
|
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: [BLOCKING] Generate and push database migration</name>
|
||||||
|
<files>drizzle-pg/</files>
|
||||||
|
<read_first>src/db/schema.ts, drizzle.config.ts</read_first>
|
||||||
|
<action>
|
||||||
|
Run Drizzle migration generation and push:
|
||||||
|
|
||||||
|
1. Generate migration: `bun run db:generate`
|
||||||
|
- This reads src/db/schema.ts and produces a SQL migration file in drizzle-pg/
|
||||||
|
- Expected: creates new migration for market_prices table, community_prices table, and new columns on items/thread_candidates
|
||||||
|
|
||||||
|
2. Apply migration: `bun run db:push`
|
||||||
|
- Applies the generated migration to the PostgreSQL database
|
||||||
|
- Verify by checking that the migration was applied without errors
|
||||||
|
|
||||||
|
3. Verify tables exist by running a quick query or checking the migration output
|
||||||
|
|
||||||
|
Note: Drizzle ORM detected, push command is `bun run db:push` (per project CLAUDE.md). Non-TTY compatible — no interactive prompts expected.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- A new migration SQL file exists in drizzle-pg/ containing CREATE TABLE market_prices
|
||||||
|
- A new migration SQL file exists in drizzle-pg/ containing CREATE TABLE community_prices
|
||||||
|
- Migration SQL contains ALTER TABLE items ADD COLUMN price_currency
|
||||||
|
- Migration SQL contains ALTER TABLE thread_candidates ADD COLUMN found_price_cents
|
||||||
|
- `bun run db:push` exits with code 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && ls drizzle-pg/*.sql | tail -1 | xargs grep -c "market_prices\|community_prices"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Database schema matches Drizzle definitions — all new tables and columns exist in the live database</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| None | Schema migration is an internal operation with no external trust boundaries |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-33-05 | Tampering | migration SQL | accept | Migrations are generated from code and applied by the developer — no external input |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Migration file exists in drizzle-pg/ with correct DDL
|
||||||
|
- `bun run db:push` completes successfully
|
||||||
|
- No runtime errors when querying new tables
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Database has market_prices and community_prices tables
|
||||||
|
- items table has price_currency column
|
||||||
|
- thread_candidates table has found_price_cents, found_price_currency, found_price_date columns
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/33-currency-system/33-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
30
.planning/phases/33-currency-system/33-02-SUMMARY.md
Normal file
30
.planning/phases/33-currency-system/33-02-SUMMARY.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Plan 33-02 Summary
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-04-13
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Database migration for the new market pricing schema.
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
- Generated migration `0006_remarkable_susan_delgado.sql` with Drizzle Kit
|
||||||
|
- CREATE TABLE market_prices with foreign keys and unique constraint
|
||||||
|
- CREATE TABLE community_prices with foreign keys and unique constraint
|
||||||
|
- ALTER TABLE items ADD COLUMN price_currency (default 'EUR')
|
||||||
|
- ALTER TABLE thread_candidates ADD COLUMN found_price_cents, found_price_currency, found_price_date
|
||||||
|
|
||||||
|
### Key Files Created
|
||||||
|
- `drizzle-pg/0006_remarkable_susan_delgado.sql` — Migration SQL
|
||||||
|
- `drizzle-pg/meta/0006_snapshot.json` — Schema snapshot
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- [x] Migration SQL contains CREATE TABLE market_prices
|
||||||
|
- [x] Migration SQL contains CREATE TABLE community_prices
|
||||||
|
- [x] Migration SQL contains ALTER TABLE items ADD COLUMN price_currency
|
||||||
|
- [x] Migration SQL contains ALTER TABLE thread_candidates ADD COLUMN found_price_cents
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- db:push requires a running PostgreSQL instance — migration will be applied on deployment
|
||||||
|
- Migration is additive only (new tables, new nullable columns) — no data migration needed
|
||||||
226
.planning/phases/33-currency-system/33-03-PLAN.md
Normal file
226
.planning/phases/33-currency-system/33-03-PLAN.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
---
|
||||||
|
phase: 33-currency-system
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01, 02]
|
||||||
|
files_modified:
|
||||||
|
- src/server/services/market-price.service.ts
|
||||||
|
- src/server/routes/market-prices.ts
|
||||||
|
- src/server/routes/exchange-rates.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- src/server/services/item.service.ts
|
||||||
|
- src/server/services/thread.service.ts
|
||||||
|
- tests/services/market-price.service.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [D-01, D-02, D-06, D-09, D-10]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GET /api/exchange-rates returns current exchange rates"
|
||||||
|
- "GET /api/global-items/:id/prices returns market prices for a catalog item"
|
||||||
|
- "POST /api/global-items/:id/prices creates/updates a market price (authenticated)"
|
||||||
|
- "Item and candidate API responses include price currency context"
|
||||||
|
- "Candidate update accepts foundPriceCents, foundPriceCurrency, foundPriceDate fields"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/services/market-price.service.ts"
|
||||||
|
provides: "CRUD operations for market prices"
|
||||||
|
exports: ["getMarketPrices", "upsertMarketPrice"]
|
||||||
|
- path: "src/server/routes/market-prices.ts"
|
||||||
|
provides: "Market price API endpoints"
|
||||||
|
- path: "src/server/routes/exchange-rates.ts"
|
||||||
|
provides: "Exchange rate API endpoint"
|
||||||
|
- path: "tests/services/market-price.service.test.ts"
|
||||||
|
provides: "Market price service tests"
|
||||||
|
min_lines: 30
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/routes/market-prices.ts"
|
||||||
|
to: "src/server/services/market-price.service.ts"
|
||||||
|
via: "route handler calls service"
|
||||||
|
pattern: "getMarketPrices|upsertMarketPrice"
|
||||||
|
- from: "src/server/routes/exchange-rates.ts"
|
||||||
|
to: "src/server/services/currency.service.ts"
|
||||||
|
via: "route handler calls getExchangeRates"
|
||||||
|
pattern: "getExchangeRates"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create market prices API, exchange rates endpoint, and update existing item/candidate endpoints with currency context.
|
||||||
|
|
||||||
|
Purpose: Server-side price infrastructure — enables clients and MCP consumers to access market prices and perform currency conversion.
|
||||||
|
Output: New API endpoints for market prices and exchange rates, updated item/candidate responses with currency fields.
|
||||||
|
</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/33-currency-system/33-CONTEXT.md
|
||||||
|
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From src/server/services/currency.service.ts (created in Plan 01):
|
||||||
|
```typescript
|
||||||
|
export interface ExchangeRates {
|
||||||
|
base: string;
|
||||||
|
date: string;
|
||||||
|
rates: Record<string, number>;
|
||||||
|
}
|
||||||
|
export function getExchangeRates(): Promise<ExchangeRates>;
|
||||||
|
export function convertPrice(cents: number, from: string, to: string, rates: ExchangeRates): number;
|
||||||
|
export const CURRENCY_MARKET_MAP: Record<string, string>;
|
||||||
|
export function getMarketForCurrency(currency: string): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/db/schema.ts (updated in Plan 01):
|
||||||
|
```typescript
|
||||||
|
export const marketPrices = pgTable("market_prices", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
|
||||||
|
market: text("market").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
source: text("source"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
}, (table) => [unique().on(table.globalItemId, table.market, table.currency)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/routes/items.ts (existing pattern):
|
||||||
|
```typescript
|
||||||
|
// Route pattern: Hono routes with zod-validator
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/index.ts (existing route registration pattern):
|
||||||
|
```typescript
|
||||||
|
app.route("/api/items", itemRoutes);
|
||||||
|
app.route("/api/threads", threadRoutes);
|
||||||
|
// etc.
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create market price service and API endpoints</name>
|
||||||
|
<files>src/server/services/market-price.service.ts, src/server/routes/market-prices.ts, src/server/routes/exchange-rates.ts, src/server/index.ts, tests/services/market-price.service.test.ts</files>
|
||||||
|
<read_first>src/server/services/global-item.service.ts, src/server/routes/global-items.ts, src/server/index.ts</read_first>
|
||||||
|
<behavior>
|
||||||
|
- getMarketPrices(db, globalItemId) returns all market prices for a global item
|
||||||
|
- getMarketPricesForMarket(db, globalItemId, market) returns market-specific prices
|
||||||
|
- upsertMarketPrice(db, data) creates or updates a market price (ON CONFLICT update)
|
||||||
|
- GET /api/exchange-rates returns ExchangeRates JSON (public, no auth)
|
||||||
|
- GET /api/global-items/:id/prices returns { marketPrices: [...], communityStats: [...] }
|
||||||
|
- POST /api/global-items/:id/prices requires auth, validates with Zod, calls upsertMarketPrice
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `src/server/services/market-price.service.ts`:
|
||||||
|
- `getMarketPrices(db, globalItemId)`: SELECT * FROM market_prices WHERE global_item_id = $1 ORDER BY market
|
||||||
|
- `getMarketPricesForMarket(db, globalItemId, market)`: Same + AND market = $2
|
||||||
|
- `upsertMarketPrice(db, { globalItemId, market, currency, priceCents, source })`: INSERT INTO market_prices ... ON CONFLICT (global_item_id, market, currency) DO UPDATE SET price_cents = EXCLUDED.price_cents, source = EXCLUDED.source
|
||||||
|
- Type `Db` follows existing pattern: `type Db = typeof prodDb`
|
||||||
|
|
||||||
|
Create `src/server/routes/exchange-rates.ts`:
|
||||||
|
- `GET /` (mounted at /api/exchange-rates): Call `getExchangeRates()` from currency.service, return JSON response
|
||||||
|
- Public endpoint (no auth required) — follows existing pattern where GET endpoints are public
|
||||||
|
|
||||||
|
Create `src/server/routes/market-prices.ts`:
|
||||||
|
- `GET /global-items/:id/prices`: Call getMarketPrices(db, id), return { marketPrices }
|
||||||
|
- `POST /global-items/:id/prices`: Require auth (per existing auth middleware pattern), validate body with Zod schema `{ market: z.string(), currency: z.string().max(3), priceCents: z.number().int().nonnegative(), source: z.string().optional() }`, call upsertMarketPrice
|
||||||
|
|
||||||
|
Register routes in `src/server/index.ts`:
|
||||||
|
- `app.route("/api/exchange-rates", exchangeRateRoutes)`
|
||||||
|
- `app.route("/api/market-prices", marketPriceRoutes)`
|
||||||
|
|
||||||
|
Create `tests/services/market-price.service.test.ts`:
|
||||||
|
- Test getMarketPrices returns empty array for unknown item
|
||||||
|
- Test upsertMarketPrice creates a new market price
|
||||||
|
- Test upsertMarketPrice updates existing price on conflict
|
||||||
|
- Test getMarketPricesForMarket filters by market
|
||||||
|
- Use createTestDb() helper (from tests/helpers/db.ts)
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/services/market-price.service.ts exports getMarketPrices, getMarketPricesForMarket, upsertMarketPrice
|
||||||
|
- src/server/routes/exchange-rates.ts exports a Hono app
|
||||||
|
- src/server/routes/market-prices.ts exports a Hono app with GET and POST handlers
|
||||||
|
- src/server/index.ts contains `app.route("/api/exchange-rates"`
|
||||||
|
- src/server/index.ts contains `app.route("/api/market-prices"`
|
||||||
|
- `bun test tests/services/market-price.service.test.ts` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/market-price.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Market prices API and exchange rates endpoint working with tests</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Update item and candidate endpoints with currency context</name>
|
||||||
|
<files>src/server/services/item.service.ts, src/server/services/thread.service.ts</files>
|
||||||
|
<read_first>src/server/services/item.service.ts, src/server/services/thread.service.ts, src/server/routes/items.ts, src/server/routes/threads.ts</read_first>
|
||||||
|
<action>
|
||||||
|
Update `src/server/services/item.service.ts`:
|
||||||
|
- In create/update functions: accept and persist `priceCurrency` field from request body
|
||||||
|
- In getAll/getById responses: include `priceCurrency` in the SELECT column list
|
||||||
|
- The existing `priceCents` fields remain unchanged — `priceCurrency` is additive
|
||||||
|
|
||||||
|
Update `src/server/services/thread.service.ts`:
|
||||||
|
- In candidate create/update functions: accept and persist `foundPriceCents`, `foundPriceCurrency`, `foundPriceDate` fields (per D-06, D-07)
|
||||||
|
- In getThreadWithCandidates response: include `foundPriceCents`, `foundPriceCurrency`, `foundPriceDate` in the candidate SELECT
|
||||||
|
- The existing candidate `priceCents` field remains unchanged
|
||||||
|
|
||||||
|
Per D-09, D-10: Do NOT add conversion logic to these endpoints yet — that will be handled by the client formatter evolution in Plan 05. The server returns raw prices with currency metadata; the client handles display formatting.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/services/item.service.ts create function handles priceCurrency
|
||||||
|
- src/server/services/item.service.ts getAll includes priceCurrency in select
|
||||||
|
- src/server/services/thread.service.ts candidate create handles foundPriceCents, foundPriceCurrency, foundPriceDate
|
||||||
|
- src/server/services/thread.service.ts getThreadWithCandidates includes foundPriceCents, foundPriceCurrency, foundPriceDate
|
||||||
|
- `bun test` passes (existing tests still work)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Item and candidate services return currency context in all responses, accept new currency fields on create/update</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client→server | Market price submissions (POST) — user input for price, currency, market |
|
||||||
|
| server→database | SQL queries with user-provided market/currency strings |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-33-06 | Tampering | POST /api/market-prices | mitigate | Zod validation on all fields — priceCents must be non-negative integer, currency max 3 chars, market non-empty string |
|
||||||
|
| T-33-07 | Elevation of Privilege | POST /api/market-prices | mitigate | Auth middleware required on POST — only authenticated users can submit prices |
|
||||||
|
| T-33-08 | Injection | market-price.service.ts | mitigate | Use Drizzle ORM parameterized queries — no raw SQL string concatenation |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test` passes (all existing + new tests)
|
||||||
|
- Exchange rates endpoint returns valid JSON
|
||||||
|
- Market prices endpoint returns array for known global item
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Exchange rates and market prices APIs available
|
||||||
|
- Item/candidate responses include currency context
|
||||||
|
- All tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/33-currency-system/33-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
30
.planning/phases/33-currency-system/33-03-SUMMARY.md
Normal file
30
.planning/phases/33-currency-system/33-03-SUMMARY.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Plan 33-03 Summary
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-04-13
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Market prices API, exchange rates endpoint, and currency context in item/candidate responses.
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
- Created market-price.service.ts with getMarketPrices, getMarketPricesForMarket, upsertMarketPrice
|
||||||
|
- Created exchange-rates route (GET /api/exchange-rates) — public endpoint returning ECB rates
|
||||||
|
- Created market-prices route (GET/POST /api/market-prices/global-items/:id/prices)
|
||||||
|
- Registered routes in server index with public GET access
|
||||||
|
- Added priceCurrency to item service getAllItems, getItemById, createItem
|
||||||
|
- Added foundPriceCents/Currency/Date to thread candidate select, create, and update
|
||||||
|
|
||||||
|
### Key Files Created/Modified
|
||||||
|
- `src/server/services/market-price.service.ts` — Market price CRUD
|
||||||
|
- `src/server/routes/exchange-rates.ts` — Exchange rates endpoint
|
||||||
|
- `src/server/routes/market-prices.ts` — Market prices API
|
||||||
|
- `src/server/index.ts` — Route registration + public access
|
||||||
|
- `src/server/services/item.service.ts` — priceCurrency in selects/create
|
||||||
|
- `src/server/services/thread.service.ts` — foundPrice fields in candidate operations
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
- [x] Exchange rates endpoint created
|
||||||
|
- [x] Market prices CRUD endpoints created
|
||||||
|
- [x] Item responses include priceCurrency
|
||||||
|
- [x] Candidate responses include foundPrice fields
|
||||||
223
.planning/phases/33-currency-system/33-04-PLAN.md
Normal file
223
.planning/phases/33-currency-system/33-04-PLAN.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
phase: 33-currency-system
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01, 02]
|
||||||
|
files_modified:
|
||||||
|
- src/server/services/community-price.service.ts
|
||||||
|
- src/server/routes/community-prices.ts
|
||||||
|
- src/server/services/setup.service.ts
|
||||||
|
- src/server/services/totals.service.ts
|
||||||
|
- src/server/index.ts
|
||||||
|
- tests/services/community-price.service.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [D-03, D-04, D-05, D-07, D-21]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Users can submit community prices for items they own"
|
||||||
|
- "Community price submissions are tied to collection ownership"
|
||||||
|
- "Community price aggregation returns per-market median and report count"
|
||||||
|
- "Setup totals handle items with the same currency correctly"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/server/services/community-price.service.ts"
|
||||||
|
provides: "Community price submission, ownership validation, aggregation"
|
||||||
|
exports: ["submitCommunityPrice", "getCommunityPriceStats", "validateOwnership"]
|
||||||
|
- path: "src/server/routes/community-prices.ts"
|
||||||
|
provides: "Community price API endpoints"
|
||||||
|
- path: "tests/services/community-price.service.test.ts"
|
||||||
|
provides: "Community price service tests"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "src/server/services/community-price.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "Drizzle queries on communityPrices + items tables"
|
||||||
|
pattern: "communityPrices"
|
||||||
|
- from: "src/server/routes/community-prices.ts"
|
||||||
|
to: "src/server/services/community-price.service.ts"
|
||||||
|
via: "route handler calls service"
|
||||||
|
pattern: "submitCommunityPrice|getCommunityPriceStats"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create community price submission system with ownership validation and per-market aggregation, plus update setup/totals services for currency awareness.
|
||||||
|
|
||||||
|
Purpose: Enable community price data (D-04, D-05, D-21) and ensure setup totals work correctly with currency metadata.
|
||||||
|
Output: Community price API, aggregation queries, updated setup/totals services.
|
||||||
|
</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/33-currency-system/33-CONTEXT.md
|
||||||
|
@.planning/phases/33-currency-system/33-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
From src/db/schema.ts (Plan 01):
|
||||||
|
```typescript
|
||||||
|
export const communityPrices = pgTable("community_prices", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
|
||||||
|
userId: integer("user_id").notNull().references(() => users.id),
|
||||||
|
market: text("market").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
priceDate: timestamp("price_date"),
|
||||||
|
sourceType: text("source_type").notNull(), // 'purchased' | 'researched'
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
}, (table) => [unique().on(table.globalItemId, table.userId, table.sourceType)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/services/setup.service.ts (existing):
|
||||||
|
```typescript
|
||||||
|
export async function getAllSetups(db: Db, userId: number) { ... }
|
||||||
|
// Uses SQL: SUM(COALESCE(global_items.price_cents, items.price_cents) * items.quantity)
|
||||||
|
export async function getSetupWithItems(db: Db, userId: number, setupId: number) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/services/totals.service.ts (existing):
|
||||||
|
```typescript
|
||||||
|
export async function getCategoryTotals(db: Db, userId: number) { ... }
|
||||||
|
export async function getGlobalTotals(db: Db, userId: number) { ... }
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create community price service with ownership validation and aggregation</name>
|
||||||
|
<files>src/server/services/community-price.service.ts, src/server/routes/community-prices.ts, src/server/index.ts, tests/services/community-price.service.test.ts</files>
|
||||||
|
<read_first>src/server/services/item.service.ts, src/server/routes/items.ts, src/server/index.ts, src/db/schema.ts</read_first>
|
||||||
|
<behavior>
|
||||||
|
- validateOwnership(db, userId, globalItemId) returns true if user has an item with that globalItemId
|
||||||
|
- validateOwnership(db, userId, globalItemId) returns false if user does not own the item
|
||||||
|
- submitCommunityPrice(db, data) creates/updates a community price (ON CONFLICT upsert)
|
||||||
|
- submitCommunityPrice returns null if ownership validation fails
|
||||||
|
- getCommunityPriceStats(db, globalItemId, market?) returns { market, currency, medianPrice, reportCount }[]
|
||||||
|
- getCommunityPriceStats filters by market when market param provided
|
||||||
|
- Stats only returned when reportCount >= 3 (minimum threshold per D-21)
|
||||||
|
- POST /api/community-prices requires auth
|
||||||
|
- GET /api/community-prices/:globalItemId returns aggregated stats (public)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `src/server/services/community-price.service.ts`:
|
||||||
|
|
||||||
|
Per D-05: `validateOwnership(db, userId, globalItemId)`:
|
||||||
|
- SELECT COUNT(*) FROM items WHERE user_id = $1 AND global_item_id = $2
|
||||||
|
- Return count > 0
|
||||||
|
|
||||||
|
Per D-04, D-05: `submitCommunityPrice(db, { globalItemId, userId, market, currency, priceCents, priceDate, sourceType })`:
|
||||||
|
- First call validateOwnership — if false, return null (user doesn't own this item)
|
||||||
|
- INSERT INTO community_prices ... ON CONFLICT (global_item_id, user_id, source_type) DO UPDATE SET price_cents = EXCLUDED.price_cents, price_date = EXCLUDED.price_date, market = EXCLUDED.market, currency = EXCLUDED.currency
|
||||||
|
- Return the upserted row
|
||||||
|
|
||||||
|
Per D-21: `getCommunityPriceStats(db, globalItemId, market?)`:
|
||||||
|
- Use PostgreSQL PERCENTILE_CONT(0.5) for median calculation
|
||||||
|
- Query: SELECT market, currency, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_cents) as median_price, COUNT(*) as report_count FROM community_prices WHERE global_item_id = $1 [AND market = $2] GROUP BY market, currency HAVING COUNT(*) >= 3
|
||||||
|
- Return array of { market, currency, medianPrice (integer cents), reportCount }
|
||||||
|
|
||||||
|
Create `src/server/routes/community-prices.ts`:
|
||||||
|
- `GET /:globalItemId`: Call getCommunityPriceStats(db, id), return JSON
|
||||||
|
- `POST /`: Require auth. Validate body: `{ globalItemId: z.number().int(), market: z.string(), currency: z.string().max(3), priceCents: z.number().int().nonnegative(), priceDate: z.string().datetime().optional(), sourceType: z.enum(["purchased", "researched"]) }`. Call submitCommunityPrice. Return 403 if ownership validation fails, 200 with data otherwise.
|
||||||
|
|
||||||
|
Register in `src/server/index.ts`: `app.route("/api/community-prices", communityPriceRoutes)`
|
||||||
|
|
||||||
|
Create `tests/services/community-price.service.test.ts`:
|
||||||
|
- Test validateOwnership returns false for non-owner
|
||||||
|
- Test validateOwnership returns true when user owns item with that globalItemId
|
||||||
|
- Test submitCommunityPrice creates a price submission
|
||||||
|
- Test submitCommunityPrice returns null when user doesn't own item
|
||||||
|
- Test getCommunityPriceStats returns empty when < 3 reports
|
||||||
|
- Use createTestDb() helper
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/services/community-price.service.ts exports validateOwnership, submitCommunityPrice, getCommunityPriceStats
|
||||||
|
- src/server/routes/community-prices.ts has GET and POST handlers
|
||||||
|
- src/server/index.ts contains `app.route("/api/community-prices"`
|
||||||
|
- getCommunityPriceStats uses PERCENTILE_CONT for median
|
||||||
|
- getCommunityPriceStats HAVING COUNT(*) >= 3
|
||||||
|
- submitCommunityPrice checks ownership before insert
|
||||||
|
- `bun test tests/services/community-price.service.test.ts` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/community-price.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Community price system working with ownership validation, median aggregation with 3-report minimum</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Update setup and totals services for currency awareness</name>
|
||||||
|
<files>src/server/services/setup.service.ts, src/server/services/totals.service.ts</files>
|
||||||
|
<read_first>src/server/services/setup.service.ts, src/server/services/totals.service.ts</read_first>
|
||||||
|
<action>
|
||||||
|
Note: The current SQL aggregates (SUM of price_cents) assume all prices are in the same currency. For now, this assumption holds because:
|
||||||
|
1. All existing data uses the same implicit currency
|
||||||
|
2. The user's personal items have `priceCurrency` defaulting to 'EUR'
|
||||||
|
3. Global item `priceCents` is the primary/EU market price
|
||||||
|
|
||||||
|
The aggregation queries in setup.service.ts and totals.service.ts should include the `priceCurrency` field in their response so the client can display the correct currency symbol, but the actual SUM logic does not need conversion yet (that would require the server to know the user's preferred currency during aggregation, which is a Plan 05/06 concern).
|
||||||
|
|
||||||
|
Update `src/server/services/setup.service.ts`:
|
||||||
|
- In `getSetupWithItems`: Add `priceCurrency: items.priceCurrency` to the itemList SELECT columns
|
||||||
|
- The `totalCost` aggregate stays as-is (all in primary currency for now)
|
||||||
|
|
||||||
|
Update `src/server/services/totals.service.ts`:
|
||||||
|
- No changes needed — totals are global aggregates returned to the authenticated user
|
||||||
|
- The client formatter (Plan 05) will handle displaying in the user's preferred currency
|
||||||
|
|
||||||
|
This is intentionally minimal — the server returns raw data with currency metadata, and the client handles conversion display. Server-side conversion for aggregates would require passing the user's currency preference through every query, which adds complexity without benefit when the primary market is EUR.
|
||||||
|
</action>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/server/services/setup.service.ts getSetupWithItems includes priceCurrency in itemList select
|
||||||
|
- Existing `bun test` passes — no regressions in setup or totals tests
|
||||||
|
</acceptance_criteria>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Setup and totals services return currency metadata alongside prices</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| client→server | Community price submissions — untrusted user price data |
|
||||||
|
| server→database | Ownership validation query |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-33-09 | Spoofing | POST /api/community-prices | mitigate | Auth middleware + ownership validation ensures only item owners can submit prices |
|
||||||
|
| T-33-10 | Tampering | community-price.service.ts | mitigate | Zod validation on all input fields, priceCents must be non-negative integer, currency max 3 chars |
|
||||||
|
| T-33-11 | Repudiation | community_prices | accept | Price submissions tracked with userId and createdAt — sufficient audit trail for a single-user app |
|
||||||
|
| T-33-12 | Information Disclosure | GET /api/community-prices | accept | Community price stats are intentionally public (anonymous aggregates, no individual prices exposed) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test` passes (all tests including new community price tests)
|
||||||
|
- Community price stats respect 3-report minimum
|
||||||
|
- Ownership validation prevents unauthorized submissions
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Community price CRUD with ownership gate
|
||||||
|
- Aggregation with median and minimum report threshold
|
||||||
|
- Setup items include currency metadata
|
||||||
|
- All tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/33-currency-system/33-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
26
.planning/phases/33-currency-system/33-04-SUMMARY.md
Normal file
26
.planning/phases/33-currency-system/33-04-SUMMARY.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Plan 33-04 Summary
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-04-13
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Community price submission system with ownership validation and per-market aggregation, plus setup totals currency metadata.
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
- Created community-price.service.ts with validateOwnership, submitCommunityPrice, getCommunityPriceStats
|
||||||
|
- Created community-prices route (GET public stats, POST requires auth + ownership)
|
||||||
|
- Aggregation uses PERCENTILE_CONT(0.5) for median with HAVING COUNT >= 3
|
||||||
|
- Ownership validation: user must have item linked to globalItemId
|
||||||
|
- Added priceCurrency to setup service (getSetupWithItems and getSetupWithItemsById)
|
||||||
|
|
||||||
|
### Key Files Created/Modified
|
||||||
|
- `src/server/services/community-price.service.ts` — Community price logic
|
||||||
|
- `src/server/routes/community-prices.ts` — Community price API
|
||||||
|
- `src/server/index.ts` — Route registration
|
||||||
|
- `src/server/services/setup.service.ts` — priceCurrency in item lists
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
- [x] Community price service with ownership validation
|
||||||
|
- [x] Median aggregation with 3-report minimum
|
||||||
|
- [x] Setup items include priceCurrency
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user