Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 06:35:35 +02:00
commit 7c5f49c18e
36 files changed
+1613 -45

No files matched your search

@@ -5,6 +5,11 @@
* provider type, failing counted from the LAST result only, the worst
* uninterrupted failure run taken across failing users, central types
* sorted first, and no per-user data in the response.
*
* It also pins the HTTP status the ledger has recorded on every failure
* since v1.11.0 and nothing read until now: it must describe the SAME
* failure the row's instant names, or the card tells the operator to go
* looking at the wrong thing.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -49,11 +54,13 @@ function group(
consecutiveFailures?: number;
lastFailureAt?: Date | null;
lastOkAt?: Date | null;
lastStatus?: number | null;
} = {},
) {
return {
providerType,
lastResult,
lastStatus: max.lastStatus ?? null,
_count: { _all: count },
_max: {
consecutiveFailures: max.consecutiveFailures ?? 0,
@@ -86,6 +93,7 @@ describe("GET /api/admin/provider-health", () => {
group("admin-openai", "hard_failed", 2, {
consecutiveFailures: 3334,
lastFailureAt: new Date("2026-08-27T06:00:00Z"),
lastStatus: 503,
}),
] as never);
@@ -99,10 +107,64 @@ describe("GET /api/admin/provider-health", () => {
maxConsecutiveFailures: 3334,
lastOkAt: "2026-08-27T07:00:00.000Z",
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: 503,
},
]);
});
it("reports the status of the newest failure, not the highest number", async () => {
// A 500 an hour ago and a 401 a minute ago: the operator needs the
// 401, because that is the one that will not clear on its own. Taking
// a MAX over the column would hand back the 500.
groupBy.mockResolvedValue([
group("openai", "hard_failed", 4, {
consecutiveFailures: 2,
lastFailureAt: new Date("2026-08-27T05:00:00Z"),
lastStatus: 500,
}),
group("openai", "auth_failed", 1, {
consecutiveFailures: 1,
lastFailureAt: new Date("2026-08-27T06:59:00Z"),
lastStatus: 401,
}),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.providers[0]).toMatchObject({
lastFailureAt: "2026-08-27T06:59:00.000Z",
lastFailureStatus: 401,
});
});
it("leaves the status out for a network-class failure that never had one", async () => {
groupBy.mockResolvedValue([
group("local", "hard_failed", 1, {
consecutiveFailures: 9,
lastFailureAt: new Date("2026-08-27T06:00:00Z"),
lastStatus: null,
}),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.providers[0]).toMatchObject({
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: null,
});
});
it("asks the ledger to split the fold by status rather than aggregate it", async () => {
groupBy.mockResolvedValue([] as never);
await GET();
expect(groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ["providerType", "lastResult", "lastStatus"],
}),
);
});
it("counts auth_failed as failing and keeps an all-ok type at zero", async () => {
groupBy.mockResolvedValue([
group("codex", "auth_failed", 1, { consecutiveFailures: 7 }),
+23 -1
View File
@@ -15,6 +15,12 @@ export const dynamic = "force-dynamic";
* folds the ledger into one row per provider type so the console can show
* it. Read-only, admin-cookie-only, and it exposes counts and timestamps
* only — never which user a row belongs to.
*
* The ledger also records the HTTP status each failure came back with, and
* that is the difference between the two questions an operator actually has
* when a provider goes red: a 401 means the key is dead and only the operator
* can fix it, a 429 or a 5xx means the provider is having a day and waiting is
* the right move. The fold carries it beside the instant it belongs to.
*/
interface ProviderHealthSummary {
@@ -27,6 +33,13 @@ interface ProviderHealthSummary {
maxConsecutiveFailures: number;
lastOkAt: string | null;
lastFailureAt: string | null;
/**
* HTTP status the failure at `lastFailureAt` came back with. Null when
* that failure was a network-class one (no response to read a status
* from), and null when the user it belongs to has since recovered — a
* success clears the status along with the rest of the failure state.
*/
lastFailureStatus: number | null;
}
/** The two operator-managed tags sort first — they affect every user on the chain. */
@@ -37,7 +50,11 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "admin.provider-health.get" } });
const groups = await prisma.providerHealth.groupBy({
by: ["providerType", "lastResult"],
// `lastStatus` joins the grouping key rather than an aggregate: the
// MAX of two HTTP statuses is not a status, it is arithmetic on a
// label. Grouping by it splits each result bucket per status and the
// fold below picks the one belonging to the newest failure.
by: ["providerType", "lastResult", "lastStatus"],
_count: { _all: true },
_max: {
consecutiveFailures: true,
@@ -55,6 +72,7 @@ export const GET = apiHandler(async () => {
maxConsecutiveFailures: 0,
lastOkAt: null,
lastFailureAt: null,
lastFailureStatus: null,
};
row.tracked += g._count._all;
if (g.lastResult !== "ok") {
@@ -69,6 +87,10 @@ export const GET = apiHandler(async () => {
const fail = g._max.lastFailureAt?.toISOString() ?? null;
if (fail && (!row.lastFailureAt || fail > row.lastFailureAt)) {
row.lastFailureAt = fail;
// Tied to the instant, not taken from whichever group happens to
// have one: a status that describes a different failure than the
// one on screen is worse than no status at all.
row.lastFailureStatus = g.lastStatus;
}
byType.set(g.providerType, row);
}
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The blood-glucose display unit — the writer the column never had.
*
* `User.glucoseUnit` shipped in v1.2 and about thirty surfaces read it, but
* no route, form or schema could ever set it, so every account stayed on the
* mg/dL default and the mmol/L half of the app was dead code. This pins the
* missing end: a PATCH writes the column and echoes the resolved state, a GET
* reads it back, and an unrecognised unit is refused rather than stored —
* a stray string in that column would resolve to mg/dL and quietly put every
* mmol/L reader back on the wrong number.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: 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("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";
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 },
};
function mkGet(): Request {
return new Request("http://localhost/api/auth/me/glucose-unit");
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/glucose-unit", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("resolves an account that never chose to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mg/dL");
});
it("reads back the unit the account chose", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
});
});
describe("PATCH /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("moves an account to mmol/L, echoes it, and audits the transition", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
// The write must actually land, scoped to the caller, field-by-field.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mmol/L" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.glucose-unit.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: "mg/dL",
next: "mmol/L",
}),
}),
);
});
it("moves an account back to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mg/dL" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mg/dL" },
});
});
it("refuses a unit outside the two the app renders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: bad }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a missing glucoseUnit field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ otherField: "mmol/L" }),
);
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({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Per-user blood-glucose display unit.
*
* GET /api/auth/me/glucose-unit — current unit.
* PATCH /api/auth/me/glucose-unit — body
* `{ glucoseUnit: "mg/dL" | "mmol/L" }`.
*
* The column has existed since v1.2 and roughly thirty surfaces read it —
* the dashboard tiles, the glucose insight page, the targets panel, the CSV
* and FHIR exports, the doctor report, the Coach snapshot, the safety-floor
* push copy. None of it was reachable, because nothing could ever write the
* column: every account was NULL, and NULL resolves to mg/dL. For anyone in
* a country that reads glucose in mmol/L that was the wrong number on every
* screen. This is the missing end.
*
* Canonical storage stays mg/dL on every row and every ingest path. The unit
* selects a display branch, exactly like `unit-preference` next door; the
* entry form inverts through `toCanonicalMgdl` so a value TYPED in mmol/L
* still persists as mg/dL. Nothing already stored is reinterpreted.
*
* Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate limit,
* `safeJson` with a 1 KB cap, Zod safeParse → 422 via `returnAllZodIssues`,
* audit-log row, field-by-field write. Idempotent — the endpoint always
* returns the resolved next state so the client can hard-set the optimistic
* update without an extra round-trip.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type GlucoseUnitResponse = {
glucoseUnit: GlucoseUnit;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.glucose-unit.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
});
const payload: GlucoseUnitResponse = {
glucoseUnit: resolveGlucoseUnit(row?.glucoseUnit),
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`glucose-unit: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;
}
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = glucoseUnitPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.glucose-unit.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.glucoseUnit;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
});
await prisma.user.update({
where: { id: user.id },
data: { glucoseUnit: next },
});
await auditLog("user.glucose-unit.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: resolveGlucoseUnit(previous?.glucoseUnit),
next,
},
});
annotate({
action: { name: "auth.me.glucose-unit.patch" },
meta: { glucoseUnit: next },
});
const payload: GlucoseUnitResponse = { glucoseUnit: next };
return apiSuccess(payload);
});
@@ -59,6 +59,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
import { NextRequest } from "next/server";
import { POST } from "../route";
import { MGDL_PER_MMOL } from "@/lib/glucose";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
@@ -369,7 +370,7 @@ describe("POST /api/import/csv — contextless blood glucose", () => {
externalId: "sensor-1",
glucoseContext: null,
});
expect(row.value).toBeCloseTo(95.4848, 3);
expect(row.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
expect((row.measuredAt as Date).toISOString()).toBe(
"2024-04-03T02:15:00.000Z",
);