diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 5c10e8d93..abeceb8de 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -44,6 +44,7 @@ import { } from "@/lib/optimistic-lock"; import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate"; import { modulePrefsPatchSchema } from "@/lib/validations/modules"; +import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job"; export const dynamic = "force-dynamic"; @@ -140,6 +141,12 @@ export const PATCH = apiHandler(async (req: Request) => { }); if ("conflict" in guarded) return guarded.conflict; + await recomputeSupplementsIfNutrientsTurnedOn( + user.id, + existing?.modulePreferencesJson, + merged, + ); + // A module toggle can add or remove a Health Score pillar, so the // cached composite was computed from a composition that no longer // holds. Without this the score stays stale for up to an hour after a diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index 8fa2a9216..5cd3cfff8 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -31,6 +31,12 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ enqueueUserMedicationComplianceBackfill: refoldMock, })); +// The supplement nutrient rows are keyed by the same local day. +const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true }))); +vi.mock("@/lib/medications/ingredients/recompute-job", () => ({ + enqueueSupplementNutrientRecompute: nutrientMock, +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -118,6 +124,7 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "America/New_York" })); expect(res.status).toBe(200); expect(refoldMock).toHaveBeenCalledWith("user-1"); + expect(nutrientMock).toHaveBeenCalledWith("user-1"); }); it("does not re-fold when the zone is unchanged", async () => { @@ -130,6 +137,7 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); expect(res.status).toBe(200); expect(refoldMock).not.toHaveBeenCalled(); + expect(nutrientMock).not.toHaveBeenCalled(); }); it("trims whitespace before validating", async () => { diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 0bc0c0503..ad4ff0485 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -29,6 +29,7 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; +import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { @@ -69,6 +70,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { // job; best-effort, the singleton key collapses rapid re-picks. if ((before?.timezone ?? null) !== tz) { const refold = await enqueueUserMedicationComplianceBackfill(user.id); + // The supplement nutrient rows are keyed by the same local day. + await enqueueSupplementNutrientRecompute(user.id); annotate({ meta: { timezone_compliance_refold_enqueued: refold.enqueued, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index c899bd3ba..86b6b465c 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -1,5 +1,6 @@ import type { NextRequest } from "next/server"; +import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { apiHandler, requireGuardianAuth, @@ -239,6 +240,8 @@ export const PATCH = apiHandler( } if (patch.timezone !== undefined) { invalidateUserMedications(access.recordId, { evict: true }); + // The supplement nutrient rows are keyed by the local day. + await enqueueSupplementNutrientRecompute(access.recordId); } settings = { ...record, diff --git a/src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts b/src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts new file mode 100644 index 000000000..85a96b078 --- /dev/null +++ b/src/lib/medications/ingredients/__tests__/recompute-on-switch.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const send = vi.hoisted(() => vi.fn(async () => "job-1")); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ send }), +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("../recompute", () => ({ + recomputeSupplementNutrientsForUser: vi.fn(), +})); + +import { recomputeSupplementsIfNutrientsTurnedOn } from "../recompute-job"; + +beforeEach(() => send.mockClear()); + +describe("the supplement recompute after a module switch", () => { + it("queues the full recompute when nutrients comes back on", async () => { + await recomputeSupplementsIfNutrientsTurnedOn( + "user-1", + { nutrients: false }, + { nutrients: true }, + ); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1]).toMatchObject({ userId: "user-1" }); + }); + + it("queues nothing when the switch did not move to on", async () => { + await recomputeSupplementsIfNutrientsTurnedOn( + "user-1", + { nutrients: true }, + { nutrients: true, mood: false }, + ); + await recomputeSupplementsIfNutrientsTurnedOn( + "user-1", + { nutrients: true }, + { nutrients: false }, + ); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/ingredients/recompute-job.ts b/src/lib/medications/ingredients/recompute-job.ts index 2f0e00759..e42624420 100644 --- a/src/lib/medications/ingredients/recompute-job.ts +++ b/src/lib/medications/ingredients/recompute-job.ts @@ -17,6 +17,8 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import { normalisePrefs } from "@/lib/modules/gate"; +import { isOptInModule } from "@/lib/modules/registry"; import { recomputeSupplementNutrientsForUser } from "./recompute"; @@ -67,6 +69,28 @@ export async function enqueueSupplementNutrientRecompute( return { queued: false }; } +/** The account's own `nutrients` switch in a stored preference blob. */ +function nutrientsSwitchOn(prefs: unknown): boolean { + const value = normalisePrefs(prefs).nutrients; + return isOptInModule("nutrients") ? value === true : value !== false; +} + +/** + * Queue the full recompute when a preference write turned the account's + * `nutrients` switch back on. While the module is off nothing is written + * (`recompute.ts`), so a dose removed, a composition changed or a zone moved + * in the meantime left the stored rows as they were; the first pass after + * the switch puts them right instead of the next unrelated change. + */ +export async function recomputeSupplementsIfNutrientsTurnedOn( + userId: string, + before: unknown, + after: unknown, +): Promise { + if (nutrientsSwitchOn(before) || !nutrientsSwitchOn(after)) return; + await enqueueSupplementNutrientRecompute(userId); +} + /** The pg-boss handler body for one job. */ export async function runSupplementNutrientRecompute( payload: SupplementNutrientRecomputePayload, diff --git a/src/lib/record-settings/modules.ts b/src/lib/record-settings/modules.ts index 13428f2ad..0c2df12e9 100644 --- a/src/lib/record-settings/modules.ts +++ b/src/lib/record-settings/modules.ts @@ -35,6 +35,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { prisma, toJson } from "@/lib/db"; +import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job"; export interface RecordModuleWrite { /** The record whose modules these are — never the actor, under a switch. */ @@ -59,10 +60,19 @@ export async function writeRecordModulePreferences( input: RecordModuleWrite, ): Promise<{ cycleTrackingEnabled: boolean | null }> { if (input.modulePreferences !== undefined) { + const before = await prisma.user.findUnique({ + where: { id: input.recordId }, + select: { modulePreferencesJson: true }, + }); await prisma.user.update({ where: { id: input.recordId }, data: { modulePreferencesJson: toJson(input.modulePreferences) }, }); + await recomputeSupplementsIfNutrientsTurnedOn( + input.recordId, + before?.modulePreferencesJson, + input.modulePreferences, + ); } if (input.cycleTrackingEnabled !== undefined) { await getOrCreateCycleProfile(input.recordId);