docs(30): create onboarding redesign plans (3 plans, 2 waves)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
436
.planning/phases/30-onboarding-redesign/30-01-PLAN.md
Normal file
436
.planning/phases/30-onboarding-redesign/30-01-PLAN.md
Normal file
@@ -0,0 +1,436 @@
|
||||
---
|
||||
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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
|
||||
### Task 1: Create shared hobby configuration
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/shared/schemas.ts
|
||||
- src/client/lib/iconData.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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<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];
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "export const HOBBIES" src/shared/hobbyConfig.ts && grep "getTagsForHobbies" src/shared/hobbyConfig.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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`
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 2: Add popular-items-by-tags query to discovery service
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/services/discovery.service.ts
|
||||
- src/db/schema.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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<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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "getPopularItemsByTags" src/server/services/discovery.service.ts && grep "inArray" src/server/services/discovery.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<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>
|
||||
</task>
|
||||
|
||||
### Task 3: Add popular-items endpoint to discovery routes
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/routes/discovery.ts
|
||||
- src/server/services/discovery.service.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "popular-items" src/server/routes/discovery.ts && grep "getPopularItemsByTags" src/server/routes/discovery.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<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>
|
||||
|
||||
### Task 4: Create onboarding service with batch item creation
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/services/item.service.ts
|
||||
- src/server/services/settings.service.ts
|
||||
- src/db/schema.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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<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 };
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "completeOnboarding" src/server/services/onboarding.service.ts && grep "onboardingComplete" src/server/services/onboarding.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 5: Create onboarding route with Zod validation
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/server/index.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/server/routes/settings.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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<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;
|
||||
```
|
||||
|
||||
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);
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "completeOnboardingSchema" src/shared/schemas.ts && grep "/api/onboarding" src/server/index.ts && grep "completeOnboarding" src/server/routes/onboarding.ts && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user