mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user