diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 728e429f9..e8fffb7d4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2650,7 +2650,15 @@ paths: application/json: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" - "401": *a1 + "401": + description: The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = + `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or + was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion + was checked and refused (signature, origin, relying-party id or challenge mismatch). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "403": description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. content: @@ -2658,12 +2666,20 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: The assertion resolved to a user row that no longer exists. + description: "`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this + server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without + a code: the assertion resolved to a user row that no longer exists." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) + `credential` is not a WebAuthn authentication response." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 "429": *a5 /api/auth/refresh: post: @@ -4497,7 +4513,12 @@ paths: (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent - for the batch as sent: resending it unchanged is refused the same way." + for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: + every issue caused by one entry has a `path` that starts `entries.` (the entry's index in the request, + then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are + the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` + is `\"\"` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about + the batch itself, and no split recovers it." content: application/json: schema: @@ -5131,11 +5152,14 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: Body validation failed (multi-issue envelope, breadcrumbed as + description: "Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the - medication. The latter two carry no issue list — branch on the code. + medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is + kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is + counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry + no issue list — branch on the code." content: application/json: schema: @@ -6397,7 +6421,16 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 - "422": *a4 + "422": + description: "Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: + `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) + `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its + stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after + ownership, so a foreign medication is still a 404." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a13 get: tags: @@ -6721,13 +6754,36 @@ paths: tags: - Medications summary: Batched adherence read for every medication of the caller - description: Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in - the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero + description: >- + Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the + array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null. + + + v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, + weighted by expected doses across the applicable medications; `null` when none applies), so a client never + averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every + row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a + 422 rather than a rounded window. + parameters: + - in: query + name: days + schema: + description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / + `compliance180` / `compliance365`. + type: string + enum: + - "30" + - "60" + - "90" + - "180" + - "365" + description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / + `compliance180` / `compliance365`. responses: "200": - description: One adherence row per medication. + description: One adherence row per medication, and the account aggregate in `meta`. content: application/json: schema: @@ -8773,9 +8829,10 @@ paths: summary: Update per-user notification preferences description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic - update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side - MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert - — null switches it off. Rate-limit 60/min per user.' + update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that + announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); + `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. + Rate-limit 60/min per user.' requestBody: required: true content: @@ -12620,7 +12677,20 @@ paths: window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read - cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach." + cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — + `days` cuts `stability` to the period a client shows; every other block is unchanged by it." + parameters: + - in: query + name: days + schema: + description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422." + type: string + enum: + - "30" + - "90" + - "180" + - "365" + description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422." responses: "200": description: The aggregate bundle. @@ -22283,7 +22353,10 @@ paths: "422": description: "Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so - an empty key would resolve to whichever unrelated row stored it first." + an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = + `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: + false`); checked after the token's scope, so a token without this medication's scope learns nothing about + it." content: application/json: schema: @@ -33578,25 +33651,26 @@ components: measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; medication — - medication.intake.import.invalid_format; medications — medications.category.invalid, - medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, - medications.course.invalid, medications.course.invalidRange, medications.course.notFound, - medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, - medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, - medications.intake.bulk.too_large, medications.intake.force_slot.invalid, - medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, - medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth — - mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, - modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, - mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, - mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, - mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; - nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, - nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — - onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, + medication.intake.import.invalid_format, medication.intake.notTracked; medications — + medications.category.invalid, medications.category.limitReached, medications.category.unknown, + medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange, + medications.course.notFound, medications.course.oneShot, medications.course.overlap, + medications.course.windowRequired, medications.intake.bulk.apple_health_not_mirrored, + medications.intake.bulk.invalid, medications.intake.bulk.too_large, + medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, + medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start, + medications.mirror.limit_exceeded; mentalHealth — mentalHealth.rateLimited; module — module.disabled; + modules — modules.body.invalid_json, modules.invalid; mood — mood.bulk-delete.invalid, + mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable, mood.create.invalid, + mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found, + mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs — + notification-prefs.body.invalid_json; nutrient — nutrient.batch.invalid, nutrient.batch.too_large, + nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; + onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, - onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — + onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, + passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, @@ -42535,19 +42609,7 @@ components: accepted once the window rolls over." type: array items: - type: object - properties: - path: - type: string - code: - type: string - message: - type: string - required: - - path - - code - - message - additionalProperties: false + $ref: "#/components/schemas/ValidationIssue" required: - id - username @@ -46546,6 +46608,11 @@ components: description: "`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`." reason: + description: "Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, + `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) + `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the + per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same + answer." type: string id: description: The landed row id (absent on skips). @@ -48140,12 +48207,33 @@ components: meta: type: object properties: + aggregate: + anyOf: + - $ref: "#/components/schemas/MedicationComplianceAggregate" + - type: "null" + days: + description: The `days` the request asked for. + anyOf: + - type: number + const: 30 + - type: number + const: 60 + - type: number + const: 90 + - type: number + const: 180 + - type: number + const: 365 requestId: type: string + required: + - aggregate additionalProperties: false + description: Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate. required: - data - error + - meta additionalProperties: false MedicationComplianceSummaryEntry: type: object @@ -48168,6 +48256,18 @@ components: compliance30: description: Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false. $ref: "#/components/schemas/ComplianceResult" + compliance60: + description: "Present only on `?days=60`: the trailing 60 days." + $ref: "#/components/schemas/ComplianceResult" + compliance90: + description: "Present only on `?days=90`: the trailing 90 days." + $ref: "#/components/schemas/ComplianceResult" + compliance180: + description: "Present only on `?days=180`: the trailing 180 days." + $ref: "#/components/schemas/ComplianceResult" + compliance365: + description: "Present only on `?days=365`: the trailing 365 days." + $ref: "#/components/schemas/ComplianceResult" complianceDisplay: anyOf: - $ref: "#/components/schemas/ComplianceDisplay" @@ -48393,6 +48493,45 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + MedicationComplianceAggregate: + type: object + properties: + compliance7: + $ref: "#/components/schemas/ComplianceResult" + compliance30: + $ref: "#/components/schemas/ComplianceResult" + compliance60: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + compliance90: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + compliance180: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + compliance365: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + medicationCount: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: How many applicable medications the figure is built from. + required: + - compliance7 + - compliance30 + - medicationCount + additionalProperties: false + description: "One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: + `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is + recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 + when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a + three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a + requested `days` is present when `days` was sent and is not 30." GetMedicationComplianceResponse: type: object properties: @@ -52445,19 +52584,7 @@ components: it was published only on PUT /api/auth/profile." type: array items: - type: object - properties: - path: - type: string - code: - type: string - message: - type: string - required: - - path - - code - - message - additionalProperties: false + $ref: "#/components/schemas/ValidationIssue" required: - username - email @@ -52503,8 +52630,10 @@ components: properties: clientManaged: type: boolean - description: True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is - suppressed. + description: True when the iOS app delivers medication reminders locally. The server then keeps the reminder that + announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a + dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was + suppressed). Other channels are never affected. deliveryDefault: type: string enum: @@ -63358,7 +63487,20 @@ components: - days additionalProperties: false - type: "null" - description: Null for a sparse logger below the day floor. + description: Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days + with an entry) inside that window, so a short window can be null where the year is not. + stabilityWindowDays: + anyOf: + - type: number + const: 30 + - type: number + const: 90 + - type: number + const: 180 + - type: number + const: 365 + description: "The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not + sent. Added in v1.42.0; absent from older servers, whose score always covers the year." tags: type: array items: @@ -63814,6 +63956,7 @@ components: - weekday - timeOfDay - stability + - stabilityWindowDays - tags - contextComparison - structuredTags @@ -76257,6 +76400,33 @@ components: type: string username: type: string + timezone: + type: string + description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the + stored one is unusable. Resolved, never the raw column, so bucket day keys by this value." + glucoseUnit: + anyOf: + - type: string + enum: + - mg/dL + - mmol/L + - type: "null" + description: The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the + stored value is neither unit), and the server then shows glucose in mg/dL. + features: + type: object + properties: + trackIntake: + type: boolean + const: true + description: "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose + written against one is refused with `medication.intake.notTracked`." + required: + - trackIntake + additionalProperties: false + description: What this server understands, for a client released apart from it. Constant across accounts; a flag appears + once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that + meets a flag it does not know ignores it. accountAccess: $ref: "#/components/schemas/AccountAccess" moduleAccess: @@ -76288,6 +76458,9 @@ components: required: - id - username + - timezone + - glucoseUnit + - features - accountAccess - moduleAccess - ai diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 174f0e199..6adc921bf 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -153,6 +153,12 @@ const ACTIVE_RECORD_ONLY_FIELDS = [ * only when the claim is true and checkable. */ const NO_CLIENT_CONSUMER: Record = { + features: + "A capability probe for clients released apart from the server: the native app asks " + + "whether this server takes the record-only medication switch before the account has a " + + "medication to learn it from (iOS #116, item 18). The web client is built into the same " + + "image as the server, so everything it renders is by construction supported; a web read " + + "of the flag would be a read written to satisfy this guard.", insurerIkNumber: "No web surface reads or writes it — the account form offers the insurer name and the " + "insurance number, not the institution number. The native client does consume it: it " + diff --git a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts index 034df0f81..5a3cc6b7c 100644 --- a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts +++ b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts @@ -119,6 +119,14 @@ const DELIBERATELY_OPEN: Record = { count: 1, reason: "read filter over whole searchParams", }, + "medications/compliance/route.ts": { + count: 1, + reason: "read filter over whole searchParams", + }, + "mood/insights/route.ts": { + count: 1, + reason: "read filter over whole searchParams", + }, // --------------------------------------------------------------- // 3. Native-client ingest and device registration. This is where the diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 4967136e2..2b46fbfe7 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -74,9 +74,17 @@ import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; import { loadAiCapabilities } from "@/lib/ai/capabilities/load"; import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls"; import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; +import { storedGlucoseUnit } from "@/lib/glucose"; export const dynamic = "force-dynamic"; +/** + * Capability flags for clients that are not shipped with the server. Constant + * by design: a feature the server supports is supported for every account, + * and a per-account answer belongs in `modules` or `ai`, not here. + */ +const SERVER_FEATURES = { trackIntake: true } as const; + export const GET = apiHandler(async () => { const auth = await requireActorAuth(); const { user } = auth; @@ -331,7 +339,8 @@ export const GET = apiHandler(async () => { avatarUrl: user.avatarUpdatedAt ? buildAvatarUrl(user.id, user.avatarUpdatedAt) : null, - glucoseUnit: user.glucoseUnit ?? null, + // The stored choice, published as the closed pair the contract names. + glucoseUnit: storedGlucoseUnit(user.glucoseUnit), // v1.7.0 — global metric/imperial display preference. Canonical // storage stays SI; this only drives the display-time transform // branch. Null defaults to "metric" on the client. @@ -442,5 +451,11 @@ export const GET = apiHandler(async () => { // behind it have one reader by design, and a route that projected them // itself would be a second statement of what a record context is. recordSession: recordSessionForPayload(auth), + // v1.42 — what this server understands, for a client released on its own + // schedule (iOS #116, item 18). `trackIntake: true` says medications take + // the record-only switch, so an add form can offer it before the account + // has a medication to learn it from. A flag only ever appears here once + // the server supports it, and stays `true`; absence means an older server. + features: SERVER_FEATURES, }); }); diff --git a/src/app/api/auth/passkey/login-verify/__tests__/refusals.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/refusals.test.ts new file mode 100644 index 000000000..a41950f37 --- /dev/null +++ b/src/app/api/auth/passkey/login-verify/__tests__/refusals.test.ts @@ -0,0 +1,164 @@ +/** + * A passkey sign-in that cannot be checked answers with its own status and + * `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here + * against a mocked database, so the test pins the whole path from the + * library's refusal to the wire: an expired or already-used challenge, a + * credential this server does not know, a body that is not an assertion, and + * an assertion the WebAuthn library refuses. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + passkey: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@simplewebauthn/server", async (importOriginal) => ({ + ...(await importOriginal()), + verifyAuthenticationResponse: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + checkAuthSurfaceRateLimit: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; + +const ASSERTION = { + id: "cred-1", + rawId: "cred-1", + type: "public-key", + response: { + clientDataJSON: "e30", + authenticatorData: "AA", + signature: "AA", + }, +}; + +function request(credential: unknown = ASSERTION): NextRequest { + return new NextRequest("http://localhost/api/auth/passkey/login-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ challengeId: "ch-1", credential }), + }); +} + +async function refusal(res: Response) { + const body = (await res.json()) as { + data: null; + meta?: { errorCode?: string }; + }; + return { status: res.status, code: body.meta?.errorCode, data: body.data }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: 0, + ip: "203.0.113.1", + } as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { challenge: "c-1", user_id: null }, + ] as never); + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: "user-1", + credentialId: "cred-1", + credentialPublicKey: new Uint8Array([1]), + counter: BigInt(0), + transports: [], + } as never); +}); + +describe("POST /api/auth/passkey/login-verify — named refusals", () => { + it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + expect(await refusal(await POST(request()))).toEqual({ + status: 401, + code: "passkey.challenge.expired", + data: null, + }); + expect(auditLog).toHaveBeenCalledWith( + "auth.login.failed", + expect.objectContaining({ + details: { reason: "passkey_challenge_expired" }, + }), + ); + }); + + it("answers a challenge begun for another account the same way", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { challenge: "c-1", user_id: "someone-else" }, + ] as never); + const out = await refusal(await POST(request())); + expect(out.status).toBe(401); + expect(out.code).toBe("passkey.challenge.expired"); + }); + + it("answers a passkey that is not registered here with 404 passkey.unknown", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never); + expect(await refusal(await POST(request()))).toEqual({ + status: 404, + code: "passkey.unknown", + data: null, + }); + expect(auditLog).toHaveBeenCalledWith( + "auth.login.failed", + expect.objectContaining({ details: { reason: "passkey_unknown" } }), + ); + }); + + it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => { + const out = await refusal(await POST(request({ id: "cred-1" }))); + expect(out.status).toBe(422); + expect(out.code).toBe("passkey.response.invalid"); + }); + + it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => { + vi.mocked(verifyAuthenticationResponse).mockRejectedValue( + new Error("Unexpected authentication response origin"), + ); + const out = await refusal(await POST(request())); + expect(out.status).toBe(401); + expect(out.code).toBe("passkey.verification.failed"); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("still lets a real fault surface as one", async () => { + vi.mocked(prisma.passkey.findUnique).mockRejectedValue( + new Error("connection reset"), + ); + const res = await POST(request()); + expect(res.status).toBe(500); + }); +}); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 4032fdb15..527c29bcc 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,4 +1,8 @@ -import { verifyAuthentication } from "@/lib/auth/passkey"; +import { + PasskeyAuthError, + verifyAuthentication, + type PasskeyAuthErrorKind, +} from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { recordSignInDevice } from "@/lib/auth/login-alert"; @@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; import { isOidcOnly } from "@/lib/auth/oidc"; import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link"; +const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed"; + +/** + * How each passkey refusal answers. The challenge and verification cases are + * 401 (the sign-in did not prove anything), the unknown credential is 404 (the + * passkey is not registered on this server), and a malformed body is 422. + */ +const PASSKEY_REFUSALS: Record< + PasskeyAuthErrorKind, + { status: number; errorCode: string; message: string; auditReason: string } +> = { + challenge_expired: { + status: 401, + errorCode: "passkey.challenge.expired", + message: "The sign-in took too long or was already used. Try again.", + auditReason: "passkey_challenge_expired", + }, + unknown_passkey: { + status: 404, + errorCode: "passkey.unknown", + message: "This passkey is not registered here.", + auditReason: "passkey_unknown", + }, + malformed: { + status: 422, + errorCode: "passkey.response.invalid", + message: "The passkey response could not be read.", + auditReason: "passkey_response_invalid", + }, + verification_failed: { + status: 401, + errorCode: PASSKEY_VERIFICATION_FAILED_CODE, + message: "Passkey verification failed", + auditReason: "passkey_verification_failed", + }, +}; + export const POST = apiHandler(async (request: NextRequest) => { // OIDC_ONLY must block passkey login too, not just password login — a // credential minted before SSO was mandated must not stay a live bypass. @@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("challengeId and credential required", 422); } - const { verification, passkey } = await verifyAuthentication( - challengeId, - credential, - ); + let outcome: Awaited>; + try { + outcome = await verifyAuthentication(challengeId, credential); + } catch (err) { + // A known client-side case — an expired challenge, a passkey that is no + // longer registered here, a malformed or refused assertion — answers + // with its own status and code (iOS #116, item 20) so the app can tell + // the person what to do instead of reporting a server error. Anything + // else is a real fault and keeps propagating. + if (!(err instanceof PasskeyAuthError)) throw err; + const refusal = PASSKEY_REFUSALS[err.kind]; + await auditLog("auth.login.failed", { + ipAddress: ip, + details: { reason: refusal.auditReason }, + }); + annotate({ + action: { name: "auth.login.passkey.refused" }, + meta: { reason: err.kind }, + }); + return apiError(refusal.message, refusal.status, { + errorCode: refusal.errorCode, + }); + } + const { verification, passkey } = outcome; if (!verification.verified) { await auditLog("auth.login.failed", { ipAddress: ip, details: { reason: "passkey_verification_failed" }, }); - return apiError("Passkey verification failed", 401); + return apiError("Passkey verification failed", 401, { + errorCode: PASSKEY_VERIFICATION_FAILED_CODE, + }); } const user = await prisma.user.findUnique({ diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 43cba330c..37c3850c6 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -577,4 +577,22 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { ); expect(res.status).toBe(429); }); + + it("refuses a dose for a record-only medication with medication.intake.notTracked", async () => { + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-1", + userId: "user-1", + name: "Metformin", + trackIntake: false, + } as never); + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-record-001" }), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("medication.intake.notTracked"); + }); }); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 2d45fc4bd..6b53f9c88 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -31,6 +31,8 @@ import { import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { isRecordOnly } from "@/lib/medications/intake-tracking"; +import { refuseUntrackedIntake } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; const RATE_LIMIT = 60; @@ -224,6 +226,11 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API endpoint for this medication is disabled", 403); } + // A record-only medication takes no new dose — the same refusal every + // dose-writing route gives (iOS #116, item 15). Checked after the scope + // gate so a token without this medication's scope learns nothing about it. + if (isRecordOnly(medication)) return refuseUntrackedIntake(); + // The external ingest path runs without a User record in scope; // resolve the timezone once — band attribution and the compliance // recompute below both anchor on the user's local day. diff --git a/src/app/api/measurements/batch/__tests__/issue-paths.test.ts b/src/app/api/measurements/batch/__tests__/issue-paths.test.ts new file mode 100644 index 000000000..8d7edf6c0 --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/issue-paths.test.ts @@ -0,0 +1,183 @@ +/** + * `POST /api/measurements/batch` — which issue belongs to which entry. + * + * A whole-batch 422 (`measurement.batch.invalid`) lists every problem in + * `details.issues`. The native client splits such a batch: it drops the + * entries the issues name and resends the rest, so one bad reading does not + * cost the valid ones. That only works if every problem caused by one entry + * carries a path that starts `entries.`, and if the problems that are not + * about an entry (the wrapper itself) never do. Both halves are pinned here, + * and the first case proves the split by resending what is left. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { update: vi.fn() }, + measurement: { findMany: vi.fn() }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + return (fn as (tx: unknown) => unknown)(prisma); + } + }), + }, +})); + +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: vi.fn( + async (_tx: unknown, input: Record) => ({ + status: "inserted", + row: { id: "row-1", ...input }, + }), + ), +})); + +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() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ + maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/arrivals/emit-shared", () => ({ + emitDataArrival: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +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 post(body: unknown) { + return POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +const AT = new Date(Date.now() - 3_600_000).toISOString(); + +function entry(i: number, overrides: Record = {}) { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 64, + unit: "count/min", + startDate: AT, + endDate: AT, + externalId: `uuid-${i}`, + ...overrides, + }; +} + +type Issue = { path: string; code: string }; + +async function issuesFor(body: unknown): Promise { + const res = await post(body); + expect(res.status).toBe(422); + const json = (await res.json()) as { + details: { issues: Issue[] }; + meta?: { errorCode?: string }; + }; + expect(json.meta?.errorCode).toBe("measurement.batch.invalid"); + return json.details.issues; +} + +/** The entry index an issue names, or null when it names the batch itself. */ +function entryIndex(path: string): number | null { + const m = /^entries\.(\d+)(?:\.|$)/.exec(path); + return m ? Number(m[1]) : null; +} + +/** The paths that are about the batch wrapper and no single entry. */ +const BATCH_LEVEL_PATHS = new Set(["", "entries", "syncTrigger"]); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + limit: 60, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([]); +}); + +describe("POST /api/measurements/batch — every entry problem names its entry", () => { + it("names each bad entry as entries., so the rest of the batch can be resent", async () => { + const entries = [ + entry(0), + entry(1, { startDate: "yesterday" }), + entry(2), + entry(3, { value: "64" }), + "not an entry", + entry(5, { hkIdentifier: "", unit: "" }), + entry(6), + ]; + const issues = await issuesFor({ entries }); + + expect(issues.length).toBeGreaterThan(0); + for (const issue of issues) { + expect(entryIndex(issue.path), issue.path).not.toBeNull(); + } + const bad = new Set(issues.map((i) => entryIndex(i.path))); + expect([...bad].sort()).toEqual([1, 3, 4, 5]); + + // The split the client performs: drop the named entries, resend the rest. + const kept = entries.filter((_, i) => !bad.has(i)); + const res = await post({ entries: kept }); + expect(res.status).toBe(200); + }); + + it("names the batch itself, never an entry, when the wrapper is wrong", async () => { + const cases: unknown[] = [ + [], + { entries: [] }, + { entries: "x" }, + { entries: [entry(0)], syncTrigger: "sometimes" }, + ]; + let seen = 0; + for (const body of cases) { + for (const issue of await issuesFor(body)) { + expect(BATCH_LEVEL_PATHS.has(issue.path), issue.path).toBe(true); + expect(entryIndex(issue.path)).toBeNull(); + seen += 1; + } + } + expect(seen).toBeGreaterThanOrEqual(cases.length); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 5a11e30ba..367f82e11 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -7,6 +7,7 @@ import { buildCompliancePayload, complianceCacheKey, type CompliancePayload, + type CompliancePayloadWire, } from "@/lib/medications/compliance-payload"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -113,6 +114,16 @@ export const GET = apiHandler( }, }); - return apiSuccess(payload); + // The report windows ride the cached cell for the batched read only; this + // route's published shape does not carry them. + const wire: CompliancePayloadWire = { + applicable: payload.applicable, + notApplicableReason: payload.notApplicableReason, + compliance7: payload.compliance7, + compliance30: payload.compliance30, + dailyCompliance: payload.dailyCompliance, + complianceDisplay: payload.complianceDisplay, + }; + return apiSuccess(wire); }, ); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 5e068be58..1705cb8a1 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -71,6 +71,7 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), + assertMedicationTakesIntake: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/cache/invalidate", () => ({ diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index b7a9a4519..053deba20 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -79,6 +79,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({ vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), + assertMedicationTakesIntake: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/cache/invalidate", () => ({ diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index b61961363..2cf36546a 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -27,7 +27,10 @@ import { restoreForIntake, } from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; -import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { + assertMedicationOwnership, + assertMedicationTakesIntake, +} from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; @@ -111,8 +114,11 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { const intakeSource = authMethod === "bearer" ? "API" : "WEB"; const { id } = await params; - // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. - const guard = await assertMedicationOwnership(id, user.id); + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. This is a + // dose write, so the guard also refuses a record-only medication with + // `medication.intake.notTracked` (iOS #116, item 15): its history stays, + // but a new dose would be stored and then counted nowhere. + const guard = await assertMedicationTakesIntake(id, user.id); if (guard) return guard; const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts index 0f49d87d4..1f7059142 100644 --- a/src/app/api/medications/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -274,4 +274,70 @@ describe("GET /api/medications/compliance", () => { ); expect(prisma.medication.findMany).not.toHaveBeenCalled(); }); + + it("carries one account-wide figure in meta.aggregate, from applicable medications only", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + medication("med-2"), + { + ...medication("mirror-1"), + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + schedules: [], + }, + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + const body = await res.json(); + const items = body.data as Array<{ + compliance7: { totalExpected: number; missed: number }; + compliance30: { totalExpected: number }; + }>; + expect(body.meta.aggregate.medicationCount).toBe(2); + // Summed over the two scheduled medications; the mirror adds nothing. + expect(body.meta.aggregate.compliance7.totalExpected).toBe( + items[0].compliance7.totalExpected + items[1].compliance7.totalExpected, + ); + expect(body.meta.aggregate.compliance30.totalExpected).toBe( + items[0].compliance30.totalExpected + items[1].compliance30.totalExpected, + ); + expect(body.meta.aggregate.compliance7.totalExpected).toBeGreaterThan(0); + expect(body.meta).not.toHaveProperty("days"); + }); + + it("answers meta.aggregate null when no medication's adherence applies", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + const body = await res.json(); + expect(body.meta).toEqual({ aggregate: null }); + }); + + it("adds a complianceN block per medication and to the aggregate on ?days=N", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + ] as never); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/medications/compliance?days=90"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + const item = body.data[0] as Record; + expect(item.compliance90.totalExpected).toBeGreaterThan( + item.compliance30.totalExpected, + ); + expect(body.meta.days).toBe(90); + expect(body.meta.aggregate.compliance90).toEqual(item.compliance90); + }); + + it("refuses a window the ledger does not serve", async () => { + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/medications/compliance?days=45"), + ); + expect(res.status).toBe(422); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 59d17ee03..d8e0bd49e 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,15 +1,22 @@ import pLimit from "p-limit"; +import { z } from "zod/v4"; import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; import { + apiError, + apiSuccessWithMeta, + returnAllZodIssues, +} from "@/lib/api-response"; +import { + aggregateCompliance, buildCompliancePayload, complianceCacheKey, type CompliancePayload, } from "@/lib/medications/compliance-payload"; +import { COMPLIANCE_REPORT_DAYS } from "@/lib/analytics/compliance"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; @@ -28,9 +35,29 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; * wire shape — the cards render rates / streak / display block only; the * detail page keeps the per-id route for the grid. */ -export const GET = apiHandler(async () => { +/** + * `?days=N` adds a `complianceN` block per medication and to the account + * aggregate, for a report that covers its own window rather than the fixed 7 + * and 30 days. Only the windows the ledger serves are accepted; anything else + * is a 422, never a silently rounded window. + */ +const querySchema = z.object({ + days: z + .enum(COMPLIANCE_REPORT_DAYS.map(String) as [string, ...string[]]) + .transform(Number) + .optional(), +}); + +export const GET = apiHandler(async (request: Request) => { const { user, actor } = await requireRecordAuth("read", "medications"); + const query = querySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!query.success) return returnAllZodIssues(query.error, 422); + const days = query.data.days as + (typeof COMPLIANCE_REPORT_DAYS)[number] | undefined; + // v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to // the record. Two reasons, and both are the same reason from opposite ends: // a delegate hammering this route must burn their own allowance rather than @@ -74,7 +101,7 @@ export const GET = apiHandler(async () => { // on a cold cache. Warm / stale cells return without touching the // database at all; `Promise.all` keeps the response in list order. const limit = pLimit(3); - const items = await Promise.all( + const results = await Promise.all( medications.map((medication) => limit(async () => { const payload = await cachedSwr( @@ -83,22 +110,69 @@ export const GET = apiHandler(async () => { () => buildCompliancePayload(medication, user.id, userTz), annotate, ); - return { - medicationId: medication.id, - applicable: payload.applicable, - notApplicableReason: payload.notApplicableReason, - compliance7: payload.compliance7, - compliance30: payload.compliance30, - complianceDisplay: payload.complianceDisplay, - }; + return { payload, medicationId: medication.id }; }), ), ); + // A cell cached before the report windows existed carries none; it reads + // as the not-applicable placeholder until its next rebuild rather than + // failing the whole read. + const windowFor = (payload: CompliancePayload, n: number) => + payload.reportWindows?.[n as keyof CompliancePayload["reportWindows"]]; + + const items = results.map(({ payload, medicationId }) => ({ + medicationId, + applicable: payload.applicable, + notApplicableReason: payload.notApplicableReason, + compliance7: payload.compliance7, + compliance30: payload.compliance30, + ...(days !== undefined && days !== 30 + ? { [`compliance${days}`]: windowFor(payload, days) ?? null } + : {}), + complianceDisplay: payload.complianceDisplay, + })); + + // The account-wide figure the widget ring and the report show, built only + // from medications whose adherence means something (`applicable`). + const applicable = results + .map((r) => r.payload) + .filter((payload) => payload.applicable); + const reportWindow = + days !== undefined && days !== 30 + ? applicable.map((payload) => windowFor(payload, days)) + : []; + const aggregate = + applicable.length === 0 + ? null + : { + compliance7: aggregateCompliance( + applicable.map((p) => p.compliance7), + ), + compliance30: aggregateCompliance( + applicable.map((p) => p.compliance30), + ), + ...(days !== undefined && days !== 30 + ? { + [`compliance${days}`]: reportWindow.every(Boolean) + ? aggregateCompliance( + reportWindow as NonNullable< + (typeof reportWindow)[number] + >[], + ) + : null, + } + : {}), + medicationCount: applicable.length, + }; + annotate({ action: { name: "medication.compliance_summary.read" }, - meta: { count: items.length }, + meta: { count: items.length, days: days ?? null }, }); - return apiSuccess(items); + return apiSuccessWithMeta(items, { + aggregate, + ...(days !== undefined ? { days } : {}), + }); }); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index cc1bf78d9..473eb90f9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -381,6 +381,45 @@ describe("POST /api/medications/intake", () => { expect(res.status).toBe(422); }); + it("refuses to resolve an open dose of a record-only medication with medication.intake.notTracked", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + takenAt: null, + skipped: false, + scheduledFor: new Date("2026-05-18T10:00:00.000Z"), + medication: { trackIntake: false }, + } as never); + const res = await POST(req({ intakeId: "e1", status: "taken" })); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("medication.intake.notTracked"); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + it("still lets the owner correct a dose a record-only medication already has", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + takenAt: new Date("2026-05-18T10:05:00.000Z"), + skipped: false, + scheduledFor: new Date("2026-05-18T10:00:00.000Z"), + medication: { trackIntake: false }, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + skipped: true, + takenAt: null, + } as never); + const res = await POST(req({ intakeId: "e1", status: "skipped" })); + expect(res.status).toBe(200); + }); + it("marks event as skipped", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 25765da23..464d57691 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -244,6 +244,29 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { vi.useRealTimers(); }); + it("skips an entry for a record-only medication as intake_not_tracked and keeps the rest", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1", trackIntake: false }, + ] as never); + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:00.000Z", + takenAt: "2026-06-15T05:01:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.entries).toEqual([ + { index: 0, status: "skipped", reason: "intake_not_tracked" }, + ]); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => { // Existing TAKEN row at the 07:00 slot. The bulk entry is a pending // echo (no takenAt, skipped false) — must NOT clear takenAt. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 5064c0699..69fd01a1f 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -86,6 +86,10 @@ import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; +import { + INTAKE_NOT_TRACKED_REASON, + isRecordOnly, +} from "@/lib/medications/intake-tracking"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -256,6 +260,7 @@ async function postBulk(request: NextRequest): Promise { trackInjectionSites: true, allowedInjectionSites: true, externalSource: true, + trackIntake: true, }, }); const ownedSet = new Set(ownedMedications.map((m) => m.id)); @@ -366,6 +371,16 @@ async function postBulk(request: NextRequest): Promise { continue; } + // A record-only medication takes no new dose (iOS #116, item 15). The + // single-dose routes answer 422 `medication.intake.notTracked`; here the + // same decision is one entry's outcome, so the rest of the batch lands. + if (isRecordOnly(medById.get(entry.medicationId) ?? {})) { + const reason = INTAKE_NOT_TRACKED_REASON; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + continue; + } + // Both dedup keys on this route are only idempotent while the value // is STABLE across client launches: `(userId, externalId)` for the // Apple dose event, and the `@unique` `idempotency_key` column for diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 93c8edaad..e4f6b0ca7 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -54,7 +54,11 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; -import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; +import { + TRACKED_INTAKE_EVENT_WHERE, + isRecordOnly, +} from "@/lib/medications/intake-tracking"; +import { refuseUntrackedIntake } from "@/lib/medications/route-guards"; // The query + body schemas moved to `@/lib/validations/medication` when this // endpoint joined the published contract: the OpenAPI registry generates from @@ -265,6 +269,7 @@ export const POST = apiHandler(async (request: NextRequest) => { deliveryForm: true, trackInjectionSites: true, allowedInjectionSites: true, + trackIntake: true, }, }, }, @@ -273,6 +278,16 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Intake event not found", 404); } + // A record-only medication keeps the doses it already has, and an owner may + // still correct one of those (that is an edit of history). What it no longer + // takes is a NEW dose: resolving or snoozing an event that is still open + // would record one, and it would then be counted nowhere (iOS #116, item + // 15). Refused by name, the same code the per-medication route answers with. + const stillOpen = existing.takenAt === null && !existing.skipped; + if (stillOpen && isRecordOnly(existing.medication)) { + return refuseUntrackedIntake(); + } + // v1.36.1 — this route is admitted for delegates because marking a dose is // the thing a person standing next to the patient needs to do. It is an // UPDATE, though, and two of the transitions it can express are not that. diff --git a/src/app/api/mood/insights/__tests__/route.test.ts b/src/app/api/mood/insights/__tests__/route.test.ts new file mode 100644 index 000000000..55836ce48 --- /dev/null +++ b/src/app/api/mood/insights/__tests__/route.test.ts @@ -0,0 +1,86 @@ +/** + * `GET /api/mood/insights?days=N` serves the stability score for the period + * a client shows. The aggregate is cached once per user with every window in + * it; the route picks one, names it, and keeps the per-window map off the + * wire. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/cache/server-cache", () => ({ + cachedSwr: vi.fn(), + caches: { moodInsights: {} }, +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const year = { score: 40, stdDev: 0.9, band: "variable", days: 300 }; +const month = { score: 90, stdDev: 0.2, band: "verySteady", days: 28 }; + +const AGGREGATE = { + summary: { totalEntries: 300 }, + heatmap: { windowDays: 365, cells: [] }, + tags: [], + stability: year, + stabilityByWindow: { 30: month, 90: null, 180: year, 365: year }, +}; + +const call = (query = "") => + (GET as (req: Request) => Promise)( + new Request(`http://localhost/api/mood/insights${query}`), + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER", timezone: "UTC" }, + } as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(cachedSwr).mockResolvedValue(AGGREGATE as never); +}); + +describe("GET /api/mood/insights — stability per period", () => { + it("serves the whole year by default and names the window", async () => { + const body = await (await call()).json(); + expect(body.data.stability).toEqual(year); + expect(body.data.stabilityWindowDays).toBe(365); + expect(body.data).not.toHaveProperty("stabilityByWindow"); + }); + + it("serves the chosen window as stability on ?days=30", async () => { + const body = await (await call("?days=30")).json(); + expect(body.data.stability).toEqual(month); + expect(body.data.stabilityWindowDays).toBe(30); + }); + + it("answers null, not the year's score, when the window has too few days", async () => { + const body = await (await call("?days=90")).json(); + expect(body.data.stability).toBeNull(); + expect(body.data.stabilityWindowDays).toBe(90); + }); + + it("refuses a window it does not offer", async () => { + const res = await call("?days=45"); + expect(res.status).toBe(422); + expect(cachedSwr).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index 95d37c5c9..354d5895d 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -1,10 +1,14 @@ -import { apiSuccess } from "@/lib/api-response"; +import { z } from "zod/v4"; + +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { fetchMoodAggregates, + MOOD_STABILITY_WINDOWS, type MoodAggregates, + type MoodStabilityWindow, } from "@/lib/insights/mood-aggregates"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -26,9 +30,28 @@ export const dynamic = "force-dynamic"; * aggregate is served immediately and a single background recompute * warms a fresh one, so an active logger never re-pays the cold compute. */ -export const GET = apiHandler(async () => { +/** + * `?days=N` cuts `stability` to the trailing N days, so a period picker shows + * the steadiness of the period it shows. Without it the score covers the + * whole year, as it always has; `stabilityWindowDays` names the window the + * served score covers either way. + */ +const querySchema = z.object({ + days: z + .enum(MOOD_STABILITY_WINDOWS.map(String) as [string, ...string[]]) + .transform((v) => Number(v) as MoodStabilityWindow) + .optional(), +}); + +export const GET = apiHandler(async (request: Request) => { const { user } = await requireAuth(); + const query = querySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!query.success) return returnAllZodIssues(query.error, 422); + const stabilityWindowDays: MoodStabilityWindow = query.data.days ?? 365; + // Per-domain gate: the mood-insights aggregate surface only serves the // mood module's analysis page, so it must be gated on the mood module // (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled. @@ -51,5 +74,17 @@ export const GET = apiHandler(async () => { }, }); - return apiSuccess(result); + // The per-window map is the cache's, not the wire's: the chosen window is + // served as `stability`. An aggregate cached before the map existed has + // only the year, so a shorter window reads as not yet computed (`null`) + // until the next rebuild rather than as the year's score under a new name. + const { stabilityByWindow, ...wire } = result; + return apiSuccess({ + ...wire, + stability: + stabilityWindowDays === 365 + ? result.stability + : (stabilityByWindow?.[stabilityWindowDays] ?? null), + stabilityWindowDays, + }); }); diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index f958c417a..ac2f8cea2 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -496,9 +496,20 @@ export function calculateCompliance( * `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by * filtering on `row.at`. */ +/** + * The report windows a client may ask the batched compliance read for + * (`GET /api/medications/compliance?days=N`), beyond the 7 and 30 days every + * payload carries. Each is a suffix of the 365-day ledger window, so all of + * them are tallies over the rows the bundle already minted. + */ +export const COMPLIANCE_REPORT_DAYS = [30, 60, 90, 180, 365] as const; +export type ComplianceReportDays = (typeof COMPLIANCE_REPORT_DAYS)[number]; + export interface MedicationComplianceBundle { compliance7: ComplianceResult; compliance30: ComplianceResult; + /** One result per report window, keyed by its length in days. */ + reportWindows: Record; complianceDisplay: ComplianceDisplay; /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ ledgerRows: DoseHistoryRow[]; @@ -704,9 +715,17 @@ export function buildMedicationComplianceBundle( currentDose, }; + const reportWindows = Object.fromEntries( + COMPLIANCE_REPORT_DAYS.map((days) => [ + days, + days === 30 ? compliance30 : resultForWindow(days), + ]), + ) as Record; + return { compliance7, compliance30, + reportWindows, complianceDisplay, ledgerRows, ledgerFrom, diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index c2403c1dd..0f22c09d6 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -6,6 +6,20 @@ export function apiSuccess(data: T, status = 200) { return NextResponse.json({ data, error: null }, { status }); } +/** + * A success envelope that also carries `meta`: figures ABOUT the payload + * (an aggregate across its rows, the window it was cut to) that do not belong + * inside any one row. `data` keeps the shape the route always had, so a + * client that never reads `meta` decodes the response unchanged. + */ +export function apiSuccessWithMeta( + data: T, + meta: Record, + status = 200, +) { + return NextResponse.json({ data, error: null, meta }, { status }); +} + /** * Sanitised view of a single Zod issue. We surface `path`, `code`, * `message` and — for `unrecognized_keys` only — the bounded `keys` diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 8598beb0d..d2847ad82 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -304,6 +304,33 @@ export async function createAuthenticationOptions(userId?: string) { return { options, challengeId: challenge.id }; } +/** + * Why a passkey assertion could not be checked at all — as opposed to an + * assertion that was checked and did not verify. Each kind is a case a + * client can name to the person (the passkey was removed here, the sign-in + * took too long), so the sign-in route answers it with its own status and + * `meta.errorCode` instead of letting it surface as a server fault. + * + * - `challenge_expired`: the challenge is gone (expired, already used, never + * issued) or was begun for a different account. + * - `unknown_passkey`: no passkey with this credential id is registered here. + * - `malformed`: the assertion body is not an authentication response. + * - `verification_failed`: the WebAuthn library refused the assertion + * (origin, RP id, challenge or signature mismatch). + */ +export type PasskeyAuthErrorKind = + "challenge_expired" | "unknown_passkey" | "malformed" | "verification_failed"; + +export class PasskeyAuthError extends Error { + constructor( + readonly kind: PasskeyAuthErrorKind, + message: string, + ) { + super(message); + this.name = "PasskeyAuthError"; + } +} + export async function verifyAuthentication( challengeId: string, response: unknown, @@ -318,7 +345,10 @@ export async function verifyAuthentication( type: "authentication", }); if (!challenge) { - throw new Error("Challenge expired or not found"); + throw new PasskeyAuthError( + "challenge_expired", + "Challenge expired or not found", + ); } // v1.4.43 W13 L-3 — explicit Zod narrowing instead of the previous @@ -329,7 +359,10 @@ export async function verifyAuthentication( // type-narrowing gap a future refactor could trip on. const parsed = authenticationResponseSchema.safeParse(response); if (!parsed.success) { - throw new Error("Malformed passkey authentication response"); + throw new PasskeyAuthError( + "malformed", + "Malformed passkey authentication response", + ); } const typedResponse = parsed.data as unknown as AuthenticationResponseJSON; @@ -340,27 +373,41 @@ export async function verifyAuthentication( }); if (!passkey) { - throw new Error("Passkey not found"); + throw new PasskeyAuthError("unknown_passkey", "Passkey not found"); } // A challenge begun for one account (the re-proof arm names the account) // is answered only by that account's passkey. if (challenge.userId !== null && challenge.userId !== passkey.userId) { - throw new Error("Challenge expired or not found"); + throw new PasskeyAuthError( + "challenge_expired", + "Challenge expired or not found", + ); } - const verification = await verifyAuthenticationResponse({ - response: typedResponse, - expectedChallenge: challenge.challenge, - expectedOrigin: getExpectedOrigin(), - expectedRPID: getRpId(), - credential: { - id: passkey.credentialId, - publicKey: passkey.credentialPublicKey, - counter: Number(passkey.counter), - transports: passkey.transports as Transport[], - }, - }); + let verification: VerifiedAuthenticationResponse; + try { + verification = await verifyAuthenticationResponse({ + response: typedResponse, + expectedChallenge: challenge.challenge, + expectedOrigin: getExpectedOrigin(), + expectedRPID: getRpId(), + credential: { + id: passkey.credentialId, + publicKey: passkey.credentialPublicKey, + counter: Number(passkey.counter), + transports: passkey.transports as Transport[], + }, + }); + } catch (err) { + // The library throws, rather than answering `verified: false`, for an + // origin, RP id or challenge mismatch and for a bad signature. Those are + // a refused assertion, not a server fault. + throw new PasskeyAuthError( + "verification_failed", + err instanceof Error ? err.message : "Passkey verification failed", + ); + } if (verification.verified) { // Update counter + stamp last-used so the management UI can surface diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index c525463e4..f03203b8c 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -95,6 +95,18 @@ export function glucoseBandParams( }; } +/** + * The stored glucose-unit choice as the closed pair, or `null` when none was + * made. The column is free text (a restored backup writes it unchecked), so a + * value that is neither unit reads as no choice rather than passing through to + * a client that decodes the pair. + */ +export function storedGlucoseUnit( + value: string | null | undefined, +): GlucoseUnit | null { + return value === "mg/dL" || value === "mmol/L" ? value : null; +} + export function resolveGlucoseUnit( userPreference: string | null | undefined, ): GlucoseUnit { diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 25c98de65..8b69a9ebc 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -410,6 +410,23 @@ describe("computeMoodAggregates", () => { ]); expect(agg.stability).toBeNull(); }); + + it("scores stability per trailing window, so a steady month after a turbulent year reads steady", () => { + // Days 0..29 sit at 4; days 30..119 swing between 1 and 5. + const entries: MoodAggregateEntry[] = []; + for (let d = 0; d < 30; d++) entries.push(entry(d, 4)); + for (let d = 30; d < 120; d++) entries.push(entry(d, d % 2 ? 1 : 5)); + const agg = computeMoodAggregates({ entries, measurements: [], now: NOW }); + + expect(agg.stabilityByWindow[30]?.score).toBe(100); + expect(agg.stabilityByWindow[30]?.days).toBe(30); + expect(agg.stabilityByWindow[90]?.score).toBeLessThan( + agg.stabilityByWindow[30]!.score, + ); + // The whole-year entry is the score `stability` has always carried. + expect(agg.stabilityByWindow[365]).toEqual(agg.stability); + expect(agg.stability?.days).toBe(120); + }); }); describe("bucketForHour", () => { diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 70dff13dd..458382e7b 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -328,6 +328,14 @@ export function pairDailyBuckets( export type HeatmapWindowDays = 30 | 90 | 365; +/** + * The trailing windows the stability score is offered over. 365 is the + * whole daily series the aggregate keeps, which is what `stability` has + * always covered. + */ +export const MOOD_STABILITY_WINDOWS = [30, 90, 180, 365] as const; +export type MoodStabilityWindow = (typeof MOOD_STABILITY_WINDOWS)[number]; + /** * Adaptive heatmap window so the grid is never mostly-empty. * @@ -497,6 +505,13 @@ export interface MoodAggregates { * descriptive band. `null` for a sparse logger (< STABILITY_MIN_DAYS). */ stability: MoodStability | null; + /** + * The same score over the shorter trailing windows a client can pick + * (`GET /api/mood/insights?days=N`). Cached with the rest of the aggregate + * so a period switch costs nothing; the route serves the chosen one as + * `stability` and does not publish this map. + */ + stabilityByWindow: Record; tags: TagSummaryRow[]; /** * v1.38 — how the day's pleasantness sat on days carrying each context @@ -608,6 +623,16 @@ export function computeMoodAggregates(args: { const weekday = computeWeekdayAverages(moodDaily, now, tz); const timeOfDay = computeTimeOfDayAverages(entries); const stability = computeMoodStability(moodDaily); + const stabilityByWindow = Object.fromEntries( + MOOD_STABILITY_WINDOWS.map((days) => [ + days, + days === 365 + ? stability + : computeMoodStability( + moodDaily.filter((bucket) => bucket.dayOffset < days), + ), + ]), + ) as Record; const tags = computeTagSummary(entries, now); const structuredTags = computeStructuredTagSummary(entries, now); const tagInfluence = computeTagInfluence(entries, now); @@ -689,6 +714,7 @@ export function computeMoodAggregates(args: { weekday, timeOfDay, stability, + stabilityByWindow, tags, structuredTags, tagInfluence, diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 89d87ce9e..ace53d2c0 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -223,6 +223,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { metadata: expect.objectContaining({ phase: "ORANGE", timeOfDay: "08:00", + followUp: true, }), }), ); @@ -248,6 +249,34 @@ describe("handleReminderCheck per-slot reminder windows", () => { metadata: expect.objectContaining({ phase: "YELLOW", timeOfDay: "08:00", + // GREEN announced this window; YELLOW escalates an open dose. + followUp: true, + }), + }), + ); + }); + + it("marks the phase that announces the dose as the one reminder that is not a follow-up", async () => { + vi.useFakeTimers(); + // 55 minutes before the 09:30 end of a 07:30 to 09:30 window: GREEN. + vi.setSystemTime(new Date("2026-07-28T08:35:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + medicationWithSchedule({ + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:30" }], + }), + ] as never); + setIntakeEvents([]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "GREEN", + timeOfDay: "08:00", + followUp: false, }), }), ); diff --git a/src/lib/jobs/__tests__/reminder-phases.test.ts b/src/lib/jobs/__tests__/reminder-phases.test.ts index 8a7b38263..00515b6de 100644 --- a/src/lib/jobs/__tests__/reminder-phases.test.ts +++ b/src/lib/jobs/__tests__/reminder-phases.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import { determinePhase, getPhaseKeyboard, + openingPhase, getPhaseMessage, resolvePhaseThresholds, DEFAULT_PHASE_CONFIG, @@ -142,3 +143,26 @@ describe("getPhaseKeyboard — localised", () => { } }); }); + +describe("openingPhase", () => { + it("is GREEN for a window longer than the YELLOW lead", () => { + const t = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 120); + expect(openingPhase(t, 120)).toBe("GREEN"); + }); + + it("is YELLOW for a point slot, which never shows GREEN", () => { + const t = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 0); + expect(openingPhase(t, 0)).toBe("YELLOW"); + // The same window walked tick by tick reaches YELLOW before anything else. + expect(determinePhase(30, -30, t)).toBe("YELLOW"); + expect(determinePhase(45, -45, t)).toBeNull(); + }); + + it("is YELLOW when the GREEN lead is not longer than the YELLOW lead", () => { + const t = resolvePhaseThresholds( + { ...DEFAULT_PHASE_CONFIG, greenValue: 20, yellowValue: 30 }, + 120, + ); + expect(openingPhase(t, 120)).toBe("YELLOW"); + }); +}); diff --git a/src/lib/jobs/reminder-phases.ts b/src/lib/jobs/reminder-phases.ts index 08100eac5..8447e17ea 100644 --- a/src/lib/jobs/reminder-phases.ts +++ b/src/lib/jobs/reminder-phases.ts @@ -84,6 +84,31 @@ export function resolvePhaseThresholds( }; } +/** + * The first phase a slot's window reaches, which is the reminder that + * announces the dose; every later phase is a follow-up for a dose that is + * still open. + * + * It is not always GREEN. GREEN needs the window to have opened AND to be + * within `greenMinBefore` of its end, while YELLOW needs only the second + * condition with the smaller `yellowMinBefore`. A window no longer than the + * YELLOW lead (a point slot such as 07:00 to 07:00 is zero minutes long) + * reaches YELLOW first and never shows GREEN, and so does any configuration + * whose GREEN lead is not longer than its YELLOW lead. + */ +export function openingPhase( + thresholds: ReturnType, + windowDurationMin: number, +): ReminderPhase { + if ( + windowDurationMin <= thresholds.yellowMinBefore || + thresholds.greenMinBefore <= thresholds.yellowMinBefore + ) { + return "YELLOW"; + } + return "GREEN"; +} + /** * Determine the current phase for a schedule based on minutes to window end. * diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 41958f516..af5929de3 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -31,6 +31,7 @@ import { determinePhase, getPhaseMessage, getPhaseKeyboard, + openingPhase, } from "@/lib/jobs/reminder-phases"; import { claimNotificationEvent, @@ -555,6 +556,9 @@ export async function handleReminderCheck( windowStart: phaseWindow.start, minutesToEnd, currentPhase, + followUp: + currentPhase !== + openingPhase(thresholds, windowDuration), } : null; }) @@ -570,6 +574,7 @@ export async function handleReminderCheck( windowStart, minutesToEnd, currentPhase, + followUp, } = phaseCandidate; const slotInstant = slotScheduledFor.getTime(); if (liveEraStart !== null && slotInstant < liveEraStart) { @@ -767,6 +772,12 @@ export async function handleReminderCheck( medicationId: med.id, scheduleId: schedule.id, phase: currentPhase, + // False for the reminder that announces the dose (the + // slot's opening phase), true for every escalation after + // it. A client that reminds locally at dose time + // (`medication.clientManaged`) replaces only the first + // one, so only that one is kept off APNs. + followUp, date: slotScheduledFor.toLocaleDateString("sv-SE", { timeZone: userTz, }), diff --git a/src/lib/medications/__tests__/compliance-aggregate.test.ts b/src/lib/medications/__tests__/compliance-aggregate.test.ts new file mode 100644 index 000000000..5c32fcffb --- /dev/null +++ b/src/lib/medications/__tests__/compliance-aggregate.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { aggregateCompliance } from "../compliance-payload"; + +function result(taken: number, missed: number, skipped = 0, streak = 0) { + return { + totalExpected: taken + missed + skipped, + taken, + skipped, + missed, + rate: + taken + missed > 0 ? Math.round((100 * taken) / (taken + missed)) : 100, + streak, + }; +} + +describe("aggregateCompliance", () => { + it("weights by expected doses rather than averaging the rates", () => { + // A three-times-daily tablet at 60/90 and a weekly injection at 4/4. + // The mean of the rates would read 83 %; the doses say 64/94 = 68 %. + const out = aggregateCompliance([result(60, 30), result(4, 0)]); + expect(out).toMatchObject({ + totalExpected: 94, + taken: 64, + missed: 30, + rate: 68, + }); + }); + + it("keeps skipped doses out of the rate, as a single medication's tally does", () => { + expect(aggregateCompliance([result(9, 1, 5)])?.rate).toBe(90); + expect(aggregateCompliance([result(9, 1, 5)])?.totalExpected).toBe(15); + }); + + it("takes the shortest streak, because a day counts only when every medication was taken", () => { + expect( + aggregateCompliance([result(5, 0, 0, 12), result(5, 0, 0, 3)])?.streak, + ).toBe(3); + }); + + it("is null when nothing contributes", () => { + expect(aggregateCompliance([])).toBeNull(); + }); +}); diff --git a/src/lib/medications/__tests__/route-guards.test.ts b/src/lib/medications/__tests__/route-guards.test.ts index e75d004f9..fcbebbfce 100644 --- a/src/lib/medications/__tests__/route-guards.test.ts +++ b/src/lib/medications/__tests__/route-guards.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { assertMedicationOwnership } from "../route-guards"; +import { + assertMedicationOwnership, + assertMedicationTakesIntake, +} from "../route-guards"; function buildClient(med: { id: string; userId: string } | null) { return { @@ -72,3 +75,34 @@ describe("assertMedicationOwnership", () => { expect(call.select).toEqual({ id: true, userId: true }); }); }); + +describe("assertMedicationTakesIntake", () => { + function client( + med: { id: string; userId: string; trackIntake: boolean } | null, + ) { + return { medication: { findUnique: vi.fn().mockResolvedValue(med) } }; + } + + it("admits a tracked medication the caller holds", async () => { + const c = client({ id: "med-1", userId: "user-1", trackIntake: true }); + expect(await assertMedicationTakesIntake("med-1", "user-1", c)).toBeNull(); + expect(c.medication.findUnique).toHaveBeenCalledWith({ + where: { id: "med-1" }, + select: { id: true, userId: true, trackIntake: true }, + }); + }); + + it("refuses a record-only medication by name", async () => { + const c = client({ id: "med-1", userId: "user-1", trackIntake: false }); + const result = await assertMedicationTakesIntake("med-1", "user-1", c); + expect(result?.status).toBe(422); + const body = await result!.json(); + expect(body.meta.errorCode).toBe("medication.intake.notTracked"); + }); + + it("answers 404, not 422, for a record-only medication of another account", async () => { + const c = client({ id: "med-1", userId: "owner", trackIntake: false }); + const result = await assertMedicationTakesIntake("med-1", "intruder", c); + expect(result?.status).toBe(404); + }); +}); diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 3f552c714..2b1a1de10 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -15,7 +15,9 @@ import { buildMedicationComplianceBundle, expectsDoses, lastNonSkippedTakenAt, + COMPLIANCE_REPORT_DAYS, type ComplianceDisplay, + type ComplianceReportDays, type ComplianceResult, type DailyComplianceEntry, } from "@/lib/analytics/compliance"; @@ -47,8 +49,17 @@ export interface CompliancePayload { compliance30: ComplianceResult; dailyCompliance: Record; complianceDisplay: ComplianceDisplay | null; + /** + * The report windows the batched read serves on `?days=N`. Cached with the + * rest of the cell so a report read costs no second ledger pass; NOT on the + * per-medication wire, which keeps its published shape. + */ + reportWindows: Record; } +/** The cached payload minus the fields only the batched read serves. */ +export type CompliancePayloadWire = Omit; + /** * Legacy-compatible placeholder for a medication whose adherence percentage * is explicitly not applicable. @@ -68,6 +79,13 @@ const NOT_APPLICABLE_LEGACY_COMPLIANCE: ComplianceResult = { streak: 0, }; +const NOT_APPLICABLE_REPORT_WINDOWS = Object.fromEntries( + COMPLIANCE_REPORT_DAYS.map((days) => [ + days, + NOT_APPLICABLE_LEGACY_COMPLIANCE, + ]), +) as Record; + /** The medication slice the payload builder consumes. */ export interface ComplianceMedicationInput { id: string; @@ -194,6 +212,7 @@ export async function buildCompliancePayload( compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE, dailyCompliance: {}, complianceDisplay: null, + reportWindows: NOT_APPLICABLE_REPORT_WINDOWS, }; } @@ -207,6 +226,7 @@ export async function buildCompliancePayload( compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE, dailyCompliance: {}, complianceDisplay: null, + reportWindows: NOT_APPLICABLE_REPORT_WINDOWS, }; } @@ -314,5 +334,49 @@ export async function buildCompliancePayload( compliance30: bundle.compliance30, dailyCompliance, complianceDisplay: bundle.complianceDisplay, + reportWindows: bundle.reportWindows, + }; +} + +/** + * One adherence figure for the whole account, in the per-medication + * `ComplianceResult` shape, weighted by expected doses: the counts are summed + * across every applicable medication and the rate is recomputed from the sums + * with the same rule a single medication's tally uses + * (`round(100 · taken / (taken + missed))`, capped at 100, 100 when nothing + * was expected). Averaging the per-medication rates instead would let a + * weekly injection weigh as much as a three-times-daily tablet. + * + * `streak` is the shortest per-medication streak: a day only extends the + * account's streak when every medication's doses were taken that day. + * + * `null` when no medication contributes, so a client shows nothing rather + * than a vacuous 100 %. + */ +export function aggregateCompliance( + results: readonly ComplianceResult[], +): ComplianceResult | null { + if (results.length === 0) return null; + let taken = 0; + let skipped = 0; + let missed = 0; + let streak = Number.POSITIVE_INFINITY; + for (const r of results) { + taken += r.taken; + skipped += r.skipped; + missed += r.missed; + streak = Math.min(streak, r.streak); + } + const denominator = taken + missed; + return { + totalExpected: taken + skipped + missed, + taken, + skipped, + missed, + rate: + denominator > 0 + ? Math.min(100, Math.round((taken / denominator) * 100)) + : 100, + streak, }; } diff --git a/src/lib/medications/intake-tracking.ts b/src/lib/medications/intake-tracking.ts index 448379d8a..ec2a927e6 100644 --- a/src/lib/medications/intake-tracking.ts +++ b/src/lib/medications/intake-tracking.ts @@ -66,3 +66,15 @@ export function scheduleWireFields( ? { schedules: [], recordedSchedules: schedules } : { schedules }; } + +/** + * Error code for a dose written against a record-only medication. Every route + * that records a dose answers it the same way (iOS #116, item 15): the history + * the medication already has stays, but nothing new is counted, so a new + * write is refused by name rather than stored and then counted nowhere. + * The bulk route reports the same decision per entry as `intake_not_tracked`. + */ +export const INTAKE_NOT_TRACKED_ERROR_CODE = "medication.intake.notTracked"; + +/** Per-entry skip reason for the bulk route, the batch form of the code. */ +export const INTAKE_NOT_TRACKED_REASON = "intake_not_tracked"; diff --git a/src/lib/medications/route-guards.ts b/src/lib/medications/route-guards.ts index 6147062fd..01425ce66 100644 --- a/src/lib/medications/route-guards.ts +++ b/src/lib/medications/route-guards.ts @@ -22,6 +22,7 @@ */ import { apiError } from "@/lib/api-response"; +import { INTAKE_NOT_TRACKED_ERROR_CODE } from "@/lib/medications/intake-tracking"; interface MedicationOwnershipPrisma { medication: { @@ -53,6 +54,51 @@ export async function assertMedicationOwnership( return null; } +/** The 422 a dose write against a record-only medication answers with. */ +export function refuseUntrackedIntake(): Response { + return apiError( + "This medication is kept as a record; its intake is not tracked", + 422, + { errorCode: INTAKE_NOT_TRACKED_ERROR_CODE }, + ); +} + +interface MedicationIntakeGuardPrisma { + medication: { + findUnique: (args: { + where: { id: string }; + select: { id: true; userId: true; trackIntake: true }; + }) => Promise<{ id: string; userId: string; trackIntake: boolean } | null>; + }; +} + +/** + * The ownership guard for a route that RECORDS a dose against the + * medication: the same 404 as {@link assertMedicationOwnership}, and then a + * 422 `medication.intake.notTracked` when the medication is kept as a record + * only (`trackIntake: false`). One read answers both, so the refusal cannot + * be reached for a medication the caller does not hold — an unknown id and a + * foreign one still look identical. + */ +export async function assertMedicationTakesIntake( + medicationId: string, + userId: string, + prismaClient?: MedicationIntakeGuardPrisma, +): Promise { + const client = + prismaClient ?? + ((await loadPrisma()) as unknown as MedicationIntakeGuardPrisma); + const med = await client.medication.findUnique({ + where: { id: medicationId }, + select: { id: true, userId: true, trackIntake: true }, + }); + if (!med || med.userId !== userId) { + return apiError("Medication not found", 404); + } + if (med.trackIntake === false) return refuseUntrackedIntake(); + return null; +} + async function loadPrisma(): Promise { // Lazy import so test files that stub `@/lib/db` at the module level // see their stub instead of the real Prisma client. Matches the diff --git a/src/lib/notifications/__tests__/client-managed-apns.test.ts b/src/lib/notifications/__tests__/client-managed-apns.test.ts index 75a7a7b8c..47984be11 100644 --- a/src/lib/notifications/__tests__/client-managed-apns.test.ts +++ b/src/lib/notifications/__tests__/client-managed-apns.test.ts @@ -146,6 +146,43 @@ describe("dispatchNotification — client-managed APNs suppression", () => { }); }); + it("keeps a follow-up for a dose still open on APNs, because the phone sends none", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + + await dispatchNotification({ + ...MEDICATION_PAYLOAD, + metadata: { + ...MEDICATION_PAYLOAD.metadata, + phase: "ORANGE", + followUp: true, + }, + }); + + expect(sendViaApnsMock).toHaveBeenCalledTimes(1); + expect(sendViaTelegramMock).toHaveBeenCalledTimes(1); + expect(recordPushAttemptMock).not.toHaveBeenCalled(); + }); + + it("still keeps the reminder that announces the dose off APNs", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + + await dispatchNotification({ + ...MEDICATION_PAYLOAD, + metadata: { + ...MEDICATION_PAYLOAD.metadata, + phase: "YELLOW", + followUp: false, + }, + }); + + expect(sendViaApnsMock).not.toHaveBeenCalled(); + expect(sendViaTelegramMock).toHaveBeenCalledTimes(1); + }); + it("honours the roaming deliveryDefault mapping", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ notificationPrefs: { medication: { deliveryDefault: "client" } }, diff --git a/src/lib/notifications/client-managed-apns.ts b/src/lib/notifications/client-managed-apns.ts index b63cc38d0..99ec47ed2 100644 --- a/src/lib/notifications/client-managed-apns.ts +++ b/src/lib/notifications/client-managed-apns.ts @@ -37,6 +37,12 @@ interface ClientManagedGate { userId: string, metadata: Record | undefined, ) => Record; + /** + * Which dispatches of the event the flag covers. Absent: all of them. The + * flag says the phone delivers this reminder itself, so it covers exactly + * what the phone replaces and nothing the phone does not send. + */ + covers?: (metadata: Record | undefined) => boolean; } function str(value: unknown): string { @@ -62,6 +68,13 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record = { phase: str(metadata?.phase), dose_at: str(metadata?.scheduledAt), }), + // The iOS client reminds once, at dose time, and sends no follow-ups of + // its own (iOS #116, item 21). So the flag keeps only the reminder that + // announces the dose off APNs; the escalations for a dose that is still + // open (`followUp: true`, set by the reminder cron) are pushed as usual. + // A dispatch that does not say is treated as the announcement, the + // behaviour every reminder had before the cron started saying. + covers: (metadata) => metadata?.followUp !== true, }, MEASUREMENT_REMINDER: { isClientManaged: isMeasurementReminderClientManaged, @@ -71,9 +84,15 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record = { }; /** - * Does this event type have a client-managed opt-out at all? Cheap check - * the dispatcher runs before paying for the preferences read. + * Does a client-managed opt-out apply to this dispatch at all? Cheap check + * the dispatcher runs before paying for the preferences read: the event type + * has to have the opt-out, and the opt-out has to cover this dispatch. */ -export function hasClientManagedApnsGate(eventType: string): boolean { - return eventType in CLIENT_MANAGED_APNS_EVENTS; +export function hasClientManagedApnsGate( + eventType: string, + metadata?: Record, +): boolean { + const gate = CLIENT_MANAGED_APNS_EVENTS[eventType]; + if (!gate) return false; + return gate.covers ? gate.covers(metadata) : true; } diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 2d8f9d793..e8ccc3f25 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -326,7 +326,10 @@ export async function dispatchNotification( if ( channel.type === "APNS" && !delivery.managed && - hasClientManagedApnsGate(recipientPayload.eventType) && + hasClientManagedApnsGate( + recipientPayload.eventType, + recipientPayload.metadata, + ) && (await resolveClientManagedApns()) ) { const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType]; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 30c23e921..179ae6c80 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -248,7 +248,10 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "measurement.restore.invalid", "measurement.update.server_owned_source", ], - medication: ["medication.intake.import.invalid_format"], + medication: [ + "medication.intake.import.invalid_format", + "medication.intake.notTracked", + ], medications: [ "medications.category.invalid", "medications.category.limitReached", @@ -306,6 +309,12 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "onboarding.restart.rateLimited", "onboarding.tour.invalid", ], + passkey: [ + "passkey.challenge.expired", + "passkey.response.invalid", + "passkey.unknown", + "passkey.verification.failed", + ], personal_records: ["personal_records.invalid_query"], practitioner: ["practitioner.invalid"], profile: [ diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index fe8f6c784..5f0ecb677 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -501,6 +501,30 @@ const accountPayload = z .looseObject({ id: z.string(), username: z.string(), + // Enumerated because clients cut days and convert glucose by them: the + // day key and the unit must be the server's, not the device's. + timezone: z + .string() + .describe( + "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the stored one is unusable. Resolved, never the raw column, so bucket day keys by this value.", + ), + glucoseUnit: z + .enum(["mg/dL", "mmol/L"]) + .nullable() + .describe( + "The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the stored value is neither unit), and the server then shows glucose in mg/dL.", + ), + features: z + .object({ + trackIntake: z + .literal(true) + .describe( + "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.", + ), + }) + .describe( + "What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.", + ), accountAccess: accountAccessBlock, moduleAccess: moduleAccessMap, // v1.39 — enumerated because every AI surface decides on it: a client diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index d38f507b0..22bb8c852 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -47,6 +47,7 @@ import { errorEnvelope, loginPasswordSchema, notImplementedResponse, + validationIssue, } from "./shared"; import { passkeyLoginOptionsSchema } from "@/lib/validations/auth"; @@ -633,13 +634,7 @@ const authProfileUpdateResponse = z "Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence.", ), rejectedFields: z - .array( - z.object({ - path: z.string(), - code: z.string(), - message: z.string(), - }), - ) + .array(validationIssue) .optional() .describe( "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.", @@ -1446,10 +1441,20 @@ export const authPaths: NonNullable = { }, "404": { description: - "The assertion resolved to a user row that no longer exists.", + "`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without a code: the assertion resolved to a user row that no longer exists.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + "401": { + description: + "The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion was checked and refused (signature, origin, relying-party id or challenge mismatch).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) `credential` is not a WebAuthn authentication response.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/openapi/routes/ingest.ts b/src/lib/openapi/routes/ingest.ts index 216619427..93705b487 100644 --- a/src/lib/openapi/routes/ingest.ts +++ b/src/lib/openapi/routes/ingest.ts @@ -87,7 +87,7 @@ export const ingestPaths: NonNullable = { }, "422": { description: - "Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first.", + "Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it.", content: { "application/json": { schema: errorEnvelope } }, }, "429": { diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b063609c8..bc54361ad 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -1079,7 +1079,7 @@ export const measurementPaths: NonNullable = { ...stdResponses, "422": { description: - "The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way.", + 'The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: every issue caused by one entry has a `path` that starts `entries.` (the entry\'s index in the request, then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` is `""` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about the batch itself, and no split recovers it.', content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 5eaeb8528..59c23b3cb 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -88,6 +88,7 @@ import { medicationCadenceResponse, medicationComplianceResponse, medicationComplianceSummaryEntry, + medicationComplianceAggregate, scheduleRevisionResource, scheduleRevisionListResponse, medicationExtractRequest, @@ -212,7 +213,12 @@ const bulkIntakeEntryResult = z .describe( "`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.", ), - reason: z.string().optional(), + reason: z + .string() + .optional() + .describe( + "Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same answer.", + ), id: z.string().optional().describe("The landed row id (absent on skips)."), }) .meta({ id: "BulkMedicationIntakeEntryResult" }); @@ -312,7 +318,7 @@ export const medicationPaths: NonNullable = { }, "422": { description: - "Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication. The latter two carry no issue list — branch on the code.", + "Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry no issue list — branch on the code.", content: { "application/json": { schema: errorEnvelope } }, }, "401": stdResponses["401"], @@ -1437,6 +1443,11 @@ export const medicationPaths: NonNullable = { }, ...stdResponses, ...recordWriteRateLimitResponse, + "422": { + description: + "Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after ownership, so a foreign medication is still a 404.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, get: { @@ -1699,17 +1710,49 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "Batched adherence read for every medication of the caller", description: - "Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.", + "Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.\n\n" + + "v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, weighted by expected doses across the applicable medications; `null` when none applies), so a client never averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a 422 rather than a rounded window.", + requestParams: { + query: z.object({ + days: z + .enum(["30", "60", "90", "180", "365"]) + .optional() + .describe( + "Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`.", + ), + }), + }, responses: { ...recordRefusal(), "200": { - description: "One adherence row per medication.", + description: + "One adherence row per medication, and the account aggregate in `meta`.", content: { "application/json": { - schema: dataEnvelope( - z.array(medicationComplianceSummaryEntry), - "ListMedicationComplianceResponse", - ), + schema: z + .object({ + data: z.array(medicationComplianceSummaryEntry), + error: z.null(), + meta: z + .object({ + aggregate: medicationComplianceAggregate.nullable(), + days: z + .union([ + z.literal(30), + z.literal(60), + z.literal(90), + z.literal(180), + z.literal(365), + ]) + .optional() + .describe("The `days` the request asked for."), + requestId: z.string().optional(), + }) + .describe( + "Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate.", + ), + }) + .meta({ id: "ListMedicationComplianceResponse" }), }, }, }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 4c54d773f..83d8de485 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -936,6 +936,18 @@ export const medicationComplianceSummaryEntry = z compliance30: complianceResult.describe( "Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.", ), + compliance60: complianceResult + .optional() + .describe("Present only on `?days=60`: the trailing 60 days."), + compliance90: complianceResult + .optional() + .describe("Present only on `?days=90`: the trailing 90 days."), + compliance180: complianceResult + .optional() + .describe("Present only on `?days=180`: the trailing 180 days."), + compliance365: complianceResult + .optional() + .describe("Present only on `?days=365`: the trailing 365 days."), complianceDisplay: complianceDisplay.nullable(), }) .meta({ @@ -1732,3 +1744,27 @@ export const medicationSideEffect = z description: "One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.", }); + +/** + * v1.42.0 — the account-wide adherence figure the batched read carries in + * `meta.aggregate`, beside the per-medication rows. + */ +export const medicationComplianceAggregate = z + .object({ + compliance7: complianceResult, + compliance30: complianceResult, + compliance60: complianceResult.nullable().optional(), + compliance90: complianceResult.nullable().optional(), + compliance180: complianceResult.nullable().optional(), + compliance365: complianceResult.nullable().optional(), + medicationCount: z + .number() + .int() + .positive() + .describe("How many applicable medications the figure is built from."), + }) + .meta({ + id: "MedicationComplianceAggregate", + description: + "One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a requested `days` is present when `days` was sent and is not 30.", + }); diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 64f88bbde..87410935d 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -955,7 +955,14 @@ const moodAggregatesResponse = z days: z.number().int(), }) .nullable() - .describe("Null for a sparse logger below the day floor."), + .describe( + "Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not.", + ), + stabilityWindowDays: z + .union([z.literal(30), z.literal(90), z.literal(180), z.literal(365)]) + .describe( + "The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year.", + ), tags: z .array( z.object({ @@ -1277,7 +1284,17 @@ export const moodPaths: NonNullable = { tags: ["Mood"], summary: "Pre-computed mood aggregates (v1.8.5)", description: - "The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach.", + "The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it.", + requestParams: { + query: z.object({ + days: z + .enum(["30", "90", "180", "365"]) + .optional() + .describe( + "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422.", + ), + }), + }, responses: { "200": { description: "The aggregate bundle.", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 261cd63a5..f0eff0b6e 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -56,6 +56,7 @@ import { recordRefusal, stdResponses, updatedAtTokenField, + validationIssue, } from "./shared"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime @@ -259,7 +260,7 @@ const notificationPrefsResolved = z clientManaged: z .boolean() .describe( - "True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is suppressed.", + "True when the iOS app delivers medication reminders locally. The server then keeps the reminder that announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was suppressed). Other channels are never affected.", ), deliveryDefault: z .enum(["server", "client"]) @@ -587,13 +588,7 @@ const profileUpdateResponse = z .boolean() .describe("Whether a KVNR is on file (the value itself is not echoed)."), rejectedFields: z - .array( - z.object({ - path: z.string(), - code: z.string(), - message: z.string(), - }), - ) + .array(validationIssue) .optional() .describe( "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.", @@ -1638,7 +1633,7 @@ export const profilePaths: NonNullable = { tags: ["Notifications"], summary: "Update per-user notification preferences", description: - 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.', + 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.', requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index bfde7afe5..1b4599b4d 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -32,7 +32,7 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/ * built for could not read it from the contract. `params` never ships: some * Zod codes embed the offending value in it and that is user content. */ -const validationIssue = z +export const validationIssue = z .object({ path: z .string() diff --git a/tests/integration/timezone-per-user.test.ts b/tests/integration/timezone-per-user.test.ts index c064e7f9c..8a191e52a 100644 --- a/tests/integration/timezone-per-user.test.ts +++ b/tests/integration/timezone-per-user.test.ts @@ -197,6 +197,32 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => { } }); + // The fields a native client decides on, enumerated in `AccountPayload`: + // the unit as the closed pair the contract names (a restored backup can + // write anything into the free-text column), and the capability flags. + it("GET /api/auth/me publishes the glucose unit as the closed pair and the server features", async () => { + const user = await seedAucklandUser(); + const prisma = getPrismaClient(); + const { GET } = await import("@/app/api/auth/me/route"); + + await prisma.user.update({ + where: { id: user.id }, + data: { glucoseUnit: "mmol/L" }, + }); + let body = (await (await GET()).json()) as { + data: { glucoseUnit: string | null; features: Record }; + }; + expect(body.data.glucoseUnit).toBe("mmol/L"); + expect(body.data.features).toEqual({ trackIntake: true }); + + await prisma.user.update({ + where: { id: user.id }, + data: { glucoseUnit: "mmol" }, + }); + body = (await (await GET()).json()) as typeof body; + expect(body.data.glucoseUnit).toBeNull(); + }); + it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => { await seedAucklandUser();