mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account payload; glucoseUnit reads as the closed pair the contract names, and features.trackIntake tells a separately released client that medications take the record-only switch. - Refuse a new dose for a record-only medication with medication.intake.notTracked on every dose-writing route; the bulk route reports it per entry as intake_not_tracked, and correcting an already resolved event stays open. - Answer passkey sign-in failures by name instead of a 500: passkey.challenge.expired, passkey.unknown, passkey.response.invalid and passkey.verification.failed, each with an audit row. - Carry one account-wide adherence figure in meta.aggregate on the batched compliance read, weighted by expected doses, and accept ?days=30|60|90|180|365 for a report window. - Accept ?days=30|90|180|365 on the mood insights read so stability covers the period shown, and name the window it covers. - Keep only the reminder that announces a dose off APNs when the phone owns medication reminders; follow-ups for a dose still open are pushed again, marked by a followUp flag the reminder cron sets. - Document which batch validation issues belong to an entry and pin it with a test that splits and resends a batch; publish rejectedFields items as ValidationIssue.
This commit is contained in:
1 parent
7bcb6f71d9
commit
bb563b6506
50 files changed
+1789
-142
No files matched your search
+234
-61
@@ -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.<n>` (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
|
||||
|
||||
@@ -153,6 +153,12 @@ const ACTIVE_RECORD_ONLY_FIELDS = [
|
||||
* only when the claim is true and checkable.
|
||||
*/
|
||||
const NO_CLIENT_CONSUMER: Record<string, string> = {
|
||||
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 " +
|
||||
|
||||
@@ -119,6 +119,14 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@simplewebauthn/server")>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof verifyAuthentication>>;
|
||||
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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.<n>`, 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<string, unknown>) => ({
|
||||
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<string, unknown> = {}) {
|
||||
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<Issue[]> {
|
||||
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.<n>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<string, { totalExpected: number }>;
|
||||
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<Response>)(
|
||||
new Request("http://localhost/api/medications/compliance?days=45"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Response>)(
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<ComplianceReportDays, ComplianceResult>;
|
||||
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<ComplianceReportDays, ComplianceResult>;
|
||||
|
||||
return {
|
||||
compliance7,
|
||||
compliance30,
|
||||
reportWindows,
|
||||
complianceDisplay,
|
||||
ledgerRows,
|
||||
ledgerFrom,
|
||||
|
||||
@@ -6,6 +6,20 @@ export function apiSuccess<T>(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<T>(
|
||||
data: T,
|
||||
meta: Record<string, unknown>,
|
||||
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`
|
||||
|
||||
+63
-16
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<MoodStabilityWindow, MoodStability | null>;
|
||||
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<MoodStabilityWindow, MoodStability | null>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof resolvePhaseThresholds>,
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, DailyComplianceEntry>;
|
||||
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<ComplianceReportDays, ComplianceResult>;
|
||||
}
|
||||
|
||||
/** The cached payload minus the fields only the batched read serves. */
|
||||
export type CompliancePayloadWire = Omit<CompliancePayload, "reportWindows">;
|
||||
|
||||
/**
|
||||
* 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<ComplianceReportDays, ComplianceResult>;
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
@@ -66,3 +66,15 @@ export function scheduleWireFields<T>(
|
||||
? { 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";
|
||||
@@ -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<Response | null> {
|
||||
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<MedicationOwnershipPrisma> {
|
||||
// Lazy import so test files that stub `@/lib/db` at the module level
|
||||
// see their stub instead of the real Prisma client. Matches the
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -37,6 +37,12 @@ interface ClientManagedGate {
|
||||
userId: string,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown>;
|
||||
/**
|
||||
* 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<string, unknown> | undefined) => boolean;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
@@ -62,6 +68,13 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record<string, ClientManagedGate> = {
|
||||
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<string, ClientManagedGate> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
): boolean {
|
||||
const gate = CLIENT_MANAGED_APNS_EVENTS[eventType];
|
||||
if (!gate) return false;
|
||||
return gate.covers ? gate.covers(metadata) : true;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -248,7 +248,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"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<Record<string, readonly string[]>> =
|
||||
"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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["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.",
|
||||
"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": {
|
||||
|
||||
@@ -1079,7 +1079,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...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.<n>` (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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...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<ZodOpenApiObject["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 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" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
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.",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
};
|
||||
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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user