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

+19 -2
View File
@@ -11,6 +11,7 @@ import {
mergeDerivedModulePreferences,
} from "@/lib/modules/registry";
import {
everyOnboardingQuestionSettled,
readHeldUnitPreferences,
resolveOnboardingSteps,
type HeldUnitPreferences,
@@ -32,8 +33,9 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
* flow, and the two live side by side rather than one replacing the other.
* The legacy half is unchanged: the optional profile fields, the completion
* stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The
* needs half runs only for a record that actually answered the questions
* (`needs.recordTarget` is set) and is what turns the answers into a module
* needs half runs only for a record that actually FINISHED the questions —
* every one of them answered or deliberately passed — and is what turns them
* into a module
* map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a
* re-derivation would re-apply the questionnaire over decisions taken in
* Settings since. `POST /api/onboarding/restart` clears that marker when the
@@ -129,6 +131,21 @@ async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
});
return toOnboardingStateDto(record, held);
}
if (!everyOnboardingQuestionSettled(steps)) {
// The confirm screen is the only caller that may derive, and it is reached
// only once every question has an answer or a deliberate pass. Q1 alone is
// not enough: an abandoned flow would have every remaining question read
// as its conservative default, switch off the surfaces that default
// implies, and latch the result so nobody could re-derive it. This route
// is also the LEGACY wizard's endpoint, and that wizard knows nothing
// about these questions — so a person who answered Q1, left, and later
// finished the old wizard must not have a module map derived for them.
annotate({
action: { name: "onboarding.needs.complete" },
meta: { outcome: "incomplete" },
});
return toOnboardingStateDto(record, held);
}
let derived = false;
if (record.modulesDerivedAt === null) {