mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Add the route that chooses what counts toward the score
PATCH /api/auth/me/health-score-config takes the pillars a person wants counted and stores the rest as the deselection list. A selection that cannot produce a score is refused with a sentence naming what is missing, using the same rule the scorer applies at read time. The write evicts the score caches, and so does the modules route, which changes the same composition and never did.
This commit is contained in:
1 parent
4e649de4f7
commit
2f6f3573a2
6 files changed
+668
-1
No files matched your search
@@ -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<typeof import("@/lib/cache/invalidate")>();
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(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<Response>)(
|
||||
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<Response>)(
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -51,6 +51,12 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user