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({