mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(nutrients): recompute supplement rows when the module returns or the zone moves
This commit is contained in:
1 parent
37645fc4bb
commit
cc9d523bc8
7 files changed
+96
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user