docs(18): research global items and public profiles domain

This commit is contained in:
2026-04-05 12:38:40 +02:00
parent 9cfbed1dce
commit c9117cd51a

View File

@@ -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>
## 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.
```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<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)