mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
1d83e10764
commit
2c87a93827
5 files changed
+294
No files matched your search
@@ -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;
|
||||
@@ -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".
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user