Files
GearBox/.planning/milestones/v2.2-phases/30-onboarding-redesign/30-01-PLAN.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
Phases 28-31 archived to milestones/v2.2-phases/
Requirements and roadmap snapshots archived to milestones/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:35 +02:00

15 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements
phase plan type wave depends_on files_modified autonomous requirements
30 01 backend 1
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
true
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:
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<string>();
  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` - src/server/services/discovery.service.ts - src/db/schema.ts Add a new function `getPopularItemsByTags` to `src/server/services/discovery.service.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<Array<{
  id: number;
  brand: string | null;
  model: string;
  category: string | null;
  weightGrams: number | null;
  priceCents: number | null;
  imageFilename: string | null;
  description: string | null;
  ownerCount: number;
}>> {
  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<number>`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<number>`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" <acceptance_criteria>

  • 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 </acceptance_criteria>
- src/server/routes/discovery.ts - src/server/services/discovery.service.ts Add a new GET endpoint to `src/server/routes/discovery.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" <acceptance_criteria>

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

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`:
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<OnboardingResult> {
  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`:
export const completeOnboardingSchema = z.object({
  globalItemIds: z.array(z.number().int().positive()).max(50),
});
  1. Create src/server/routes/onboarding.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<Env>();

// 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;
  1. Register route in src/server/index.ts:

Add after existing route registrations:

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

<success_criteria>

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

<threat_model>

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

<must_haves>

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