fix(nutrients): recompute supplement rows when the module returns or the zone moves

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:49:58 +02:00
1 parent 37645fc4bb
commit cc9d523bc8
7 files changed
+96

No files matched your search

+7
View File
@@ -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
@@ -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 () => {
+3
View File
@@ -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,
@@ -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,
@@ -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();
});
});
@@ -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<void> {
if (nutrientsSwitchOn(before) || !nutrientsSwitchOn(after)) return;
await enqueueSupplementNutrientRecompute(userId);
}
/** The pg-boss handler body for one job. */
export async function runSupplementNutrientRecompute(
payload: SupplementNutrientRecomputePayload,
+10
View File
@@ -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);