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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user