# Phase 18: Global Items & Public Profiles - Research **Researched:** 2026-04-04 **Domain:** Full-stack feature: new database tables, services, routes, and client pages for global item catalog and user profiles **Confidence:** HIGH ## Summary Phase 18 adds two interconnected features: (1) a global item catalog that all users share, searchable by brand/model, with owner count derived from user item links; and (2) user profiles with display name, avatar, and bio, plus setup visibility toggling. Both features follow the existing service/route/hook/page pattern established across the codebase. The codebase already uses PostgreSQL via Drizzle ORM (`drizzle-orm/pg-core`), so ILIKE search, boolean columns, and junction tables are native operations. The image upload and presigned URL infrastructure (MinIO/S3) from Phase 17 is ready for avatar uploads. TanStack Router file-based routing means new pages just need new route files in `src/client/routes/`. **Primary recommendation:** Follow the existing CRUD pattern exactly (schema -> service -> route -> Zod schema -> hook -> page). Global items need a new service file; profiles extend the existing auth service and users table. Public endpoints bypass the `requireAuth` middleware by registering routes before or outside the `/api/*` auth middleware, or by adding path-specific skips. ## User Constraints (from CONTEXT.md) ### Locked Decisions - D-01: Create `globalItems` table: `id` (serial), `brand` (text, not null), `model` (text, not null), `category` (text), `weightGrams` (double precision), `priceCents` (integer), `imageUrl` (text), `description` (text), `createdAt` (timestamp). Separate from user items table. - D-02: Create `itemGlobalLinks` junction table: `itemId` (FK -> items), `globalItemId` (FK -> globalItems). A user item can optionally link to one global item. - D-03: Global items are not user-owned -- they're shared catalog entries. No userId column. - D-04: Global item search: full-text search on brand + model via `ILIKE` (simple, sufficient for initial catalog size). - D-05: Global item page shows: brand, model, category, specs (weight/price), image, description, and owner count (count of linked user items). - D-06: JSON seed file (`src/db/global-items-seed.json`) with curated initial catalog. Migration script imports on first run. - D-07: Seed covers common bikepacking gear categories as a starting point. Can be expanded later. - D-08: Extend `users` table with: `displayName` (text), `avatarUrl` (text), `bio` (text). All nullable -- profile is optional. - D-09: Profile edit page at `/settings/profile` or within existing settings page. - D-10: Public profile page at `/users/:id` -- shows display name, avatar, bio, and public setups. No auth required. - D-11: Avatar upload uses existing image upload + MinIO storage (from Phase 17). - D-12: Add `isPublic` boolean column to `setups` table, default `false`. All existing setups remain private. - D-13: Public setups are viewable at `/setups/:id/public` (or similar) without authentication. - D-14: Setup toggle UI in setup edit/detail view -- simple switch/checkbox. - D-15: Public profile page lists only the user's public setups. - D-16: `GET /api/global-items` -- search/list global catalog (public, no auth needed) - D-17: `GET /api/global-items/:id` -- global item detail with owner count (public) - D-18: `POST /api/items/:id/link` -- link a personal item to a global item (auth required) - D-19: `DELETE /api/items/:id/link` -- unlink (auth required) - D-20: `GET /api/users/:id/profile` -- public profile data - D-21: `PUT /api/auth/profile` -- update own profile (auth required) - D-22: `GET /api/setups/:id/public` -- public setup view (no auth) ### Claude's Discretion - Exact seed data content and quantity - Global item search implementation details (ILIKE vs tsvector) - Profile page layout and component structure - Public setup URL scheme - Whether to add a "link to global item" button in item edit form or a separate flow - Avatar upload integration with existing ImageUpload component - MCP tool additions for global items ### Deferred Ideas (OUT OF SCOPE) - Freeform reviews/ratings (requires moderation -- future milestone) - Follow users / activity feeds (social features -- future milestone) - Comments on setups (moderation needed -- future milestone) - Fork/copy public setups as templates (future feature) ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | GLOB-01 | A global item catalog exists with brand, model, category, manufacturer specs, and image | New `globalItems` table in schema.ts, new global-item.service.ts, seed JSON file | | GLOB-02 | Global catalog is seeded with initial items from manufacturer data | JSON seed file + migration/seed script that imports on first run | | GLOB-03 | User can search the global catalog by name or brand | `ilike` operator from drizzle-orm on brand/model columns | | GLOB-04 | User can link a personal collection item to a global catalog entry | `itemGlobalLinks` junction table, link/unlink endpoints on item routes | | GLOB-05 | Global item pages show basic info and owner count | SQL COUNT on itemGlobalLinks joined to globalItems | | PROF-01 | User has a profile with display name, avatar, and bio | Add nullable columns to `users` table, profile update endpoint | | PROF-02 | User can view their own public profile page | Public profile route at `/users/$userId`, fetches from `/api/users/:id/profile` | | PROF-03 | User can set a setup as public or private | `isPublic` boolean column on setups, toggle in setup detail view | | PROF-04 | Public setups are viewable by anyone without authentication | Public setup endpoint that skips auth middleware | | PROF-05 | Public profile page lists the user's public setups | Profile endpoint joins setups where isPublic=true and userId matches | ## Project Constraints (from CLAUDE.md) - **Stack**: React 19 + Hono + Drizzle ORM + PostgreSQL, running on Bun - **Routing**: TanStack Router file-based routes -- never edit `routeTree.gen.ts` manually - **Data fetching**: TanStack React Query via custom hooks in `src/client/hooks/` - **Validation**: Zod schemas in `src/shared/schemas.ts` (source of truth for types) - **Types**: Inferred from Zod schemas + Drizzle table definitions in `src/shared/types.ts` -- no manual type duplication - **Services**: Pure business logic, take db instance, no HTTP awareness - **Prices as cents**: `priceCents: integer` - **Styling**: Tailwind CSS v4 - **Lint**: Biome (tabs, double quotes, organized imports) - **Testing**: Bun test runner, PGlite for in-memory test databases - **Images**: MinIO/S3 storage with presigned URLs, URL enrichment at route level not service level - **Auth**: Public-read, authenticated-write. `requireAuth` middleware on `/api/*` - **Branching**: Feature branch off Develop, merge via PR ## Standard Stack ### Core (already in project) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | drizzle-orm | ^0.45.1 | ORM for schema, queries, migrations | Already used throughout | | drizzle-kit | ^0.31.9 | Migration generation | Already used | | hono | ^4.12.8 | HTTP routes + middleware | Already used | | @hono/zod-validator | (installed) | Request validation | Already used | | zod | ^4.3.6 | Schema validation | Already used | | @tanstack/react-query | ^5.90.21 | Server state management | Already used | | @tanstack/react-router | ^1.167.0 | File-based client routing | Already used | | @aws-sdk/client-s3 | (installed) | Image upload to MinIO | Already used for image storage | ### No New Dependencies Required This phase uses only existing libraries. No new packages needed. ## Architecture Patterns ### New Files to Create ``` src/ ├── db/ │ ├── schema.ts # MODIFY: add globalItems, itemGlobalLinks, users profile cols, setups isPublic │ └── global-items-seed.json # NEW: seed data for global catalog ├── server/ │ ├── services/ │ │ ├── global-item.service.ts # NEW: global item CRUD + search + owner count │ │ └── profile.service.ts # NEW: profile CRUD + public profile data │ ├── routes/ │ │ ├── global-items.ts # NEW: /api/global-items routes │ │ └── profiles.ts # NEW: /api/users/:id/profile + public setup routes │ └── index.ts # MODIFY: register new routes ├── shared/ │ ├── schemas.ts # MODIFY: add global item + profile + setup visibility schemas │ └── types.ts # MODIFY: add new types ├── client/ │ ├── hooks/ │ │ ├── useGlobalItems.ts # NEW: global item queries │ │ └── useProfile.ts # NEW: profile queries + mutations │ ├── routes/ │ │ ├── global-items/ │ │ │ ├── index.tsx # NEW: global catalog search/browse page │ │ │ └── $globalItemId.tsx # NEW: global item detail page │ │ └── users/ │ │ └── $userId.tsx # NEW: public profile page │ └── components/ │ └── (new components as needed) # Profile card, global item card, etc. tests/ ├── services/ │ ├── global-item.service.test.ts # NEW │ └── profile.service.test.ts # NEW ├── routes/ │ ├── global-items.test.ts # NEW │ └── profiles.test.ts # NEW ``` ### Pattern 1: Schema Additions (Drizzle pg-core) **What:** New tables and column additions using existing Drizzle patterns. **When to use:** All schema changes in this phase. ```typescript // In src/db/schema.ts -- add boolean import import { boolean, // NEW for this phase doublePrecision, integer, pgTable, primaryKey, serial, text, timestamp, unique, } from "drizzle-orm/pg-core"; // Global Items table (no userId -- shared catalog) export const globalItems = pgTable("global_items", { id: serial("id").primaryKey(), brand: text("brand").notNull(), model: text("model").notNull(), category: text("category"), weightGrams: doublePrecision("weight_grams"), priceCents: integer("price_cents"), imageUrl: text("image_url"), description: text("description"), createdAt: timestamp("created_at").defaultNow().notNull(), }); // Junction table: user item <-> global item (1:1 from item side) export const itemGlobalLinks = pgTable("item_global_links", { id: serial("id").primaryKey(), itemId: integer("item_id") .notNull() .references(() => items.id, { onDelete: "cascade" }) .unique(), // Each user item links to at most one global item globalItemId: integer("global_item_id") .notNull() .references(() => globalItems.id, { onDelete: "cascade" }), }); // Extend users table -- add profile columns: // displayName: text("display_name"), // avatarUrl: text("avatar_url"), // bio: text("bio"), // Extend setups table -- add visibility: // isPublic: boolean("is_public").notNull().default(false), ``` ### Pattern 2: ILIKE Search in Drizzle **What:** PostgreSQL case-insensitive pattern matching for global item search. **When to use:** `GET /api/global-items?q=search_term` ```typescript import { ilike, or, sql } from "drizzle-orm"; export async function searchGlobalItems(db: Db, query?: string) { const baseQuery = db.select().from(globalItems); if (query) { const pattern = `%${query}%`; return baseQuery.where( or( ilike(globalItems.brand, pattern), ilike(globalItems.model, pattern), ) ); } return baseQuery; } ``` ### Pattern 3: Public Routes (No Auth Required) **What:** Some endpoints in this phase must work without authentication. The current middleware applies `requireAuth` to ALL `/api/*` routes except `/api/auth/*`. **When to use:** Global item GET endpoints, public profile, public setup view. Two approaches: 1. **Add path skips in the auth middleware** (recommended -- minimal change): ```typescript // In src/server/index.ts auth middleware app.use("/api/*", async (c, next) => { if (c.req.path.startsWith("/api/auth")) return next(); if (c.req.path === "/api/health") return next(); // NEW: skip auth for public-read endpoints if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next(); if (c.req.path.match(/^\/api\/users\/\d+\/profile$/) && c.req.method === "GET") return next(); if (c.req.path.match(/^\/api\/setups\/\d+\/public$/) && c.req.method === "GET") return next(); return requireAuth(c, next); }); ``` 2. **Register public routes before the auth middleware** (cleaner but requires route restructuring). Recommendation: Use approach 1 -- add specific GET-method skips. It's consistent with the existing `/api/auth` and `/api/health` skip pattern. **Important:** The `userId` will be undefined for unauthenticated requests. Public service functions must NOT require userId. ### Pattern 4: Owner Count via SQL **What:** Count how many user items link to a global item. **When to use:** Global item detail page (GLOB-05). ```typescript import { count, eq } from "drizzle-orm"; export async function getGlobalItemWithOwnerCount(db: Db, id: number) { const [item] = await db.select().from(globalItems).where(eq(globalItems.id, id)); if (!item) return null; const [{ ownerCount }] = await db .select({ ownerCount: count() }) .from(itemGlobalLinks) .where(eq(itemGlobalLinks.globalItemId, id)); return { ...item, ownerCount }; } ``` ### Pattern 5: Seed Script for Global Items **What:** Import JSON seed data into globalItems table. **When to use:** First-run or migration (GLOB-02). ```typescript // src/db/seed-global-items.ts import seedData from "./global-items-seed.json"; import { globalItems } from "./schema.ts"; export async function seedGlobalItems(db: Db) { const existing = await db.select({ id: globalItems.id }).from(globalItems).limit(1); if (existing.length > 0) return; // Already seeded await db.insert(globalItems).values(seedData); } ``` The JSON seed file should contain an array of objects matching the globalItems schema (without `id` and `createdAt`). ### Anti-Patterns to Avoid - **Don't add userId to globalItems:** These are shared catalog entries, not user-owned data (D-03). - **Don't use full-text search (tsvector):** ILIKE is sufficient for the initial catalog size (D-04). tsvector adds complexity for minimal benefit at this scale. - **Don't enrich image URLs in services:** Follow the Phase 17 pattern -- URL enrichment happens at the route level, keeping services storage-agnostic. - **Don't duplicate types:** Infer from Zod schemas and Drizzle table definitions per project convention. - **Don't make existing setups public by default:** D-12 says default `false`, all existing setups remain private. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | ILIKE search | Custom string matching | `ilike()` from drizzle-orm | Built-in, SQL-injection safe, handles escaping | | Image presigned URLs | Custom URL signing | `withImageUrl`/`withImageUrls` from storage.service.ts | Already built in Phase 17 | | File upload handling | Custom multipart parser | Existing `POST /api/images` endpoint + ImageUpload component | Avatar upload reuses existing infrastructure | | Route parameter validation | Manual parseInt | `parseId()` from `src/server/lib/params.ts` | Already handles NaN, negatives | | Query invalidation | Manual cache management | TanStack React Query `invalidateQueries` | Standard pattern across all hooks | ## Common Pitfalls ### Pitfall 1: Auth Middleware Blocking Public Endpoints **What goes wrong:** New GET endpoints for global items, public profiles, and public setups return 401 because they go through `requireAuth`. **Why it happens:** The auth middleware in `index.ts` applies to ALL `/api/*` routes. **How to avoid:** Add explicit path/method checks before `requireAuth` call. Test unauthenticated access in route tests. **Warning signs:** Public pages showing "Authentication required" errors. ### Pitfall 2: userId Undefined on Public Routes **What goes wrong:** Service functions try to use `userId` from context on public endpoints, causing crashes. **Why it happens:** Public endpoints skip auth, so `c.get("userId")` is undefined. **How to avoid:** Public service functions must NOT take userId as required parameter. Use separate service functions for public data access. **Warning signs:** TypeError on undefined when accessing public pages. ### Pitfall 3: Missing Migration for Column Additions **What goes wrong:** Adding columns to existing tables (users, setups) without generating and applying a migration. **Why it happens:** Forgetting `bun run db:generate` after schema changes. **How to avoid:** Always run `bun run db:generate` after any schema.ts change, then `bun run db:push`. **Warning signs:** Column not found errors at runtime. ### Pitfall 4: Seed Data Idempotency **What goes wrong:** Global items get duplicated on every server restart. **Why it happens:** Seed script runs without checking if data already exists. **How to avoid:** Check for existing rows before inserting. Use a guard like `SELECT COUNT(*) FROM global_items`. **Warning signs:** Duplicate entries in global catalog. ### Pitfall 5: ILIKE SQL Injection via Wildcards **What goes wrong:** User search input containing `%` or `_` matches unintended rows. **Why it happens:** These are LIKE wildcards. A search for "100%" would match everything. **How to avoid:** Escape `%` and `_` in user input before wrapping in `%..%`. Replace `%` with `\%` and `_` with `\_`. **Warning signs:** Unexpected search results with special characters. ### Pitfall 6: TanStack Router Route Tree Not Regenerating **What goes wrong:** New route files exist but pages 404. **Why it happens:** The route tree auto-generation didn't run after adding new route files. **How to avoid:** Run `bun run dev:client` (or the Vite dev server) -- it watches for new route files. Or run the TanStack Router plugin manually. **Warning signs:** New routes return 404, `routeTree.gen.ts` doesn't include new routes. ### Pitfall 7: Boolean Column Default in Existing Rows **What goes wrong:** Migration adds `isPublic` column but existing rows have NULL instead of false. **Why it happens:** Adding a nullable boolean column without `.notNull().default(false)`. **How to avoid:** Define as `boolean("is_public").notNull().default(false)` -- Drizzle generates the migration with a DEFAULT clause that backfills existing rows. **Warning signs:** Existing setups show as `null` visibility instead of private. ## Code Examples ### Zod Schemas for New Features ```typescript // In src/shared/schemas.ts // Global item schemas export const searchGlobalItemsSchema = z.object({ q: z.string().optional(), }); export const linkItemSchema = z.object({ globalItemId: z.number().int().positive(), }); // Profile schemas export const updateProfileSchema = z.object({ displayName: z.string().max(100).optional(), avatarUrl: z.string().optional(), bio: z.string().max(500).optional(), }); // Setup visibility export const updateSetupSchema = z.object({ name: z.string().min(1, "Setup name is required"), isPublic: z.boolean().optional(), }); ``` ### Public Setup Service Function ```typescript // No userId required -- this is a public endpoint export async function getPublicSetupWithItems(db: Db, setupId: number) { const [setup] = await db .select() .from(setups) .where(and(eq(setups.id, setupId), eq(setups.isPublic, true))); if (!setup) return null; const itemList = await db .select({ id: items.id, name: items.name, weightGrams: items.weightGrams, priceCents: items.priceCents, quantity: items.quantity, categoryName: categories.name, categoryIcon: categories.icon, classification: setupItems.classification, }) .from(setupItems) .innerJoin(items, eq(setupItems.itemId, items.id)) .innerJoin(categories, eq(items.categoryId, categories.id)) .where(eq(setupItems.setupId, setupId)); return { ...setup, items: itemList }; } ``` ### Profile Query in Public Profile Route ```typescript // Public profile: user info + public setups export async function getPublicProfile(db: Db, userId: number) { const [user] = await db .select({ id: users.id, displayName: users.displayName, avatarUrl: users.avatarUrl, bio: users.bio, }) .from(users) .where(eq(users.id, userId)); if (!user) return null; const publicSetups = await db .select({ id: setups.id, name: setups.name, createdAt: setups.createdAt, }) .from(setups) .where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); return { ...user, setups: publicSetups }; } ``` ### Client Hook Pattern ```typescript // src/client/hooks/useGlobalItems.ts export function useGlobalItems(query?: string) { return useQuery({ queryKey: ["global-items", query], queryFn: () => { const params = query ? `?q=${encodeURIComponent(query)}` : ""; return apiGet(`/api/global-items${params}`); }, }); } export function useGlobalItem(id: number | null) { return useQuery({ queryKey: ["global-items", id], queryFn: () => apiGet(`/api/global-items/${id}`), enabled: id != null, }); } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | SQLite schema | PostgreSQL via drizzle-orm/pg-core | Phase 14 | boolean type natively available, ILIKE supported | | Local file images | MinIO/S3 presigned URLs | Phase 17 | Avatar upload uses existing infrastructure | | Single-user (no userId) | Multi-user with userId scoping | Phase 16 | All new endpoints need userId awareness | | Cookie sessions only | OIDC + API keys + OAuth | Phase 15 | Auth middleware already handles all auth methods | ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Bun test runner | | Config file | bunfig.toml (if exists) / none | | Quick run command | `bun test tests/services/global-item.service.test.ts` | | Full suite command | `bun test` | ### Phase Requirements -> Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | GLOB-01 | Global items table CRUD | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 | | GLOB-02 | Seed data imports correctly | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 | | GLOB-03 | Search by brand/model via ILIKE | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 | | GLOB-04 | Link/unlink item to global item | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 | | GLOB-05 | Owner count on global item detail | unit | `bun test tests/services/global-item.service.test.ts` | Wave 0 | | PROF-01 | Profile fields on users table | unit | `bun test tests/services/profile.service.test.ts` | Wave 0 | | PROF-02 | Public profile data endpoint | integration | `bun test tests/routes/profiles.test.ts` | Wave 0 | | PROF-03 | Setup isPublic toggle | unit | `bun test tests/services/setup.service.test.ts` | Extend existing | | PROF-04 | Public setup view without auth | integration | `bun test tests/routes/profiles.test.ts` | Wave 0 | | PROF-05 | Public profile lists public setups only | integration | `bun test tests/routes/profiles.test.ts` | Wave 0 | ### Sampling Rate - **Per task commit:** `bun test tests/services/global-item.service.test.ts && bun test tests/services/profile.service.test.ts` - **Per wave merge:** `bun test` - **Phase gate:** Full suite green before `/gsd:verify-work` ### Wave 0 Gaps - [ ] `tests/services/global-item.service.test.ts` -- covers GLOB-01 through GLOB-05 - [ ] `tests/services/profile.service.test.ts` -- covers PROF-01 - [ ] `tests/routes/global-items.test.ts` -- covers GLOB-01 through GLOB-05 at route level - [ ] `tests/routes/profiles.test.ts` -- covers PROF-02 through PROF-05 at route level - [ ] `createTestDb` helper may need updating to return user with profile fields ## Open Questions 1. **Global item imageUrl handling** - What we know: Global items have `imageUrl` (text) which stores a URL string (not a MinIO filename). This is different from user items which store `imageFilename`. - What's unclear: Should global item images be stored in MinIO (uploaded at seed time) or reference external URLs? - Recommendation: Use external URLs for seed data (manufacturer images). If admin upload is added later, switch to MinIO filenames. Keep the column as `imageUrl` -- it's a URL either way. 2. **Profile edit UI placement** - What we know: D-09 says `/settings/profile` or within existing settings page. - What's unclear: Separate route or section within `settings.tsx`? - Recommendation: Add a "Profile" section within the existing `settings.tsx` page. It already has sections for API Keys, units, currency. A new tab/section keeps navigation simple. 3. **MCP tool additions** - What we know: CONTEXT.md lists this as Claude's discretion. - What's unclear: Which MCP tools to add for global items. - Recommendation: Add `search_global_items` and `get_global_item` tools. Linking can happen through existing `update_item` with a global item reference. Defer to implementation time. ## Sources ### Primary (HIGH confidence) - `src/db/schema.ts` -- Current Drizzle schema with pg-core imports, confirmed boolean not yet imported - `src/server/index.ts` -- Auth middleware pattern, route registration - `src/server/middleware/auth.ts` -- How requireAuth works, path skip pattern - `src/server/services/item.service.ts` -- Service pattern (db + userId params) - `src/server/services/setup.service.ts` -- Setup CRUD with SQL aggregates - `src/server/services/storage.service.ts` -- Image URL enrichment at route level - `src/client/hooks/useItems.ts` -- Hook pattern with React Query - `src/shared/schemas.ts` -- Zod validation schema pattern - `tests/helpers/db.ts` -- PGlite test database creation pattern - `tests/routes/setups.test.ts` -- Route test pattern with Hono test app ### Secondary (MEDIUM confidence) - drizzle-orm `ilike` operator -- standard PostgreSQL ILIKE, available in drizzle-orm exports - drizzle-orm `boolean` column type -- standard in pg-core, not yet used in project but straightforward ## Metadata **Confidence breakdown:** - Standard stack: HIGH - no new dependencies, all existing libraries - Architecture: HIGH - follows established patterns exactly - Pitfalls: HIGH - derived from direct codebase analysis of auth middleware and service patterns **Research date:** 2026-04-04 **Valid until:** 2026-05-04 (stable -- no dependency changes expected)