Merge branch 'v143/fixdata' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 06:22:02 +02:00
commit a806676a71
62 files changed
+2018 -209

No files matched your search

@@ -66,10 +66,14 @@ const post = (body: unknown) =>
const NOW = new Date("2026-10-16T12:00:00Z");
const send = vi.fn();
function settings(dryRun: unknown, method = "hour_mean_v1") {
function settings(
dryRun: unknown,
method = "hour_mean_v1",
appliedAt: Date | null = null,
) {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
pulseDayMeanMethod: method,
pulseDayMeanAppliedAt: null,
pulseDayMeanAppliedAt: appliedAt,
pulseDayMeanDryRunJson: dryRun,
} as never);
}
@@ -136,6 +140,7 @@ describe("GET", () => {
method: "hour_mean_v1",
appliedAt: null,
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
lastRebuild: null,
});
});
@@ -199,7 +204,9 @@ describe("POST dry_run", () => {
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
expect.objectContaining({
update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } },
}),
);
});
});
@@ -264,10 +271,71 @@ describe("POST apply", () => {
});
});
describe("POST rebuild", () => {
const failedRebuild = {
status: "failed",
startedAt: "2026-10-15T09:00:00.000Z",
finishedAt: "2026-10-15T09:05:00.000Z",
accounts: null,
rows: null,
};
it("is refused before any apply", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
const res = await post({ action: "rebuild" });
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.dryRunRequired",
);
expect(send).not.toHaveBeenCalled();
});
it("runs the recalculation again after a stopped one, without a new dry run", async () => {
// The dry run is three weeks old: apply would refuse it, a rebuild does not.
settings(
{ ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild },
"weighted_hour_mean_v2",
new Date("2026-09-20T09:00:00.000Z"),
);
const res = await post({ action: "rebuild" });
expect(res.status).toBe(202);
expect(send).toHaveBeenCalledWith(
"pulse-day-mean",
{ action: "rebuild" },
expect.anything(),
);
expect(
vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0],
).toMatchObject({
update: {
pulseDayMeanDryRunJson: { rebuild: { status: "queued" } },
},
});
expect(auditLog).toHaveBeenCalledWith(
"admin.pulseDayMean.rebuild",
expect.anything(),
);
});
it("shows a rebuild left queued with no job behind it as failed", async () => {
settings(
{
...doneAt("2026-10-15T08:00:00.000Z"),
rebuild: { ...failedRebuild, status: "running", finishedAt: null },
},
"weighted_hour_mean_v2",
new Date("2026-10-15T09:00:00.000Z"),
);
const body = await (await get()).json();
expect(body.data.lastRebuild.status).toBe("failed");
expect(body.data.lastDryRun.status).toBe("done");
});
});
describe("POST body", () => {
it("is strict", async () => {
expect((await post({ action: "apply", force: true })).status).toBe(422);
expect((await post({ action: "rebuild" })).status).toBe(422);
expect((await post({ action: "purge" })).status).toBe(422);
expect(send).not.toHaveBeenCalled();
});
@@ -4,7 +4,7 @@
*
* GET answers the active method, when it was applied and the last dry run
* (status and result: per-account ordinal labels only, never an id). POST
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
* `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run
* computes both methods for every account and day and writes nothing but its
* result; `apply` needs a successful dry run from the last seven days (409
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
@@ -31,6 +31,7 @@ import {
PULSE_DAY_MEAN_SEND_OPTIONS,
readPulseDayMeanStatus,
writeDryRun,
writeRebuild,
type PulseDayMeanPayload,
} from "@/lib/jobs/pulse-day-mean";
import { annotate } from "@/lib/logging/context";
@@ -41,6 +42,7 @@ import {
import {
hasFreshDryRun,
pulseDayMeanActionSchema,
type PulseDayMeanStatus,
} from "@/lib/validations/pulse-day-mean";
export const dynamic = "force-dynamic";
@@ -54,6 +56,33 @@ function busy() {
});
}
/**
* Queue the rebuild and record it as queued, so the card shows it from the
* moment it is asked for and a rebuild that never starts reads as failed.
* When another run holds the singleton, the previous rebuild state is put
* back and the answer is false.
*/
async function sendRebuild(
boss: NonNullable<ReturnType<typeof getGlobalBoss>>,
previous: PulseDayMeanStatus["lastRebuild"],
): Promise<boolean> {
await writeRebuild({
status: "queued",
startedAt: new Date().toISOString(),
finishedAt: null,
accounts: null,
rows: null,
});
const payload: PulseDayMeanPayload = { action: "rebuild" };
const jobId = await boss.send(
PULSE_DAY_MEAN_QUEUE,
payload,
PULSE_DAY_MEAN_SEND_OPTIONS,
);
if (jobId === null) await writeRebuild(previous);
return jobId !== null;
}
export const GET = apiHandler(async () => {
await requireAdmin();
const status = await readPulseDayMeanStatus();
@@ -110,6 +139,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ enqueued: true }, 202);
}
if (action === "rebuild") {
// The recalculation again, after an apply whose rebuild stopped. The
// method is already switched; the dry run that justified it stands.
const status = await readPulseDayMeanStatus();
if (status.appliedAt === null) {
return apiError(
"Nothing applied yet; run a dry run and apply first",
409,
{ errorCode: "maintenance.pulseDayMean.dryRunRequired" },
);
}
const enqueued = await sendRebuild(boss, status.lastRebuild);
if (!enqueued) return busy();
await auditLog("admin.pulseDayMean.rebuild", {
userId: user.id,
ipAddress: getClientIp(request),
details: { method: status.method },
});
annotate({ meta: { pulse_day_mean_enqueued: true } });
return apiSuccess({ enqueued: true }, 202);
}
if (!hasFreshDryRun(await readPulseDayMeanStatus())) {
return apiError(
"Run a dry run first; apply needs one finished in the last seven days",
@@ -135,13 +186,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
setActivePulseDayMeanMethod(APPLIED_METHOD);
const payload: PulseDayMeanPayload = { action: "rebuild" };
const jobId = await boss.send(
PULSE_DAY_MEAN_QUEUE,
payload,
PULSE_DAY_MEAN_SEND_OPTIONS,
const enqueued = await sendRebuild(
boss,
(await readPulseDayMeanStatus()).lastRebuild,
);
const enqueued = jobId !== null;
await auditLog("admin.pulseDayMean.apply", {
userId: user.id,
+31 -2
View File
@@ -26,10 +26,14 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { auditCInstrument } from "@/lib/mental-health/audit-c";
import { createAuditC } from "@/lib/profile/life-circumstances";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
/** How far ahead of the server clock a `takenAt` may lie. */
const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000;
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
async function postAuditC(request: NextRequest): Promise<Response> {
@@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise<Response> {
}
const input = parsed.data;
// The cut-off follows the language of the wording that was answered, and
// the only wording this account is ever shown is the one the read route
// delivers for its language. The stored language is that one, decided
// here; a client naming another wording answered something this server
// did not offer (or the account language changed under an open form), and
// is refused rather than scored against the wrong table.
const offered = auditCInstrument(user.locale ?? "en").locale;
if (
input.locale !== undefined &&
auditCInstrument(input.locale).locale !== offered
) {
return apiError("The wording answered is not the one offered", 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
// An entry dated in the future would hold the "newest" place for as long
// as the clock takes to reach it. A few minutes of device clock skew pass.
const takenAt = input.takenAt ? new Date(input.takenAt) : undefined;
if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) {
return apiError("takenAt lies in the future", 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
const { result, replayed } = await createAuditC(user.id, {
items: input.items,
takenAt: input.takenAt ? new Date(input.takenAt) : undefined,
takenAt,
tz: input.tz,
locale: input.locale ?? user.locale ?? "en",
locale: offered,
source: input.source,
externalId: input.externalId,
});
+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,
@@ -40,9 +40,25 @@ vi.mock("@/lib/auth/oidc", async () => {
};
});
vi.mock("@/lib/managed-profiles/claim-cookie", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/managed-profiles/claim-cookie")
>()),
readClaimCookie: vi.fn(),
}));
vi.mock("@/lib/managed-profiles/handover", () => ({
isHandoverUsable: vi.fn(),
}));
import { GET } from "../route";
import { readClaimCookie } from "@/lib/managed-profiles/claim-cookie";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
} from "@/lib/auth/oidc";
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
return new NextRequest(`http://localhost${path}`);
@@ -248,6 +264,34 @@ describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => {
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
});
it("asks the provider for a fresh sign-in on a claim, and only there", async () => {
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
vi.mocked(buildAuthorizationUrl).mockReturnValue(
"https://idp.example.com/authorize?x=1",
);
vi.mocked(readClaimCookie).mockReturnValue({
handoverId: "handover-1",
binding: "binding-1",
} as never);
vi.mocked(isHandoverUsable).mockResolvedValue(true);
await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({
forceLogin: true,
});
vi.mocked(buildAuthorizationUrl).mockClear();
await GET(makeRequest("/api/auth/oidc/login"));
expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({
forceLogin: false,
});
});
it("routes the claim's other failures to the claim page too", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
+2
View File
@@ -156,6 +156,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
nonce,
codeChallenge,
redirectUri,
// A claim only; an ordinary sign-in keeps single sign-on.
forceLogin: claimBinding !== null,
});
const response = NextResponse.redirect(authorizationUrl);
@@ -50,6 +50,7 @@ const { profile, userUpdate, transaction } = vi.hoisted(() => {
};
const userUpdate = vi.fn(async () => ({}));
const tx = {
$queryRaw: vi.fn(async () => []),
userHealthProfile: profile,
user: { update: userUpdate },
};
@@ -44,6 +44,7 @@ const {
const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) }));
const userUpdate = vi.fn(async () => ({}));
const tx = {
$queryRaw: vi.fn(async () => []),
userHealthProfile: {
findUnique: profileFindUnique,
upsert: profileUpsert,
@@ -246,6 +247,7 @@ describe("about-me module gate", () => {
transaction.mockImplementationOnce(
async (callback: (client: unknown) => unknown) => {
const result = await callback({
$queryRaw: vi.fn(async () => []),
userHealthProfile: {
findUnique: profileFindUnique,
upsert: txProfileUpsert,
+4
View File
@@ -42,6 +42,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { lockAiSections } from "@/lib/profile/life-circumstances";
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
@@ -246,6 +247,9 @@ export const PUT = apiHandler(async (req: Request) => {
// A PUT here replaces the eight sections it lists and leaves the
// life-circumstance switches (v1.43) exactly as stored.
if (parsed.data.aiIncludedSections !== undefined) {
// Taken before the read, so a switch changed concurrently is read
// after it commits and carried over as it now stands.
await lockAiSections(tx, user.id);
const stored = await tx.userHealthProfile.findUnique({
where: { userId: user.id },
select: { aiIncludedSections: true },
@@ -37,7 +37,7 @@ vi.mock("@/lib/db", () => ({
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
mentalHealthAssessment: { findFirst: vi.fn(async () => null) },
mentalHealthAssessment: { findMany: vi.fn(async () => []) },
user: { findUnique: vi.fn(), update: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
+34 -31
View File
@@ -81,8 +81,10 @@ import {
import {
classifyHourBucket,
HOUR_BUCKET_SKIP_REASONS,
combineHourWrites,
parseHourBucketId,
writeHourBuckets,
type HourBucketEntry,
type HourBucketWrite,
} from "@/lib/measurements/intraday-hour-buckets";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
@@ -394,7 +396,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.43 — hourly sums for the intraday profile, and the zone they are cut in
// (the account's, the one `/api/auth/me` publishes as `timezone`).
const hourWrites: HourBucketWrite[] = [];
const hourWrites: HourBucketEntry[] = [];
let accountTz: string | undefined;
const batchNow = new Date();
@@ -484,6 +486,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
dateKey: hourId.dateKey,
hour: hourId.hour,
value: hourMapped.value,
startMs: startDate.getTime(),
});
continue;
}
@@ -984,28 +987,24 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// v1.43 — the hours, after the measurements. The last entry for one slot
// wins, as for the day rows; an earlier one in the same batch is a
// superseded duplicate.
// v1.43 — the hours, after the measurements. One write per slot: the last
// entry for one start wins (an earlier one in the same batch is a
// superseded duplicate), and the two starts of the hour a fall-back day
// repeats are summed (`combineHourWrites`).
let hourInsertedCount = 0;
let hourUpdatedCount = 0;
const hourLanded: HourBucketWrite[] = [];
if (hourWrites.length > 0 && accountTz) {
const lastBySlot = new Map<string, HourBucketWrite>();
for (const w of hourWrites) {
lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w);
const { writes, superseded } = combineHourWrites(hourWrites);
for (const index of superseded) {
results[index] = {
index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount++;
}
for (const w of hourWrites) {
if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) {
results[w.index] = {
index: w.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount++;
}
}
const toWrite = [...lastBySlot.values()];
const toWrite = writes.map(({ write }) => write);
try {
const statuses = await writeHourBuckets(
prisma,
@@ -1013,21 +1012,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
accountTz,
toWrite,
);
for (const w of toWrite) {
const status = statuses.get(w.index) ?? "inserted";
results[w.index] = { index: w.index, status };
if (status === "inserted") hourInsertedCount++;
else hourUpdatedCount++;
hourLanded.push(w);
for (const { write, contributors } of writes) {
const status = statuses.get(write.index) ?? "inserted";
for (const index of contributors) {
results[index] = { index, status };
if (status === "inserted") hourInsertedCount++;
else hourUpdatedCount++;
}
hourLanded.push(write);
}
} catch (err) {
for (const w of toWrite) {
results[w.index] = {
index: w.index,
status: "failed",
reason: "write_failed",
};
failedCount++;
for (const { contributors } of writes) {
for (const index of contributors) {
results[index] = {
index,
status: "failed",
reason: "write_failed",
};
failedCount++;
}
}
annotate({
action: { name: "measurement.batch.hour-bucket.failed" },
@@ -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,