From 2c87a93827a404e2b130835a8c0d44a3bda93566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 20:11:52 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 99 +++++++++++++++ .../0174_v1186_diabetes_opt_in/migration.sql | 15 +++ prisma/schema.prisma | 7 ++ src/app/api/auth/me/diabetes/route.ts | 118 ++++++++++++++++++ src/lib/openapi/routes/profile.ts | 55 ++++++++ 5 files changed, 294 insertions(+) create mode 100644 prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql create mode 100644 src/app/api/auth/me/diabetes/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b55ba0e06..3b6ddd195 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql new file mode 100644 index 000000000..eaaa0fce8 --- /dev/null +++ b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 948d0db58..f87097c6e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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". diff --git a/src/app/api/auth/me/diabetes/route.ts b/src/app/api/auth/me/diabetes/route.ts new file mode 100644 index 000000000..56d72da64 --- /dev/null +++ b/src/app/api/auth/me/diabetes/route.ts @@ -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); +}); diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 2a5654939..cd329e1ea 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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 = { }, }, }, + "/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"],