2f3cdf8016
- 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
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
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 };
|
||
});
|
||
}
|