Files
GearBox/.planning/phases/18-global-items-public-profiles/18-RESEARCH.md

27 KiB

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>

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)

</user_constraints>

<phase_requirements>

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

</phase_requirements>

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.

// 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

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):
// 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);
});
  1. 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).

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).

// 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

// 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

// 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

// 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

// 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<GlobalItem[]>(`/api/global-items${params}`);
    },
  });
}

export function useGlobalItem(id: number | null) {
  return useQuery({
    queryKey: ["global-items", id],
    queryFn: () => apiGet<GlobalItemWithOwnerCount>(`/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)