diff --git a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts new file mode 100644 index 000000000..7cb6f40f6 --- /dev/null +++ b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts @@ -0,0 +1,400 @@ +/** + * v1.35.0 — GET/PATCH /api/auth/me/health-score-config. + * + * Covers the read projection, the positive-selection-in / + * deselection-out inversion, the write-time breadth refusal, the + * monotonic recipe version, and the score-cache eviction. The + * invalidation assertion is here because a missing one is invisible: + * the write succeeds, the response is right, and the person keeps + * seeing the old number for an hour. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserHealthScore: vi.fn() }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; +import type { ScorePillarId } from "@/lib/analytics/score/types"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const ALL_EIGHT: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", +]; + +interface ResolvedBody { + pillars: ScorePillarId[]; + excludedPillars: ScorePillarId[]; + hasSelection: boolean; + version: number; + changedAt: string | null; + updatedAt?: string; +} + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/health-score-config", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +/** Mutable row, so a write is visible to the read that follows it. */ +function primeUser(healthScoreConfigJson: unknown = null) { + const row: { healthScoreConfigJson: unknown; updatedAt: Date } = { + healthScoreConfigJson, + updatedAt: new Date("2026-07-31T09:00:00.000Z"), + }; + vi.mocked(prisma.user.findUnique).mockImplementation((() => + Promise.resolve({ ...row })) as never); + vi.mocked(prisma.user.update).mockImplementation(((args: { + data?: { healthScoreConfigJson?: unknown }; + }) => { + if (args.data && "healthScoreConfigJson" in args.data) { + row.healthScoreConfigJson = args.data.healthScoreConfigJson; + } + return Promise.resolve({ updatedAt: row.updatedAt }); + }) as never); + return row; +} + +function writtenConfig(): { + excludedPillars: ScorePillarId[]; + version: number; + changedAt: string; +} { + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as { + data: { + healthScoreConfigJson: { + excludedPillars: ScorePillarId[]; + version: number; + changedAt: string; + }; + }; + }; + return call.data.healthScoreConfigJson; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/health-score-config", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config"), + ); + expect(res.status).toBe(401); + }); + + it("reports every pillar counting, and no selection, for a fresh account", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: ResolvedBody }; + expect(env.data.pillars).toEqual(ALL_EIGHT); + expect(env.data.hasSelection).toBe(false); + expect(env.data.version).toBe(0); + expect(env.data.updatedAt).toBeUndefined(); + }); + + it("reports a stored selection as the pillars it leaves counting", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ + excludedPillars: ["SLEEP", "LIPIDS"], + version: 2, + changedAt: "2026-07-30T08:00:00.000Z", + }); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/health-score-config"), + ); + const env = (await res.json()) as { data: ResolvedBody }; + expect(env.data.pillars).toEqual( + ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"), + ); + expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]); + expect(env.data.hasSelection).toBe(true); + expect(env.data.version).toBe(2); + expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z"); + }); +}); + +describe("PATCH /api/auth/me/health-score-config", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("stores the complement of the selection and returns the selection", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: selection }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: ResolvedBody }; + expect(env.data.pillars).toEqual(selection); + expect(env.data.hasSelection).toBe(true); + + const stored = writtenConfig(); + expect(stored.excludedPillars).toEqual( + ALL_EIGHT.filter((id) => !selection.includes(id)), + ); + expect(stored.version).toBe(1); + expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(auditLog).toHaveBeenCalledWith( + "user.health_score_config.update", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("increments the recipe version on every accepted write", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ + excludedPillars: ["LIPIDS"], + version: 7, + changedAt: "2026-07-01T00:00:00.000Z", + }); + + await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(writtenConfig().version).toBe(8); + }); + + it("keeps a kept-everything selection as an authored choice", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + const env = (await res.json()) as { data: ResolvedBody }; + expect(writtenConfig().excludedPillars).toEqual([]); + expect(env.data.hasSelection).toBe(true); + }); + + it("evicts the score caches so the old number is not served on", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); + }); +}); + +describe("PATCH — the breadth rule", () => { + it("refuses activity and wellbeing alone, in words a person can act on", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }), + ); + expect(res.status).toBe(422); + const env = (await res.json()) as { + error: string; + meta?: { errorCode?: string; reason?: string }; + }; + expect(env.meta?.errorCode).toBe("health_score_config.too_narrow"); + expect(env.meta?.reason).toBe("measured_physiological_domain_required"); + expect(env.error).toContain("at least one physical measurement"); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + }); + + it("refuses the cardiometabolic triple, which is three pillars in one area", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }), + ); + expect(res.status).toBe(422); + const env = (await res.json()) as { + error: string; + meta?: { reason?: string }; + }; + expect(env.meta?.reason).toBe("three_domains_required"); + expect(env.error).toContain("three different areas"); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses an empty selection rather than storing a score that cannot exist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: [] }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("accepts the smallest selection that clears the rule", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); +}); + +describe("PATCH — envelope and transport", () => { + it("rejects an unknown pillar id with 422 and writes nothing", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an unknown body key with 422 (strict stays strict)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a userId in the body", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request( + "http://localhost/api/auth/me/health-score-config", + { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }, + ); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ALL_EIGHT }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("guards the write on the base token and 409s a stale one", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ + pillars: ALL_EIGHT, + baseUpdatedAt: "2026-07-24T08:00:00.000Z", + }), + ); + expect(res.status).toBe(409); + const env = (await res.json()) as { meta?: { errorCode?: string } }; + expect(env.meta?.errorCode).toBe("health_score_config_conflict"); + expect(prisma.user.update).not.toHaveBeenCalled(); + // A write that never landed must not evict the caches either. + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/health-score-config/route.ts b/src/app/api/auth/me/health-score-config/route.ts new file mode 100644 index 000000000..4020933dc --- /dev/null +++ b/src/app/api/auth/me/health-score-config/route.ts @@ -0,0 +1,219 @@ +/** + * v1.35.0 — which pillars count toward the Health Score. + * + * GET /api/auth/me/health-score-config — the resolved composition: + * which pillars count, which + * the person took out, whether + * they have chosen at all, and + * the recipe version. + * PATCH /api/auth/me/health-score-config — body `{ pillars: [...] }`, + * the pillars that should + * count. Field-by-field write + * of the columns this route + * owns; no mass assignment. + * + * The body speaks the positive selection because that is what a person + * chooses. The column stores its complement (see + * `src/lib/analytics/score/config.ts`), so the inversion happens once, + * here, on the way in. + * + * Two things this route is responsible for beyond storing a list. + * + * **It refuses a selection that cannot produce a score.** Three distinct + * areas of health and at least one physical measurement, or the + * composite does not exist. Saving such a selection would leave the + * person with a settings page that says one thing and a score that has + * silently vanished, so the write is refused with a sentence they can + * act on. The scorer applies the same rule again at read time through + * the same function, deliberately: a config written before a rule change + * or edited outside the app still meets an honest refusal rather than a + * missing number. + * + * **It evicts the score caches.** The person just changed what the + * number means; serving the old one for the next hour would make the + * change look broken. `invalidateUserHealthScore` is the same call the + * lab and assessment write paths make. + * + * `userId` is always narrowed from `requireAuth()`; the body never + * carries it. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + takeBaseToken, + guardedUserUpdate, + invalidBaseTokenError, +} from "@/lib/optimistic-lock"; +import { evaluateScoreBreadth } from "@/lib/analytics/score/breadth"; +import { + healthScoreConfigFromSelection, + resolveHealthScoreConfig, + scorePillarIdSchema, +} from "@/lib/analytics/score/config"; +import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +/** + * The request body. `.strict()` and enum-typed: a client sending an id + * this build does not know is a mistake worth reporting, unlike a + * stored blob, which is filtered instead so one stale string cannot + * discard a recipe (see the resolver). + */ +const healthScoreConfigPatchSchema = z + .object({ + pillars: z.array(scorePillarIdSchema).max(SCORE_PILLAR_IDS.length), + }) + .strict(); + +/** + * Why a selection cannot produce a score, said the way a person would + * say it. The cardiometabolic sentence is the one that surprises people: + * blood pressure, glucose and cholesterol are three pillars but one area. + */ +const BREADTH_REFUSAL = { + measured_physiological_domain_required: + "A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.", + three_domains_required: + "A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one.", +} as const; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.health-score-config.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { healthScoreConfigJson: true, updatedAt: true }, + }); + const resolved = resolveHealthScoreConfig(row?.healthScoreConfigJson); + // A person who never chose has nothing to guard a write against, so + // they get no token — the first save is the unconditional write. + if (row?.healthScoreConfigJson == null) return apiSuccess(resolved); + return apiSuccess({ ...resolved, updatedAt: row.updatedAt?.toISOString() }); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `health-score-config:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "health-score-config.body.invalid_json"); + } + + // Strip the optimistic-concurrency token before the strict parse, the + // way the modules route does: the schema must stay strict, so the + // transport token can never reach it. + const taken = takeBaseToken(body ?? {}); + if ("invalid" in taken) return invalidBaseTokenError(); + const base = taken.base; + + const parsed = healthScoreConfigPatchSchema.safeParse(taken.rest ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.health-score-config.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "health_score_config.invalid", + }); + } + + const breadth = evaluateScoreBreadth(parsed.data.pillars); + if (!breadth.ok) { + annotate({ + action: { name: "auth.me.health-score-config.patch.too_narrow" }, + meta: { + reason: breadth.reason, + selected: parsed.data.pillars.length, + domains: breadth.domains.length, + }, + }); + return apiError(BREADTH_REFUSAL[breadth.reason], 422, { + errorCode: "health_score_config.too_narrow", + reason: breadth.reason, + }); + } + + // The recipe version is monotonic per account: a score record has to + // be able to say which recipe produced it, and a comparison between + // two windows has to be able to see that the recipe moved between + // them. Read the previous version, increment, write. + const existing = await prisma.user.findUnique({ + where: { id: user.id }, + select: { healthScoreConfigJson: true }, + }); + const previous = resolveHealthScoreConfig(existing?.healthScoreConfigJson); + const config = healthScoreConfigFromSelection({ + selection: parsed.data.pillars, + version: previous.version + 1, + changedAt: new Date(), + }); + + const guarded = await guardedUserUpdate({ + userId: user.id, + base, + data: { healthScoreConfigJson: config }, + conflict: { + action: "auth.me.health-score-config.conflict", + errorCode: "health_score_config_conflict", + message: "The score selection changed since it was loaded", + }, + }); + if ("conflict" in guarded) return guarded.conflict; + + // The composite the caches hold was computed from the old selection. + invalidateUserHealthScore(user.id); + + const resolved = resolveHealthScoreConfig(config); + + await auditLog("user.health_score_config.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + counted: resolved.pillars, + excluded: resolved.excludedPillars, + version: resolved.version, + }, + }); + + annotate({ + action: { name: "auth.me.health-score-config.patch" }, + meta: { + counted: resolved.pillars.length, + excluded: resolved.excludedPillars.length, + version: resolved.version, + }, + }); + + return apiSuccess({ ...resolved, updatedAt: guarded.updatedAt }); +}); diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index 3bc33635d..1e23957ac 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -51,6 +51,12 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => { }; }); +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserHealthScore: vi.fn() }; +}); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -66,6 +72,7 @@ import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -320,6 +327,39 @@ describe("PATCH /api/auth/me/modules", () => { }); }); +/** + * v1.35.0 — four of the score's pillars come and go with a module + * toggle, so a toggle changes what the composite is made of. The score + * caches hold the old composition and are served for up to an hour, so + * the person who just turned a module off keeps seeing a number + * computed from it. Nothing about that is visible in a test that does + * not assert the eviction, which is why it went unnoticed for so long. + */ +describe("PATCH /api/auth/me/modules — the score caches", () => { + it("evicts the score caches after a module toggle lands", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ sleep: false }), + ); + expect(res.status).toBe(200); + expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); + }); + + it("does not evict them when the write never landed", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }), + ); + expect(res.status).toBe(409); + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.22 (M3) — optimistic concurrency. The base token is stripped BEFORE * the `.strict()` schema parse, so it never trips strict validation; a stale diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 637876743..05060be90 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -35,6 +35,7 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { takeBaseToken, @@ -137,6 +138,12 @@ export const PATCH = apiHandler(async (req: Request) => { }); if ("conflict" in guarded) return guarded.conflict; + // 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 + // change the person made on purpose. + invalidateUserHealthScore(user.id); + const modules = await resolveModuleMap(user.id); await auditLog("user.modules.update", { diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts index 27a3272a6..e26683bf1 100644 --- a/src/lib/analytics/score/__tests__/breadth.test.ts +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -138,7 +138,7 @@ describe("evaluateScoreBreadth", () => { }); describe("the scorer refuses exactly what the write refuses", () => { - for (const { what, ids, reason } of CASES) { + for (const { what, ids } of CASES) { it(`agrees on ${what}`, () => { const composite = computeComposite({ pillars: ids.map((id) => pillar(id)), diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 1e631c218..a0abfff9e 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -330,6 +330,7 @@ export const USER_RESET = { moodTagLayoutJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, + healthScoreConfigJson: Prisma.DbNull, globalExcludedInjectionSites: [], moodReminderEnabled: false, notificationPrefs: Prisma.DbNull,