fix(onboarding): derive the module map only from a questionnaire that was finished

The only gate on the derivation was Q1 being set, so one answer was enough to
turn the whole questionnaire into a module map. Two ways that landed wrong and
they compound. Somebody answers who the record is for, closes the tab, and
later finishes the legacy four-step wizard, which posts to this same endpoint
and knows nothing about these questions: the five unanswered ones each read as
their conservative default, eleven surfaces are written off, and the
once-only marker makes it unrepeatable. Or any client patches one answer and
posts the confirm straight back.

The gate now asks whether every question has an answer or a deliberate pass.
A half-answered flow derives nothing, is not stamped complete, and keeps its
confirm step pending, so finishing the questions later still does the work.
The legacy arm is untouched: a caller with no setup row still answers the
fixed acknowledgement it always did.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:42:38 +02:00
1 parent 7a1f3fe32d
commit cc6e6ca221
6 files changed
+165 -12

No files matched your search

+40 -1
View File
@@ -127,13 +127,18 @@ function statusOf(state: OnboardingStateDto, id: OnboardingStepId) {
return state.steps.find((step) => step.id === id)?.status;
}
/** The five answers a "blood pressure and medication" setup gives. */
/**
* The answers a "blood pressure and medication" setup gives — all six
* questions, because the confirm screen is only reached once every one of them
* has an answer or a deliberate pass.
*/
async function answerTheQuestions() {
await patchAnswer({ step: "who", recordTarget: "me" });
await patchAnswer({ step: "areas", areas: ["blood-pressure", "labs"] });
await patchAnswer({ step: "medication", medication: "yes" });
await patchAnswer({ step: "sources", status: "skipped" });
await patchAnswer({ step: "visit", visit: "no" });
await patchAnswer({ step: "units", status: "skipped" });
}
async function modulePrefs(userId: string) {
@@ -339,6 +344,40 @@ describe("POST /api/onboarding/complete", () => {
expect(profile.cycleTrackingEnabled).toBe(true);
});
it("derives nothing from a questionnaire that was never finished", async () => {
const user = await makeUser("half");
await signIn(user.id);
// Q1 and nothing else — the tab was closed, or the legacy four-step wizard
// posted here afterwards. Either way the remaining five questions have no
// answer, and reading them as their conservative defaults would switch off
// eleven surfaces and latch the result.
await patchAnswer({ step: "who", recordTarget: "me" });
const res = await postComplete();
expect(res.status).toBe(200);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: user.id },
select: { modulePreferencesJson: true },
});
expect(row.modulePreferencesJson).toBeNull();
const record = await getPrismaClient().onboardingRecord.findUniqueOrThrow({
where: { userId: user.id },
});
expect(record.modulesDerivedAt).toBeNull();
expect(record.completedAt).toBeNull();
const state = await readState(res);
expect(statusOf(state, "confirm")).toBe("pending");
// Finishing the questions later still derives, so the gate defers the
// work rather than losing it.
await answerTheQuestions();
await postComplete();
expect((await modulePrefs(user.id)).medications).toBe(true);
});
it("leaves a record that never answered the questions exactly as it was", async () => {
const user = await makeUser("legacy");
await signIn(user.id);