FAS 1b: Progressive onboarding (3-layer flow)

- Add PROGRESSIVE_ONBOARDING feature flag to KNOWN_FLAGS
- Add onboarding_step column to users table (a/b/c)
- New backend endpoints:
  - GET /v1/onboarding/status – check current step + feature flag
  - POST /v1/onboarding/quick-start – complete Step A (goal + precision)
  - POST /v1/onboarding/complete-b – complete Step B (diet, allergens, household)
  - POST /v1/onboarding/complete-c – complete Step C (health profile)
  - POST /v1/onboarding/skip – GDPR-friendly skip
- Refactor mobile onboarding screen into 3 progressive layers
- Update auth store with onboardingStep state
- Update tab layout to only block on Step A
- Update registration to set onboardingStep='a'
- Add i18n keys for Step B/C titles across all 12 locales
- Add Zod validation schemas (quickStartInputSchema, onboardingStatusSchema)
- Add tests for validation and feature flag
- Preserve existing /v1/me/onboarding for backward compatibility
- Migration: 0004_progressive_onboarding.sql
This commit is contained in:
Sven (AAMOS AI)
2026-08-06 00:39:44 +07:00
parent b2b6601f54
commit 2f3cdf8016
24 changed files with 4338 additions and 3837 deletions
+246
View File
@@ -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 };
});
}
+2
View File
@@ -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;
}
+73
View File
@@ -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 099", 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);
});
});