mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat: let an account choose the unit its glucose is shown in
`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.
This commit is contained in:
1 parent
841593a5b2
commit
5078867203
18 files changed
+1005
-21
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5681,6 +5681,12 @@
|
||||
"imperial": "야드파운드법",
|
||||
"saved": "단위 설정을 저장했어요",
|
||||
"saveError": "단위 설정을 저장하지 못했어요."
|
||||
},
|
||||
"glucoseUnit": {
|
||||
"title": "혈당",
|
||||
"description": "혈당 수치를 표시할 단위입니다. 어느 쪽을 골라도 저장 방식은 같습니다.",
|
||||
"saved": "혈당 단위를 저장했습니다",
|
||||
"saveError": "혈당 단위를 저장하지 못했습니다."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
@@ -558,14 +572,16 @@ export function MeasurementForm({
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
|
||||
@@ -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<typeof import("@/hooks/use-auth")>(
|
||||
"@/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(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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 `<select>`.
|
||||
function selectedOption(html: string): string | null {
|
||||
return /<option[^>]*selected[^>]*>/.exec(html)?.[0] ?? null;
|
||||
}
|
||||
|
||||
it("shows mg/dL for an account that never chose", () => {
|
||||
expect(selectedOption(render())).toContain('value="mg/dL"');
|
||||
});
|
||||
|
||||
it("shows the unit an account already chose", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser("mmol/L"),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
expect(selectedOption(render())).toContain('value="mmol/L"');
|
||||
});
|
||||
|
||||
it("disables the select when unauthenticated", () => {
|
||||
const select = /<select[^>]*settings-glucose-unit-select[^>]*>/.exec(
|
||||
render(false),
|
||||
);
|
||||
expect(select).not.toBeNull();
|
||||
expect(select![0]).toContain("disabled");
|
||||
});
|
||||
|
||||
it("PATCHes the glucose-unit endpoint field-by-field", () => {
|
||||
expect(componentSource).toMatch(
|
||||
/apiFetchRaw\(\s*"\/api\/auth\/me\/glucose-unit"[\s\S]{0,200}?method: "PATCH"[\s\S]{0,200}?JSON\.stringify\(\{ glucoseUnit: next \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes the account payload every glucose surface reads from", () => {
|
||||
expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/);
|
||||
});
|
||||
|
||||
it("is actually mounted on the settings page", () => {
|
||||
// A control nobody can reach is the same defect as no control.
|
||||
const accountSection = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/account-section/index.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(accountSection).toMatch(/<GlucoseUnitSelect\s/);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -421,14 +422,17 @@ export function AccountSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone + unit system + hour format share one grid block —
|
||||
all personal display preferences (like language above). The
|
||||
unit and time-format dropdowns PATCH their own endpoints on
|
||||
change; the timezone saves through the form's submit
|
||||
handler. */}
|
||||
{/* Timezone, unit system, glucose unit and the date/hour formats
|
||||
share one grid block — all personal display preferences (like
|
||||
language above). The glucose unit is its own dropdown rather
|
||||
than a branch of the unit system because metric countries are
|
||||
split on which unit they read glucose in. Every dropdown here
|
||||
PATCHes its own endpoint on change; the timezone saves through
|
||||
the form's submit handler. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TimezonePicker value={timezone} onChange={setTimezone} />
|
||||
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
|
||||
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
|
||||
<TimeFormatSelect isAuthenticated={isAuthenticated} />
|
||||
<DateFormatSelect isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* Blood-glucose display unit as a Profile dropdown.
|
||||
*
|
||||
* The preference column and its thirty-odd readers shipped in v1.2 with no
|
||||
* control to set them, so every account stayed on the mg/dL default and the
|
||||
* mmol/L half of the app was unreachable. This is the control.
|
||||
*
|
||||
* It sits beside the metric/imperial dropdown because it is the same kind of
|
||||
* setting — how a number is shown, not what is stored — but it is a separate
|
||||
* choice: metric countries are split on which unit they read glucose in, so
|
||||
* folding it into metric/imperial would get one of them wrong. The option
|
||||
* labels are the unit symbols themselves, which read the same in every
|
||||
* locale.
|
||||
*
|
||||
* Persistence mirrors the unit-system select: a change PATCHes
|
||||
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`
|
||||
* plus `queryKeys.userGlucoseUnit()`, so the dashboard tiles, the glucose
|
||||
* insight page and the targets panel re-render on the next /me refetch.
|
||||
*/
|
||||
export function GlucoseUnitSelect({
|
||||
isAuthenticated,
|
||||
id = "glucose-unit",
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function scheduleClear() {
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
}
|
||||
clearTimerRef.current = setTimeout(() => {
|
||||
clearTimerRef.current = null;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const value: GlucoseUnit =
|
||||
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: GlucoseUnit) => {
|
||||
const res = await apiFetchRaw("/api/auth/me/glucose-unit", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ glucoseUnit: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(json.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(t("settings.dashboard.glucoseUnit.saved"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userGlucoseUnit(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.dashboard.glucoseUnit.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
},
|
||||
});
|
||||
|
||||
function handleSelect(next: GlucoseUnit) {
|
||||
if (next === value || mutation.isPending || !isAuthenticated) return;
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
if (clearTimerRef.current !== null) {
|
||||
clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("settings.dashboard.glucoseUnit.title")}</Label>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
data-testid="settings-glucose-unit-select"
|
||||
value={value}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
onChange={(e) => handleSelect(e.target.value as GlucoseUnit)}
|
||||
>
|
||||
{/* Unit symbols, not translated copy — they read the same
|
||||
everywhere, and a locale that "translated" one would be
|
||||
naming a different measurement. */}
|
||||
<option value="mg/dL">mg/dL</option>
|
||||
<option value="mmol/L">mmol/L</option>
|
||||
</NativeSelect>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg ?? t("settings.dashboard.glucoseUnit.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
injectionSitePrefsPatchSchema,
|
||||
labsLocalOcrPatchSchema,
|
||||
unitPreferencePatchSchema,
|
||||
glucoseUnitPatchSchema,
|
||||
useCentralCodexPatchSchema,
|
||||
} from "@/lib/validations/user-prefs";
|
||||
import {
|
||||
@@ -555,6 +556,16 @@ const unitPreferenceResponse = z
|
||||
.object({ unitPreference: z.enum(["metric", "imperial"]) })
|
||||
.meta({ id: "UnitPreferenceResponse" });
|
||||
|
||||
const glucoseUnitPatchRequest = glucoseUnitPatchSchema.meta({
|
||||
id: "GlucoseUnitPatchRequest",
|
||||
description:
|
||||
"Which unit blood-glucose readings are rendered in. Presentation only — every stored reading is mg/dL either way.",
|
||||
});
|
||||
|
||||
const glucoseUnitResponse = z
|
||||
.object({ glucoseUnit: z.enum(["mg/dL", "mmol/L"]) })
|
||||
.meta({ id: "GlucoseUnitResponse" });
|
||||
|
||||
const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({
|
||||
id: "DocumentsAutoAiReadPatchRequest",
|
||||
description:
|
||||
@@ -1577,6 +1588,60 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
glucoseUnitResponse,
|
||||
"GetGlucoseUnitEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Set the blood-glucose display unit",
|
||||
description:
|
||||
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
|
||||
"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.\n\n" +
|
||||
"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: glucoseUnitPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved next unit.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
glucoseUnitResponse,
|
||||
"PatchGlucoseUnitEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"413": {
|
||||
description: "Request body exceeds 1024 bytes. Nothing was written.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/documents-auto-ai-read": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -31,6 +31,15 @@ export const authKeys = {
|
||||
* without a manual reload.
|
||||
*/
|
||||
userUnitPreference: () => ["user", "unit-preference"] as const,
|
||||
/**
|
||||
* The mg/dL vs mmol/L glucose display control behind
|
||||
* `GET /api/auth/me/glucose-unit`. A separate axis from the
|
||||
* metric/imperial preference above — metric countries are split on
|
||||
* which one they read glucose in — so it carries its own key. The
|
||||
* PATCH also invalidates `authMe()`, since every glucose surface
|
||||
* resolves the unit from the account payload rather than from here.
|
||||
*/
|
||||
userGlucoseUnit: () => ["user", "glucose-unit"] as const,
|
||||
|
||||
passkeys: () => ["passkeys"] as const,
|
||||
|
||||
|
||||
@@ -20,6 +20,17 @@ export const unitPreferencePatchSchema = z.object({
|
||||
unitPreference: z.enum(["metric", "imperial"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* `PATCH /api/auth/me/glucose-unit` — mg/dL vs mmol/L display branch.
|
||||
*
|
||||
* Not the metric/imperial preference: the two split along different lines.
|
||||
* Germany is metric and reads glucose in mg/dL; the UK and the Nordics read
|
||||
* it in mmol/L. Canonical storage stays mg/dL either way.
|
||||
*/
|
||||
export const glucoseUnitPatchSchema = z.object({
|
||||
glucoseUnit: z.enum(["mg/dL", "mmol/L"]),
|
||||
});
|
||||
|
||||
/** `PATCH /api/auth/me/documents-auto-ai-read` — the auto-read opt-in. */
|
||||
export const documentsAutoAiReadPatchSchema = z.object({
|
||||
documentsAutoAiRead: z.boolean(),
|
||||
|
||||
Reference in new issue
Block a user