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
globalItemstable: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
itemGlobalLinksjunction 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
userstable with:displayName(text),avatarUrl(text),bio(text). All nullable -- profile is optional. - D-09: Profile edit page at
/settings/profileor 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
isPublicboolean column tosetupstable, defaultfalse. 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.tsmanually - 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.
requireAuthmiddleware 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:
- 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);
});
- 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-05tests/services/profile.service.test.ts-- covers PROF-01tests/routes/global-items.test.ts-- covers GLOB-01 through GLOB-05 at route leveltests/routes/profiles.test.ts-- covers PROF-02 through PROF-05 at route levelcreateTestDbhelper may need updating to return user with profile fields
Open Questions
-
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 storeimageFilename. - 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.
- What we know: Global items have
-
Profile edit UI placement
- What we know: D-09 says
/settings/profileor within existing settings page. - What's unclear: Separate route or section within
settings.tsx? - Recommendation: Add a "Profile" section within the existing
settings.tsxpage. It already has sections for API Keys, units, currency. A new tab/section keeps navigation simple.
- What we know: D-09 says
-
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_itemsandget_global_itemtools. Linking can happen through existingupdate_itemwith 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 importedsrc/server/index.ts-- Auth middleware pattern, route registrationsrc/server/middleware/auth.ts-- How requireAuth works, path skip patternsrc/server/services/item.service.ts-- Service pattern (db + userId params)src/server/services/setup.service.ts-- Setup CRUD with SQL aggregatessrc/server/services/storage.service.ts-- Image URL enrichment at route levelsrc/client/hooks/useItems.ts-- Hook pattern with React Querysrc/shared/schemas.ts-- Zod validation schema patterntests/helpers/db.ts-- PGlite test database creation patterntests/routes/setups.test.ts-- Route test pattern with Hono test app
Secondary (MEDIUM confidence)
- drizzle-orm
ilikeoperator -- standard PostgreSQL ILIKE, available in drizzle-orm exports - drizzle-orm
booleancolumn 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)