diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index aa9eb6813..50a4563c4 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -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; } diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 089715897..5b91fa3de 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -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 diff --git a/src/lib/record-settings/modules.ts b/src/lib/record-settings/modules.ts new file mode 100644 index 000000000..13428f2ad --- /dev/null +++ b/src/lib/record-settings/modules.ts @@ -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; + /** + * 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 }; +}