diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index 271aeae..ed7409a 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -1,577 +1,915 @@ -# Architecture Research +# Architecture Research: v2.0 Platform Foundation -**Domain:** Gear management app -- v1.3 Research & Decision Tools (candidate comparison, setup impact preview, ranking/pros-cons) -**Researched:** 2026-03-16 +**Domain:** Multi-user gear management and discovery platform +**Researched:** 2026-04-03 **Confidence:** HIGH ## System Overview: Integration Map -The v1.3 features integrate primarily on the thread detail page and its data layer. This diagram shows where new components slot in relative to the current architecture. +The v2.0 transformation touches every layer of the stack. This is not a feature addition -- it is a structural migration. The diagram below shows the current architecture (left) and the target architecture (right), with change annotations. ``` +CURRENT (v1.x) TARGET (v2.0) +============== ============= + CLIENT LAYER -+-----------------------------------------------------------------+ -| Routes | -| +-------------------+ +--------------------+ | -| | /threads/$threadId| | (existing routes) | | -| | [MODIFIED] | | [NO CHANGE] | | -| +--------+----------+ +--------------------+ | -| | | -| Components (NEW) | -| +----------------------+ +---------------------------+ | -| | CandidateCompare | | SetupImpactSelector | | -| | (side-by-side table) | | (setup picker + delta row)| | -| +----------------------+ +---------------------------+ | -| +----------------------+ | -| | CandidateRankList | | -| | (drag-to-reorder) | | -| +----------------------+ | -| | -| Components (MODIFIED) | -| +----------------------+ +---------------------------+ | -| | CandidateCard | | CandidateForm | | -| | +rank badge | | +pros/cons fields | | -| | +pros/cons display | +---------------------------+ | -| +----------------------+ | -| | -| Hooks (NEW) Hooks (MODIFIED) | -| +---------------------+ +---------------------+ | -| | useReorderCandidates| | useCandidates.ts | | -| | useSetupImpact | | +reorder mutation | | -| +---------------------+ | +pros/cons in update| | -| +---------------------+ | -| | -| Stores (MODIFIED) | -| +---------------------+ | -| | uiStore.ts | | -| | +compareMode bool | | -| | +selectedSetupId | | -| +---------------------+ | -+-----------------------------------------------------------------+ -| API Layer: lib/api.ts -- NO CHANGE | -+-----------------------------------------------------------------+ ++------------------------------+ +----------------------------------------+ +| TanStack Router (file-based) | | TanStack Router (file-based) | +| Routes: /, /collection, | | Routes: /, /collection, /threads, | +| /threads, /setups, /login, | | /setups, /login, /settings, | +| /settings | | /profile/:username, [NEW] | +| | | /discover, [NEW] | +| | | /items/:id, [NEW] | ++------------------------------+ +----------------------------------------+ +| Hooks: useItems, useThreads, | | Hooks: same + useProfile, [NEW] | +| useSetups, useCandidates, | | useDiscover, useGlobalItems, | +| useCategories, useAuth | | useReviews | ++------------------------------+ +----------------------------------------+ +| lib/api.ts (fetch wrapper) | | lib/api.ts (unchanged) | ++------------------------------+ +----------------------------------------+ + SERVER LAYER -| Routes (MODIFIED) | -| +--------------------+ | -| | threads.ts | | -| | +PATCH /reorder | | -| +--------------------+ | -| | -| Services (MODIFIED) | -| +--------------------+ | -| | thread.service.ts | | -| | +reorderCandidates | | -| | +pros/cons in CRUD | | -| +--------------------+ | -+---------+---------------------------------------------------+---+ ++------------------------------+ +----------------------------------------+ +| Hono app | | Hono app | +| Middleware: | | Middleware: | +| db injection (c.set("db")) | | db injection | +| requireAuth (cookie/apikey)| | oidcAuth (@hono/oidc-auth) [REPLACE]| +| | | extractUser (c.set("user")) [NEW] | ++------------------------------+ +----------------------------------------+ +| Routes: | | Routes: | +| /api/auth/* (login/setup) | | /api/auth/* (OIDC callbacks)[REPLACE]| +| /api/items | | /api/items (scoped by user) [MODIFY] | +| /api/categories | | /api/categories (user-owned)[MODIFY] | +| /api/threads | | /api/threads (user-owned) [MODIFY] | +| /api/setups | | /api/setups (user-owned) [MODIFY] | +| /api/totals | | /api/totals (user-scoped) [MODIFY] | +| /api/images | | /api/images [MODIFY] | +| /api/settings | | /api/settings (user-scoped) [MODIFY] | +| /mcp | | /api/global-items [NEW] | +| | | /api/reviews [NEW] | +| | | /api/profiles [NEW] | +| | | /api/discover [NEW] | +| | | /mcp (user-scoped) [MODIFY] | ++------------------------------+ +----------------------------------------+ +| Services: item, thread, | | Services: same (add userId param) | +| setup, category, auth, | | + globalItem.service [NEW] | +| csv, image, totals | | + review.service [NEW] | +| | | + profile.service [NEW] | +| | | + discover.service [NEW] | +| | | auth.service (gutted) [REPLACE]| ++------------------------------+ +----------------------------------------+ + DATABASE LAYER -| schema.ts (MODIFIED) | -| +----------------------------------------------------------+ | -| | thread_candidates: | | -| | +sort_order INTEGER NOT NULL DEFAULT 0 | | -| | +pros TEXT | | -| | +cons TEXT | | -| +----------------------------------------------------------+ | -| | -| tests/helpers/db.ts (MODIFIED -- add new columns) | -+-----------------------------------------------------------------+ -``` ++------------------------------+ +----------------------------------------+ +| SQLite (bun:sqlite) | | PostgreSQL (postgres.js) [REPLACE] | +| Drizzle ORM (sqlite-core) | | Drizzle ORM (pg-core) [REPLACE] | +| Schema: sqliteTable | | Schema: pgTable [REPLACE] | +| | | | +| Tables: | | Tables: | +| categories (no userId) | | categories (+userId) [MODIFY] | +| items (no userId) | | items (+userId, +globalId) [MODIFY] | +| threads (no userId) | | threads (+userId) [MODIFY] | +| threadCandidates | | threadCandidates [NO CHANGE]| +| setups (no userId) | | setups (+userId,+isPublic) [MODIFY] | +| setupItems | | setupItems [NO CHANGE]| +| settings | | settings (+userId) [MODIFY] | +| users (password-based) | | users (OIDC subject) [REPLACE] | +| sessions (local) | | (removed - OIDC handles) [DELETE] | +| apiKeys | | apiKeys (+userId) [MODIFY] | +| | | globalItems [NEW] | +| | | reviews [NEW] | ++------------------------------+ +----------------------------------------+ -## Feature-by-Feature Integration - -### Feature 1: Side-by-Side Candidate Comparison - -**Scope:** Client-only derived view. All candidate data is already fetched by `useThread(threadId)` which returns `thread.candidates[]` with all fields including weight, price, notes, image, productUrl, and status. No new API endpoint is needed. The comparison view is a toggle on the thread detail page that reorganizes the existing data into a table layout. - -**Integration points:** - -| Layer | File | Change Type | Details | -|-------|------|-------------|---------| -| Client | `routes/threads/$threadId.tsx` | MODIFY | Add "Compare" toggle button; conditionally render grid vs comparison layout | -| Client | NEW `components/CandidateCompare.tsx` | NEW | Side-by-side table component; accepts `candidates[]` array | -| Client | `stores/uiStore.ts` | MODIFY | Add `compareMode: boolean`, `toggleCompareMode()` | - -**Data flow:** - -``` -useThread(threadId) -> thread.candidates[] (already fetched) - | - +-- compareMode? (local UI state in uiStore) - | - +-- false: existing CandidateCard grid (unchanged) - +-- true: CandidateCompare table (new component) - | - +-- Columns: [Field Label] [Candidate A] [Candidate B] [Candidate N] - +-- Rows: Image, Name, Weight, Price, Status, Notes, Link - +-- Delta row: weight and price diffs relative to lightest/cheapest -``` - -**No server changes required.** Thread data already includes all comparison fields. The component is purely presentational, transforming the existing `CandidateWithCategory[]` array into a column-per-candidate table. - -**CandidateCompare component structure:** - -```typescript -// src/client/components/CandidateCompare.tsx -interface CandidateCompareProps { - candidates: CandidateWithCategory[]; - isActive: boolean; -} - -export function CandidateCompare({ candidates, isActive }: CandidateCompareProps) { - // Rows: image, name, weight (with delta), price (with delta), status, notes, link - // Highlight lowest weight in blue, lowest price in green - // Delta: weight diff from lightest candidate, price diff from cheapest - // Scrollable horizontally if > 3-4 candidates -} -``` - -**Weight/price delta display:** - -```typescript -// Derive relative comparison -- no server needed -const minWeight = Math.min(...candidates.filter(c => c.weightGrams != null).map(c => c.weightGrams!)); -const minPrice = Math.min(...candidates.filter(c => c.priceCents != null).map(c => c.priceCents!)); - -// For each candidate: -const weightDelta = candidate.weightGrams != null - ? candidate.weightGrams - minWeight - : null; -// Display: "+34g" in gray, "lightest" in blue (delta === 0) -``` - -**UI toggle placement:** A "Compare" button in the thread detail header, next to "Add Candidate". Toggling it swaps the layout from the card grid to the comparison table. Toggle state lives in `uiStore.compareMode` so it persists if the user navigates to a candidate edit panel and returns. - ---- - -### Feature 2: Setup Impact Preview - -**Scope:** For each candidate, show the weight and cost delta it would create if added to a user-selected setup. The user picks a setup from a dropdown on the thread detail page; all candidate cards (or comparison table) then display a "+/-" delta row. - -**Integration points:** - -| Layer | File | Change Type | Details | -|-------|------|-------------|---------| -| Client | `routes/threads/$threadId.tsx` | MODIFY | Add setup selector; pass `selectedSetupId` to card/compare components | -| Client | NEW `components/SetupImpactRow.tsx` | NEW | Small delta display row: "+320g / +$89" | -| Client | `hooks/useSetups.ts` | NO CHANGE | `useSetup(setupId)` already fetches setup with items and their `weightGrams`/`priceCents` | -| Client | `stores/uiStore.ts` | MODIFY | Add `impactSetupId: number | null`, `setImpactSetupId(id)` | -| Server | ALL | NO CHANGE | Impact is computed client-side from already-available data | - -**Data flow:** - -``` -User selects setup from dropdown - | - +-- uiStore.impactSetupId = selectedSetupId - | - +-- useSetup(impactSetupId) (conditional query, already exists) - | - +-- setup.items[] with weightGrams and priceCents - | - +-- setupTotalWeight = setup.items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0) - +-- setupTotalCost = setup.items.reduce((sum, i) => sum + (i.priceCents ?? 0), 0) - | - +-- Per candidate: - weightImpact = candidate.weightGrams (absolute, not a replacement) - costImpact = candidate.priceCents - // "Adding this item would add +320g / +$89 to the setup" - // Note: this is an "add to" preview, not a "replace existing item" preview - // Thread items are potential new additions, not replacements -``` - -**Why client-only:** The setup total weight is available from `useSetup(setupId)` which returns `items[]` with all fields. Adding a candidate to a setup is purely additive: `newTotal = setupCurrentTotal + candidateWeight`. No server endpoint needed. The `useSetup` query is already conditionally enabled and cached by React Query. - -**Setup selector placement:** A compact dropdown (using existing `useSetups()` data) in the thread detail header area beneath the thread name. "Preview impact on setup: [Select setup...]". When null, the impact row is hidden. Persists in `uiStore` for the session. - -**SetupImpactRow component:** - -```typescript -// src/client/components/SetupImpactRow.tsx -interface SetupImpactRowProps { - candidateWeightGrams: number | null; - candidatePriceCents: number | null; - setupTotalWeight: number; // existing setup total - setupTotalCost: number; // existing setup total (cents) -} - -export function SetupImpactRow({ candidateWeightGrams, candidatePriceCents, setupTotalWeight, setupTotalCost }: SetupImpactRowProps) { - const unit = useWeightUnit(); - const currency = useCurrency(); - // Display: "+320g" and "+$89" with soft color (gray or blue) - // If null weight/price, show "--" -} +IMAGE STORAGE ++------------------------------+ +----------------------------------------+ +| Local filesystem (./uploads) | | S3-compatible (MinIO) [REPLACE] | +| serveStatic("/uploads/*") | | Presigned URLs [REPLACE] | ++------------------------------+ +----------------------------------------+ ``` --- -### Feature 3: Candidate Ranking (Drag-to-Reorder) with Pros/Cons +## Component-by-Component Analysis -**Scope:** Users can drag candidates to set a preferred rank order. Each candidate gains `pros` and `cons` text fields. Rank order is persisted to the database as a `sortOrder` integer on `thread_candidates`. `framer-motion` is already installed (v12.37.0) and has `Reorder` components built-in. +### 1. Database Migration: SQLite to PostgreSQL -**Integration points:** +**What changes:** The entire database layer switches from `bun:sqlite` + `drizzle-orm/bun-sqlite` to `postgres.js` + `drizzle-orm/postgres-js`. This is a rewrite of the schema file and the db connection module, not an automated migration. -| Layer | File | Change Type | Details | -|-------|------|-------------|---------| -| DB | `schema.ts` | MODIFY | Add `sortOrder INTEGER NOT NULL DEFAULT 0`, `pros TEXT`, `cons TEXT` to `threadCandidates` | -| DB | Drizzle migration | NEW | Three new columns via `db:generate` | -| Shared | `schemas.ts` | MODIFY | Add `sortOrder`, `pros`, `cons` to `createCandidateSchema` and `updateCandidateSchema` | -| Shared | `types.ts` | NO CHANGE | Auto-infers from Drizzle schema | -| Server | `thread.service.ts` | MODIFY | `getThreadWithCandidates` orders by `sort_order ASC`; add `reorderCandidates` function; `createCandidate` sets `sortOrder` to max+1; include `pros`/`cons` in create/update | -| Server | `routes/threads.ts` | MODIFY | Add `PATCH /:id/candidates/reorder` endpoint | -| Client | `hooks/useCandidates.ts` | MODIFY | Add `useReorderCandidates(threadId)` mutation | -| Client | `routes/threads/$threadId.tsx` | MODIFY | Render `Reorder.Group` from framer-motion; wire reorder mutation | -| Client | `components/CandidateCard.tsx` | MODIFY | Add rank badge (1st, 2nd, 3rd); add pros/cons display (collapsed by default, expand on hover or click) | -| Client | `components/CandidateForm.tsx` | MODIFY | Add pros and cons textarea fields | -| Test | `tests/helpers/db.ts` | MODIFY | Add `sort_order`, `pros`, `cons` columns to `thread_candidates` CREATE TABLE | +**Why a rewrite, not a migration tool:** Drizzle uses different table constructors per dialect (`sqliteTable` vs `pgTable`), different column type imports (`drizzle-orm/sqlite-core` vs `drizzle-orm/pg-core`), and different driver bindings. There is no Drizzle utility to convert between dialects. The schema must be rewritten manually. -**Schema change:** +**Schema translation map:** + +| SQLite (current) | PostgreSQL (target) | Notes | +|---|---|---| +| `sqliteTable` | `pgTable` | Import from `drizzle-orm/pg-core` | +| `integer("id").primaryKey({ autoIncrement: true })` | `serial("id").primaryKey()` | Or `integer().primaryKey().generatedAlwaysAsIdentity()` | +| `text("name")` | `varchar("name", { length: 255 })` or `text("name")` | Use `varchar` where length matters | +| `integer("price_cents")` | `integer("price_cents")` | Same | +| `real("weight_grams")` | `real("weight_grams")` or `doublePrecision("weight_grams")` | `real` maps to Postgres float4 | +| `integer("created_at", { mode: "timestamp" })` | `timestamp("created_at").defaultNow()` | Native Postgres timestamp | +| `text("status").default("active")` | `text("status").default('active')` | Same, or use Postgres `enum` | +| `.all()` | `.execute()` or collect from async iterator | Postgres queries are async | + +**Connection module change:** ```typescript -// In src/db/schema.ts -- thread_candidates table additions -export const threadCandidates = sqliteTable("thread_candidates", { - // ... existing fields unchanged ... - sortOrder: integer("sort_order").notNull().default(0), - pros: text("pros"), - cons: text("cons"), +// CURRENT: src/db/index.ts +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +const sqlite = new Database("gearbox.db"); +export const db = drizzle(sqlite, { schema }); + +// TARGET: src/db/index.ts +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +const client = postgres(process.env.DATABASE_URL!); +export const db = drizzle(client, { schema }); +``` + +**Critical implication -- synchronous to async:** SQLite queries via `bun:sqlite` are synchronous (`.get()`, `.all()`, `.run()`). Postgres queries are async. Every service function that currently returns a value synchronously will need to become `async` and return a `Promise`. This cascades through routes (which are already async in Hono) but changes every service signature. + +**Service layer impact example:** + +```typescript +// CURRENT (synchronous) +export function getAllItems(db: Db) { + return db.select(...).from(items).all(); // returns Item[] +} + +// TARGET (async) +export async function getAllItems(db: Db) { + return db.select(...).from(items); // returns Promise +} +``` + +**Test infrastructure change:** The current `createTestDb()` uses in-memory SQLite. For Postgres testing, options are: +1. **PGlite** -- Postgres compiled to WASM, runs in-process like SQLite. Drop-in for tests. Drizzle supports it via `drizzle-orm/pglite`. This is the recommended approach. +2. **Testcontainers** -- Spin up a real Postgres Docker container per test suite. More realistic but slower. +3. **Shared test database** -- Single Postgres instance with schema reset between tests. Fast but not isolated. + +**Recommendation:** Use PGlite for unit/integration tests (fastest, no Docker dependency, matches Postgres semantics). Use real Postgres for E2E tests. + +**Data migration (one-time):** Export existing SQLite data as INSERT statements or CSV, import into Postgres. The schema shapes will be different (new columns), so this needs a custom migration script. Since this is a single-user app becoming multi-user, the existing data becomes the first user's data. + +--- + +### 2. Multi-Tenancy: Adding userId to Entity Tables + +**What changes:** Every entity table (items, categories, threads, setups, settings) gains a `userId` column as a NOT NULL foreign key to the `users` table. All queries are scoped by the authenticated user. + +**Schema additions:** + +```typescript +// All entity tables gain: +userId: integer("user_id").notNull().references(() => users.id), + +// The users table is redesigned for OIDC: +export const users = pgTable("users", { + id: serial("id").primaryKey(), + externalId: varchar("external_id", { length: 255 }).notNull().unique(), // OIDC subject ID + username: varchar("username", { length: 100 }).notNull().unique(), + displayName: varchar("display_name", { length: 255 }), + avatarUrl: text("avatar_url"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// Sessions table: REMOVED (OIDC middleware handles sessions via JWT cookies) +// passwordHash on users: REMOVED (OIDC handles passwords) +``` + +**Service layer pattern change -- userId injection:** + +The existing service pattern takes `(db, ...)` as first arguments. Adding userId requires passing it through from the middleware. + +```typescript +// CURRENT +export function getAllItems(db: Db) { + return db.select(...).from(items).all(); +} + +// TARGET -- userId as second param +export async function getAllItems(db: Db, userId: number) { + return db.select(...).from(items).where(eq(items.userId, userId)); +} +``` + +**Middleware provides userId:** + +```typescript +// New middleware: extractUser +// Runs after OIDC auth, resolves OIDC subject to local user ID +app.use("/api/*", async (c, next) => { + const oidcUser = c.get("oidc-user"); // from @hono/oidc-auth + if (!oidcUser) return next(); // public read routes + const user = await getOrCreateUser(db, oidcUser.sub, oidcUser); + c.set("userId", user.id); + return next(); }); ``` -**Sort order in service:** +**Route layer change -- extract userId and pass to services:** ```typescript -// In thread.service.ts -- getThreadWithCandidates -// Change: add .orderBy(asc(threadCandidates.sortOrder)) -const candidateList = db - .select({ ... }) - .from(threadCandidates) - .innerJoin(categories, eq(threadCandidates.categoryId, categories.id)) - .where(eq(threadCandidates.threadId, threadId)) - .orderBy(asc(threadCandidates.sortOrder)) // NEW - .all(); -``` - -**New service function for batch reorder:** - -```typescript -// In thread.service.ts -export function reorderCandidates( - db: Db = prodDb, - threadId: number, - orderedIds: number[], // candidate IDs in new rank order -) { - return db.transaction((tx) => { - for (let i = 0; i < orderedIds.length; i++) { - tx.update(threadCandidates) - .set({ sortOrder: i, updatedAt: new Date() }) - .where( - sql`${threadCandidates.id} = ${orderedIds[i]} AND ${threadCandidates.threadId} = ${threadId}`, - ) - .run(); - } - }); -} -``` - -**New API endpoint:** - -```typescript -// In routes/threads.ts -- new PATCH route -// Schema: z.object({ orderedIds: z.array(z.number().int().positive()) }) -app.patch("/:id/candidates/reorder", zValidator("json", reorderCandidatesSchema), (c) => { +// CURRENT +app.get("/", (c) => { const db = c.get("db"); - const threadId = Number(c.req.param("id")); - const { orderedIds } = c.req.valid("json"); - reorderCandidates(db, threadId, orderedIds); - return c.json({ success: true }); + const items = getAllItems(db); + return c.json(items); +}); + +// TARGET +app.get("/", (c) => { + const db = c.get("db"); + const userId = c.get("userId"); + const items = await getAllItems(db, userId); + return c.json(items); }); ``` -**Client-side drag with framer-motion Reorder:** +**Tables that get userId:** + +| Table | userId Behavior | Notes | +|---|---|---| +| `items` | Required. User owns their items. | Also gains `globalItemId` FK (optional) | +| `categories` | Required. Users define their own categories. | Seed "Uncategorized" per user on first login | +| `threads` | Required. User owns their research threads. | | +| `setups` | Required. User owns their setups. | Also gains `isPublic` boolean | +| `settings` | Required. Per-user settings. | Change PK from `key` to `(userId, key)` | +| `apiKeys` | Required. User manages their own API keys. | | +| `threadCandidates` | No change. Scoped via thread's userId. | Accessed through parent thread | +| `setupItems` | No change. Scoped via setup's userId. | Accessed through parent setup | + +**Categories: user-scoped, not global.** Each user creates their own categories. This keeps the model simple -- no shared namespace conflicts. The "Uncategorized" category is seeded per user on account creation. + +--- + +### 3. Authentication: OIDC via External Provider + +**What changes:** The entire auth system (users table with passwordHash, sessions table, login/setup routes, requireAuth middleware) is replaced with OIDC-based authentication delegated to an external provider. + +**Recommended provider: Authentik** because: +- Full OIDC/OAuth2 support (needed for standard web app auth flow) +- Modern UI, actively maintained (Python/Django, 40k+ GitHub stars) +- Self-hosted, open-source (matches project constraints) +- Supports user registration, password management, MFA out of the box +- The project already plans to run Postgres (Authentik requires Postgres + Redis) +- More capable than Authelia (which is only a forward-auth proxy, not a full IdP) +- Lighter than Keycloak (~600MB vs 1GB+, simpler config) + +**OIDC integration with Hono:** + +Use `@hono/oidc-auth` -- official Hono third-party middleware. It handles the Authorization Code Flow, token exchange, and session cookies (as signed JWTs). No session table needed. ```typescript -// In routes/threads/$threadId.tsx -import { Reorder } from "framer-motion"; +// Configuration +import { oidcAuthMiddleware, getAuth } from "@hono/oidc-auth"; -// Local state tracks optimistic order -const [orderedCandidates, setOrderedCandidates] = useState(thread.candidates); +// Environment variables: +// OIDC_AUTH_SECRET - signing key for session JWT +// OIDC_ISSUER - Authentik issuer URL +// OIDC_CLIENT_ID - from Authentik provider config +// OIDC_CLIENT_SECRET - from Authentik provider config +// OIDC_REDIRECT_URI - callback URL -// Sync with server data when it changes -useEffect(() => { - setOrderedCandidates(thread.candidates); -}, [thread.candidates]); +app.use("/api/*", oidcAuthMiddleware()); +``` -// On drag end, persist to server -const reorderCandidates = useReorderCandidates(threadId); +**Auth flow:** -function handleReorderEnd() { - reorderCandidates.mutate(orderedCandidates.map(c => c.id)); +``` +User visits app -> no session cookie + -> Redirect to Authentik login page + -> User logs in / registers at Authentik + -> Authentik redirects back with authorization code + -> @hono/oidc-auth exchanges code for tokens + -> Middleware sets signed JWT cookie (stateless session) + -> Subsequent requests: middleware validates JWT, sets oidc-user in context +``` + +**What gets removed:** +- `src/server/services/auth.service.ts` -- password hashing, session CRUD (gutted; API key management stays) +- `src/server/routes/auth.ts` -- login, setup, password change routes (replaced with OIDC callbacks) +- `requireAuth` middleware -- replaced by `oidcAuthMiddleware()` +- `users.passwordHash` column -- removed +- `sessions` table -- removed entirely + +**What stays:** +- API key system -- still needed for MCP and programmatic access. API keys bypass OIDC and authenticate directly. The `apiKeys` table gains a `userId` column. +- Rate limiting middleware on auth-adjacent routes. + +**Public read vs authenticated write:** This pattern remains. OIDC middleware can be configured to not block GET requests: + +```typescript +// Custom wrapper: require auth only for write operations +app.use("/api/*", async (c, next) => { + if (c.req.method === "GET") return next(); // public read + return oidcAuthMiddleware()(c, next); // authenticated write +}); +``` + +**User provisioning on first login:** When the OIDC callback succeeds and no local user exists for the `sub` (subject) claim, create one: + +```typescript +async function getOrCreateUser(db: Db, sub: string, claims: OidcClaims) { + let user = await db.select().from(users) + .where(eq(users.externalId, sub)).limit(1).then(r => r[0]); + + if (!user) { + user = await db.insert(users).values({ + externalId: sub, + username: claims.preferred_username ?? claims.sub, + displayName: claims.name ?? null, + avatarUrl: claims.picture ?? null, + }).returning().then(r => r[0]); + + // Seed default category for new user + await db.insert(categories).values({ + name: "Uncategorized", + icon: "package", + userId: user.id, + }); + } + + return user; } - -// Render - - {orderedCandidates.map((candidate, index) => ( - - - - ))} - -``` - -**Rank badge on CandidateCard:** - -```typescript -// Rank is passed as a prop, displayed as "1" / "2" / "3" / "..." badge -// Top 3 get medal-style styling (gold/silver/bronze), rest are plain gray -const RANK_STYLES = { - 1: "bg-amber-100 text-amber-700", // gold - 2: "bg-gray-200 text-gray-600", // silver - 3: "bg-orange-100 text-orange-600", // bronze -}; -``` - -**Pros/cons display in CandidateCard:** Show a small "+" (green) and "-" (red) indicator if the candidate has pros/cons content. Full text shown on hover tooltip or in the edit panel. Not shown inline on the card to preserve the compact layout. - -**New candidate sort order:** When creating a new candidate, set `sortOrder` to the current count of candidates in the thread (appends to end): - -```typescript -// In createCandidate service -const existingCount = db - .select({ count: sql`COUNT(*)` }) - .from(threadCandidates) - .where(eq(threadCandidates.threadId, threadId)) - .get()?.count ?? 0; - -// Insert with sortOrder = existingCount (0-indexed, so new item goes to end) ``` --- -## New vs Modified Files -- Complete Inventory +### 4. Global Item Database -### New Files (3) +**What changes:** A new `globalItems` table holds manufacturer/canonical gear data. User items can optionally link to a global item via `globalItemId` FK. Global items are not owned by any user -- they are platform-level data. + +**Schema:** + +```typescript +export const globalItems = pgTable("global_items", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + brand: varchar("brand", { length: 255 }), + weightGrams: real("weight_grams"), + priceCents: integer("price_cents"), // MSRP + productUrl: text("product_url"), + imageFilename: text("image_filename"), + categorySlug: varchar("category_slug", { length: 100 }), // platform-level category + description: text("description"), + specs: jsonb("specs"), // flexible key-value for category-specific specs + ownerCount: integer("owner_count").default(0).notNull(), // denormalized count + avgRating: real("avg_rating"), // denormalized from reviews + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// Index for search +// CREATE INDEX idx_global_items_name ON global_items USING gin(to_tsvector('english', name)); +``` + +**Relationship to user items:** + +```typescript +export const items = pgTable("items", { + // ... existing fields ... + userId: integer("user_id").notNull().references(() => users.id), + globalItemId: integer("global_item_id").references(() => globalItems.id), // nullable +}); +``` + +When a user adds a global item to their collection, a user-owned `items` row is created with `globalItemId` pointing to the canonical entry. The user can override weight/price/notes locally. The global item's `ownerCount` is incremented (denormalized counter, updated via trigger or service logic). + +**Data flow for global item search:** + +``` +User searches for gear -> GET /api/global-items?q=tent + -> Full-text search on globalItems.name (Postgres ts_vector) + -> Returns matches with ownerCount, avgRating + -> User clicks "Add to Collection" + -> POST /api/items { ...globalItemDefaults, globalItemId: 42 } + -> Creates user item linked to global item + -> Increment globalItems.ownerCount +``` + +**New service: `globalItem.service.ts`** +- `searchGlobalItems(db, query, limit, offset)` -- full-text search +- `getGlobalItem(db, id)` -- single item with aggregated stats +- `getGlobalItemOwners(db, globalItemId)` -- users who own this item (public profiles only) +- `getGlobalItemSetups(db, globalItemId)` -- public setups containing this item + +**New routes: `/api/global-items`** +- `GET /` -- search/browse (public) +- `GET /:id` -- detail with stats (public) +- `GET /:id/owners` -- owner list (public) +- `GET /:id/setups` -- setups containing this item (public) + +**Seeding:** Global items are initially seeded from manufacturer data (CSV import or manual entry). Users do not create global items directly -- they create user items that may or may not link to global items. A future admin interface could allow promoting frequently-created user items to global items. + +--- + +### 5. Structured Reviews + +**What changes:** Users can review global items with structured ratings -- no freeform text (as per project constraints). + +**Schema:** + +```typescript +export const reviews = pgTable("reviews", { + id: serial("id").primaryKey(), + userId: integer("user_id").notNull().references(() => users.id), + globalItemId: integer("global_item_id").notNull().references(() => globalItems.id), + overallRating: integer("overall_rating").notNull(), // 1-5 + weightRating: integer("weight_rating"), // 1-5: "lighter than expected" to "heavier" + durabilityRating: integer("durability_rating"), // 1-5 + valueRating: integer("value_rating"), // 1-5: price vs quality + ownershipMonths: integer("ownership_months"), // how long they've owned it + wouldRecommend: boolean("would_recommend"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (table) => ({ + uniqueReview: unique().on(table.userId, table.globalItemId), // one review per user per item +})); +``` + +**Why structured, not freeform:** The project explicitly defers freeform text until moderation infrastructure exists. Structured ratings (integer scales, boolean) require no moderation, are trivially aggregatable, and provide the "crowd-verified data" value prop. + +**New service: `review.service.ts`** +- `createReview(db, userId, globalItemId, data)` -- insert or upsert +- `getReviewsForItem(db, globalItemId)` -- all reviews with user info +- `getReviewStats(db, globalItemId)` -- aggregated averages +- `getUserReview(db, userId, globalItemId)` -- single user's review + +**Denormalization:** When a review is created/updated, recompute `globalItems.avgRating` from the reviews table. This avoids a JOIN on every global item list query. + +--- + +### 6. Public Profiles and Setup Sharing + +**What changes:** Users get public profile pages showing their shared setups. Setups gain an `isPublic` boolean. + +**Schema changes:** + +```typescript +export const setups = pgTable("setups", { + // ... existing fields ... + userId: integer("user_id").notNull().references(() => users.id), + isPublic: boolean("is_public").default(false).notNull(), + description: text("description"), // optional setup description for public view +}); +``` + +**New routes:** + +``` +GET /api/profiles/:username -- public profile (user info + public setups) +GET /api/profiles/:username/setups -- public setups with totals +``` + +**New client routes:** + +``` +/profile/:username -- public profile page +``` + +**New service: `profile.service.ts`** +- `getPublicProfile(db, username)` -- user info + public setup count + item count +- `getPublicSetups(db, userId)` -- setups where `isPublic = true` with totals + +--- + +### 7. Discovery Feed + +**What changes:** A new discovery page aggregates public content: recently shared setups, popular global items, new reviews. + +**New client route: `/discover`** + +**New route: `/api/discover`** +- `GET /feed` -- mixed feed of recent public activity +- `GET /popular-items` -- global items sorted by ownerCount +- `GET /recent-setups` -- recently published public setups + +**New service: `discover.service.ts`** +- `getDiscoveryFeed(db, cursor, limit)` -- cursor-paginated feed +- `getPopularItems(db, limit)` -- top global items by owner count +- `getRecentPublicSetups(db, cursor, limit)` -- latest public setups + +**Query pattern:** No separate "feed" table. Discovery queries are real-time aggregations over existing tables with appropriate indexes: + +```sql +-- Popular items +SELECT * FROM global_items ORDER BY owner_count DESC LIMIT 20; + +-- Recent public setups with user info +SELECT s.*, u.username, u.avatar_url +FROM setups s +JOIN users u ON s.user_id = u.id +WHERE s.is_public = true +ORDER BY s.updated_at DESC +LIMIT 20; +``` + +**Indexes needed:** +- `globalItems.ownerCount` DESC for popular items +- `setups.isPublic` + `setups.updatedAt` DESC for recent public setups +- `reviews.globalItemId` for review aggregation + +--- + +### 8. Image Storage: Local to S3-Compatible (MinIO) + +**What changes:** Images move from local filesystem (`./uploads/`) to S3-compatible object storage (MinIO, self-hosted). The image service changes from file write to S3 PUT, and image URLs change from `/uploads/filename.jpg` to presigned S3 URLs or a proxy endpoint. + +**Why MinIO:** Self-hosted, S3-compatible API (use standard `@aws-sdk/client-s3`), handles multi-instance deployment, no vendor lock-in. Runs as a single Docker container alongside the app. + +**New image flow:** + +``` +Upload: + Client -> POST /api/images (multipart) -> image.service.ts + -> S3 PutObject to MinIO bucket + -> Return { filename: "uuid.jpg" } + +Serve: + Option A: Presigned GET URL (generated on demand, expires in 1h) + -> Client receives presigned URL in item response + -> Browser fetches directly from MinIO + + Option B: Proxy through app server + -> GET /api/images/:filename -> S3 GetObject -> stream to client + -> Simpler CORS, but adds server load +``` + +**Recommendation:** Start with Option B (proxy) for simplicity. The existing pattern of returning `imageFilename` in item responses stays the same; only the serving endpoint changes from `serveStatic("/uploads/*")` to a dynamic handler that streams from S3. Migrate to presigned URLs later if performance demands it. + +**image.service.ts changes:** + +```typescript +// CURRENT +await Bun.write(join(uploadsDir, filename), buffer); + +// TARGET +import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT, // e.g., http://minio:9000 + region: "us-east-1", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY!, + secretAccessKey: process.env.S3_SECRET_KEY!, + }, + forcePathStyle: true, // required for MinIO +}); + +await s3.send(new PutObjectCommand({ + Bucket: "gearbox-images", + Key: filename, + Body: Buffer.from(buffer), + ContentType: contentType, +})); +``` + +**Migration of existing images:** One-time script uploads all files from `./uploads/` to the MinIO bucket. + +--- + +## New vs Modified Components -- Complete Inventory + +### New Files | File | Purpose | |------|---------| -| `src/client/components/CandidateCompare.tsx` | Side-by-side comparison table; pure presentational, no new API | -| `src/client/components/SetupImpactRow.tsx` | Delta display row (+weight/+price vs setup total); pure presentational | -| Drizzle migration file | Three new columns on `thread_candidates` (`sort_order`, `pros`, `cons`) | +| `src/db/schema.ts` | Full rewrite from sqlite-core to pg-core | +| `src/db/index.ts` | Postgres connection via postgres.js | +| `src/server/middleware/oidcAuth.ts` | OIDC auth middleware wrapping @hono/oidc-auth | +| `src/server/middleware/extractUser.ts` | Resolves OIDC subject to local userId, sets in context | +| `src/server/services/globalItem.service.ts` | Global item CRUD, search, stats | +| `src/server/services/review.service.ts` | Structured review CRUD, aggregation | +| `src/server/services/profile.service.ts` | Public profile queries | +| `src/server/services/discover.service.ts` | Discovery feed queries | +| `src/server/routes/globalItems.ts` | Global item endpoints | +| `src/server/routes/reviews.ts` | Review endpoints | +| `src/server/routes/profiles.ts` | Public profile endpoints | +| `src/server/routes/discover.ts` | Discovery feed endpoints | +| `src/client/routes/discover.tsx` | Discovery page | +| `src/client/routes/profile/$username.tsx` | Public profile page | +| `src/client/routes/items/$id.tsx` | Global item detail page | +| `src/client/hooks/useGlobalItems.ts` | Global item search/detail hooks | +| `src/client/hooks/useReviews.ts` | Review hooks | +| `src/client/hooks/useProfile.ts` | Profile hooks | +| `src/client/hooks/useDiscover.ts` | Discovery feed hooks | +| `src/shared/schemas.ts` | Review schemas, global item schemas (extend existing) | +| `tests/helpers/db.ts` | Rewrite to use PGlite | +| `e2e/seed.ts` | Rewrite for Postgres + new schema | +| Migration script | One-time SQLite-to-Postgres data migration | +| Image migration script | One-time upload of ./uploads/ to MinIO | -### Modified Files (12) +### Modified Files (Every Service + Route) | File | What Changes | |------|-------------| -| `src/db/schema.ts` | Add `sortOrder`, `pros`, `cons` to `threadCandidates` | -| `src/shared/schemas.ts` | Add `sortOrder`, `pros`, `cons` to candidate schemas; add `reorderCandidatesSchema` | -| `src/server/services/thread.service.ts` | Order candidates by `sortOrder`; add `reorderCandidates` function; include `pros`/`cons` in create/update; set `sortOrder` on create | -| `src/server/routes/threads.ts` | Add `PATCH /:id/candidates/reorder` endpoint | -| `src/client/hooks/useCandidates.ts` | Add `useReorderCandidates(threadId)` mutation | -| `src/client/components/CandidateCard.tsx` | Accept `rank` prop; show rank badge; show pros/cons indicators | -| `src/client/components/CandidateForm.tsx` | Add pros and cons textarea fields | -| `src/client/routes/threads/$threadId.tsx` | Add compare toggle; add setup selector; add `Reorder.Group` DnD; manage local order state | -| `src/client/stores/uiStore.ts` | Add `compareMode`, `toggleCompareMode()`, `impactSetupId`, `setImpactSetupId()` | -| `tests/helpers/db.ts` | Add `sort_order`, `pros`, `cons` columns to `thread_candidates` CREATE TABLE | +| Every service file (`*.service.ts`) | All functions become `async`, gain `userId` parameter, queries scoped by userId | +| Every route file (`routes/*.ts`) | Extract `userId` from context, pass to services, await async service calls | +| `src/server/index.ts` | Replace auth middleware chain, add new route registrations, remove serveStatic for uploads | +| `src/shared/schemas.ts` | Add review/globalItem/profile schemas | +| `src/shared/types.ts` | Add new types from new schemas + Drizzle tables | +| `src/server/services/auth.service.ts` | Remove password/session management, keep API key management (add userId scoping) | +| `src/server/routes/auth.ts` | Replace login/setup with OIDC callback handling | +| `src/server/services/image.service.ts` | Replace filesystem writes with S3 operations | +| `src/server/routes/images.ts` | Add proxy endpoint for S3 reads, remove serveStatic | +| `src/client/hooks/useAuth.ts` | Adapt to OIDC-based auth state | +| `src/client/routes/login.tsx` | Redirect to OIDC provider instead of local login form | +| `src/server/mcp/` | All MCP tools gain userId scoping | +| All test files | Switch to PGlite, update for async services, add userId to test data | -### Unchanged Files +### Removed Files -| File | Why No Change | -|------|-------------| -| `src/client/lib/api.ts` | Existing `apiPatch` handles reorder endpoint | -| `src/client/hooks/useSetups.ts` | `useSetup(id)` already fetches items with weight/price; `useSetups()` provides dropdown data | -| `src/client/hooks/useThreads.ts` | `CandidateWithCategory` type auto-updates from schema inference | -| `src/server/services/setup.service.ts` | No setup changes needed | -| `src/server/routes/setups.ts` | No setup endpoint changes needed | -| `src/server/services/totals.service.ts` | Impact is client-computed, not a server aggregate | -| `src/server/routes/totals.ts` | No new endpoints | -| `package.json` | `framer-motion` already installed at v12.37.0 | +| File | Reason | +|------|--------| +| `sessions` table in schema | OIDC handles sessions | +| Password hashing logic in auth.service | OIDC handles passwords | --- -## Data Flow Changes Summary +## Data Flow Changes -### Existing Data Flows (unchanged) +### Existing Flows (Modified) ``` -useThread(id) -> GET /api/threads/:id -> getThreadWithCandidates(db, id) -> thread + candidates[] -useSetups() -> GET /api/setups -> getAllSetups(db) -> setups[] -useSetup(id) -> GET /api/setups/:id -> getSetupWithItems(db, id) -> setup + items[] +CURRENT: + useItems() -> GET /api/items -> getAllItems(db) -> all items in db + +TARGET: + useItems() -> GET /api/items -> getAllItems(db, userId) -> items WHERE userId = X ``` -### New Data Flows +This pattern applies uniformly to items, categories, threads, setups, settings, and API keys. + +### New Flows ``` -Side-by-Side Comparison (client-only): - thread.candidates[] (already in cache) - -> uiStore.compareMode == true - -> CandidateCompare component - -> renders column-per-candidate table - -> no API call +Global Item Search: + User types in search box on Discover page + -> useGlobalItems(query) -> GET /api/global-items?q=tent + -> searchGlobalItems(db, "tent") + -> Postgres full-text search on global_items + -> Return results with ownerCount, avgRating -Setup Impact Preview (client-only computation): - uiStore.impactSetupId -> useSetup(impactSetupId) - -> setup.items[].reduce(sum, weightGrams) = setupCurrentWeight - -> per candidate: impactWeight = candidate.weightGrams - -> SetupImpactRow displays "+Xg / +$Y" - -> no API call +Add Global Item to Collection: + User clicks "Add to Collection" on global item + -> POST /api/items { name, weightGrams, ..., globalItemId: 42 } + -> createItem(db, userId, data) with globalItemId + -> Increment globalItems.ownerCount + -> Item appears in user's collection -Candidate Reorder (new write endpoint): - DnD drag end -> setOrderedCandidates (local state) - -> useReorderCandidates.mutate(orderedIds) - -> PATCH /api/threads/:id/candidates/reorder - -> reorderCandidates(db, threadId, orderedIds) [transaction loop] - -> invalidate ["threads", threadId] - -> useThread refetches -> candidates come back in new sortOrder +Submit Review: + User clicks "Rate" on a global item they own + -> POST /api/reviews { globalItemId, overallRating, ... } + -> createReview(db, userId, data) + -> Recompute globalItems.avgRating + -> Review appears on item detail page + +View Public Setup: + Visitor navigates to /profile/username + -> useProfile(username) -> GET /api/profiles/username + -> getPublicProfile(db, username) + -> Return user info + public setups list + -> Click setup -> full setup detail with items and totals + +Discovery Feed: + User visits /discover + -> useDiscover() -> GET /api/discover/feed + -> getDiscoveryFeed(db, cursor, limit) + -> Mixed content: popular items, recent setups, trending reviews + -> Cursor-based pagination for infinite scroll ``` --- ## Build Order (Dependency-Aware) -Features have a clear dependency order based on shared schema migration and UI surface: +The transformation has a strict dependency chain. Each phase must complete before the next can begin. ``` -Phase 1: Schema + Pros/Cons fields - +-- Add sort_order, pros, cons to threadCandidates schema - +-- Run single Drizzle migration (batch all three columns together) - +-- Update tests/helpers/db.ts - +-- Add pros/cons to candidate create/update service + route - +-- Add pros/cons fields to CandidateForm - +-- Add pros/cons display indicators to CandidateCard - +-- Dependencies: none -- foundation for Phase 2 and 3 +Phase 1: Database Migration (SQLite -> Postgres) + ├── Rewrite src/db/schema.ts from sqliteTable to pgTable + ├── Rewrite src/db/index.ts for postgres.js connection + ├── Rewrite tests/helpers/db.ts for PGlite + ├── Make all service functions async + ├── Update all route handlers to await service calls + ├── Generate fresh Drizzle migrations + ├── Update e2e/seed.ts for Postgres + ├── Fix all tests (async assertions, PGlite) + ├── Docker compose: add Postgres container + └── Data migration script (SQLite -> Postgres, one-time) + DEPENDENCY: None. This is the foundation. + RISK: Highest risk phase. Touches every file. Must be done first and fully. -Phase 2: Ranking (Drag-to-Reorder) - +-- Requires Phase 1 (sort_order column) - +-- Add reorderCandidates service function - +-- Add PATCH /reorder route - +-- Add useReorderCandidates hook - +-- Add Reorder.Group to threadId route - +-- Show rank badges on CandidateCard - +-- Dependencies: Phase 1 (sort_order in DB) +Phase 2: Multi-User Data Model (userId on all tables) + ├── Add userId column to items, categories, threads, setups, settings, apiKeys + ├── Update users table (add externalId, remove passwordHash) + ├── Add userId parameter to every service function + ├── Update every route to extract userId from context and pass to services + ├── Add user creation/lookup service (getOrCreateUser) + ├── Seed "Uncategorized" category per user + ├── Update all tests with userId in test data + ├── Update MCP tools with userId scoping + └── Drizzle migration for new columns + FK constraints + DEPENDENCY: Phase 1 (Postgres schema must be in place) + RISK: Medium. Mechanical but broad. Every query changes. -Phase 3: Side-by-Side Comparison - +-- No schema dependency (uses existing fields) - +-- Can be built alongside Phase 2, but benefits from rank display being complete - +-- Add compareMode to uiStore - +-- Create CandidateCompare component - +-- Wire toggle button in thread detail header - +-- Dependencies: none (pure client-side with existing data) +Phase 3: External Auth (OIDC) + ├── Install @hono/oidc-auth + ├── Create OIDC middleware wrapper + ├── Create extractUser middleware + ├── Replace requireAuth with OIDC auth chain + ├── Remove password-based auth routes (login, setup, password change) + ├── Remove sessions table + ├── Update API key auth to work alongside OIDC (bypass for X-API-Key) + ├── Update client login page to redirect to OIDC provider + ├── Update useAuth hook for OIDC-based state + ├── Deploy Authentik (Docker compose) + └── Configure Authentik provider (OIDC client) + DEPENDENCY: Phase 2 (users table with externalId must exist) + RISK: Medium. External dependency on Authentik. Test OIDC flow thoroughly. -Phase 4: Setup Impact Preview - +-- No schema dependency - +-- Easiest to build after the thread detail page already has setup selector UI - +-- Add impactSetupId to uiStore - +-- Create SetupImpactRow component - +-- Wire setup selector dropdown in thread detail header - +-- useSetup() conditionally enabled when impactSetupId set - +-- Dependencies: none (uses existing useSetup hook) +Phase 4: Image Storage (S3/MinIO) + ├── Install @aws-sdk/client-s3 + ├── Update image.service.ts (S3 put instead of file write) + ├── Add image proxy route (GET /api/images/:filename -> S3 get) + ├── Remove serveStatic("/uploads/*") + ├── Deploy MinIO container (Docker compose) + ├── Migrate existing images to MinIO bucket + └── Update image deletion to S3 DeleteObject + DEPENDENCY: None technically, but deploy alongside Phase 1-3 infra. + RISK: Low. Well-understood pattern. S3 SDK is mature. + +Phase 5: Global Items + Reviews + ├── Create globalItems table + ├── Add globalItemId FK to items table + ├── Create reviews table + ├── Create globalItem.service.ts + ├── Create review.service.ts + ├── Create routes/globalItems.ts + ├── Create routes/reviews.ts + ├── Add Postgres full-text search index on globalItems.name + ├── Create client hooks (useGlobalItems, useReviews) + ├── Create global item detail page (/items/:id) + ├── Add "Add to Collection" flow + ├── Add review submission UI + └── Seed initial global items (CSV import) + DEPENDENCY: Phases 1-3 (Postgres, multi-user, auth) + RISK: Medium. Full-text search needs Postgres-specific config. + +Phase 6: Public Profiles + Discovery + ├── Add isPublic to setups table + ├── Create profile.service.ts + ├── Create discover.service.ts + ├── Create routes/profiles.ts + ├── Create routes/discover.ts + ├── Create /profile/:username client route + ├── Create /discover client route + ├── Add setup sharing toggle UI + ├── Add discovery feed with cursor pagination + └── Add indexes for discovery queries + DEPENDENCY: Phase 5 (global items provide content for discovery) + RISK: Low. Standard CRUD + queries. Design-heavy more than technically complex. ``` -**Recommended sequence:** Phase 1 + 2 together (schema-touching work in one pass), then Phase 3, then Phase 4. Phases 3 and 4 are independent pure-client additions that can be built in parallel. +**Phase ordering rationale:** +1. Postgres must come first because everything else depends on it (OIDC needs async queries, multi-tenancy needs FK constraints on userId, global items need full-text search). +2. Multi-user data model before auth because the schema must support userId before auth can populate it. +3. Auth before platform features because platform features require knowing who the user is. +4. Image storage is independent but bundles well with infrastructure setup. +5. Global items before discovery because the discovery feed aggregates global item data. --- -## Architectural Patterns +## Patterns to Follow -### Pattern 1: Optimistic Local State for Drag Reorder +### Pattern 1: userId Injection via Middleware -> Context -> Service -**What:** Maintain a local `orderedCandidates` state in the route component. Apply drag updates immediately (optimistic), sync to server on drag end only. -**When to use:** Any list that supports drag reorder where immediate visual feedback matters. -**Trade-offs:** Local state can drift from server if the reorder request fails. Given single-user SQLite, failure is extremely unlikely. A full optimistic update with rollback would be overengineering here. +**What:** Middleware resolves the authenticated user and stores `userId` in Hono context. Routes extract it and pass to services. Services never access auth state directly. + +**Why:** Maintains the existing testable service pattern. Services remain pure functions of `(db, userId, data)` with no HTTP awareness. ```typescript -// Local state drives the render; server state updates it on fetch -const [orderedCandidates, setOrderedCandidates] = useState(thread.candidates); +// Middleware sets +c.set("userId", user.id); -useEffect(() => { - // Sync when server data changes (after reorder mutation settles) - setOrderedCandidates(thread.candidates); -}, [thread.candidates]); +// Route extracts +const userId = c.get("userId"); +const items = await getAllItems(db, userId); + +// Service uses +export async function getAllItems(db: Db, userId: number) { ... } ``` -### Pattern 2: Client-Computed Derived Data (No New Endpoints) +### Pattern 2: Dual Auth Path (OIDC + API Key) -**What:** Derive comparison deltas and setup impact numbers from data already in the React Query cache. -**When to use:** When all required data is already fetched, computation is simple (arithmetic), and the result is not needed on the server. -**Trade-offs:** Correctly avoids API proliferation. The risk is stale data, but React Query's default `staleTime: 0` means data is fresh. +**What:** OIDC handles browser sessions. API keys bypass OIDC for programmatic access (MCP, scripts). Both resolve to a userId. ```typescript -// Impact preview: pure client arithmetic, no fetch -const setupWeight = setup.items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0); -const candidateImpact = candidateWeightGrams ?? 0; -const newTotal = setupWeight + candidateImpact; -const delta = candidateImpact; // "+320g" +app.use("/api/*", async (c, next) => { + if (c.req.method === "GET") return next(); // public read + + // API key takes priority (no OIDC redirect for API clients) + const apiKey = c.req.header("X-API-Key"); + if (apiKey) { + const userId = await verifyApiKey(db, apiKey); + if (!userId) return c.json({ error: "Invalid API key" }, 401); + c.set("userId", userId); + return next(); + } + + // Fall through to OIDC + return oidcAuthMiddleware()(c, next); +}); ``` -### Pattern 3: uiStore for Cross-Panel Persistent UI State +### Pattern 3: Global Item Linking (User Item -> Global Item) -**What:** The existing pattern — Zustand `uiStore` holds UI mode flags like panel open/closed, dialog state. Extend this for `compareMode` and `impactSetupId`. -**When to use:** UI state that needs to survive component unmount/remount within a session (e.g., user opens a candidate edit panel and returns to find compare mode still active). -**Trade-offs:** Do not put server data in `uiStore`. Only boolean flags and selected IDs. Server data stays in React Query. +**What:** User items optionally reference a global item. The user's local copy can override fields. The global item tracks aggregate stats (ownerCount, avgRating). + +**Why:** Separates user-customizable data from canonical data. A user might note a different weight for their specific unit, or a sale price. The global item maintains the manufacturer spec. + +### Pattern 4: Cursor-Based Pagination for Discovery + +**What:** Discovery feed uses cursor-based pagination (`?cursor=&limit=20`) instead of offset-based (`?page=2`). The cursor is the ID or timestamp of the last returned item. + +**Why:** Offset pagination breaks when new content is inserted (items shift between pages). Cursor pagination is stable, performant (uses indexed seek), and natural for infinite scroll. + +```typescript +export async function getRecentPublicSetups(db: Db, cursor?: number, limit = 20) { + let query = db.select(...).from(setups) + .where(eq(setups.isPublic, true)) + .orderBy(desc(setups.updatedAt)) + .limit(limit); + + if (cursor) { + query = query.where(and(eq(setups.isPublic, true), lt(setups.id, cursor))); + } + + return query; +} +``` --- ## Anti-Patterns to Avoid -### Anti-Pattern 1: Server Endpoint for Comparison Deltas +### Anti-Pattern 1: Shared Global Categories -**What people do:** Build a `GET /api/threads/:id/compare` endpoint that returns weight/price diffs -**Why it's wrong:** All candidate data is already fetched by `useThread(threadId)`. A round-trip to compute `Math.min` and subtraction is unnecessary. -**Do this instead:** Compute deltas in the `CandidateCompare` component from `thread.candidates[]`. +**What people do:** Make categories global (shared across all users) to avoid duplication. +**Why bad:** Category naming is personal. One user's "Shelter" is another's "Tents". Shared categories create namespace conflicts and force consensus. Global items use a separate `categorySlug` for platform-level categorization. +**Do instead:** User-scoped categories. Each user creates their own. Seed "Uncategorized" per user. -### Anti-Pattern 2: Server Endpoint for Impact Preview +### Anti-Pattern 2: Row-Level Security in Application Code -**What people do:** Build a `GET /api/threads/:id/impact?setupId=X` endpoint -**Why it's wrong:** Setup weight totals are already fetched by `useSetup(setupId)` which returns `items[]` with weights. The impact is additive arithmetic. -**Do this instead:** Sum `setup.items[].weightGrams` client-side, add `candidate.weightGrams`. No round-trip. +**What people do:** Add complex permission checks in every query (can this user see this item? is this setup public?). +**Why bad:** Scattered authorization logic is hard to audit and easy to miss. +**Do instead:** Consistent pattern: private data always filtered by `WHERE userId = X`. Public data (profiles, shared setups, global items) has dedicated read-only endpoints. No mixed-permission queries. -### Anti-Pattern 3: Storing Rank as a Linked List +### Anti-Pattern 3: Storing OIDC Tokens in the Database -**What people do:** Store `prevId`/`nextId` on each candidate for ordering -**Why it's wrong:** Linked list ordering is complex to maintain transactionally, especially for batch reorders. Queries become multi-step. -**Do this instead:** Use a plain `sort_order` integer (0-indexed). On reorder, update all affected rows in a single transaction loop. Integer order is simple, fast, and trivially queryable with `ORDER BY sort_order`. +**What people do:** Save access/refresh tokens from the OIDC provider in a local sessions table. +**Why bad:** `@hono/oidc-auth` already handles this via signed JWT cookies. Storing tokens adds a session table, cleanup logic, and token refresh management that the middleware handles automatically. +**Do instead:** Let the OIDC middleware manage session state. Only store the `externalId` (OIDC subject) in the users table for local user lookup. -### Anti-Pattern 4: External DnD Library When framer-motion is Already Present +### Anti-Pattern 4: Migrating SQLite Data via Drizzle Migrations -**What people do:** Install `@dnd-kit/sortable` or `react-beautiful-dnd` for drag reorder -**Why it's wrong:** `framer-motion` is already in `package.json` at v12.37.0 and includes `Reorder.Group` / `Reorder.Item` components designed exactly for this use case. Adding another DnD library duplicates functionality and bloats the bundle. -**Do this instead:** Use `framer-motion`'s `Reorder` components. `import { Reorder } from "framer-motion"` -- no new dependency. +**What people do:** Try to use `drizzle-kit push` or `drizzle-kit migrate` to move data from SQLite to Postgres. +**Why bad:** Drizzle migrations are DDL (schema changes), not DML (data transfer). They cannot move data between databases. The schema syntax is dialect-specific. +**Do instead:** Write a one-time migration script: read from SQLite, transform, insert into Postgres. Use Drizzle's query builder for both reads and writes. -### Anti-Pattern 5: Full Optimistic Update with Rollback for Reorder +### Anti-Pattern 5: Making All Queries Public-Aware from Day 1 -**What people do:** Implement `onMutate` with cache snapshot and `onError` rollback in the reorder mutation -**Why it's wrong for this app:** Single-user SQLite on localhost. The reorder endpoint will not fail under any realistic condition. Full optimistic update infrastructure (snapshot, rollback, error handling) is meaningful in multi-user or network-failure scenarios. -**Do this instead:** Local `useState` provides immediate visual feedback. The mutation runs fire-and-forget style. If it somehow fails, `onError` can `invalidateQueries` to restore server state. No manual rollback needed. - -### Anti-Pattern 6: Pros/Cons as Separate Database Tables - -**What people do:** Create a `candidate_annotations` table with `type: "pro"|"con"` rows -**Why it's wrong:** Pros/cons are simple text fields per candidate, edited as textarea inputs. Modeling them as a separate table with individual row creation/deletion adds CRUD complexity for zero benefit at this scale. -**Do this instead:** Two text columns (`pros TEXT`, `cons TEXT`) on `thread_candidates`. Store multi-line text directly. Simple, fast, and fits the existing update mutation pattern. +**What people do:** Add `isPublic` checks to every query from the start, even before discovery features exist. +**Why bad:** Adds complexity to every query before it's needed. Multi-tenancy (userId scoping) is sufficient for phases 1-4. +**Do instead:** Add `isPublic` only when implementing profiles/discovery (Phase 6). Until then, all data is private-by-default (scoped by userId). --- -## Integration Summary Table +## Scalability Considerations -| New Feature | API Changes | Schema Changes | New Components | Key Modified Files | -|-------------|-------------|----------------|----------------|--------------------| -| Side-by-side comparison | None | None | `CandidateCompare.tsx` | `$threadId.tsx`, `uiStore.ts` | -| Setup impact preview | None | None | `SetupImpactRow.tsx` | `$threadId.tsx`, `uiStore.ts` | -| Ranking (DnD) | `PATCH /threads/:id/candidates/reorder` | `sort_order` on `thread_candidates` | None (uses `Reorder.Group`) | `$threadId.tsx`, `thread.service.ts`, `routes/threads.ts`, `useCandidates.ts` | -| Pros/Cons fields | Extend existing PUT candidate | `pros`, `cons` on `thread_candidates` | None | `CandidateForm.tsx`, `CandidateCard.tsx`, `thread.service.ts`, `schemas.ts` | +| Concern | At 10 users | At 1K users | At 10K users | +|---------|-------------|-------------|--------------| +| Database | Single Postgres, fine | Single Postgres, fine | Connection pooling, read replicas | +| Image storage | MinIO single instance | MinIO, fine to 1TB+ | CDN in front of MinIO | +| Global item search | Basic LIKE query | Postgres full-text search | Add search index, consider pg_trgm | +| Discovery feed | Direct queries | Add query caching (Redis) | Materialized views | +| Auth | Authentik single instance | Authentik, fine | Authentik HA setup | --- ## Sources -- [framer-motion Reorder documentation](https://www.framer.com/motion/reorder/) -- `Reorder.Group` and `Reorder.Item` API for drag-to-reorder lists -- [framer-motion v12 changelog](https://github.com/framer/motion/releases) -- confirms `Reorder` available in v12 (already installed) -- [Drizzle ORM orderBy documentation](https://orm.drizzle.team/docs/select#order-by) -- `asc()` for sort_order ordering -- [TanStack React Query optimistic updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates) -- pattern reference for the reorder mutation approach -- [Zustand documentation](https://zustand.docs.pmnd.rs/) -- confirms store extend pattern for new UI state slices +- [Drizzle ORM PostgreSQL docs](https://orm.drizzle.team/docs/get-started/postgresql-new) -- pg-core schema syntax, driver support +- [Drizzle ORM Migrations docs](https://orm.drizzle.team/docs/migrations) -- migration workflow +- [Drizzle same schema discussion #3396](https://github.com/drizzle-team/drizzle-orm/discussions/3396) -- confirms no cross-dialect schema sharing, PGlite recommendation +- [@hono/oidc-auth NPM](https://www.npmjs.com/package/@hono/oidc-auth) -- OIDC middleware for Hono, stateless JWT sessions +- [Authentik docs - OAuth2 provider](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/) -- OIDC provider configuration +- [Authentik vs Authelia vs Keycloak 2026](https://blog.elest.io/authentik-vs-authelia-vs-keycloak-choosing-the-right-self-hosted-identity-provider-in-2026/) -- provider comparison, resource usage +- [State of Open-Source Identity 2025](https://blog.houseoffoss.com/post/the-state-of-open-source-identity-in-2025-authentik-vs-authelia-vs-keycloak-vs-zitadel) -- ecosystem landscape +- [MinIO GitHub](https://github.com/minio/minio) -- S3-compatible self-hosted object storage +- [MinIO S3 setup guide](https://oneuptime.com/blog/post/2026-01-27-minio-s3-compatible-storage/view) -- deployment and Node.js integration --- -*Architecture research for: GearBox v1.3 Research & Decision Tools* -*Researched: 2026-03-16* +*Architecture research for: GearBox v2.0 Platform Foundation* +*Researched: 2026-04-03* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index 81e59ae..408728b 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -1,21 +1,28 @@ # Feature Research -**Domain:** Gear management — candidate comparison, setup impact preview, and candidate ranking -**Researched:** 2026-03-16 -**Confidence:** HIGH (existing codebase fully understood; UX patterns verified via multiple sources) +**Domain:** Multi-user gear management and discovery platform +**Researched:** 2026-04-03 +**Confidence:** MEDIUM-HIGH --- ## Context -This is a subsequent milestone research file for **v1.3 Research & Decision Tools**. -The features below are **additive** to v1.2. All three features operate within the existing -`threads/$threadId` page and its data model. +This is the feature research for **v2.0 Platform Foundation** -- transforming GearBox from a single-user gear tracker into a multi-user platform with discovery, global item database, structured reviews, and setup sharing. -**Existing data model relevant to this milestone:** -- `threadCandidates`: id, threadId, name, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename, status — no rank, pros, or cons columns yet -- `setups` + `setupItems`: stores weight/cost per setup item with classification (base/worn/consumable) -- `getSetupWithItems` already returns `classification` per item — available for impact preview +**Existing features (already built through v1.4):** +- Gear collection CRUD with categories, weight/price, images, quantity +- Planning threads with candidate comparison, ranking, pros/cons, impact preview +- Named setups (loadouts) with classification, donut chart visualization +- Search/filter, CSV import/export, item duplication +- Dashboard home page, onboarding wizard +- Single-user auth (cookie sessions + API keys), MCP server (19 tools) + +**Key project constraints:** +- No freeform UGC until moderation infrastructure exists (structured input only) +- Discovery-first, not social-first +- External auth provider (self-hosted, open-source) +- Postgres for multi-user platform --- @@ -23,119 +30,150 @@ The features below are **additive** to v1.2. All three features operate within t ### Table Stakes (Users Expect These) -Features users assume exist in any comparison or decision tool. Missing these makes the thread -detail page feel incomplete as a decision workspace. +Features users assume exist on any multi-user gear platform. Missing these makes the platform feel broken or pointless. | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| -| Side-by-side comparison view | Any comparison tool in any domain shows attributes aligned per-column. Card grid (current) forces mental juggling between candidates. E-commerce, spec sheets, gear apps — all use tabular layout for comparison. | MEDIUM | Rows = attributes (image, name, weight, price, status, notes, link), columns = candidates. Sticky attribute-label column during horizontal scroll. Max 3–4 candidates usable on desktop; 2 on mobile. Toggle between grid view (current) and table view. | -| Weight delta per candidate | Gear apps (LighterPack, GearGrams) display weight totals prominently. Users replacing an item need the delta, not just the raw weight of the candidate. | LOW | Pure client-side computation: `candidate.weightGrams - existingItemWeight`. No API call needed if setup data already loaded via `useSetup`. | -| Cost delta per candidate | Same reasoning as weight delta. A purchase decision is always the weight vs. cost tradeoff. | LOW | Same pattern as weight delta. Color-coded: green for savings/lighter, red for more expensive/heavier. | -| Setup selector for impact preview | User needs to pick which setup to compute deltas against — not all setups contain the same category of item being replaced. | MEDIUM | Dropdown of setup names populated from `useSetups()`. When selected, loads setup via `useSetup(id)`. "No setup selected" state shows raw candidate values only, no delta. | +| **User registration and authentication** | Cannot have multi-user without accounts. Every platform has sign-up/login. | HIGH | External auth provider integration (Authentik, Keycloak, or similar). Replaces current single-user cookie auth. All existing entities need userId FK. | +| **User profiles (public)** | Every community platform has profiles. Users need identity to share and be discovered. | LOW | Minimal: display name, avatar URL, bio text, joined date. Public profile page lists user's public setups. No follower counts needed. | +| **Setup visibility controls** | Users will not share setups if they cannot control what is public. Privacy is table stakes for any sharing platform. | LOW | Binary public/private toggle per setup. Default to private (opt-in sharing). Existing setups migrated as private. | +| **Public setup detail pages** | Shared setup links must resolve to a readable page. If sharing is a feature, the shared thing must be viewable. | MEDIUM | Read-only view with item list, weight/cost totals, donut chart, creator attribution. No auth required for public setups. Extends existing setup detail view. | +| **Global item database (searchable)** | Users expect to find gear by name rather than entering specs from scratch every time. LighterPack's weakness is fully manual data entry. | HIGH | Central product catalog with brand, model, category, manufacturer weight, MSRP, product URL, image. Users search and link rather than re-enter. Seed with 200-500 items in core categories to bootstrap. This is the foundational dependency for reviews, aggregation, and item detail pages. | +| **Link personal items to global items** | Once a global DB exists, users expect to connect their gear to canonical entries for richer data. | MEDIUM | Optional FK from user items to global items. Enables aggregation (owner count, avg weight, reviews). Must handle items not yet in global DB gracefully. | +| **Item detail page (aggregated)** | When browsing gear, clicking an item should show consolidated info: specs, who owns it, ratings. Standard on any product platform. | HIGH | Aggregated view combining: manufacturer specs from global DB, owner count, setup appearances, average ratings, crowd-reported weights. This is the integration hub for all platform features. | +| **Structured reviews (ratings)** | Any product-oriented community needs evaluation. Users expect to rate gear and see what others think. | MEDIUM | Overall 1-5 star rating plus 3-5 dimension ratings (varies by product category). Attached to global items, not personal items. One review per user per global item. No freeform text per project constraint. | +| **Discovery browse page** | Users expect a way to find interesting setups and gear beyond their own collection. Without this, multi-user adds no value. | MEDIUM | Not algorithmic for v2.0. Three sections: recent public setups, recently reviewed items, popular gear (most owned). Simple sorted lists with pagination. | +| **Search global items** | Must be able to find products by name/brand in the global database. Powers linking, browsing, and review discovery. | MEDIUM | Full-text search on name, brand, category. Used in "link my item" flow, discovery browsing, and review lookup. Postgres full-text search or trigram index. | ### Differentiators (Competitive Advantage) -Features not found in LighterPack, GearGrams, or any other gear app. Directly serve the -"decide between candidates" workflow that is unique to GearBox. +Features that set GearBox apart from LighterPack, GearGrams, Trailspace, and MyGear. Aligned with core value: "help people make better gear decisions." | Feature | Value Proposition | Complexity | Notes | |---------|-------------------|------------|-------| -| Drag-to-rank ordering | Makes priority explicit without a numeric input. Ranking communicates "this is my current top pick." Maps to how users mentally stack-rank options during research. No competitor has this in the gear domain. | MEDIUM | `@dnd-kit/sortable` is the current standard (actively maintained; `react-beautiful-dnd` is abandoned as of 2025). Requires new `rank` integer column on `threadCandidates`. Persist order via PATCH endpoint. | -| Per-candidate pros/cons fields | Freeform text capturing the reasoning behind ranking. LighterPack and GearGrams have notes per item but no structured decision rationale. Differentiates GearBox as a decision tool, not just a list tracker. | LOW | Two textarea fields per candidate. New `pros` and `cons` text columns on `threadCandidates`. Visible in comparison view rows and candidate edit panel. | -| Impact preview with category-matched delta | Setup items have a category. The most meaningful delta is weight saved within the same category (e.g., comparing sleeping pads, subtract current sleeping pad weight from setup total). More actionable than comparing against the entire setup total. | MEDIUM | Use `candidate.categoryId` to find matching setup items and compute delta. Edge case: no item of that category in the setup → show "not in setup." Data already available from `getSetupWithItems`. | +| **Crowd-verified specs** | LighterPack trusts user-entered data blindly. GearBox can show "manufacturer says 450g, 12 owners measured avg 478g." Real-world weight verification is unique and high-value for weight-conscious users. | MEDIUM | Aggregate weightGrams from all user items linked to a global item. Compare against manufacturer spec. Display on item detail page. Needs sufficient linked items to be meaningful (threshold: 3+ owners). | +| **Review dimensions per product category** | Trailspace and OutdoorGearLab use editorial ratings with fixed dimensions. GearBox crowd-sources structured ratings with category-specific dimensions: a tent gets "weather protection, ventilation, setup ease" while a stove gets "boil time, fuel efficiency, packability." More relevant than one-size-fits-all. | MEDIUM | Define 3-5 rating dimensions per product category via admin config. Store dimension ratings alongside overall rating. Display as radar chart or bar chart on item detail page. | +| **"X people own this" social proof** | Shows popularity and real adoption. No gear tracker does this because they lack a global item database. Simple count, powerful signal. | LOW | Count of users who linked a collection item to this global item. Displayed prominently on item detail page and in search results. Zero implementation complexity once linking exists. | +| **Setup composition insights** | "This item appears in 47 bikepacking setups, commonly paired with Y and Z." Cross-setup analysis no competitor offers. Answers "what do people use this with?" | MEDIUM | Query across all public setups containing a given global item. Show co-occurrence patterns. Powerful but can be deferred to v2.x if query performance is a concern. | +| **Setup impact preview with global items** | Already built for personal items. Extending to global items lets users preview "adding this from the store to my setup changes weight by X." Bridges research and collection management. | LOW | Already exists for personal items. Add "preview in my setup" button on global item detail pages. Reuse existing impact preview logic. | +| **Planning threads with global item integration** | Research threads that pull in specs, reviews, and owner data from the global DB. Candidates link to global items for richer comparison than manual data entry. | MEDIUM | Add optional globalItemId to thread candidates. Auto-populate weight, price, image from global item. Show community ratings and owner count inline on candidates. | +| **Real-world weight distribution** | Histogram showing "owners report weights between 440g-490g" for a product. Beats a single manufacturer number. Valuable for ultralight community. | LOW | Aggregate weightGrams from all linked items. Display min/max/avg. Histogram if 10+ data points. | +| **Copy/fork public setups** | Use someone else's setup as a starting template. LighterPack has clunky CSV-based copying. One-click fork is much better UX. | LOW | Create new setup copying all items from a public setup. Items must exist in user's collection (or be linked to same global items). Clear UX for "items you do not own yet." | ### Anti-Features (Commonly Requested, Often Problematic) | Feature | Why Requested | Why Problematic | Alternative | |---------|---------------|-----------------|-------------| -| Custom comparison attributes | "I want to compare battery life, durability, color..." | PROJECT.md explicitly rejects this as a complexity trap. Custom attributes require schema generalization, dynamic rendering, and data entry friction for every candidate. | Notes field and pros/cons fields cover the remaining use cases. | -| Score/rating calculation | Automatically rank candidates by computed score | Score algorithms require encoding the user's weight-vs-price preference — personalization complexity. Users distrust opaque scores. | Manual drag-to-rank expresses the user's own weighting without encoding it in an algorithm. | -| Side-by-side comparison across threads | Compare candidates from different research threads | Candidates belong to different purchase decisions — mixing them is conceptually incoherent. Different categories are never apples-to-apples. | Thread remains the scope boundary. Cross-thread planning is what setups are for. | -| Comparison permalink/share | Share a comparison view URL | GearBox is single-user, no auth for v1. Sharing requires auth, user management, public/private visibility. | Out of scope for v1 per PROJECT.md. Future feature. | -| Classification-aware impact preview as MVP requirement | Show delta broken down by base/worn/consumable | While data is available, the classification breakdown adds significant UI complexity. The flat delta answers "will this make my setup lighter?" which is 90% of the use case. | Flat delta for MVP. Classification-aware breakdown as a follow-up enhancement (P2). | +| **Freeform text reviews** | Users want to explain their experience in detail | Requires moderation, spam filtering, content policy, reporting infrastructure. PROJECT.md explicitly defers until moderation exists. | Structured ratings with predefined dimensions. Short predefined tags for pros/cons (e.g., "lightweight", "durable", "runs small"). | +| **Comments on setups** | Social engagement, questions about gear choices | Moderation burden, notification system, spam, harassment risk. Deferred in PROJECT.md. | Link to user profile. Contact happens outside platform. | +| **Follow users / activity feed** | Social graph, staying updated on people | Turns a gear tool into a social network. Notification infrastructure, feed ranking, engagement metrics, retention loops. Project decision: discovery-first, not social-first. | Discovery feed shows popular/recent content without requiring social connections. | +| **Marketplace / buy-sell** | Users want to trade used gear | Payment processing, fraud prevention, disputes, shipping logistics, tax compliance. Massive liability. | Link to product URLs on global items. Users buy through retailers. | +| **AI gear recommendations** | "What tent should I buy for bikepacking?" | Training data requirements, bias, liability for bad recommendations, hallucination risk. | Global item pages with ratings, owner counts, and setup co-occurrence do implicit recommendation. "People who own X also own Y." | +| **Wiki-style open item editing** | Community wants to correct/enrich global item specs | Edit wars, vandalism, quality degradation, dispute resolution. PROJECT.md explicitly rules this out. | Structured contributions only: report measured weight, submit rating. Admin approval for spec corrections. Trusted contributor program later. | +| **Price tracking / deal alerts** | Users want to know when gear goes on sale | Requires scraping retailer sites, fragile, legal gray area, maintenance burden. PROJECT.md rules this out. | Store product URL so users can check prices manually. | +| **Real-time collaborative setups** | "Plan a group trip together" | WebSocket infrastructure, conflict resolution, permissions model, presence indicators. Massive complexity for niche use case. | Each user builds their own setup. Fork public setups as templates. | +| **Gamification (badges, points, levels)** | Drive engagement and contributions | Incentivizes quantity over quality. Users game systems for points rather than providing genuine data. Creates toxic dynamics. | Soft social proof: "contributed X reviews" on profile. No points, no leaderboards. | +| **Instagram-style infinite scroll feed** | Addictive browsing experience | Engagement-maximizing design conflicts with utility-focused tool. Users come to research decisions, not scroll endlessly. | Paginated, filterable discovery page. Browse with intent, not addiction. | --- ## Feature Dependencies ``` -[Side-by-side comparison view] - └──requires──> [All candidate fields visible in UI] - (weightGrams, priceCents, notes, productUrl already in schema) - └──enhances──> [Pros/cons fields] (displayed as comparison rows) - └──enhances──> [Drag-to-rank] (rank number shown as position in comparison columns) - └──enhances──> [Impact preview] (delta displayed per-column inline) - -[Impact preview (weight + cost delta)] - └──requires──> [Setup selector] (user picks which setup to compute delta against) - └──requires──> [Setup data client-side] (useSetup hook already exists, no new API) - └──requires──> [Candidate weight/price data] (already in threadCandidates schema) - -[Setup selector] - └──requires──> [useSetups() hook] (already exists in src/client/hooks/useSetups.ts) - └──requires──> [useSetup(id) hook] (already exists, loads items with classification) - -[Drag-to-rank] - └──requires──> [rank INTEGER column on threadCandidates] (new — schema migration) - └──requires──> [PATCH /api/threads/:id/candidates/rank endpoint] (new API endpoint) - └──enhances──> [Side-by-side comparison] (rank visible as position indicator) - └──enhances──> [Card grid view] (rank badge on each CandidateCard) - -[Pros/cons fields] - └──requires──> [pros TEXT column on threadCandidates] (new — schema migration) - └──requires──> [cons TEXT column on threadCandidates] (new — schema migration) - └──requires──> [updateCandidateSchema extended] (add pros/cons to Zod schema) - └──enhances──> [CandidateForm edit panel] (new textarea fields) - └──enhances──> [Side-by-side comparison] (pros/cons rows in comparison table) +[External Auth Provider] + | + v +[Multi-User Data Model (userId FK on all entities)] + | + +---> [Postgres Migration] (concurrent access, auth provider needs Postgres) + | + +---> [User Profiles (public)] + | | + | +---> [Public Profile Pages] + | | | + | | +---> [Discovery Feed (browse users' public content)] + | | + | +---> [Setup Visibility Controls (public/private)] + | | + | +---> [Public Setup Detail Pages] + | | + | +---> [Copy/Fork Public Setups] + | + +---> [Global Item Database] + | + +---> [Search Global Items] + | + +---> [Link Personal Items to Global Items] + | | + | +---> [Owner Count ("X people own this")] + | | + | +---> [Crowd-Verified Specs (aggregated weight)] + | | + | +---> [Setup Appearances Count] + | | + | +---> [Real-World Weight Distribution] + | + +---> [Structured Reviews] + | | + | +---> [Review Dimensions per Category] + | | + | +---> [Average Ratings Display] + | + +---> [Item Detail Pages (aggregated hub)] + | | + | +---> [Setup Composition Insights] + | + +---> [Planning Thread Global Item Integration] + | + +---> [Candidate Auto-populate from Global DB] ``` ### Dependency Notes -- **Side-by-side comparison is independent of schema changes.** It can be built using - existing candidate data. No migrations required. Delivers value immediately. -- **Impact preview is independent of schema changes.** Uses existing `useSetups` and - `useSetup` hooks client-side. Delta computation is pure math in the component. - No new API endpoint needed for MVP. -- **Drag-to-rank requires schema migration.** `rank` column must be added to - `threadCandidates`. Default ordering on migration = `createdAt` ascending. -- **Pros/cons requires schema migration.** Two nullable `text` columns on - `threadCandidates`. Low risk — nullable, backwards compatible. -- **Comparison view enhances everything.** Best delivered after rank and pros/cons - schema work is done so the full table is useful from day one. +- **Multi-user data model is the absolute foundation.** Every feature depends on userId ownership. Items, setups, threads, categories, reviews -- all need user scoping. This is the biggest single migration. +- **Postgres migration is coupled with auth.** The external auth provider (Authentik, Keycloak) needs Postgres. Migrating the app DB at the same time avoids running two databases. Do these together. +- **Global item database is the second foundation.** Reviews, item detail pages, owner counts, crowd-verified specs, and planning thread integration all depend on canonical global item records. Without this, multi-user is just "LighterPack with accounts." +- **Structured reviews require global items.** Reviews attach to global items, not personal collection items. Otherwise reviews fragment across duplicate user-entered items with no way to aggregate. +- **Item detail pages are the integration point.** They combine global item specs, aggregated user data, reviews, owner count, and setup appearances. Should be built after all data sources exist. +- **Discovery feed requires profiles + public content.** Cannot browse without user identity and visibility controls producing public content to show. +- **Linking is the bridge.** Personal items link to global items. This single FK enables owner count, crowd-verified specs, weight distribution, and setup appearances. Prioritize this flow. --- ## MVP Definition -### Launch With (v1.3 milestone) +### Launch With (v2.0 Platform Foundation) -- [ ] **Side-by-side comparison view** — Core deliverable. Replace mental juggling of the card - grid with a scannable table. No schema changes. Highest ROI, lowest risk. -- [ ] **Impact preview: flat weight + cost delta per candidate** — Shows `+/- X g` and - `+/- $Y` vs. the selected setup. Pure client-side math. No schema changes. -- [ ] **Setup selector** — Dropdown of user's setups. Required for impact preview. One - interaction: pick a setup, see deltas update. -- [ ] **Drag-to-rank** — Requires `rank` column migration. `@dnd-kit/sortable` handles - the drag UX. Persist via new PATCH endpoint. -- [ ] **Pros/cons text fields** — Requires `pros` + `cons` column migration. Trivially low - implementation complexity once schema is in place. +- [ ] **External auth provider integration** -- Nothing works without multi-user identity +- [ ] **Postgres migration** -- Required for concurrent access; auth provider dependency +- [ ] **Multi-user data model** -- userId on items, setups, threads, categories; data isolation +- [ ] **User profiles (minimal)** -- Display name, avatar, bio; public profile page +- [ ] **Setup visibility controls** -- Public/private toggle, default private +- [ ] **Public setup detail pages** -- Shareable read-only view with attribution +- [ ] **Global item database with seed data** -- Schema, admin seeding, search +- [ ] **Link personal items to global items** -- Association flow in collection UI +- [ ] **Structured reviews** -- Overall rating + dimension ratings on global items +- [ ] **Item detail pages** -- Aggregated specs, owner count, average ratings +- [ ] **Discovery browse page** -- Recent public setups, recently reviewed, popular items -### Add After Validation (v1.x) +### Add After Validation (v2.x) -- [ ] **Classification-aware impact preview** — Delta broken down by base/worn/consumable. - Higher complexity UI. Add once flat delta is validated as useful. - Trigger: user feedback requests "which classification does this affect?" -- [ ] **Rank indicator on card grid** — Small "1st", "2nd" badge on CandidateCard. - Trigger: users express confusion about which candidate is ranked first without entering - comparison view. -- [ ] **Comparison view on mobile** — Horizontal scroll works but is not ideal. Consider - attribute-focus swipe view. Trigger: usage data shows mobile traffic on thread pages. +- [ ] **Crowd-verified specs display** -- "Manufacturer: 450g, Community avg: 478g" (needs 3+ owners per item to be meaningful) +- [ ] **Setup composition insights** -- "Commonly paired with" co-occurrence analysis +- [ ] **Planning thread global item integration** -- Candidates auto-populate from global DB +- [ ] **Popular gear rankings by category** -- Most owned, highest rated per category +- [ ] **Copy/fork public setups** -- One-click template from public setups +- [ ] **Review dimension customization** -- Admin configures rating dimensions per product category +- [ ] **Real-world weight distribution** -- Histogram on item detail pages +- [ ] **Global item suggestion workflow** -- Users propose new items for admin review -### Future Consideration (v2+) +### Future Consideration (v3+) -- [ ] **Comparison permalink** — Requires auth/multi-user work first. -- [ ] **Auto-fill from product URL** — Fragile scraping, rejected in PROJECT.md. -- [ ] **Custom comparison attributes** — Explicitly rejected in PROJECT.md. +- [ ] **Freeform reviews with moderation** -- After moderation infrastructure exists +- [ ] **Comments on setups** -- After moderation infrastructure exists +- [ ] **Follow users / activity feed** -- After discovery model is validated +- [ ] **OAuth / social login** -- After external auth provider is stable +- [ ] **Trusted contributor program** -- Verified users can edit global item specs --- @@ -143,120 +181,122 @@ Features not found in LighterPack, GearGrams, or any other gear app. Directly se | Feature | User Value | Implementation Cost | Priority | |---------|------------|---------------------|----------| -| Side-by-side comparison view | HIGH | MEDIUM | P1 | -| Setup impact preview (flat delta) | HIGH | LOW | P1 | -| Setup selector for impact preview | HIGH | LOW | P1 | -| Drag-to-rank ordering | MEDIUM | MEDIUM | P1 | -| Pros/cons text fields | MEDIUM | LOW | P1 | -| Classification-aware impact preview | MEDIUM | HIGH | P2 | -| Rank indicator on card grid | LOW | LOW | P2 | -| Mobile-optimized comparison view | LOW | MEDIUM | P3 | +| External auth provider | HIGH | HIGH | P1 | +| Postgres migration | HIGH | HIGH | P1 | +| Multi-user data model (userId on entities) | HIGH | HIGH | P1 | +| User profiles (basic) | HIGH | LOW | P1 | +| Setup visibility controls | HIGH | LOW | P1 | +| Public setup detail pages | HIGH | MEDIUM | P1 | +| Global item database (schema + seed) | HIGH | HIGH | P1 | +| Link personal items to global items | HIGH | MEDIUM | P1 | +| Search global items | HIGH | MEDIUM | P1 | +| Structured reviews | HIGH | MEDIUM | P1 | +| Item detail pages (aggregated) | HIGH | HIGH | P1 | +| Discovery browse page | MEDIUM | MEDIUM | P1 | +| Crowd-verified specs | HIGH | LOW | P2 | +| Setup composition insights | MEDIUM | MEDIUM | P2 | +| Planning thread global DB integration | MEDIUM | MEDIUM | P2 | +| Copy/fork public setups | MEDIUM | LOW | P2 | +| Popular gear rankings | MEDIUM | LOW | P2 | +| Freeform reviews + moderation | MEDIUM | HIGH | P3 | +| Follow users | LOW | MEDIUM | P3 | +| Setup comments | LOW | MEDIUM | P3 | **Priority key:** -- P1: Must have for this milestone launch -- P2: Should have, add when possible -- P3: Nice to have, future consideration +- P1: Must have for v2.0 platform launch +- P2: Should have, add in v2.x once core is validated +- P3: Future consideration, requires new infrastructure (moderation, notifications) --- ## Competitor Feature Analysis -| Feature | LighterPack | GearGrams | OutPack | Our Approach | -|---------|-------------|-----------|---------|--------------| -| Side-by-side candidate comparison | None (list only) | None (library + trip list) | None | Inline comparison table on thread detail page, toggle from grid view | -| Impact preview / weight delta | None (duplicate lists manually to compare) | None (no delta concept) | None | Per-candidate delta vs. selected setup, computed client-side | -| Candidate ranking | None | None | None | Drag-to-rank with persisted `rank` column | -| Pros/cons annotation | None (notes field only) | None (notes field only) | None | Dedicated `pros` and `cons` fields separate from general notes | -| Status tracking | None | "wish list" item flag only | None | Already built in v1.2 (researching/ordered/arrived) | +| Feature | LighterPack | GearGrams | Trailspace | MyGear | GearBox v2.0 | +|---------|-------------|-----------|------------|--------|-------------| +| Gear lists/setups | Yes, drag-and-drop | Yes, trip-based | No (review only) | Yes, "Locker" | Yes, named setups with classification | +| Weight tracking | Base/worn/consumable | Carried/worn/consumable | No | Basic | Base/worn/consumable + unit conversion + donut charts | +| User profiles | Minimal (no bio) | Minimal | Review history page | Full social profile | Display name, avatar, bio, public setups | +| Sharing | Public link, embed code | Public link | N/A | Social feed posts | Public/private toggle, shareable URLs | +| Global item database | No (all user-entered) | No | Yes (editorial catalog) | No | Yes, seeded + crowd-enriched with verified specs | +| Structured reviews | No | No | Yes (summary/pros/cons + rating) | Basic star rating | Dimension ratings per product category | +| Item aggregation | No | No | Editorial scores only | No | Owner count, avg weight, setup appearances, crowd specs | +| Discovery/browse | No | No | Browse by category | AI-tagged social feed | Browse setups, items, popular gear (intent-driven, not feed) | +| Purchase research | No | No | Price comparison links | No | Planning threads with candidates, ranking, impact preview | +| Crowd-verified specs | No | No | No | No | Manufacturer vs. community-measured weight comparison | +| Mobile app | No | Yes (iOS/Android) | No | Yes (iOS/Android) | No (responsive web, per project constraint) | -**Key insight:** No existing gear management tool has a comparison view, delta preview, or -ranking system for candidates within a research thread. This is an unmet-need gap. -The features are adapted from general product comparison UX (e-commerce) to the gear domain. +### Competitive Positioning + +GearBox occupies a unique niche: the only platform combining **gear management** (LighterPack's strength), **structured community reviews** (Trailspace's strength), and **crowd-verified specs** (nobody does this). The planning threads feature has no direct competitor equivalent in the gear domain. + +**Key advantages over each competitor:** +- **vs. LighterPack:** Global item database eliminates manual spec entry. Multi-user with profiles and sharing. Structured reviews provide community intelligence. +- **vs. GearGrams:** Richer comparison tools (planning threads). Crowd-verified specs. Item detail pages with aggregated data. +- **vs. Trailspace:** Not just reviews -- full gear management and setup composition. Users own and track their gear, not just review it. Crowd ratings, not editorial-only. +- **vs. MyGear:** Not social-first (no engagement loops, no AI tagging gimmicks). Utility-focused: research decisions, verify specs, compare options. Hobby-agnostic data model. + +**Accepted gaps:** +- No mobile native app (web-first, responsive design sufficient per project constraints) +- No social feed in the Instagram sense (intentional: discovery-first, not social-first) +- No freeform text content (intentional: structured input only until moderation exists) --- -## Implementation Notes by Feature +## Implementation Notes for Key Features -### Side-by-side Comparison View +### Global Item Database Schema -- Rendered as a transposed table: rows = attribute labels, columns = candidates. -- Rows: Image (thumbnail), Name, Weight, Price, Status, Notes, Link, Pros, Cons, Rank, Impact Delta (weight), Impact Delta (cost). -- Sticky first column (attribute label) while candidate columns scroll horizontally for 3+. -- Candidate images at reduced aspect ratio (square thumbnail ~80px). -- Weight/price cells use existing `formatWeight` / `formatPrice` formatters with the user's preferred unit. -- Status cell reuses existing `StatusBadge` component. -- "Pick as winner" action available per column (reuses existing `openResolveDialog`). -- Toggle between grid view (current) and table view. Preserve both modes. Default to grid; - user activates comparison mode explicitly. -- Comparison mode is a UI state only (Zustand or local component state) — no URL change needed. +The global item table is distinct from user items. It represents canonical products: -### Impact Preview +- `globalItems`: id, brand, model, name (display), categoryId, manufacturerWeightGrams, manufacturerPriceCents, productUrl, imageFilename, description, createdAt, updatedAt, createdByUserId +- User items get optional `globalItemId` FK for linking +- Admin-seeded initially; later users can suggest additions via a proposal workflow -- Setup selector: `