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, createHouseholdWithDefaults, discardEmptySoloHouseholds, getActiveHouseholdId, } from "../lib/helpers.js"; import { t } from "../lib/i18n.js"; import { KNOWN_FLAGS } from "@app/feature-flags"; import { syncOnboardingMemory } from "../lib/onboardingMemory.js"; /** * 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); // Normalize goals: multi-select UI sends `goals`; legacy clients send `primaryGoal`. const goals = input.goals?.length ? input.goals : input.primaryGoal ? [input.primaryGoal] : []; const primaryGoal = goals[0]; // Upsert minimal preferences await app.db .insert(schema.userPreferences) .values({ userId: req.userId, goals, ...(primaryGoal ? { primaryGoal } : {}), }) .onConflictDoUpdate({ target: schema.userPreferences.userId, set: { goals, ...(primaryGoal ? { primaryGoal } : {}), updatedAt: new Date(), }, }); // Auto-create a household so the user can scan, shop, plan and budget // immediately instead of hitting the "no household" wall in 21 endpoints. let householdId = await getActiveHouseholdId(app.db, req.userId); if (!householdId) { const [user] = await app.db .select({ locale: schema.users.locale }) .from(schema.users) .where(eq(schema.users.id, req.userId)) .limit(1); const householdName = t("onboarding.householdDefaultName", user?.locale ?? "sv-SE"); const household = await createHouseholdWithDefaults(app.db, { userId: req.userId, name: householdName, size: input.persons, }); householdId = household.id; } // 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: { goals, primaryGoal, precisionMode: input.precisionMode, householdId }, ip: req.ip, correlationId: req.correlationId, }); return { ok: true, step: user!.onboardingStep, onboardingCompleted: user!.onboardingCompleted, householdId, }; }); /** 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") { if (householdId) { // Auto-created household already exists; update name if user provided a custom one. await app.db .update(schema.households) .set({ name: input.householdChoice.name, updatedAt: new Date() }) .where(eq(schema.households.id, householdId)); } else { const household = await createHouseholdWithDefaults(app.db, { userId: req.userId, name: input.householdChoice.name, }); 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(); await discardEmptySoloHouseholds(app.db, req.userId, household.id); 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() }, }); } // S5: spegla alla uttryckligen angivna preferenser i minne + smaksignaler. const [completedPrefs] = await app.db .select() .from(schema.userPreferences) .where(eq(schema.userPreferences.userId, req.userId)) .limit(1); if (completedPrefs) { await syncOnboardingMemory(app.db, req.userId, completedPrefs); } 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 }; }); }