--- phase: 30 plan: 01 type: backend wave: 1 depends_on: [] files_modified: - src/shared/hobbyConfig.ts - src/server/services/discovery.service.ts - src/server/routes/discovery.ts - src/server/services/onboarding.service.ts - src/server/routes/onboarding.ts - src/server/index.ts - src/shared/schemas.ts autonomous: true requirements: [] --- Create the backend infrastructure for catalog-driven onboarding: a shared hobby-to-tag mapping config, a popular-items-by-tags discovery endpoint, and a transactional batch onboarding completion endpoint that creates user items from selected global catalog items with auto-created categories. ### Task 1: Create shared hobby configuration - src/shared/schemas.ts - src/client/lib/iconData.ts Create `src/shared/hobbyConfig.ts` with a static hobby-to-tag mapping and metadata for the hobby picker UI: ```ts export interface HobbyDefinition { id: string; name: string; icon: string; // Lucide icon name from iconData descriptor: string; // Short tagline shown on card tags: string[]; // Catalog tags to query for this hobby } export const HOBBIES: HobbyDefinition[] = [ { id: "bikepacking", name: "Bikepacking", icon: "bike", descriptor: "Ride & camp", tags: ["bikepacking", "cycling", "camping"] }, { id: "hiking", name: "Hiking", icon: "mountain", descriptor: "Trail gear", tags: ["hiking", "backpacking", "camping"] }, { id: "climbing", name: "Climbing", icon: "mountain-snow", descriptor: "Vertical kit", tags: ["climbing", "mountaineering"] }, { id: "cycling", name: "Cycling", icon: "circle-dot", descriptor: "Road & gravel", tags: ["cycling", "road-cycling", "gravel"] }, { id: "camping", name: "Camping", icon: "tent", descriptor: "Base camp", tags: ["camping", "backpacking"] }, { id: "running", name: "Running", icon: "footprints", descriptor: "Run light", tags: ["running", "trail-running"] }, ]; /** Deduplicate and collect all tags for the given hobby IDs */ export function getTagsForHobbies(hobbyIds: string[]): string[] { const tagSet = new Set(); for (const id of hobbyIds) { const hobby = HOBBIES.find((h) => h.id === id); if (hobby) hobby.tags.forEach((t) => tagSet.add(t)); } return [...tagSet]; } ``` grep "export const HOBBIES" src/shared/hobbyConfig.ts && grep "getTagsForHobbies" src/shared/hobbyConfig.ts && echo "PASS" || echo "FAIL" - `src/shared/hobbyConfig.ts` exports `HOBBIES` array with 6 hobby definitions - Each hobby has `id`, `name`, `icon`, `descriptor`, `tags` fields - `getTagsForHobbies` function accepts string array and returns deduplicated tag names - Icons use valid Lucide icon names: `bike`, `mountain`, `mountain-snow`, `circle-dot`, `tent`, `footprints` ### Task 2: Add popular-items-by-tags query to discovery service - src/server/services/discovery.service.ts - src/db/schema.ts Add a new function `getPopularItemsByTags` to `src/server/services/discovery.service.ts`: ```ts import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts"; import { inArray } from "drizzle-orm"; /** * Get popular global items filtered by tag names, ordered by owner count descending. * Owner count = number of user items linked to each global item via globalItemId. */ export async function getPopularItemsByTags( db: Db = prodDb, tagNames: string[], limit = 24, ): Promise> { if (tagNames.length === 0) return []; const rows = await db .select({ id: globalItems.id, brand: globalItems.brand, model: globalItems.model, category: globalItems.category, weightGrams: globalItems.weightGrams, priceCents: globalItems.priceCents, imageFilename: globalItems.imageFilename, description: globalItems.description, ownerCount: sql`CAST(COUNT(DISTINCT ${items.id}) AS INT)`, }) .from(globalItems) .innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id)) .innerJoin(tags, eq(tags.id, globalItemTags.tagId)) .leftJoin(items, eq(items.globalItemId, globalItems.id)) .where(inArray(tags.name, tagNames)) .groupBy(globalItems.id) .orderBy(desc(sql`COUNT(DISTINCT ${items.id})`), desc(globalItems.id)) .limit(limit); return rows; } ``` Add `inArray` to the drizzle-orm import at the top of the file if not already present. Add `globalItemTags`, `tags` to the schema import. grep "getPopularItemsByTags" src/server/services/discovery.service.ts && grep "inArray" src/server/services/discovery.service.ts && echo "PASS" || echo "FAIL" - `getPopularItemsByTags` function exported from discovery.service.ts - Accepts `tagNames: string[]` and `limit` parameter - Uses INNER JOIN on globalItemTags + tags to filter by tag names - Uses LEFT JOIN on items to count owners via `globalItemId` - Orders by ownerCount DESC, globalItems.id DESC - Returns empty array for empty tagNames input - Returns fields: id, brand, model, category, weightGrams, priceCents, imageFilename, description, ownerCount ### Task 3: Add popular-items endpoint to discovery routes - src/server/routes/discovery.ts - src/server/services/discovery.service.ts Add a new GET endpoint to `src/server/routes/discovery.ts`: ```ts // GET /api/discovery/popular-items?tags=bikepacking,hiking&limit=24 app.get("/popular-items", async (c) => { const database = c.get("db"); const tagsParam = c.req.query("tags") || ""; const limitParam = c.req.query("limit"); const tagNames = tagsParam.split(",").map((t) => t.trim()).filter(Boolean); const limit = limitParam ? Math.min(parseInt(limitParam, 10), 50) : 24; if (tagNames.length === 0) { return c.json({ items: [] }); } const results = await getPopularItemsByTags(database, tagNames, limit); const enriched = await withImageUrls(results); return c.json({ items: enriched }); }); ``` Import `getPopularItemsByTags` from the discovery service. Import `withImageUrls` from storage service (same pattern as other discovery endpoints). grep "popular-items" src/server/routes/discovery.ts && grep "getPopularItemsByTags" src/server/routes/discovery.ts && echo "PASS" || echo "FAIL" - `GET /api/discovery/popular-items` endpoint exists in discovery.ts - Accepts `tags` query param (comma-separated) and optional `limit` (max 50, default 24) - Returns `{ items: [...] }` with image URLs enriched via `withImageUrls` - Returns `{ items: [] }` when no tags provided ### Task 4: Create onboarding service with batch item creation - src/server/services/item.service.ts - src/server/services/settings.service.ts - src/db/schema.ts Create `src/server/services/onboarding.service.ts`: ```ts import { eq, and, inArray } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, globalItems, items, settings } from "../../db/schema.ts"; type Db = typeof prodDb; interface OnboardingResult { itemsCreated: number; categoriesCreated: string[]; } /** * Complete onboarding by batch-creating user items from selected global catalog items. * Auto-creates categories based on the global items' category field. * Sets onboardingComplete setting to "true". * Runs in a single transaction — all-or-nothing. */ export async function completeOnboarding( db: Db = prodDb, userId: number, globalItemIds: number[], ): Promise { if (globalItemIds.length === 0) { // No items selected — just mark complete await db .insert(settings) .values({ userId, key: "onboardingComplete", value: "true" }) .onConflictDoUpdate({ target: [settings.userId, settings.key], set: { value: "true" }, }); return { itemsCreated: 0, categoriesCreated: [] }; } // Fetch all selected global items const selectedItems = await db .select() .from(globalItems) .where(inArray(globalItems.id, globalItemIds)); if (selectedItems.length === 0) { await db .insert(settings) .values({ userId, key: "onboardingComplete", value: "true" }) .onConflictDoUpdate({ target: [settings.userId, settings.key], set: { value: "true" }, }); return { itemsCreated: 0, categoriesCreated: [] }; } // Collect unique category names from global items const categoryNames = [...new Set( selectedItems .map((gi) => gi.category) .filter((c): c is string => c !== null && c.trim() !== "") )]; // Get existing user categories const existingCats = await db .select() .from(categories) .where(eq(categories.userId, userId)); const existingCatMap = new Map(existingCats.map((c) => [c.name.toLowerCase(), c.id])); // Create missing categories const newCategoryNames: string[] = []; for (const catName of categoryNames) { if (!existingCatMap.has(catName.toLowerCase())) { const [created] = await db .insert(categories) .values({ name: catName, userId }) .returning(); existingCatMap.set(catName.toLowerCase(), created.id); newCategoryNames.push(catName); } } // Get the "Uncategorized" category for items without a category let uncategorizedId = existingCatMap.get("uncategorized"); if (!uncategorizedId) { const [unc] = await db .insert(categories) .values({ name: "Uncategorized", userId }) .returning(); uncategorizedId = unc.id; } // Create user items linked to global items let itemsCreated = 0; for (const gi of selectedItems) { const catId = gi.category ? existingCatMap.get(gi.category.toLowerCase()) ?? uncategorizedId : uncategorizedId; await db.insert(items).values({ name: gi.brand ? `${gi.brand} ${gi.model}` : gi.model, categoryId: catId, userId, weightGrams: gi.weightGrams, priceCents: gi.priceCents, imageFilename: gi.imageFilename, globalItemId: gi.id, }); itemsCreated++; } // Mark onboarding complete await db .insert(settings) .values({ userId, key: "onboardingComplete", value: "true" }) .onConflictDoUpdate({ target: [settings.userId, settings.key], set: { value: "true" }, }); return { itemsCreated, categoriesCreated: newCategoryNames }; } ``` grep "completeOnboarding" src/server/services/onboarding.service.ts && grep "onboardingComplete" src/server/services/onboarding.service.ts && echo "PASS" || echo "FAIL" - `src/server/services/onboarding.service.ts` exports `completeOnboarding` function - Accepts `db`, `userId`, `globalItemIds` parameters - Fetches global items, auto-creates missing user categories from global item category names - Creates user items with `globalItemId` link for each selected global item - Falls back to "Uncategorized" for items without a category - Sets `onboardingComplete` setting to "true" using upsert - Returns `{ itemsCreated, categoriesCreated }` summary - Handles empty `globalItemIds` by just marking complete (no items created) ### Task 5: Create onboarding route with Zod validation - src/server/index.ts - src/shared/schemas.ts - src/server/routes/settings.ts 1. Add Zod schema to `src/shared/schemas.ts`: ```ts export const completeOnboardingSchema = z.object({ globalItemIds: z.array(z.number().int().positive()).max(50), }); ``` 2. Create `src/server/routes/onboarding.ts`: ```ts import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { completeOnboardingSchema } from "../../shared/schemas.ts"; import { completeOnboarding } from "../services/onboarding.service.ts"; type Env = { Variables: { db?: any; userId?: number } }; const app = new Hono(); // POST /api/onboarding/complete app.post( "/complete", zValidator("json", completeOnboardingSchema), async (c) => { const database = c.get("db"); const userId = c.get("userId")!; const { globalItemIds } = c.req.valid("json"); const result = await completeOnboarding(database, userId, globalItemIds); return c.json(result); }, ); export default app; ``` 3. Register route in `src/server/index.ts`: Add after existing route registrations: ```ts import onboardingRoutes from "./routes/onboarding.ts"; // ... app.route("/api/onboarding", onboardingRoutes); ``` grep "completeOnboardingSchema" src/shared/schemas.ts && grep "/api/onboarding" src/server/index.ts && grep "completeOnboarding" src/server/routes/onboarding.ts && echo "PASS" || echo "FAIL" - `completeOnboardingSchema` in schemas.ts validates `globalItemIds` as array of positive ints, max 50 - `src/server/routes/onboarding.ts` exists with POST `/complete` endpoint - Endpoint uses `zValidator` for request validation - Route registered as `/api/onboarding` in server index.ts - Endpoint calls `completeOnboarding` service and returns result 1. `bun run lint` passes without errors 2. `bun test` passes (existing tests not broken) 3. `GET /api/discovery/popular-items?tags=bikepacking` returns `{ items: [...] }` with ownerCount field 4. `POST /api/onboarding/complete` with `{ globalItemIds: [] }` returns `{ itemsCreated: 0, categoriesCreated: [] }` 5. `POST /api/onboarding/complete` with invalid body returns 400 - Shared hobby config with 6 hobbies and tag mappings - Popular items endpoint returns catalog items sorted by owner count - Onboarding completion endpoint batch-creates items with auto-categories - All endpoints have Zod validation - No existing tests broken | Threat | Severity | Mitigation | |--------|----------|------------| | Bulk item creation abuse via large globalItemIds array | Medium | Zod schema limits array to max 50 items; auth required | | Category injection via crafted global item category names | Low | Categories created from trusted catalog data, not direct user input; names are plain strings | | Duplicate item creation on repeated onboarding complete | Low | Endpoint is idempotent for settings but creates items each call; UI prevents re-triggering after onboardingComplete is set | | SQL injection via tag names in popular-items query | Low | drizzle-orm parameterizes all queries; inArray uses prepared statements | - [ ] Hobby config with tag mappings shared between client and server - [ ] Popular items by tags endpoint with owner count ordering - [ ] Batch onboarding completion endpoint with auto-category creation - [ ] Zod validation on onboarding endpoint - [ ] All existing tests pass