feat(glucose): add a diabetes opt-in preference

Introduce an explicit, user-declared "I have diabetes / clinician glucose
targets" preference (`User.hasDiabetes`, migration 0174, defaulted false) plus
its GET/PATCH endpoint at `/api/auth/me/diabetes`. The flag selects which
glucose target band a reading is judged against; it is never inferred from a
value and never asserts a diagnosis. Mirrors the existing per-user-scalar
preference pattern: 60/min rate limit, Zod validation, audit-logged,
field-by-field write. OpenAPI regenerated for the new route.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 20:17:13 +02:00
1 parent 1d83e10764
commit 2c87a93827
5 files changed
+294

No files matched your search

+99
View File
@@ -1837,6 +1837,8 @@ paths:
minLength: 1
maxLength: 32
- type: "null"
symptomsPresent:
type: boolean
required:
- type
- value
@@ -3170,6 +3172,48 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/diabetes:
get:
tags:
- Auth
summary: Read per-user diabetes opt-in flag
description: Returns the user's per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target
resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general
non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred
from a reading; asserts no diagnosis.
responses:
"200":
description: Resolved flag.
content:
application/json:
schema:
$ref: "#/components/schemas/GetDiabetesResponse"
"401": *a1
"422": *a3
"429": *a4
patch:
tags:
- Auth
summary: Toggle per-user diabetes opt-in flag
description: Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so
the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from
the body. Rate-limit 60/min per user.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DiabetesFlag"
responses:
"200":
description: Resolved next-state echoed back for optimistic-update consumers.
content:
application/json:
schema:
$ref: "#/components/schemas/PatchDiabetesResponse"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/modules:
get:
tags:
@@ -7668,6 +7712,16 @@ components:
required:
- disableCoach
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
DiabetesFlag:
type: object
properties:
hasDiabetes:
type: boolean
required:
- hasDiabetes
description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
only — never inferred from a value, never a diagnosis.
ModulePrefsPatchRequest:
type: object
properties:
@@ -13611,6 +13665,40 @@ components:
- data
- error
additionalProperties: false
GetDiabetesResponse:
type: object
properties:
data:
$ref: "#/components/schemas/DiabetesFlagOutput"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PatchDiabetesResponse:
type: object
properties:
data:
$ref: "#/components/schemas/DiabetesFlagOutput"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
GetModulesResponse:
type: object
properties:
@@ -19607,6 +19695,17 @@ components:
- disableCoach
additionalProperties: false
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
DiabetesFlagOutput:
type: object
properties:
hasDiabetes:
type: boolean
required:
- hasDiabetes
additionalProperties: false
description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
only — never inferred from a value, never a diagnosis.
InsightsLayoutBodyOutput:
type: object
properties:
@@ -0,0 +1,15 @@
-- v1.18.6 — diabetes opt-in for clinician glucose targets.
--
-- One additive, defaulted column on `users` so a user can declare "I have
-- diabetes / clinician glucose targets". When `true`, the glucose target
-- resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130,
-- postprandial < 180) instead of the general non-diabetic normal bands.
--
-- This is NEVER auto-inferred from a reading and is NEVER a diagnosis — it is
-- a user-declared preference that only selects which target band the user is
-- judged against. The default `false` backfills every existing row onto the
-- general bands (the current behaviour), so the migration is a no-op for
-- anyone who never opts in.
ALTER TABLE "users"
ADD COLUMN "has_diabetes" BOOLEAN NOT NULL DEFAULT false;
+7
View File
@@ -193,6 +193,13 @@ model User {
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
// When `true`, the glucose target resolver applies the tighter ADA glycemic
// GOAL bands (fasting 80–130, postprandial < 180) instead of the general
// non-diabetic normal bands. NEVER auto-inferred from values, NEVER a
// diagnosis — purely a user-declared preference that selects which target
// band the user is judged against. Default `false`.
hasDiabetes Boolean @default(false) @map("has_diabetes")
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
+118
View File
@@ -0,0 +1,118 @@
/**
* v1.18.6 — per-user diabetes opt-in endpoint.
*
* GET /api/auth/me/diabetes — current flag.
* PATCH /api/auth/me/diabetes — body `{ hasDiabetes: boolean }`.
*
* The flag is an explicit, user-declared preference ("I have diabetes /
* clinician glucose targets"). When `true`, the glucose target resolver
* applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial
* < 180) instead of the general non-diabetic normal bands. It is NEVER
* inferred from a reading and asserts NO diagnosis — it only selects which
* target band a glucose reading is judged against.
*
* Mirrors the `disable-coach` / `unit-preference` per-user-scalar pattern:
* 60/min rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log
* row, field-by-field write (no mass assignment). Idempotent — the endpoint
* always returns the resolved next state so the client can hard-set the
* optimistic update without an extra round-trip.
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} 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";
const patchBodySchema = z.object({
hasDiabetes: z.boolean(),
});
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type DiabetesResponse = {
hasDiabetes: boolean;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.diabetes.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { hasDiabetes: true },
});
const payload: DiabetesResponse = {
hasDiabetes: row?.hasDiabetes ?? false,
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`diabetes: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;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "diabetes.body.invalid_json");
}
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.hasDiabetes;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { hasDiabetes: true },
});
await prisma.user.update({
where: { id: user.id },
data: { hasDiabetes: next },
});
await auditLog("user.diabetes.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous?.hasDiabetes ?? false,
next,
},
});
annotate({
action: { name: "auth.me.diabetes.patch" },
meta: { hasDiabetes: next },
});
const payload: DiabetesResponse = { hasDiabetes: next };
return apiSuccess(payload);
});
+55
View File
@@ -67,6 +67,20 @@ const disableCoachFlag = z
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
});
// v1.18.6 — explicit diabetes opt-in. Shared by GET (current) and PATCH
// (request body + resolved response). `true` switches the glucose target
// resolver to the tighter ADA glycemic GOAL bands (fasting 80–130,
// postprandial < 180). Never inferred from a reading; asserts no diagnosis.
const diabetesFlag = z
.object({
hasDiabetes: z.boolean(),
})
.meta({
id: "DiabetesFlag",
description:
"Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference only — never inferred from a value, never a diagnosis.",
});
// v1.16.11 — per-user notification preferences. The PATCH request body
// is the REAL runtime validator (`notificationPrefsSchema` — every
// category and field optional, deep-merged server-side); the response
@@ -415,6 +429,47 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/diabetes": {
get: {
tags: ["Auth"],
summary: "Read per-user diabetes opt-in flag",
description:
'Returns the user\'s per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred from a reading; asserts no diagnosis.',
responses: {
"200": {
description: "Resolved flag.",
content: {
"application/json": {
schema: dataEnvelope(diabetesFlag, "GetDiabetesResponse"),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Auth"],
summary: "Toggle per-user diabetes opt-in flag",
description:
"Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from the body. Rate-limit 60/min per user.",
requestBody: {
required: true,
content: { "application/json": { schema: diabetesFlag } },
},
responses: {
"200": {
description:
"Resolved next-state echoed back for optimistic-update consumers.",
content: {
"application/json": {
schema: dataEnvelope(diabetesFlag, "PatchDiabetesResponse"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/modules": {
get: {
tags: ["Auth"],