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 };
});
}