feat(onboarding): persist goal selection and seed the dashboard

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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:21:48 +02:00
1 parent 5b756f01f2
commit 5f601426a9
5 files changed
+431 -15

No files matched your search

@@ -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);
});
});
+58 -3
View File
@@ -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({
+16 -12
View File
@@ -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) {
+104
View File
@@ -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<DashboardWidgetId>(
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));
});
});
+108
View File
@@ -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<DashboardWidgetId>();
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,
});
}