mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
refactor(modules): one record-keyed write for a record's module state
The guardian's modules route and the setup flow's confirm both decide a record's modules, and each had its own copy of what that means: write the preference blob, write the delegated cycle column, invalidate the health score. Two copies of a three-step write drift, and the second one was added by this branch. They share one function now, keyed on the record, which is the carrier the managed-profile work needs. Authentication stays at the routes and stays different on purpose — the guardian route acts on a managed record, the setup routes refuse outright under a switch until a guardian's route into a record's setup lands with the rest of that record's configuration. So does the merge: what the answers imply and what somebody just ticked are different questions about what may be overwritten. The actor's own modules route keeps its own write, because that one is guarded on a concurrency token the record-keyed callers do not carry.
This commit is contained in:
1 parent
2d2350252f
commit
07f1b6ce92
3 files changed
+110
-43
No files matched your search
@@ -3,8 +3,6 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
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 {
|
||||
@@ -23,6 +21,7 @@ import {
|
||||
readOnboardingRecordState,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
|
||||
@@ -183,26 +182,19 @@ async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
|
||||
defaults.preferences,
|
||||
holdsData,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: merged },
|
||||
});
|
||||
|
||||
// `cycle` delegates to `CycleProfile.cycleTrackingEnabled`, so the module
|
||||
// blob never owns it. Only ever switched ON here: the gate derives an
|
||||
// The same record-keyed write the guardian's modules route uses, so the
|
||||
// two surfaces that decide a record's modules cannot drift apart on how
|
||||
// the decision is stored or what it invalidates. `cycle` delegates to
|
||||
// `CycleProfile.cycleTrackingEnabled`, so the module blob never owns it,
|
||||
// and it is only ever switched ON from here: the gate derives an
|
||||
// unanswered cycle from recorded sex, and an untick is not a request to
|
||||
// retract that.
|
||||
if (defaults.cycleTracking) {
|
||||
await getOrCreateCycleProfile(userId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId },
|
||||
data: { cycleTrackingEnabled: true },
|
||||
});
|
||||
}
|
||||
await writeRecordModulePreferences({
|
||||
recordId: userId,
|
||||
modulePreferences: merged,
|
||||
...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}),
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
invalidateUserHealthScore,
|
||||
invalidateUserInsightsLayout,
|
||||
invalidateUserMedications,
|
||||
invalidateUserProfile,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
} from "@/lib/record-settings";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
@@ -251,34 +250,25 @@ export const PATCH = apiHandler(
|
||||
...managedModulePreferencesFrom(current?.modulePreferencesJson),
|
||||
...patch.modulePreferences,
|
||||
};
|
||||
if (patch.modulePreferences !== undefined) {
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
}
|
||||
// The delegated key writes its own column. An explicit boolean is
|
||||
// One write path, shared with the setup flow's derivation. The
|
||||
// delegated key writes its own column there: an explicit boolean is
|
||||
// what the cycle gate honours over the sex-derived default, so a
|
||||
// guardian turning Cycle off for a record whose sex says otherwise
|
||||
// is recorded as the deliberate answer it is.
|
||||
if (patch.cycleTrackingEnabled !== undefined) {
|
||||
await getOrCreateCycleProfile(access.recordId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId: access.recordId },
|
||||
data: { cycleTrackingEnabled: patch.cycleTrackingEnabled },
|
||||
});
|
||||
}
|
||||
const cycleProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
const written = await writeRecordModulePreferences({
|
||||
recordId: access.recordId,
|
||||
...(patch.modulePreferences !== undefined
|
||||
? { modulePreferences }
|
||||
: {}),
|
||||
...(patch.cycleTrackingEnabled !== undefined
|
||||
? { cycleTrackingEnabled: patch.cycleTrackingEnabled }
|
||||
: {}),
|
||||
});
|
||||
invalidateUserHealthScore(access.recordId);
|
||||
settings = {
|
||||
modulePreferences,
|
||||
cycleTrackingEnabled: isCycleEnabled(
|
||||
current?.gender ?? null,
|
||||
cycleProfile,
|
||||
),
|
||||
cycleTrackingEnabled: isCycleEnabled(current?.gender ?? null, {
|
||||
cycleTrackingEnabled: written.cycleTrackingEnabled,
|
||||
}),
|
||||
};
|
||||
// The module keys themselves, not the wrapper field: the record's
|
||||
// trail should say which module moved. `cycleTrackingEnabled` is
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* v1.39 (C1) — the one write path for a record's module state.
|
||||
*
|
||||
* Two surfaces decide a record's modules and they must not disagree about how
|
||||
* the decision is stored. `PATCH /api/record-settings/modules` (v1.38.15) is a
|
||||
* guardian switching a managed record's modules by hand; the setup flow's
|
||||
* confirm screen derives a map from the answers. Both write the same two
|
||||
* places — the record's `modulePreferencesJson` blob and, for the delegated
|
||||
* cycle key, the record's own `CycleProfile` column — and both have to
|
||||
* invalidate the same cache, because a module toggle adds or removes a Health
|
||||
* Score pillar and the cached composite was computed from a composition that
|
||||
* no longer holds. That is what lives here, keyed on the RECORD, which is the
|
||||
* carrier issue #939 point 2 needs.
|
||||
*
|
||||
* What deliberately does NOT live here is who may call it. The two callers
|
||||
* authenticate differently and that is not an oversight: the record-settings
|
||||
* route is a guardian acting on a managed record (`requireGuardianAuth`), and
|
||||
* the setup routes are actor-only (`requireAuth()`, which refuses outright
|
||||
* under an acting-account switch) until a guardian's route into a managed
|
||||
* record's setup arrives with the rest of that record's configuration. A
|
||||
* delegable declaration is a reviewed entry in the sharing surface guard, not
|
||||
* something a shared write helper may hand out.
|
||||
*
|
||||
* `PATCH /api/auth/me/modules` is deliberately not routed through here: it is
|
||||
* the ACTOR's own record, and its write is guarded on an optimistic-concurrency
|
||||
* base token the two record-keyed callers do not carry. Folding it in would
|
||||
* mean either dropping that guard or making it a parameter nobody but one
|
||||
* caller sets.
|
||||
*
|
||||
* Nor does the merge: "what the answers imply" and "what a guardian just
|
||||
* ticked" are different questions with different rules about what may be
|
||||
* overwritten, so each caller computes its own complete map and this writes
|
||||
* it.
|
||||
*/
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
|
||||
export interface RecordModuleWrite {
|
||||
/** The record whose modules these are — never the actor, under a switch. */
|
||||
recordId: string;
|
||||
/** The complete map to persist, or undefined to leave the blob alone. */
|
||||
modulePreferences?: Record<string, boolean>;
|
||||
/**
|
||||
* An explicit answer for the delegated cycle key, or undefined to leave the
|
||||
* column alone. The blob never owns this key; the profile column does.
|
||||
*/
|
||||
cycleTrackingEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a record's module state and invalidate what it invalidates.
|
||||
*
|
||||
* Returns the cycle profile's stored opt-in afterwards (null when the record
|
||||
* has no profile row), so a caller that publishes the resolved cycle state can
|
||||
* resolve it against the record's sex without a second read.
|
||||
*/
|
||||
export async function writeRecordModulePreferences(
|
||||
input: RecordModuleWrite,
|
||||
): Promise<{ cycleTrackingEnabled: boolean | null }> {
|
||||
if (input.modulePreferences !== undefined) {
|
||||
await prisma.user.update({
|
||||
where: { id: input.recordId },
|
||||
data: { modulePreferencesJson: toJson(input.modulePreferences) },
|
||||
});
|
||||
}
|
||||
if (input.cycleTrackingEnabled !== undefined) {
|
||||
await getOrCreateCycleProfile(input.recordId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId: input.recordId },
|
||||
data: { cycleTrackingEnabled: input.cycleTrackingEnabled },
|
||||
});
|
||||
}
|
||||
|
||||
const profile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: input.recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
});
|
||||
|
||||
// A module toggle adds or removes a Health Score pillar, so the cached
|
||||
// composite was computed from a composition that no longer holds.
|
||||
invalidateUserHealthScore(input.recordId);
|
||||
|
||||
return { cycleTrackingEnabled: profile?.cycleTrackingEnabled ?? null };
|
||||
}
|
||||
Reference in new issue
Block a user