mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385
# Conflicts: # CHANGELOG.md
This commit is contained in:
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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user