diff --git a/apps/api/src/routes/onboarding.ts b/apps/api/src/routes/onboarding.ts new file mode 100644 index 0000000..d593dd5 --- /dev/null +++ b/apps/api/src/routes/onboarding.ts @@ -0,0 +1,246 @@ +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 }; + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 20ae6ef..535a351 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -25,6 +25,7 @@ import { adminAnalyticsRoutes } from "./routes/admin-analytics.js"; import { adminReleaseGateRoutes } from "./routes/admin-release-gates.js"; import { adminWorkersRoutes } from "./routes/admin-workers.js"; import { analyticsRoutes } from "./routes/analytics.js"; +import { onboardingRoutes } from "./routes/onboarding.js"; declare module "fastify" { interface FastifyInstance { @@ -88,6 +89,7 @@ export async function buildServer(config: AppConfig) { await app.register(adminReleaseGateRoutes); await app.register(adminWorkersRoutes); await app.register(analyticsRoutes); + await app.register(onboardingRoutes); return app; } diff --git a/apps/api/test/onboarding.test.ts b/apps/api/test/onboarding.test.ts new file mode 100644 index 0000000..34fb144 --- /dev/null +++ b/apps/api/test/onboarding.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { quickStartInputSchema, onboardingStatusSchema } from "@app/validation"; +import { parse, ApiError } from "../src/lib/errors.js"; + +describe("progressive onboarding validering (FAS 1b)", () => { + it("quick-start accepterar mål och precision", () => { + const result = parse(quickStartInputSchema, { + primaryGoal: "lose_weight", + precisionMode: "exact", + }); + expect(result.primaryGoal).toBe("lose_weight"); + expect(result.precisionMode).toBe("exact"); + }); + + it("quick-start är valfri – endast precision får default", () => { + const result = parse(quickStartInputSchema, {}); + expect(result.primaryGoal).toBeUndefined(); + expect(result.precisionMode).toBe("simple"); + }); + + it("quick-start avvisar ogiltigt mål", () => { + expect(() => + parse(quickStartInputSchema, { primaryGoal: "invalid_goal" }), + ).toThrowError(ApiError); + }); + + it("quick-start avvisar ogiltig precision", () => { + expect(() => + parse(quickStartInputSchema, { precisionMode: "medium" }), + ).toThrowError(ApiError); + }); + + it("onboarding-status schema validerar korrekt struktur", () => { + const valid = { + step: "b", + onboardingCompleted: false, + hasHousehold: true, + hasHealthProfile: false, + hasPreferences: true, + }; + const result = parse(onboardingStatusSchema, valid); + expect(result.step).toBe("b"); + expect(result.hasHousehold).toBe(true); + }); + + it("onboarding-status avvisar ogiltig steg", () => { + expect(() => + parse(onboardingStatusSchema, { + step: "d", + onboardingCompleted: false, + hasHousehold: false, + hasHealthProfile: false, + hasPreferences: false, + }), + ).toThrowError(ApiError); + }); +}); + +describe("progressive onboarding feature flag", () => { + it("PROGRESSIVE_ONBOARDING finns i KNOWN_FLAGS", async () => { + const { KNOWN_FLAGS } = await import("@app/feature-flags"); + expect(KNOWN_FLAGS.PROGRESSIVE_ONBOARDING).toBe("progressive_onboarding"); + }); + + it("bucketFor ger stabil hash 0–99", async () => { + const { bucketFor } = await import("@app/feature-flags"); + const b1 = bucketFor("progressive_onboarding", "user-123"); + const b2 = bucketFor("progressive_onboarding", "user-123"); + expect(b1).toBe(b2); + expect(b1).toBeGreaterThanOrEqual(0); + expect(b1).toBeLessThan(100); + }); +}); diff --git a/apps/mobile/src/app/(auth)/register.tsx b/apps/mobile/src/app/(auth)/register.tsx index 8beb60b..f29b877 100644 --- a/apps/mobile/src/app/(auth)/register.tsx +++ b/apps/mobile/src/app/(auth)/register.tsx @@ -17,6 +17,7 @@ interface RegisterResponse { export default function RegisterScreen() { const setSession = useAuth((s) => s.setSession); const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted); + const setOnboardingStep = useAuth((s) => s.setOnboardingStep); const [displayName, setDisplayName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -65,6 +66,7 @@ export default function RegisterScreen() { await persistLanguageTag(deviceTag); } setOnboardingCompleted(false); + setOnboardingStep("a"); router.replace("/onboarding"); } catch (err) { setError(err instanceof Error ? err.message : t("common.error")); diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index fef008e..fb321dd 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -36,10 +36,12 @@ function useApplyLocalePreferences(enabled: boolean) { export default function TabsLayout() { const accessToken = useAuth((s) => s.accessToken); const onboardingCompleted = useAuth((s) => s.onboardingCompleted); + const onboardingStep = useAuth((s) => s.onboardingStep); useApplyLocalePreferences(Boolean(accessToken)); if (!accessToken) return ; - if (!onboardingCompleted) return ; + // Progressive onboarding (FAS 1b): only block if step A not done + if (!onboardingCompleted && onboardingStep === "a") return ; return ( s.setOnboardingCompleted); - const [step, setStep] = useState(0); + const setOnboardingStep = useAuth((s) => s.setOnboardingStep); + const savedStep = useAuth((s) => s.onboardingStep); + const [layer, setLayer] = useState(savedStep ?? "a"); + + // Step A state const [goal, setGoal] = useState(null); + const [mode, setMode] = useState<"simple" | "exact">("simple"); + + // Step B state const [diet, setDiet] = useState("omnivore"); const [allergens, setAllergens] = useState([]); - const [mode, setMode] = useState<"simple" | "exact">("simple"); const [householdKind, setHouseholdKind] = useState<"create" | "join" | "skip">("create"); const [householdName, setHouseholdName] = useState(t("onboarding.householdDefaultName")); const [inviteCode, setInviteCode] = useState(""); + + // Step C state const [weightKg, setWeightKg] = useState(""); const [heightCm, setHeightCm] = useState(""); const [birthYear, setBirthYear] = useState(""); + const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const toggleAllergen = (id: string) => setAllergens((prev) => (prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id])); - const finish = async () => { + const finishStepA = async () => { setBusy(true); setError(null); try { - await api("/v1/me/onboarding", { + const res = await api<{ step: "a" | "b" | "c" }>("/v1/onboarding/quick-start", { + method: "POST", + body: { + ...(goal ? { primaryGoal: goal } : {}), + precisionMode: mode, + }, + }); + setOnboardingStep(res.step); + setLayer("b"); + // Let user into the app – Step B will be shown contextually later + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setBusy(false); + } + }; + + const finishStepB = async () => { + setBusy(true); + setError(null); + try { + const res = await api<{ step: "a" | "b" | "c" }>("/v1/onboarding/complete-b", { method: "POST", body: { precisionMode: mode, - healthProfile: { - ...(weightKg ? { weightKg: Number(weightKg) } : {}), - ...(heightCm ? { heightCm: Number(heightCm) } : {}), - ...(birthYear ? { birthYear: Number(birthYear) } : {}), - }, preferences: { - ...(goal ? { primaryGoal: goal } : {}), dietPattern: diet, allergens, }, @@ -91,7 +125,32 @@ export default function OnboardingScreen() { : { kind: "skip" }, }, }); + setOnboardingStep(res.step); + setLayer("c"); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setBusy(false); + } + }; + + const finishStepC = async () => { + setBusy(true); + setError(null); + try { + await api("/v1/onboarding/complete-c", { + method: "POST", + body: { + precisionMode: mode, + healthProfile: { + ...(weightKg ? { weightKg: Number(weightKg) } : {}), + ...(heightCm ? { heightCm: Number(heightCm) } : {}), + ...(birthYear ? { birthYear: Number(birthYear) } : {}), + }, + }, + }); setOnboardingCompleted(true); + setOnboardingStep("c"); router.replace("/(tabs)"); } catch (err) { setError(err instanceof Error ? err.message : t("common.error")); @@ -100,48 +159,158 @@ export default function OnboardingScreen() { } }; - const steps = [ - // 0: Mål - - {t("onboarding.goal")} - {GOALS.map((id) => ( + const skip = async (targetLayer?: OnboardingLayer) => { + setBusy(true); + try { + await api("/v1/onboarding/skip", { + method: "POST", + body: { step: targetLayer }, + }); + setOnboardingCompleted(true); + setOnboardingStep("c"); + router.replace("/(tabs)"); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setBusy(false); + } + }; + + // Render Step A: Immediate value + if (layer === "a") { + return ( + + {t("onboarding.title")} + {t("onboarding.subtitle")} + + + {t("onboarding.goal")} + + {GOALS.map((id) => ( + setGoal(id)} + /> + ))} + + + + {t("onboarding.mode")} + setMode("simple")} style={mode === "simple" ? sel : undefined}> + {t("onboarding.modeSimple")} + {t("onboarding.modeSimpleDesc")} + + setMode("exact")} style={mode === "exact" ? sel : undefined}> + {t("onboarding.modeExact")} + {t("onboarding.modeExactDesc")} + + {t("onboarding.modesCombine")} + + {error && {error}} + + + +