Files
Cibello-app/apps/api/src/routes/onboarding.ts
T
2026-08-14 20:00:49 +07:00

286 lines
9.4 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,
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 };
});
}