fix(onboarding): never switch a module off on a record that already uses it

The derivation wrote an explicit off for every module the answers did not
name, which is right for a record being set up and wrong for one with years of
rows behind it. An established self-hoster has, for most modules, no stored
preference at all — they were default-on and nobody ever touched the toggle —
so the first confirm took away the navigation entry, the dashboard widget and
the settings entry for domains full of content. The rows survived; the
surfaces did not.

A derived off now asks first, and only for the modules it would actually
retract. Ownership is not re-answered: the measurement domains come from the
same map the MCP reads and the coach snapshot gates off, and each remaining
module names the one table its content lives in, exhaustively, so a module
added later fails to compile until somebody says where its content is. A
module whose domain holds rows keeps whatever the record already had, because
absence is what "on" looks like in the stored map.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:48:27 +02:00
1 parent cc6e6ca221
commit 2d2350252f
6 files changed
+372 -5

No files matched your search

+21 -1
View File
@@ -6,9 +6,11 @@ import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { normalisePrefs } from "@/lib/modules/gate";
import { modulesHoldingRecordData } from "@/lib/modules/domain-data";
import {
deriveOnboardingModuleDefaults,
mergeDerivedModulePreferences,
OWNED_MODULE_KEYS,
} from "@/lib/modules/registry";
import {
everyOnboardingQuestionSettled,
@@ -148,6 +150,7 @@ async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
}
let derived = false;
let keptForData: string[] = [];
if (record.modulesDerivedAt === null) {
const defaults = deriveOnboardingModuleDefaults({
recordTarget: state.needs.recordTarget,
@@ -163,9 +166,22 @@ async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
where: { id: userId },
select: { modulePreferencesJson: true },
});
// Only the modules the answers would switch OFF are worth asking about,
// and a record that already holds rows in one of those domains keeps its
// surface: the flow orders a record, and it cannot order away content
// somebody already has.
const wouldSwitchOff = OWNED_MODULE_KEYS.filter(
(key) => defaults.preferences[key] === false,
);
const holdsData = await modulesHoldingRecordData(
prisma,
userId,
wouldSwitchOff,
);
const merged = mergeDerivedModulePreferences(
normalisePrefs(row?.modulePreferencesJson),
defaults.preferences,
holdsData,
);
await prisma.user.update({
where: { id: userId },
@@ -187,6 +203,7 @@ async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
// A module toggle adds or removes a Health Score pillar, so the cached
// composite was computed from a composition that no longer holds.
invalidateUserHealthScore(userId);
keptForData = [...holdsData];
derived = true;
}
@@ -208,7 +225,10 @@ async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
annotate({
action: { name: "onboarding.needs.complete" },
meta: { outcome: derived ? "derived" : "already_derived" },
meta: {
outcome: derived ? "derived" : "already_derived",
keptForData: keptForData.length,
},
});
return toOnboardingStateDto(written, held);