import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { schema } from "@app/database"; import { quickStartInputSchema, onboardingInputSchema } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { audit, generateInviteCode, getActiveHouseholdId } from "../lib/helpers.js"; import { KNOWN_FLAGS } from "@app/feature-flags"; /** * Progressive onboarding (FAS 1b): 3-layer flow. * Step A = immediate value (goal + precision mode). * Step B = after first value (diet, allergens, household). * Step C = contextual (health profile, deep preferences). * * Preserves existing /v1/me/onboarding for backward compatibility. */ export async function onboardingRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; /** GET /v1/onboarding/status – where is the user in the progressive flow? */ app.get("/v1/onboarding/status", auth, async (req) => { const [user] = await app.db .select({ onboardingCompleted: schema.users.onboardingCompleted, onboardingStep: schema.users.onboardingStep, }) .from(schema.users) .where(eq(schema.users.id, req.userId)) .limit(1); if (!user) throw errors.notFound(); const householdId = await getActiveHouseholdId(app.db, req.userId); const [health] = await app.db .select({ userId: schema.userHealthProfiles.userId }) .from(schema.userHealthProfiles) .where(eq(schema.userHealthProfiles.userId, req.userId)) .limit(1); const [prefs] = await app.db .select({ userId: schema.userPreferences.userId }) .from(schema.userPreferences) .where(eq(schema.userPreferences.userId, req.userId)) .limit(1); // Feature-flag: progressive onboarding rollout const progressiveEnabled = await app.flags.isEnabled( KNOWN_FLAGS.PROGRESSIVE_ONBOARDING, req.userId, ); return { step: user.onboardingStep, onboardingCompleted: user.onboardingCompleted, hasHousehold: householdId != null, hasHealthProfile: health != null, hasPreferences: prefs != null, progressiveEnabled, }; }); /** POST /v1/onboarding/quick-start – complete Step A (minimal, immediate value). */ app.post("/v1/onboarding/quick-start", auth, async (req) => { const input = parse(quickStartInputSchema, req.body); // Upsert minimal preferences await app.db .insert(schema.userPreferences) .values({ userId: req.userId, ...(input.primaryGoal ? { primaryGoal: input.primaryGoal } : {}), }) .onConflictDoUpdate({ target: schema.userPreferences.userId, set: { ...(input.primaryGoal ? { primaryGoal: input.primaryGoal } : {}), updatedAt: new Date(), }, }); // Advance to step B const [user] = await app.db .update(schema.users) .set({ precisionMode: input.precisionMode, onboardingStep: "b", updatedAt: new Date(), }) .where(eq(schema.users.id, req.userId)) .returning(); await audit(app.db, { actorUserId: req.userId, action: "onboarding.quick_start", metadata: { primaryGoal: input.primaryGoal, precisionMode: input.precisionMode }, ip: req.ip, correlationId: req.correlationId, }); return { ok: true, step: user!.onboardingStep, onboardingCompleted: user!.onboardingCompleted, }; }); /** POST /v1/onboarding/complete-b – complete Step B (diet, allergens, household). */ app.post("/v1/onboarding/complete-b", auth, async (req) => { const input = parse(onboardingInputSchema, req.body); if (input.preferences) { await app.db .insert(schema.userPreferences) .values({ userId: req.userId, ...input.preferences }) .onConflictDoUpdate({ target: schema.userPreferences.userId, set: { ...input.preferences, updatedAt: new Date() }, }); } let householdId: string | null = await getActiveHouseholdId(app.db, req.userId); if (input.householdChoice.kind === "create" && !householdId) { const [household] = await app.db .insert(schema.households) .values({ name: input.householdChoice.name, inviteCode: generateInviteCode() }) .returning(); await app.db.insert(schema.householdMembers).values({ householdId: household!.id, userId: req.userId, role: "owner", }); await app.db.insert(schema.storageLocations).values([ { householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 }, { householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 }, { householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 }, ]); householdId = household!.id; } else if (input.householdChoice.kind === "join") { const [household] = await app.db .select() .from(schema.households) .where(eq(schema.households.inviteCode, input.householdChoice.inviteCode.toUpperCase())) .limit(1); if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden."); await app.db .insert(schema.householdMembers) .values({ householdId: household.id, userId: req.userId, role: "adult" }) .onConflictDoNothing(); householdId = household.id; } // Advance to step C (or mark completed if no health profile needed) const [user] = await app.db .update(schema.users) .set({ precisionMode: input.precisionMode, onboardingStep: "c", updatedAt: new Date(), }) .where(eq(schema.users.id, req.userId)) .returning(); await audit(app.db, { actorUserId: req.userId, action: "onboarding.complete_b", metadata: { householdId, hasPreferences: !!input.preferences }, ip: req.ip, correlationId: req.correlationId, }); return { ok: true, step: user!.onboardingStep, householdId }; }); /** POST /v1/onboarding/complete-c – complete Step C (health profile, final). */ app.post("/v1/onboarding/complete-c", auth, async (req) => { const input = parse(onboardingInputSchema, req.body); if (input.healthProfile) { await app.db .insert(schema.userHealthProfiles) .values({ userId: req.userId, ...input.healthProfile }) .onConflictDoUpdate({ target: schema.userHealthProfiles.userId, set: { ...input.healthProfile, updatedAt: new Date() }, }); } if (input.preferences) { await app.db .insert(schema.userPreferences) .values({ userId: req.userId, ...input.preferences }) .onConflictDoUpdate({ target: schema.userPreferences.userId, set: { ...input.preferences, updatedAt: new Date() }, }); } const [user] = await app.db .update(schema.users) .set({ precisionMode: input.precisionMode, onboardingStep: "c", onboardingCompleted: true, updatedAt: new Date(), }) .where(eq(schema.users.id, req.userId)) .returning(); await audit(app.db, { actorUserId: req.userId, action: "onboarding.complete_c", metadata: { hasHealthProfile: !!input.healthProfile }, ip: req.ip, correlationId: req.correlationId, }); return { ok: true, onboardingCompleted: user!.onboardingCompleted }; }); /** POST /v1/onboarding/skip – skip remaining steps (GDPR-friendly, user's choice). */ app.post("/v1/onboarding/skip", auth, async (req) => { const body = parse( z.object({ step: z.enum(["b", "c"]).optional() }), req.body, ); const [user] = await app.db .update(schema.users) .set({ onboardingStep: "c", onboardingCompleted: true, updatedAt: new Date(), }) .where(eq(schema.users.id, req.userId)) .returning(); await audit(app.db, { actorUserId: req.userId, action: "onboarding.skipped", metadata: { skippedStep: body.step ?? "all" }, ip: req.ip, correlationId: req.correlationId, }); return { ok: true, onboardingCompleted: user!.onboardingCompleted }; }); }