diff --git a/.planning/phases/18-global-items-public-profiles/18-RESEARCH.md b/.planning/phases/18-global-items-public-profiles/18-RESEARCH.md new file mode 100644 index 0000000..d78a570 --- /dev/null +++ b/.planning/phases/18-global-items-public-profiles/18-RESEARCH.md @@ -0,0 +1,562 @@ +# 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)