Files
Cibello-app/apps/api/src/routes/onboarding.ts
T
Sven (AAMOS AI) 2f3cdf8016 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
2026-08-06 00:40:59 +07:00

247 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
});
}