From 5f601426a97eb98fcd0cb0a2d6315bac1419f2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:48 +0200 Subject: [PATCH] feat(onboarding): persist goal selection and seed the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the goals step to actually keep its promise. The picker now posts the chosen slugs on the step-2 submit; the step endpoint validates them against the closed slug enum (422 on an unknown slug), persists them to User.onboardingGoals field-by-field, and on completion seeds dashboardWidgetsJson from the selection — promoting the goal-mapped tiles to the top and forcing them visible. The seed is one-time, gated on a null dashboardWidgetsJson via the updateMany WHERE clause, so a user who already arranged tiles is never clobbered. An empty or general-wellness-only selection leaves the column untouched (default layout). The resulting layout is what both the web dashboard and the iOS widgets contract read — no client recompute. Skip posts an explicit empty array (deliberate no preference). --- src/app/api/onboarding/__tests__/step.test.ts | 145 ++++++++++++++++++ src/app/api/onboarding/step/route.ts | 61 +++++++- src/components/onboarding/GoalsChipPicker.tsx | 28 ++-- src/lib/onboarding/__tests__/goals.test.ts | 104 +++++++++++++ src/lib/onboarding/goals.ts | 108 +++++++++++++ 5 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 src/lib/onboarding/__tests__/goals.test.ts create mode 100644 src/lib/onboarding/goals.ts diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts index 0f8f3b06c..1700626a3 100644 --- a/src/app/api/onboarding/__tests__/step.test.ts +++ b/src/app/api/onboarding/__tests__/step.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ updateMany: vi.fn(), }, }, + toJson: (v: unknown) => v, })); vi.mock("@/lib/auth/session", () => ({ @@ -249,3 +250,147 @@ describe("POST /api/onboarding/step — rate limit", () => { expect(res.status).toBe(429); }); }); + +describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => { + it("persists the selected goal slugs on the step-2 submit", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + const res = await POST( + req({ step: 2, goals: ["weight-management", "bp-tracking"] }), + ); + expect(res.status).toBe(200); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).toMatchObject({ + onboardingStep: 2, + onboardingGoals: ["weight-management", "bp-tracking"], + }); + }); + + it("persists an explicit empty array on skip (no preference)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + await POST(req({ step: 2, goals: [] })); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).toMatchObject({ onboardingGoals: [] }); + }); + + it("rejects an unknown goal slug with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] })); + expect(res.status).toBe(422); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + }); + + it("does NOT write onboardingGoals when the field is omitted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + await POST(req({ step: 2 })); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).not.toHaveProperty("onboardingGoals"); + }); +}); + +describe("POST /api/onboarding/step — dashboard seeding on completion", () => { + it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // 1st findUnique = the fresh step-read; 2nd = the seed-read. + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: ["glucose-tracking"], + dashboardWidgetsJson: null, + } as never); + // 1st updateMany = the step claim; 2nd = the gated seed write. + vi.mocked(prisma.user.updateMany) + .mockResolvedValueOnce({ count: 1 } as never) + .mockResolvedValueOnce({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + const res = await POST(req({ step: 4 })); + expect(res.status).toBe(200); + // The second updateMany is the seed; its WHERE pins the null gate + // and its data carries a dashboard layout. + expect(prisma.user.updateMany).toHaveBeenCalledTimes(2); + const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0]; + expect(seedCall.data).toHaveProperty("dashboardWidgetsJson"); + expect(seedCall.where).toHaveProperty("dashboardWidgetsJson"); + }); + + it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: ["glucose-tracking"], + dashboardWidgetsJson: { version: 1, widgets: [] }, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + const res = await POST(req({ step: 4 })); + expect(res.status).toBe(200); + // Only the step-claim updateMany fired — the seed was gated out + // because the user already has a layout. + expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); + }); + + it("does NOT seed when stored goals are empty (no preference)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: [], + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + await POST(req({ step: 4 })); + expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 50e73f716..0ef6ee0eb 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -10,9 +10,14 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; -import { prisma } from "@/lib/db"; +import { prisma, toJson } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + ONBOARDING_GOAL_SLUGS, + buildGoalSeededDashboardLayout, +} from "@/lib/onboarding/goals"; /** * v1.4.25 W14b — POST /api/onboarding/step. @@ -42,6 +47,16 @@ import { checkRateLimit } from "@/lib/rate-limit"; const stepBodySchema = z.object({ step: z.number().int().min(1).max(4), + // v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2 + // submit). Validated against the closed slug enum so an unknown slug + // 422s; deduped + capped at the full set size. Persisted to + // `User.onboardingGoals` (field-by-field, no mass assignment) and on + // completion (step 4) seeds the dashboard layout when the user never + // customized it. Omitted leaves the stored goals untouched. + goals: z + .array(z.enum(ONBOARDING_GOAL_SLUGS)) + .max(ONBOARDING_GOAL_SLUGS.length) + .optional(), }); export const POST = apiHandler(async (request: NextRequest) => { @@ -74,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); return apiError("Invalid step payload", 422); } - const { step } = parsed.data; + const { step, goals } = parsed.data; // Re-fetch the fresh User row so concurrent step submissions in // separate tabs can't race past each other — the session.user @@ -126,9 +141,14 @@ export const POST = apiHandler(async (request: NextRequest) => { onboardingCompletedAt: null, onboardingStep: { in: [current] }, }, + // Field-by-field assembly — no mass assignment. `goals` only + // lands when the client sent it (the step-2 submit); it is already + // validated against the closed slug enum by the Zod schema, so the + // array reaching Prisma can never contain an out-of-set value. data: { onboardingStep: step, ...(completing ? { onboardingCompletedAt: new Date() } : {}), + ...(goals !== undefined ? { onboardingGoals: goals } : {}), }, }); if (claimed.count !== 1) { @@ -149,11 +169,41 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + let goalsSeeded = false; if (completing) { // Mirror /api/onboarding/complete — clear the proxy-readable // pending cookie so the next navigation drops the /onboarding // redirect immediately. await setOnboardingPendingCookie(false); + + // v1.17.1 — seed the dashboard from the stored goal selection. + // ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who + // already arranged tiles is never clobbered. The seed promotes the + // goal-mapped tiles to the top and forces them visible; an empty / + // general-wellness-only selection produces null and leaves the + // column untouched (default layout). The resulting layout is what + // both the web dashboard and the iOS widgets contract read — + // server-authoritative, no client recompute. + const seedRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { onboardingGoals: true, dashboardWidgetsJson: true }, + }); + if (seedRow && seedRow.dashboardWidgetsJson == null) { + const seededLayout = buildGoalSeededDashboardLayout( + seedRow.onboardingGoals, + ); + if (seededLayout) { + // updateMany so the `dashboardWidgetsJson: null` precondition + // rides in the WHERE clause — a concurrent Settings → Dashboard + // save that lands first leaves `count = 0` and the seed is + // skipped rather than overwriting the user's fresh layout. + const seeded = await prisma.user.updateMany({ + where: { id: user.id, dashboardWidgetsJson: { equals: Prisma.JsonNull } }, + data: { dashboardWidgetsJson: toJson(seededLayout) }, + }); + goalsSeeded = seeded.count === 1; + } + } } await auditLog("onboarding.step", { @@ -167,7 +217,12 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "onboarding.step" }, - meta: { outcome: completing ? "completed" : "advanced", step }, + meta: { + outcome: completing ? "completed" : "advanced", + step, + ...(goals !== undefined ? { goals_count: goals.length } : {}), + ...(completing ? { goals_seeded: goalsSeeded } : {}), + }, }); return apiSuccess({ diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx index 63708d0b3..488acd625 100644 --- a/src/components/onboarding/GoalsChipPicker.tsx +++ b/src/components/onboarding/GoalsChipPicker.tsx @@ -24,22 +24,19 @@ import { apiPost } from "@/lib/api/api-fetch"; * v1.4.25 W14b-Content — onboarding step 1 (goals). * * Multi-select chip picker for the metrics the user wants to track. - * The goal slugs match a stable enum that v1.4.26 will land on the - * `User` row (`User.onboardingGoals`). Until that column ships the - * picker holds selection in component state only — there's no - * persistence layer to drop into. The earlier localStorage block - * had no consumer (no other surface reads the same key) and the - * single-step wizard makes pre-selection on resume unnecessary, so - * leaning on component state until the column lands keeps the code - * surface honest. + * The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS` + * (`src/lib/onboarding/goals.ts`). Selection lives in component state + * while the step is open; on advance it is persisted server-side to + * `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile + * layout on completion and feeds reminder suggestions. * * CTAs: * - Back → `/onboarding/0` - * - Skip → advance with empty goal set + * - Skip → advance with empty goal set (explicit "no preference") * - Next → advance with chosen goal set * - * Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }` - * and `router.push("/onboarding/2")`. + * Both Skip and Next call POST `/api/onboarding/step` with + * `{ step: 2, goals }` and `router.push("/onboarding/2")`. */ export type OnboardingGoalSlug = @@ -122,7 +119,14 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) { if (advancing) return; setAdvancing(true); try { - await apiPost("/api/onboarding/step", { step: 2 }); + // v1.17.1 — persist the chosen goal slugs alongside the step + // write. Skip sends an empty array (explicit "no preference"), + // not omission, so a resume can read the deliberate empty choice. + // The server seeds the dashboard from these on completion. + await apiPost("/api/onboarding/step", { + step: 2, + goals: Array.from(selected), + }); await queryClient.invalidateQueries({ queryKey: ["auth"] }); router.push("/onboarding/2"); } catch (err) { diff --git a/src/lib/onboarding/__tests__/goals.test.ts b/src/lib/onboarding/__tests__/goals.test.ts new file mode 100644 index 000000000..039be8b02 --- /dev/null +++ b/src/lib/onboarding/__tests__/goals.test.ts @@ -0,0 +1,104 @@ +/** + * v1.17.1 — onboarding goal slug enum + dashboard seeding. + * + * Goals are a personalization seed, not a clinical target store: the + * only leverage they have is which dashboard tiles get promoted and + * made visible. These tests pin (a) the closed slug guard and (b) the + * one-time seed builder's promote/forces-visible/no-op contracts. + */ +import { describe, it, expect } from "vitest"; + +import { + ONBOARDING_GOAL_SLUGS, + GOAL_WIDGET_SEED_MAP, + isOnboardingGoalSlug, + buildGoalSeededDashboardLayout, +} from "../goals"; +import { + DEFAULT_DASHBOARD_LAYOUT, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; + +describe("isOnboardingGoalSlug", () => { + it("accepts every member of the closed slug set", () => { + for (const slug of ONBOARDING_GOAL_SLUGS) { + expect(isOnboardingGoalSlug(slug)).toBe(true); + } + }); + + it("rejects unknown / non-string values", () => { + expect(isOnboardingGoalSlug("not-a-goal")).toBe(false); + expect(isOnboardingGoalSlug("")).toBe(false); + expect(isOnboardingGoalSlug(null)).toBe(false); + expect(isOnboardingGoalSlug(42)).toBe(false); + }); +}); + +describe("GOAL_WIDGET_SEED_MAP", () => { + it("only maps to ids the dashboard layout actually knows", () => { + const known = new Set( + DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId), + ); + for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) { + for (const id of ids) expect(known.has(id)).toBe(true); + } + }); + + it("leaves general-wellness without a tile preference", () => { + expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]); + }); +}); + +describe("buildGoalSeededDashboardLayout", () => { + it("returns null for an empty selection (no seed)", () => { + expect(buildGoalSeededDashboardLayout([])).toBeNull(); + }); + + it("returns null for a general-wellness-only selection", () => { + expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull(); + }); + + it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => { + expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull(); + }); + + it("promotes glucose to the top and forces it visible", () => { + const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]); + expect(layout).not.toBeNull(); + const widgets = layout!.widgets; + // glucose is default-invisible + ordered low; the seed lifts it to + // order 0 and turns both surfaces on. + const glucose = widgets.find((w) => w.id === "glucose"); + expect(glucose).toMatchObject({ + order: 0, + visible: true, + tileVisible: true, + }); + }); + + it("promotes every mapped tile for a multi-goal selection", () => { + const layout = buildGoalSeededDashboardLayout([ + "weight-management", + "bp-tracking", + ]); + expect(layout).not.toBeNull(); + const widgets = layout!.widgets; + const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]); + // Every promoted id sits in the leading block and is visible. + for (const id of promotedIds) { + const w = widgets.find((x) => x.id === id); + expect(w?.visible).toBe(true); + expect(w?.tileVisible).toBe(true); + expect(w!.order).toBeLessThan(promotedIds.size); + } + }); + + it("keeps a dense, complete widget set (no tiles dropped)", () => { + const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]); + expect(layout!.widgets.length).toBe( + DEFAULT_DASHBOARD_LAYOUT.widgets.length, + ); + const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b); + expect(orders).toEqual(orders.map((_, i) => i)); + }); +}); diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts new file mode 100644 index 000000000..4a2fc7eb6 --- /dev/null +++ b/src/lib/onboarding/goals.ts @@ -0,0 +1,108 @@ +/** + * v1.17.1 — onboarding goal slugs + dashboard seeding. + * + * The closed set of goal slugs the GoalsChipPicker offers, plus the + * server-authoritative seeding that turns a goal selection into a + * personalization signal: which dashboard tiles get promoted to the + * top and made visible. Goals are NOT a clinical target store — the + * targets surface (`/api/insights/targets`) is fully derived from + * height/age/gender + clinical references. Their only leverage is + * (a) dashboard tile pinning (here) and (b) reminder suggestions + * (read by the reminders panel from `User.onboardingGoals`). + * + * One home for the slug enum so the API validation + * (`/api/onboarding/step`), the client picker, and the seeding all + * read the same source — same-key drift was the v1.4.16 dashboard-enum + * bug class. + */ +import { + DEFAULT_DASHBOARD_LAYOUT, + serializeDashboardLayout, + type DashboardLayout, + type DashboardWidgetId, +} from "@/lib/dashboard-layout"; + +/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */ +export const ONBOARDING_GOAL_SLUGS = [ + "weight-management", + "bp-tracking", + "glucose-tracking", + "sleep-improvement", + "medication-compliance", + "general-wellness", +] as const; + +export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number]; + +export function isOnboardingGoalSlug( + value: unknown, +): value is OnboardingGoalSlug { + return ( + typeof value === "string" && + (ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value) + ); +} + +/** + * Slug → dashboard widget ids to promote. `general-wellness` maps to + * nothing — it carries no tile preference, so a general-wellness-only + * selection leaves the default layout untouched. Ids must be members + * of `DASHBOARD_WIDGET_IDS`; the seed never invents an id. + */ +export const GOAL_WIDGET_SEED_MAP: Record< + OnboardingGoalSlug, + readonly DashboardWidgetId[] +> = { + "weight-management": ["weight", "bodyFat"], + "bp-tracking": ["bp", "bpInTarget"], + "glucose-tracking": ["glucose"], + "sleep-improvement": ["sleep"], + "medication-compliance": ["medications"], + "general-wellness": [], +}; + +/** + * Build the seeded dashboard layout for a goal selection. + * + * Returns a layout where the goal-mapped tiles are promoted to the top + * of the order (preserving their relative order within the default + * layout) and forced visible on both surfaces; every other tile keeps + * its default config, shifted down. Returns `null` when there is + * nothing to promote (empty selection or general-wellness only) so the + * caller can skip the write entirely and leave the column null → + * default layout. + * + * This is a ONE-TIME seed: the caller MUST gate it on + * `User.dashboardWidgetsJson == null` so it never clobbers a user who + * already arranged tiles. + */ +export function buildGoalSeededDashboardLayout( + goals: readonly string[] | null | undefined, +): DashboardLayout | null { + if (!goals || goals.length === 0) return null; + const promoted = new Set(); + for (const slug of goals) { + if (!isOnboardingGoalSlug(slug)) continue; + for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id); + } + if (promoted.size === 0) return null; + + const base = DEFAULT_DASHBOARD_LAYOUT.widgets; + const promotedWidgets = base.filter((w) => promoted.has(w.id as DashboardWidgetId)); + const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId)); + + const ordered = [...promotedWidgets, ...rest].map((w, index) => ({ + ...w, + // Force the goal-mapped tiles visible on both surfaces; leave the + // rest exactly as the default layout had them. + ...(promoted.has(w.id as DashboardWidgetId) + ? { visible: true, tileVisible: true } + : {}), + order: index, + })); + + return serializeDashboardLayout({ + ...DEFAULT_DASHBOARD_LAYOUT, + widgets: ordered, + }); +}