From 2ea08c696693914059eb3efa579cc403daa6c3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:46:13 +0200 Subject: [PATCH 1/8] fix: record the export stamp an Apple Health import already promised The import status endpoint returns the instant the Health app stamped on the archive, the published contract requires the field, and the schema comment said the parser filled it during unpack. It never did: ExportDate sat on the parser's ignore list, so the column answered null on every job ever run, for a year. The parser reads the element now and hands the stamp to the worker on the next chunk boundary, so the value lands while the run is still going and a job that later fails still records which export it was working from. An archive that omits the element, or writes a value the runtime cannot read, leaves the column null rather than guessing at an instant. No contract change: the endpoint and the OpenAPI description already described this behaviour. --- prisma/schema.prisma | 12 +++- .../apple-health-import-export-date.test.ts | 69 +++++++++++++++++++ src/lib/jobs/apple-health-import-worker.ts | 10 +++ .../import-apple-health-export.test.ts | 52 ++++++++++++++ .../import-apple-health-export.ts | 40 ++++++++++- 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 src/lib/jobs/__tests__/apple-health-import-export-date.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 311b0c061..77a61c6cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4968,8 +4968,16 @@ model ImportJob { /// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older /// revision never short-circuits a fresh upload of the same bytes. parserRevision Int @default(1) @map("parser_revision") - /// Apple's `ExportDate` attribute string-formatted, parsed during - /// the unpack phase. NULL until the parser sees it. + /// The instant the Health app stamped on the archive itself — the + /// `value` attribute of the `` element that opens + /// `export.xml`. Written mid-run by the parse phase the moment the + /// element goes past (`onExportDate` in + /// `src/lib/measurements/import-apple-health-export.ts`, persisted by + /// `src/lib/jobs/apple-health-import-worker.ts`), so a job that later + /// fails still records which export it was working from. Consumer: the + /// status endpoint (`src/app/api/import/apple-health-export/[jobId]/ + /// status/route.ts`). NULL until the parser reaches the element, and + /// for an archive that omits it or writes an unreadable value. exportedAt DateTime? @map("exported_at") startedAt DateTime @default(now()) @map("started_at") completedAt DateTime? @map("completed_at") diff --git a/src/lib/jobs/__tests__/apple-health-import-export-date.test.ts b/src/lib/jobs/__tests__/apple-health-import-export-date.test.ts new file mode 100644 index 000000000..4b9b55f37 --- /dev/null +++ b/src/lib/jobs/__tests__/apple-health-import-export-date.test.ts @@ -0,0 +1,69 @@ +/** + * `ImportJob.exportedAt` — the worker end of the wiring. + * + * The parse side is proved behaviourally in + * `src/lib/measurements/__tests__/import-apple-health-export.test.ts`: + * the parser hands the archive's own stamp to `onExportDate`. What that + * cannot show is whether anything persists it. The column, the status + * endpoint that returns it and the published contract that requires it + * all existed for a year while the one line that would produce the value + * was a `return`, so the half of the pipe worth pinning is this one. + * + * Source-shaped rather than run-shaped: driving the worker needs a real + * `export.zip`, a queue and a database, which the ECG contract test + * beside this one already declines to do for the same reason. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const workerSource = readFileSync( + join(process.cwd(), "src/lib/jobs/apple-health-import-worker.ts"), + "utf8", +); +const parserSource = readFileSync( + join(process.cwd(), "src/lib/measurements/import-apple-health-export.ts"), + "utf8", +); +const statusRouteSource = readFileSync( + join( + process.cwd(), + "src/app/api/import/apple-health-export/[jobId]/status/route.ts", + ), + "utf8", +); + +describe("Apple Health import — the archive's export stamp reaches the column", () => { + it("subscribes to the parser's export-date hook", () => { + expect(workerSource).toMatch(/onExportDate:\s*async\s*\(exportedAt\)/); + }); + + it("writes the stamp onto the job row it is running", () => { + expect(workerSource).toMatch( + /onExportDate:[\s\S]{0,400}?prisma\s*\.?\s*importJob[\s\S]{0,200}?\.update\([\s\S]{0,200}?exportedAt/, + ); + }); + + it("keeps the write best-effort so provenance cannot fail an import", () => { + expect(workerSource).toMatch( + /onExportDate:[\s\S]{0,400}?exportedAt[\s\S]{0,120}?\.catch\(/, + ); + }); + + it("no longer discards the element that carries the stamp", () => { + // The ignore list is where `ExportDate` spent its first year. + const ignoreBlock = /Known elements we intentionally ignore/.exec( + parserSource, + ); + expect(ignoreBlock).not.toBeNull(); + const before = parserSource.slice( + Math.max(0, (ignoreBlock?.index ?? 0) - 400), + ignoreBlock?.index ?? 0, + ); + expect(before).not.toMatch(/name === "ExportDate" \|\|/); + }); + + it("still surfaces the column the contract promises", () => { + expect(statusRouteSource).toMatch(/exportedAt:\s*row\.exportedAt/); + }); +}); diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 60cc8b8e9..c610a4825 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -417,6 +417,16 @@ export async function handleAppleHealthImport( }, }); }, + // The instant the Health app stamped on the archive. Persisted as + // soon as the parser reads it, so the status endpoint answers with + // it for the rest of the run and a job that later fails still + // records which export it was working from. Best-effort: the + // stamp is provenance, and losing it must not fail an import. + onExportDate: async (exportedAt) => { + await prisma.importJob + .update({ where: { id: importJobId }, data: { exportedAt } }) + .catch(() => {}); + }, })), ecg: { discovered: 0, diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index a2a018d1b..ddb4c3c6c 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -1226,3 +1226,55 @@ describe("streamParseExportXml — cycle module off", () => { expect(result.totals.recordsRead).toBe(2); }); }); + +/** + * `ImportJob.exportedAt` — the instant the Health app stamped on the + * archive. The status endpoint and the published contract have both + * promised it since v1.4.34, but `` sat on the parser's + * ignore list, so the column could only ever answer null. These pin the + * capture: remove the `onExportDate` call from the parser and the first + * case goes red; drop the NaN guard and the third does. + */ +describe("streamParseExportXml — the archive's own export stamp", () => { + async function parseFor(xml: string) { + const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-export-date-")); + const xmlPath = join(tmp, "export.xml"); + writeFileSync(xmlPath, xml); + const stamps: Date[] = []; + await streamParseExportXml({ + xmlPath, + userId: "user-1", + userTimezone: "Europe/Berlin", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: makeFakePrisma() as any, + onExportDate: (exportedAt) => { + stamps.push(exportedAt); + }, + }); + return stamps; + } + + it("hands the caller the instant Apple wrote on the export", async () => { + const stamps = await parseFor(tinyExportXml()); + + expect(stamps).toHaveLength(1); + // "2026-05-15 14:32:01 +0200" — the fixture's own stamp. + expect(stamps[0]?.toISOString()).toBe("2026-05-15T12:32:01.000Z"); + }); + + it("stays silent for an archive that carries no stamp", async () => { + const stamps = await parseFor( + `\n`, + ); + + expect(stamps).toEqual([]); + }); + + it("stays silent rather than guessing at an unreadable stamp", async () => { + const stamps = await parseFor( + `\n\n \n`, + ); + + expect(stamps).toEqual([]); + }); +}); diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index df9ae28e6..26a1ce0bb 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -307,6 +307,13 @@ export interface StreamParseInput { * continues even if the hook throws. */ onProgress?: (snapshot: ImportJobProgress) => Promise | void; + /** + * Called once, mid-run, with the instant Apple stamped on the archive + * (``, the first child of ``). + * Never called for an archive that omits the element or writes an + * unreadable value. Best-effort, like `onProgress`. + */ + onExportDate?: (exportedAt: Date) => Promise | void; /** * Optional override of the spot-row flush batch size. Defaults to * `SPOT_FLUSH_BATCH`. Lower values make the upsert path more @@ -339,6 +346,7 @@ export async function streamParseExportXml( userTimezone, prisma, onProgress, + onExportDate, spotBatchSize = SPOT_FLUSH_BATCH, workoutBatchSize = WORKOUT_FLUSH_BATCH, } = input; @@ -775,6 +783,21 @@ export async function streamParseExportXml( let pendingError: Error | null = null; let currentWorkout: PreparedWorkout | null = null; + // Apple stamps the archive with its own export instant as the first + // child of ``. The element used to be on the ignore list, + // so `ImportJob.exportedAt` — a column the status endpoint and the + // published contract both promise — could only ever answer null. The + // value is captured in the synchronous tag handler and handed to + // `onExportDate` from the async drain, so the worker can persist it + // while the run is still going rather than only on the terminal write. + let pendingExportDate: Date | null = null; + const flushExportDate = async (): Promise => { + const stamped = pendingExportDate; + if (!stamped) return; + pendingExportDate = null; + if (onExportDate) await onExportDate(stamped); + }; + parser.onerror = (err) => { pendingError = err instanceof Error ? err : new Error(String(err)); }; @@ -983,8 +1006,17 @@ export async function streamParseExportXml( return; } + if (name === "ExportDate") { + // `value` carries Apple's own format ("2026-05-15 14:32:01 +0200"), + // the same shape every `Record` start/end date uses. An archive + // without the element, or with a value the runtime cannot read, + // leaves the column null rather than guessing at an instant. + const stamped = new Date(attrs.value ?? ""); + if (!Number.isNaN(stamped.getTime())) pendingExportDate = stamped; + return; + } + if ( - name === "ExportDate" || name === "Me" || name === "Correlation" || name === "ActivitySummary" || @@ -1049,9 +1081,11 @@ export async function streamParseExportXml( } // Drain any batches the parser filled to keep memory bounded. const shouldFlush = + pendingExportDate !== null || spotBatch.length >= spotBatchSize || workoutBatch.length >= workoutBatchSize; const drain = async (): Promise => { + await flushExportDate(); if (spotBatch.length >= spotBatchSize) await flushSpotBatch(); if (workoutBatch.length >= workoutBatchSize) await flushWorkoutBatch(); @@ -1090,6 +1124,10 @@ export async function streamParseExportXml( await pipeline(readable, sink); if (pendingError) throw pendingError; + // An archive small enough to arrive in a single chunk can finish + // before any drain ran; the stamp still has to reach the caller. + await flushExportDate(); + await emitProgress("upserting"); // Drain any partial spot/workout batches that did not hit the flush // threshold during the parse. From 7dbec3bc4d9850a364817d78206bf6f7cb79268e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:46:46 +0200 Subject: [PATCH 2/8] feat(admin): name the status a failing provider came back with The provider-health ledger has recorded the HTTP status of every failure since v1.11.0 and nothing has ever read it. The operator card could say three of five chains were failing without saying whose problem that was, which is the one thing the operator needs: a 401 is a credential only they can replace, a 429 or a 503 is the provider's afternoon and waiting is the right move. The fold carries the status of the failure whose instant the row already shows, not an aggregate over the column. A MAX of two HTTP statuses is arithmetic on a label, and a status describing a different failure than the one on screen would send the operator to the wrong place. The column joins the grouping key instead, and the fold picks the group belonging to the newest failure. Blank where there was nothing to record: a connection that never completed has no status, and a chain that has since recovered had its status cleared along with the rest of its failure state. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + prisma/schema.prisma | 8 +- .../provider-health/__tests__/route.test.ts | 62 ++++++++++++++++ src/app/api/admin/provider-health/route.ts | 24 +++++- .../provider-health-failure-status.test.tsx | 73 +++++++++++++++++++ .../admin/provider-health-section.tsx | 14 ++++ 12 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/components/admin/__tests__/provider-health-failure-status.test.tsx diff --git a/messages/de.json b/messages/de.json index 229068fe7..b437210a3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6501,6 +6501,7 @@ "trackedUsers": "{count} Nutzer erfasst", "lastOk": "letzter Erfolg {at}", "lastFailure": "letzter Fehlschlag {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "In Ordnung", "statusFailing": "{failing} von {tracked} scheitern, {run} in Folge", "typeAdminOpenai": "Server-Schlüssel (Betreiber)", diff --git a/messages/en.json b/messages/en.json index a0de65aed..047e8a121 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6501,6 +6501,7 @@ "trackedUsers": "{count} users tracked", "lastOk": "last success {at}", "lastFailure": "last failure {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Healthy", "statusFailing": "{failing} of {tracked} failing, {run} in a row", "typeAdminOpenai": "Server key (operator)", diff --git a/messages/es.json b/messages/es.json index e40df4240..259f30ae2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6501,6 +6501,7 @@ "trackedUsers": "{count} usuarios registrados", "lastOk": "último éxito {at}", "lastFailure": "último fallo {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Correcto", "statusFailing": "{failing} de {tracked} fallan, {run} seguidos", "typeAdminOpenai": "Clave del servidor (operador)", diff --git a/messages/fr.json b/messages/fr.json index da5b9e58c..9615e31bf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6501,6 +6501,7 @@ "trackedUsers": "{count} utilisateurs suivis", "lastOk": "dernier succès {at}", "lastFailure": "dernier échec {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "En bon état", "statusFailing": "{failing} sur {tracked} en échec, {run} d'affilée", "typeAdminOpenai": "Clé du serveur (opérateur)", diff --git a/messages/it.json b/messages/it.json index 9410399b1..a341ddc18 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6501,6 +6501,7 @@ "trackedUsers": "{count} utenti tracciati", "lastOk": "ultimo successo {at}", "lastFailure": "ultimo errore {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Regolare", "statusFailing": "{failing} su {tracked} in errore, {run} di fila", "typeAdminOpenai": "Chiave del server (operatore)", diff --git a/messages/ko.json b/messages/ko.json index e128a90ca..918359ecd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7083,6 +7083,7 @@ "trackedUsers": "{count}명 사용자 추적됨", "lastOk": "마지막 성공 {at}", "lastFailure": "마지막 실패 {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "정상", "statusFailing": "{tracked}개 중 {failing}개 실패, {run}회 연속", "typeAdminOpenai": "서버 키 (운영자)", diff --git a/messages/pl.json b/messages/pl.json index 1e4a83fc1..b1aa8e00e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6501,6 +6501,7 @@ "trackedUsers": "Śledzeni użytkownicy: {count}", "lastOk": "ostatni sukces {at}", "lastFailure": "ostatnia awaria {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Sprawny", "statusFailing": "{failing} z {tracked} zawodzi, {run} z rzędu", "typeAdminOpenai": "Klucz serwera (operator)", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77a61c6cf..11fce841a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5070,7 +5070,13 @@ model ProviderHealth { userId String @map("user_id") providerType String @map("provider_type") // ProviderChainType tag lastResult String @map("last_result") // "ok" | "hard_failed" | "auth_failed" - lastStatus Int? @map("last_status") // HTTP status of the last failure; null on ok/network + /// HTTP status the last failure came back with; null on a success and on + /// a network-class failure that never had a response to read one from. + /// Consumer: the operator readout (`src/app/api/admin/provider-health/ + /// route.ts`), which carries it beside the failure instant it belongs to — + /// it is what separates a dead credential the operator has to replace from + /// a provider having a bad afternoon. + lastStatus Int? @map("last_status") consecutiveFailures Int @default(0) @map("consecutive_failures") lastOkAt DateTime? @map("last_ok_at") lastFailureAt DateTime? @map("last_failure_at") diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 0ce584510..03e0fb8de 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -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 }), diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 49b4660c7..c0163dece 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -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); } diff --git a/src/components/admin/__tests__/provider-health-failure-status.test.tsx b/src/components/admin/__tests__/provider-health-failure-status.test.tsx new file mode 100644 index 000000000..018ecac80 --- /dev/null +++ b/src/components/admin/__tests__/provider-health-failure-status.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * The provider-health card and the HTTP status of the last failure. + * + * The ledger has recorded a status on every failure since v1.11.0 and no + * surface read it, so the card could say a provider was failing without + * saying whose problem it was. A 401 is a credential the operator has to + * replace; a 503 is the provider's afternoon. These pin that the status + * reaches the row, that it is left out when there was none to record, + * and that a healthy provider does not carry a stale one. + */ + +const mockQueryState = vi.hoisted(() => ({ + data: null as null | object, + isPending: false, + isError: false, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: mockQueryState.data, + isPending: mockQueryState.isPending, + isError: mockQueryState.isError, + }), +})); + +import { ProviderHealthSection } from "../provider-health-section"; + +function row(over: Record = {}) { + return { + providerType: "admin-openai", + tracked: 5, + failing: 2, + maxConsecutiveFailures: 4, + lastOkAt: null, + lastFailureAt: "2026-08-27T06:00:00.000Z", + lastFailureStatus: 401, + ...over, + }; +} + +function render(providers: object[]) { + mockQueryState.data = { providers }; + mockQueryState.isPending = false; + mockQueryState.isError = false; + return renderToStaticMarkup( + + + , + ); +} + +describe(" — the last failure's HTTP status", () => { + it("names the status beside the failure it belongs to", () => { + expect(render([row()])).toContain("HTTP 401"); + }); + + it("says nothing where the failure never had a status to record", () => { + expect(render([row({ lastFailureStatus: null })])).not.toContain("HTTP"); + }); + + it("carries no status for a provider that is not failing", () => { + expect( + render([ + row({ failing: 0, lastFailureAt: null, lastFailureStatus: 500 }), + ]), + ).not.toContain("HTTP"); + }); +}); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index 70e27328c..d4568c20f 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -18,6 +18,12 @@ import { formatDateTime } from "@/lib/format"; * read the database. The card shows one row per provider type: how many * users' chains touch it, how many of them are currently failing, the worst * uninterrupted failure run, and the last success / failure instants. + * + * The failure line also carries the HTTP status the ledger recorded for that + * failure, because "failing" on its own does not say whose problem it is. A + * 401 is a dead credential and waiting will not fix it; a 429 or a 503 is the + * provider's day and waiting is exactly right. Absent for a network-class + * failure, which never had a status to record. */ interface ProviderHealthRow { @@ -27,6 +33,7 @@ interface ProviderHealthRow { maxConsecutiveFailures: number; lastOkAt: string | null; lastFailureAt: string | null; + lastFailureStatus: number | null; } export function ProviderHealthSection() { @@ -106,6 +113,13 @@ export function ProviderHealthSection() { at: formatDateTime(p.lastFailureAt), })}` : ""} + {p.failing > 0 && + p.lastFailureAt && + p.lastFailureStatus !== null + ? ` · ${t("admin.providerHealth.lastFailureStatus", { + status: p.lastFailureStatus, + })}` + : ""} {p.failing === 0 ? ( From 5df2b36068e0b00ddda438faf771fc4c66e3f8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:46:54 +0200 Subject: [PATCH 3/8] docs(schema): say why the device ledger keeps a first-sighting instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UserKnownDevice.firstSeenAt` has a database default, no application writer and no reader anywhere in the tree, and it is invisible to the column sweep because the sweep skips a column nothing writes. Annotated rather than dropped: the first sighting a user sees is the audit row the login-alert path writes, and that row goes on the audit-retention schedule, while the ledger row does not. This column is the ledger's own copy of the same instant, reachable through the database only — the model holds salted hashes and a coarse label precisely so it has no user-facing surface, and the backup excludes it wholesale. --- prisma/schema.prisma | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11fce841a..0c6e0a00a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1302,6 +1302,20 @@ model UserKnownDevice { userId String @map("user_id") deviceHash String @map("device_hash") label String? + /// @internal: the row's own creation instant, supplied by this default — + /// `recordSignInDevice` (`src/lib/auth/login-alert.ts`) inserts the + /// fingerprint and the label and passes no timestamp. Nothing reads it. The + /// first sighting a USER sees is the `auth.login.new_device` audit row the + /// same function writes, rendered by the security-activity card, and that + /// row is removed on the audit-retention schedule. This column is the + /// ledger's own copy of the same instant, kept because the ledger outlives + /// the audit trail and an operator asking when a device first appeared on an + /// account has nowhere else to look. Deliberately reachable through the + /// database only: the ledger stores salted hashes and a coarse label + /// precisely so it has no user-facing surface. The backup does NOT select it + /// — the whole model is excluded (`src/lib/export/backup-plan.ts`), because + /// restoring a device-recognition ledger would pre-trust devices on a host + /// that has never seen them. firstSeenAt DateTime @default(now()) @map("first_seen_at") lastSeenAt DateTime @default(now()) @map("last_seen_at") From 841593a5b2f297dac31a3b7fec0b3873a05a430c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:47:01 +0200 Subject: [PATCH 4/8] fix: converge the two factors a glucose reading was converted by A value imported in mmol/L was multiplied by 18.016; one displayed in mmol/L was divided by 18.0182. The two sat in different files, both correct-looking, and a reading that went in as 5.3 did not come back out as 5.3. Latent while nobody could select mmol/L, live the moment they can. One exported constant now, on the guideline figure the display path already used, and the glucose insight page takes it from there rather than keeping a third copy. The test names the constant instead of repeating the digits, which is how the two came apart in the first place, and it asserts the round trip a reader on mmol/L actually experiences. --- src/app/insights/blood-glucose/page.tsx | 4 +--- src/lib/glucose.ts | 10 +++++++++- src/lib/measurements/__tests__/unit-aliases.test.ts | 12 ++++++++++-- src/lib/measurements/unit-aliases.ts | 6 ++---- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 9f8cb64ff..d8dcdddb2 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -7,7 +7,7 @@ import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clin import { useAuth } from "@/hooks/use-auth"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { useTranslations } from "@/lib/i18n/context"; -import { resolveGlucoseUnit } from "@/lib/glucose"; +import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose"; /** * v1.7.0 — `/insights/blood-glucose`. @@ -25,8 +25,6 @@ import { resolveGlucoseUnit } from "@/lib/glucose"; * exports. mg/dL-preference users are unaffected (scale = 1, integer * precision). */ -const MGDL_PER_MMOL = 18.0182; - export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index 96cb33ab7..14d2dee5b 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -9,7 +9,15 @@ export type GlucoseUnit = "mg/dL" | "mmol/L"; -const MGDL_PER_MMOL = 18.0182; +/** + * mg/dL per mmol/L, per the DGIM / DDG S3 guideline. Exported because it is + * the one factor for this conversion in the tree: the inbound alias resolver + * normalises a mmol/L cell with it, the glucose insight page scales its chart + * by its reciprocal, and the helpers below round with it. Two nearly-equal + * copies of a health conversion is how the same reading comes out as two + * different numbers on two screens. + */ +export const MGDL_PER_MMOL = 18.0182; export function mgdlToMmol(mgdl: number): number { return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10; diff --git a/src/lib/measurements/__tests__/unit-aliases.test.ts b/src/lib/measurements/__tests__/unit-aliases.test.ts index a12f4d860..dcf49f9ac 100644 --- a/src/lib/measurements/__tests__/unit-aliases.test.ts +++ b/src/lib/measurements/__tests__/unit-aliases.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { resolveToCanonicalUnit } from "../unit-aliases"; +import { MGDL_PER_MMOL, mgdlToMmol } from "@/lib/glucose"; describe("resolveToCanonicalUnit", () => { it("passes the canonical unit through case-insensitively", () => { @@ -53,10 +54,17 @@ describe("resolveToCanonicalUnit", () => { }); }); - it("converts glucose mmol/L to canonical mg/dL", () => { + it("converts glucose mmol/L to canonical mg/dL on the app's one factor", () => { + // This used to assert 18.016 while every display path divided by + // 18.0182, so a reading imported as mmol/L and read back as mmol/L did + // not return the number it went in as. Both ends take the same factor + // now, and the assertion names it rather than repeating a literal — + // pinning the constant twice is how the two drifted apart. const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L"); expect(out?.unit).toBe("mg/dL"); - expect(out?.value).toBeCloseTo(5.3 * 18.016, 3); + expect(out?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3); + // The round trip a mmol/L reader actually experiences. + expect(mgdlToMmol(out?.value ?? 0)).toBe(5.3); }); it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => { diff --git a/src/lib/measurements/unit-aliases.ts b/src/lib/measurements/unit-aliases.ts index bb43693ed..e068187d2 100644 --- a/src/lib/measurements/unit-aliases.ts +++ b/src/lib/measurements/unit-aliases.ts @@ -17,9 +17,7 @@ * Pure + side-effect-free; safe to import from an API route or a script. */ import { getUnitForType } from "@/lib/validations/measurement"; - -/** Glucose mmol/L → mg/dL. */ -const GLUCOSE_MMOL_TO_MGDL = 18.016; +import { MGDL_PER_MMOL } from "@/lib/glucose"; /** Exact pound → kilogram (1 lb = 0.45359237 kg). */ const LB_TO_KG = 0.45359237; /** Exact inch → centimetre (1 in = 2.54 cm). */ @@ -87,7 +85,7 @@ export function resolveToCanonicalUnit( // Glucose: mmol/L → mg/dL. if (type === "BLOOD_GLUCOSE" && MMOL_ALIASES.has(lower)) { - return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical }; + return { value: value * MGDL_PER_MMOL, unit: canonical }; } // Body mass: lb → kg. From 5078867203fc6b3ab4afc3528325a717d6da14e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 05:47:17 +0200 Subject: [PATCH 5/8] feat: let an account choose the unit its glucose is shown in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `User.glucoseUnit` has existed since v1.2 and around thirty surfaces read it — the dashboard tiles, the glucose page, the targets panel, the CSV and FHIR exports, the doctor report, the Coach snapshot, the low-reading push copy. Nothing could ever write it. No route, no schema, no field on any form, so every account resolved to the mg/dL default and the mmol/L half of the app was unreachable code. Anyone who reads glucose in mmol/L converted in their head on every screen. This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar on the pattern the unit-preference endpoint next door established, and a dropdown in the profile form beside it. Deliberately its own control rather than a branch of metric/imperial: metric countries are split on which unit they read glucose in, so one dropdown would get half of them wrong. Storage is untouched and stays untouched. Every reading is written in mg/dL by every ingest path and the unit only selects a display branch, so nothing already recorded is reinterpreted. The entry form moved with the preference, which is the half that could have hurt: it asks in the chosen unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the plausibility band accepts in silence and every surface reads back as a severe hypo. The contract gains the endpoint and nothing else; the account payload already carried the field. --- docs/api/openapi.yaml | 112 ++++++++++ messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/ko.json | 6 + messages/pl.json | 6 + .../me/glucose-unit/__tests__/route.test.ts | 210 ++++++++++++++++++ src/app/api/auth/me/glucose-unit/route.ts | 122 ++++++++++ .../measurement-form-glucose-unit.test.tsx | 98 ++++++++ .../measurements/measurement-form.tsx | 48 ++-- .../__tests__/glucose-unit-select.test.tsx | 145 ++++++++++++ .../settings/account-section/index.tsx | 14 +- .../settings/glucose-unit-select.tsx | 150 +++++++++++++ src/lib/openapi/routes/profile.ts | 65 ++++++ src/lib/query-keys/auth.ts | 9 + src/lib/validations/user-prefs.ts | 11 + 18 files changed, 1005 insertions(+), 21 deletions(-) create mode 100644 src/app/api/auth/me/glucose-unit/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/glucose-unit/route.ts create mode 100644 src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx create mode 100644 src/components/settings/__tests__/glucose-unit-select.test.tsx create mode 100644 src/components/settings/glucose-unit-select.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 168c050e7..77eca7aa4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7759,6 +7759,62 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/me/glucose-unit: + get: + tags: + - Auth + summary: Read the blood-glucose display unit + description: Which unit blood-glucose readings are rendered in — `mg/dL` or `mmol/L`. A separate choice from the + metric/imperial preference, because metric countries are split on which one they read glucose in. Canonical + storage is mg/dL on every reading and every ingest path; this changes presentation only. Any stored value other + than `mmol/L` resolves to `mg/dL`, including a null on an account that never set it. + responses: + "200": + description: The resolved unit. + content: + application/json: + schema: + $ref: "#/components/schemas/GetGlucoseUnitEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Auth + summary: Set the blood-glucose display unit + description: >- + Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. + + + Nothing already stored is converted or reinterpreted: readings stay mg/dL, and a value typed into the entry form + in mmol/L is inverted to mg/dL before it is written. + + + The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong + content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group + answer. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GlucoseUnitPatchRequest" + responses: + "200": + description: The resolved next unit. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/me/documents-auto-ai-read: get: tags: @@ -23141,6 +23197,17 @@ components: required: - unitPreference description: Which display-unit branch to render. Presentation only — the stored measurement rows stay SI either way. + GlucoseUnitPatchRequest: + type: object + properties: + glucoseUnit: + type: string + enum: + - mg/dL + - mmol/L + required: + - glucoseUnit + description: Which unit blood-glucose readings are rendered in. Presentation only — every stored reading is mg/dL either way. DocumentsAutoAiReadPatchRequest: type: object properties: @@ -45799,6 +45866,51 @@ components: - data - error additionalProperties: false + GetGlucoseUnitEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GlucoseUnitResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GlucoseUnitResponse: + type: object + properties: + glucoseUnit: + type: string + enum: + - mg/dL + - mmol/L + required: + - glucoseUnit + additionalProperties: false + PatchGlucoseUnitEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GlucoseUnitResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetDocumentsAutoAiReadEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index b437210a3..58a32857f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5681,6 +5681,12 @@ "imperial": "Imperial", "saved": "Einheiten-Einstellung gespeichert", "saveError": "Einheiten-Einstellung konnte nicht gespeichert werden." + }, + "glucoseUnit": { + "title": "Blutzucker", + "description": "In welcher Einheit Blutzuckerwerte angezeigt werden. Gespeichert werden sie so oder so gleich.", + "saved": "Blutzucker-Einheit gespeichert", + "saveError": "Die Blutzucker-Einheit konnte nicht gespeichert werden." } }, "withings": "Withings", diff --git a/messages/en.json b/messages/en.json index 047e8a121..3ba64450b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5681,6 +5681,12 @@ "imperial": "Imperial", "saved": "Unit preference saved", "saveError": "Could not save your unit preference." + }, + "glucoseUnit": { + "title": "Blood glucose", + "description": "Which unit blood-glucose readings are shown in. Readings are stored the same way either way.", + "saved": "Glucose unit saved", + "saveError": "Could not save your glucose unit." } }, "withings": "Withings", diff --git a/messages/es.json b/messages/es.json index 259f30ae2..9515185a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5681,6 +5681,12 @@ "imperial": "Imperial", "saved": "Preferencia de unidades guardada", "saveError": "No se pudo guardar tu preferencia de unidades." + }, + "glucoseUnit": { + "title": "Glucosa en sangre", + "description": "La unidad en la que se muestran las lecturas de glucosa. Se guardan igual en ambos casos.", + "saved": "Unidad de glucosa guardada", + "saveError": "No se pudo guardar la unidad de glucosa." } }, "withings": "Withings", diff --git a/messages/fr.json b/messages/fr.json index 9615e31bf..8724f604f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5681,6 +5681,12 @@ "imperial": "Impérial", "saved": "Préférence d'unités enregistrée", "saveError": "Impossible d'enregistrer votre préférence d'unités." + }, + "glucoseUnit": { + "title": "Glycémie", + "description": "L'unité dans laquelle les mesures de glycémie sont affichées. L'enregistrement est le même dans les deux cas.", + "saved": "Unité de glycémie enregistrée", + "saveError": "Impossible d'enregistrer l'unité de glycémie." } }, "withings": "Withings", diff --git a/messages/it.json b/messages/it.json index a341ddc18..c2b0a981e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5681,6 +5681,12 @@ "imperial": "Imperiale", "saved": "Preferenza unità salvata", "saveError": "Impossibile salvare la preferenza delle unità." + }, + "glucoseUnit": { + "title": "Glicemia", + "description": "L'unità in cui vengono mostrate le misurazioni della glicemia. Il salvataggio resta lo stesso in entrambi i casi.", + "saved": "Unità della glicemia salvata", + "saveError": "Impossibile salvare l'unità della glicemia." } }, "withings": "Withings", diff --git a/messages/ko.json b/messages/ko.json index 918359ecd..d82cc8fe9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5681,6 +5681,12 @@ "imperial": "야드파운드법", "saved": "단위 설정을 저장했어요", "saveError": "단위 설정을 저장하지 못했어요." + }, + "glucoseUnit": { + "title": "혈당", + "description": "혈당 수치를 표시할 단위입니다. 어느 쪽을 골라도 저장 방식은 같습니다.", + "saved": "혈당 단위를 저장했습니다", + "saveError": "혈당 단위를 저장하지 못했습니다." } }, "withings": "Withings", diff --git a/messages/pl.json b/messages/pl.json index b1aa8e00e..4ebd23e32 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5681,6 +5681,12 @@ "imperial": "Imperialne", "saved": "Zapisano preferencję jednostek", "saveError": "Nie udało się zapisać preferencji jednostek." + }, + "glucoseUnit": { + "title": "Glukoza we krwi", + "description": "Jednostka, w której pokazywane są pomiary glukozy. Zapis pozostaje taki sam w obu przypadkach.", + "saved": "Zapisano jednostkę glukozy", + "saveError": "Nie udało się zapisać jednostki glukozy." } }, "withings": "Withings", diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts new file mode 100644 index 000000000..943f37294 --- /dev/null +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -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)(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)(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)(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)( + 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)( + 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)( + 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)( + 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)( + 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)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts new file mode 100644 index 000000000..144fa8df6 --- /dev/null +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -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); +}); diff --git a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx new file mode 100644 index 000000000..af91ed46d --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * The manual entry form and the blood-glucose display unit. + * + * Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the + * account picked. Until the preference could be set at all, the form could + * hardcode "mg/dL" and be right by accident. It no longer can: a reader on + * mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the + * plausibility band (20–800) accepts without a word and every surface then + * reads back as real. So the label and the stored number have to move + * together — the field asks in the account's unit and the submit path + * inverts to canonical mg/dL. + * + * The label half is rendered. The submit half is asserted against the source: + * this project runs SSR-only component tests (`@testing-library/react` is not + * a dependency), so nothing here can type into the field and press save. What + * the source assertion pins is the wiring — that the glucose branch inverts + * through `toCanonicalMgdl` rather than sending the typed number on — and + * `src/lib/__tests__/glucose.test.ts` proves the conversion itself. + */ + +let glucoseUnit: string | null = null; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "testuser", + role: "USER", + unitPreference: "metric", + glucoseUnit, + }, + isAuthenticated: true, + isLoading: false, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + refetchQueries: vi.fn(), + }), +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiPost: vi.fn().mockResolvedValue({}), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MeasurementForm } from "../measurement-form"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +const formSource = readFileSync( + join(process.cwd(), "src/components/measurements/measurement-form.tsx"), + "utf8", +); + +describe("MeasurementForm — blood-glucose entry unit", () => { + it("asks in mg/dL for an account that never chose", () => { + glucoseUnit = null; + const html = render(); + expect(html).toContain("mg/dL"); + expect(html).not.toContain("mmol/L"); + expect(html).toContain('placeholder="95"'); + }); + + it("asks in mmol/L once the account chose it", () => { + glucoseUnit = "mmol/L"; + const html = render(); + expect(html).toContain("mmol/L"); + // A mmol/L reader must not be shown a three-digit mg/dL example. + expect(html).toContain('placeholder="5.3"'); + expect(html).not.toContain('placeholder="95"'); + }); + + it("inverts a glucose entry to canonical mg/dL before it is sent", () => { + expect(formSource).toMatch( + /if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/, + ); + }); + + it("resolves the unit from the account rather than assuming one", () => { + expect(formSource).toMatch( + /const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/, + ); + }); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 93d1da4f5..40ea77036 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -26,6 +26,8 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { useAuth } from "@/hooks/use-auth"; +import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose"; import { entryValueToCanonical, parseDecimalEntry, @@ -246,6 +248,14 @@ export function MeasurementForm({ const recordName = useActiveRecordName(); const queryClient = useQueryClient(); const unitDisplay = useUnitDisplay(); + const { user } = useAuth(); + // Glucose is its own display axis, separate from metric/imperial: it is + // stored in mg/dL and rendered in whichever of mg/dL and mmol/L the + // account chose. The form has to ask in the same unit it labels, and + // invert on the way out, or a reader on mmol/L would file "5.5" as five + // and a half mg/dL — a number the plausibility band happily accepts and + // every surface then reads back as a severe hypo. + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); // Normalize legacy BP types to combined mode const normalizedDefault = @@ -359,7 +369,9 @@ export function MeasurementForm({ return; } let canonicalValue = typed; - if (unitDisplay.isTransformed(type)) { + if (isGlucoseMode) { + canonicalValue = toCanonicalMgdl(typed, glucoseUnit); + } else if (unitDisplay.isTransformed(type)) { const inverted = unitDisplay.fromDisplay(type, typed); canonicalValue = unitDisplay.preference === "imperial" @@ -537,13 +549,15 @@ export function MeasurementForm({ // For a type with a metric/imperial transform the label follows the // user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps // its static catalogue unit. - unit: typeInfo - ? unitDisplay.isTransformed(type) - ? unitDisplay.unitFor(type) - : "unitKey" in typeInfo - ? t(typeInfo.unitKey) - : typeInfo.unit - : "", + unit: isGlucoseMode + ? glucoseUnit + : typeInfo + ? unitDisplay.isTransformed(type) + ? unitDisplay.unitFor(type) + : "unitKey" in typeInfo + ? t(typeInfo.unitKey) + : typeInfo.unit + : "", })} > setValue(e.target.value)} placeholder={ - typeInfo - ? unitDisplay.preference === "imperial" && - "placeholderImperial" in typeInfo - ? typeInfo.placeholderImperial - : "placeholder" in typeInfo - ? typeInfo.placeholder - : undefined - : undefined + isGlucoseMode && glucoseUnit === "mmol/L" + ? "5.3" + : typeInfo + ? unitDisplay.preference === "imperial" && + "placeholderImperial" in typeInfo + ? typeInfo.placeholderImperial + : "placeholder" in typeInfo + ? typeInfo.placeholder + : undefined + : undefined } required aria-required="true" diff --git a/src/components/settings/__tests__/glucose-unit-select.test.tsx b/src/components/settings/__tests__/glucose-unit-select.test.tsx new file mode 100644 index 000000000..8893020e8 --- /dev/null +++ b/src/components/settings/__tests__/glucose-unit-select.test.tsx @@ -0,0 +1,145 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; + +/** + * Settings → Profile blood-glucose unit dropdown. + * + * The control the preference column never had. It sits beside the + * metric/imperial select and is deliberately not a branch of it: metric + * countries are split on which unit they read glucose in, so one dropdown + * would get one of them wrong. + * + * SSR-only, like the unit-system select next to it: the render pass pins the + * options and the selected value, and the mutation contract — endpoint, + * method, body — is asserted against the source, because nothing here can + * dispatch a change event. + */ + +const authSpy = vi.fn< + () => { user: AuthUser | null; isAuthenticated: boolean } +>(() => ({ user: buildUser(null), isAuthenticated: true })); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +function buildUser(glucoseUnit: string | null): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + onboardingTourProgress: null, + avatarUrl: null, + glucoseUnit, + unitPreference: "metric", + timeFormat: "AUTO", + dateFormat: "AUTO", + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + lastReportPracticeName: null, + reportSelection: null, + cycleTrackingEnabled: false, + modules: {}, + } as AuthUser; +} + +import { GlucoseUnitSelect } from "../glucose-unit-select"; + +const componentSource = readFileSync( + join(process.cwd(), "src/components/settings/glucose-unit-select.tsx"), + "utf8", +); + +beforeEach(() => { + authSpy.mockClear(); + authSpy.mockImplementation(() => ({ + user: buildUser(null), + isAuthenticated: true, + })); +}); + +function render(isAuthenticated = true): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Settings — GlucoseUnitSelect", () => { + it("offers both units the app renders", () => { + const html = render(); + expect(html).toContain('data-testid="settings-glucose-unit-select"'); + expect(html).toContain('value="mg/dL"'); + expect(html).toContain('value="mmol/L"'); + }); + + // React renders a controlled select's current value as `selected` on the + // option under SSR, not as an attribute on the `