From a35b77c147698ec1e473f478b218103b1687472c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 4 Nov 2026 21:18:29 +0100 Subject: [PATCH] feat(labs): serve one marker's lab line under the labs module GET /api/biomarkers/{id}/series answers the line labSeries on /api/timeline draws for that marker, gated on the labs module instead of the opt-in timeline. The window rules move into resolveTimelineWindow and the shared query rules into withWindowRules, so both routes cut the same window and read the same loader; all starts at the marker's first reading. Refs #1342 --- CHANGELOG.md | 3 + docs/api/openapi.yaml | 522 +++++++++++------- src/__tests__/sharing-surface-guard.test.ts | 6 +- src/app/api/biomarkers/[id]/series/route.ts | 98 ++++ src/lib/day/wire-schemas.ts | 90 +-- src/lib/labs/biomarker-series.ts | 61 ++ src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/biomarkers.ts | 54 ++ .../timeline/__tests__/resolve-window.test.ts | 44 ++ src/lib/timeline/load-timeline.ts | 33 +- tests/integration/biomarker-series.test.ts | 191 +++++++ 11 files changed, 869 insertions(+), 234 deletions(-) create mode 100644 src/app/api/biomarkers/[id]/series/route.ts create mode 100644 src/lib/labs/biomarker-series.ts create mode 100644 src/lib/timeline/__tests__/resolve-window.test.ts create mode 100644 tests/integration/biomarker-series.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ddee70bfc..97946122c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ that starts at the present. The iOS app can page forward from it to write readings to Apple Health in the background, without first walking years of history. (#1339) +- `GET /api/biomarkers/{id}/series` returns one lab marker's line, the same + points and window the timeline draws for it, under the Labs module. The + iOS app can show a marker's chart with the timeline switched off. (#1342) ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e0fd36a86..1d896a3de 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -17617,6 +17617,87 @@ paths: "404": *a35 "422": *a4 "429": *a5 + /api/biomarkers/{id}/series: + get: + tags: + - Labs + summary: One marker's lab line + description: "The line `labSeries` on `GET /api/timeline` draws for this marker, cut by the same `zoom`, `from` and `to` + rules: every numeric reading in the window, oldest first, one point each (never averaged), readings in a unit + that does not convert counted in `excluded`, and the band to draw behind them. `all` starts at the marker's + first reading. Gated on the `labs` module, not on `timeline`. A point names the report it was read from only + when the caller may see documents. Cross-user ids answer 404; a merged marker answers 404 with + `meta.mergedInto`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: zoom + schema: + default: all + description: "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to` + (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points + are never averaged, whatever the zoom." + type: string + enum: + - all + - year + - quarter + - range + description: "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to` + (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points + are never averaged, whatever the zoom." + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The marker's line over the window. + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerSeriesEnvelope" + "401": *a1 + "403": + description: >- + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with + `meta.module = labs`: the record has the labs module switched off, or the operator turned it off + server-wide. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": *a35 + "422": + description: "`labs.series.invalid`: an unknown `zoom`, a malformed date, a `range` without both ends or longer than 15 + years, or `from` after `to`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/biomarkers/{id}/merge: post: tags: @@ -39393,74 +39474,75 @@ components: insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, - labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit, - labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge, - labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid, - labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, - labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — - life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, - managed_profile.guardian.managed_invitee, managed_profile.guardian.required, - managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian, - managed_profile.not_found; maintenance — maintenance.pulseDayMean.busy, - maintenance.pulseDayMean.dryRunRequired; measurement — measurement.batch.invalid, - measurement.batch.source_not_permitted, 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, medication.intake.notTracked, - medication.link.invalid; medications — medications.category.invalid, medications.category.limitReached, - medications.category.unknown, medications.course.currentOpen, medications.course.invalid, - medications.course.invalidRange, medications.course.notFound, medications.course.notRunning, - medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, - medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed, - medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType, - medications.ingredients.extract.rateLimited, medications.ingredients.invalid, - medications.ingredients.unitMismatch, 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, medications.notFound; 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; notification — - notification.action.already_used, notification.action.expired, notification.action.invalid, - notification.action.module_disabled; 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; 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; - profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, - profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; recovery — - recovery.kit.stale; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site; - route — route.retired; search — search.invalid; security_activity — security_activity.invalid; - share-link — share-link.document.decryptFailed, share-link.document.notFound, - share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, - share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; - sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, - sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, - sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — - symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, - symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, - tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, - vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, - vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, - workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) - — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + labs.create.invalid, labs.series.invalid, labs.link.invalid, labs.ocr.budgetExceeded, + labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, + labs.ocr.outputLimit, labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.ocr.tooLarge, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, + labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, + labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; + managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.handover.no_pending, + managed_profile.handover.unknown_guardian, managed_profile.not_found; maintenance — + maintenance.pulseDayMean.busy, maintenance.pulseDayMean.dryRunRequired; measurement — + measurement.batch.invalid, measurement.batch.source_not_permitted, 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, + medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid, + medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, + medications.course.invalid, medications.course.invalidRange, medications.course.notFound, + medications.course.notRunning, medications.course.oneShot, medications.course.overlap, + medications.course.windowRequired, medications.ingredients.extract.budgetExceeded, + medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge, + medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited, + medications.ingredients.invalid, medications.ingredients.unitMismatch, + 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, medications.notFound; + 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; + notification — notification.action.already_used, notification.action.expired, + notification.action.invalid, notification.action.module_disabled; 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; 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; profile_claim — profile_claim.invalid, + profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write — + record_write.rate_limited; recovery — recovery.kit.stale; report-selection — + report-selection.body.invalid_json, report-selection.body.invalid_shape, + report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search — + search.invalid; security_activity — security_activity.invalid; share-link — + share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, + symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; + timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token, + tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, + vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, + vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, + vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — + workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, + workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, + ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, @@ -77874,6 +77956,185 @@ components: - data - error additionalProperties: false + BiomarkerSeriesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerSeries" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerSeries: + type: object + properties: + zoom: + type: string + enum: + - all + - year + - quarter + - range + range: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + dataFrom: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The local day of the marker's first numeric reading; null when it has none. `all` starts here. + required: + - from + - to + - dataFrom + additionalProperties: false + series: + $ref: "#/components/schemas/TimelineLabSeries" + required: + - zoom + - range + - series + additionalProperties: false + TimelineLabSeries: + type: object + properties: + key: + type: string + description: "`lab:`, as named in `values`." + biomarkerId: + type: string + name: + type: string + description: The marker's name, as the labs list shows it. + panel: + type: + - string + - "null" + unit: + type: string + description: The marker's own unit; every point is in it. + band: + anyOf: + - type: object + properties: + low: + type: + - number + - "null" + high: + type: + - number + - "null" + required: + - low + - high + additionalProperties: false + - type: "null" + description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest + reading in the window. Null when neither is known." + points: + type: array + items: + $ref: "#/components/schemas/TimelineLabPoint" + description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A + day with two readings has two points." + excluded: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` + rather than drawn on a scale they are not in. + required: + - key + - biomarkerId + - name + - panel + - unit + - band + - points + - excluded + additionalProperties: false + TimelineLabPoint: + type: object + properties: + id: + type: string + description: The lab result's id. + date: + type: string + description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. + value: + type: number + description: In the series' `unit`. + sourceValue: + description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision + `value` is shown with. Null for a reading stored as stated. + type: + - number + - "null" + rangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. + referenceLow: + description: "The window this reading is judged against, in the series' unit: the one its report printed, else the + marker's own." + type: + - number + - "null" + referenceHigh: + type: + - number + - "null" + report: + anyOf: + - type: object + properties: + id: + type: string + title: + description: The document's title, else its file name. + type: + - string + - "null" + href: + type: string + required: + - id + - title + - href + additionalProperties: false + - type: "null" + description: The document the reading was read from, when one is linked and the caller may see documents. + required: + - id + - date + - value + - sourceValue + - rangeStatus + - referenceLow + - referenceHigh + - report + additionalProperties: false MergeBiomarkersResponse: type: object properties: @@ -93687,131 +93948,6 @@ components: - unit - points additionalProperties: false - TimelineLabSeries: - type: object - properties: - key: - type: string - description: "`lab:`, as named in `values`." - biomarkerId: - type: string - name: - type: string - description: The marker's name, as the labs list shows it. - panel: - type: - - string - - "null" - unit: - type: string - description: The marker's own unit; every point is in it. - band: - anyOf: - - type: object - properties: - low: - type: - - number - - "null" - high: - type: - - number - - "null" - required: - - low - - high - additionalProperties: false - - type: "null" - description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest - reading in the window. Null when neither is known." - points: - type: array - items: - $ref: "#/components/schemas/TimelineLabPoint" - description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A - day with two readings has two points." - excluded: - type: integer - minimum: 0 - maximum: 9007199254740991 - description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` - rather than drawn on a scale they are not in. - required: - - key - - biomarkerId - - name - - panel - - unit - - band - - points - - excluded - additionalProperties: false - TimelineLabPoint: - type: object - properties: - id: - type: string - description: The lab result's id. - date: - type: string - description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under. - value: - type: number - description: In the series' `unit`. - sourceValue: - description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision - `value` is shown with. Null for a reading stored as stated. - type: - - number - - "null" - rangeStatus: - type: string - enum: - - in-range - - below - - above - - unknown - description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known. - referenceLow: - description: "The window this reading is judged against, in the series' unit: the one its report printed, else the - marker's own." - type: - - number - - "null" - referenceHigh: - type: - - number - - "null" - report: - anyOf: - - type: object - properties: - id: - type: string - title: - description: The document's title, else its file name. - type: - - string - - "null" - href: - type: string - required: - - id - - title - - href - additionalProperties: false - - type: "null" - description: The document the reading was read from, when one is linked and the caller may see documents. - required: - - id - - date - - value - - sourceValue - - rangeStatus - - referenceLow - - referenceHigh - - report - additionalProperties: false TimelineLabMarker: type: object properties: diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 70ccac8ca..0d4cbf032 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -940,6 +940,10 @@ const DELEGABLE_ROUTES: Record = { domain: "labs", why: "One biomarker of the record, fetch-then-guard against the resolved user.", }, + "app/api/biomarkers/[id]/series/route.ts": { + domain: "labs", + why: "One marker's lab line (#1342), the timeline's `labSeries` for that marker: the marker is looked up under the resolved user, and the report behind a point is named only when the grant also covers documents. Read only.", + }, "app/api/biomarkers/[id]/merge/route.ts": { domain: "labs", why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.", @@ -2292,7 +2296,7 @@ const ACTOR_ROUTES: Record = { * manage literal: it was refused under a switch while the medication's PUT, * which used to carry the end, admitted a manager. 272 -> 274. */ -const FROZEN_ENTRY_COUNT = 275; +const FROZEN_ENTRY_COUNT = 276; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/biomarkers/[id]/series/route.ts b/src/app/api/biomarkers/[id]/series/route.ts new file mode 100644 index 000000000..e472bbb53 --- /dev/null +++ b/src/app/api/biomarkers/[id]/series/route.ts @@ -0,0 +1,98 @@ +/** + * `GET /api/biomarkers/{id}/series?zoom=&from=&to=` — one lab marker's line + * (#1342). + * + * The same line `labSeries` on `/api/timeline` draws for the marker, by the + * same zoom and window rules, for a client that shows it on the Labs screen: + * a lab line belongs to Labs, so it is gated on the `labs` module rather + * than on the opt-in `timeline`. Admission is the labs read every other + * biomarker read declares; a delegate's grant must cover labs, and the + * report behind a point is named only when it also covers documents. + * Cross-user ids answer 404, and a merged marker names where it went, as + * `GET /api/biomarkers/{id}` does. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { labSeriesQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { loadBiomarkerSeries } from "@/lib/labs/biomarker-series"; +import { mergedInto } from "@/lib/labs/biomarker-merge"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("read", "labs"); + const gate = await requireModuleEnabled(user.id, "labs"); + if (!gate.enabled) return gate.response; + const { id } = await params; + + const query = new URL(request.url).searchParams; + const parsed = labSeriesQuerySchema.safeParse({ + zoom: query.get("zoom") ?? undefined, + from: query.get("from") ?? undefined, + to: query.get("to") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "labs.series.invalid", + }); + } + if ( + parsed.data.from && + parsed.data.to && + parsed.data.from > parsed.data.to + ) { + return apiError("`from` must not be after `to`", 422, { + errorCode: "labs.series.invalid", + }); + } + + const marker = await prisma.biomarker.findFirst({ + where: { id, userId: user.id }, + select: { id: true }, + }); + if (!marker) { + const into = await mergedInto(user.id, id); + return apiError("Biomarker not found", 404, { + errorCode: "labs.biomarker.notFound", + ...(into ? { mergedInto: into } : {}), + }); + } + + const [modules, domainVisible, tz] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const result = await loadBiomarkerSeries({ + recordId: user.id, + biomarkerId: id, + query: parsed.data, + access: { modules, domainVisible, owner: grantId === null }, + tz, + }); + if (!result) { + return apiError("Biomarker not found", 404, { + errorCode: "labs.biomarker.notFound", + }); + } + + annotate({ + action: { name: "labs.biomarker.series" }, + meta: { + biomarkerId: id, + zoom: result.zoom, + points: result.series.points.length, + excluded: result.series.excluded, + }, + }); + return apiSuccess(result); + }, +); diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index eba8ef32c..fde86360f 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -356,43 +356,69 @@ export const dayNotableResponseSchema = z /* ─── GET /api/timeline ───────────────────────────────────────────────────── */ -export const timelineQuerySchema = z - .object({ - zoom: z - .enum(TIMELINE_ZOOMS) - .default("all") - .describe( - "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.", - ), - from: dateKeySchema.optional(), - to: dateKeySchema.optional(), +const timelineWindowShape = { + zoom: z + .enum(TIMELINE_ZOOMS) + .default("all") + .describe( + "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.", + ), + from: dateKeySchema.optional(), + to: dateKeySchema.optional(), +}; + +/** The `range` rules every timeline-shaped window shares. */ +function withWindowRules< + S extends z.ZodType<{ zoom: string; from?: string; to?: string }>, +>(schema: S): S { + return schema + .refine((q) => q.zoom !== "range" || (q.from && q.to), { + message: "`range` needs both `from` and `to`", + path: ["from"], + }) + .refine( + (q) => + q.zoom !== "range" || + !q.from || + !q.to || + // Both ends as the same instant of their calendar day, so the + // difference counts calendar days whatever the process zone. + (dateOnlyAtNoonUtc(q.to).getTime() - + dateOnlyAtNoonUtc(q.from).getTime()) / + 86_400_000 < + TIMELINE_RANGE_MAX_DAYS, + { + message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`, + path: ["to"], + }, + ); +} + +export const timelineQuerySchema = withWindowRules( + z.object({ + ...timelineWindowShape, values: z .string() .optional() .describe( "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.", ), - }) - .refine((q) => q.zoom !== "range" || (q.from && q.to), { - message: "`range` needs both `from` and `to`", - path: ["from"], - }) - .refine( - (q) => - q.zoom !== "range" || - !q.from || - !q.to || - // Both ends as the same instant of their calendar day, so the - // difference counts calendar days whatever the process zone. - (dateOnlyAtNoonUtc(q.to).getTime() - - dateOnlyAtNoonUtc(q.from).getTime()) / - 86_400_000 < - TIMELINE_RANGE_MAX_DAYS, - { - message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`, - path: ["to"], - }, - ); + }), +); + +/** + * The window of `GET /api/biomarkers/{id}/series` (#1342): the timeline's + * own zoom, `from` and `to`, under the same rules, so one marker's line is + * cut exactly as `labSeries` on the timeline cuts it. + */ +export const labSeriesQuerySchema = withWindowRules( + z.object({ + ...timelineWindowShape, + zoom: timelineWindowShape.zoom.describe( + "The window, as on the timeline: `all` from the marker's first reading, `year` and `quarter` back from `to` (today by default), `range` a chosen stretch that requires `from` and `to`, at most 15 years apart. Points are never averaged, whatever the zoom.", + ), + }), +); const timelineItem = z .object({ @@ -509,7 +535,7 @@ const timelineLabPoint = z }) .meta({ id: "TimelineLabPoint" }); -const timelineLabSeries = z +export const timelineLabSeries = z .object({ key: z.string().describe("`lab:`, as named in `values`."), biomarkerId: z.string(), diff --git a/src/lib/labs/biomarker-series.ts b/src/lib/labs/biomarker-series.ts new file mode 100644 index 000000000..c4df87d79 --- /dev/null +++ b/src/lib/labs/biomarker-series.ts @@ -0,0 +1,61 @@ +/** + * One lab marker's line outside the timeline (#1342). + * + * `GET /api/biomarkers/{id}/series` answers what `labSeries` on + * `/api/timeline` answers for that one marker, under the labs module rather + * than the opt-in timeline. Nothing is re-implemented here: the window is + * the timeline's own (`resolveTimelineWindow`), the line is `loadLabSeries`, + * so the points, the unit handling, the band and the `excluded` count are + * the timeline's to the byte. The only difference is where `all` starts: + * at the marker's first numeric reading, since this line shows nothing else. + */ +import type { TimelineLabSeries, TimelineZoom } from "@/lib/day/contract"; +import { prisma } from "@/lib/db"; +import type { TimelineAccess } from "@/lib/timeline/lanes"; +import { loadLabSeries } from "@/lib/timeline/lab-series"; +import { resolveTimelineWindow } from "@/lib/timeline/load-timeline"; +import { userDayKey } from "@/lib/tz/format"; + +export interface BiomarkerSeries { + zoom: TimelineZoom; + range: { from: string; to: string; dataFrom: string | null }; + series: TimelineLabSeries; +} + +/** + * The line of `biomarkerId` in the record, or null when the record has no + * such marker (or the caller may not see labs). + */ +export async function loadBiomarkerSeries(args: { + recordId: string; + biomarkerId: string; + query: { zoom: TimelineZoom; from?: string; to?: string }; + access: TimelineAccess; + tz: string; + now?: Date; +}): Promise { + const { recordId, biomarkerId, query, access, tz } = args; + const first = await prisma.labResult.findFirst({ + where: { + userId: recordId, + biomarkerId, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "asc" }, + select: { takenAt: true }, + }); + const dataFrom = first ? userDayKey(first.takenAt, tz) : null; + const today = userDayKey(args.now ?? new Date(), tz); + const { from, to } = resolveTimelineWindow(query, today, dataFrom); + const [series] = await loadLabSeries({ + recordId, + ids: [biomarkerId], + from, + to, + tz, + access, + }); + if (!series) return null; + return { zoom: query.zoom, range: { from, to, dataFrom }, series }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 3b2ceb3f2..4867385e8 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -273,6 +273,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "labs.biomarker.update.invalid", "labs.biomarker.update.referenceRangeInvalid", "labs.create.invalid", + "labs.series.invalid", "labs.link.invalid", "labs.ocr.budgetExceeded", "labs.ocr.extractFailed", diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 893724720..1e611356b 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -15,6 +15,12 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; +import { + dateKeySchema, + labSeriesQuerySchema, + timelineLabSeries, +} from "@/lib/day/wire-schemas"; +import { TIMELINE_ZOOMS } from "@/lib/day/contract"; import { createBiomarkerSchema, @@ -173,6 +179,22 @@ const notFound = { }, }; +const biomarkerSeries = z + .object({ + zoom: z.enum(TIMELINE_ZOOMS), + range: z.object({ + from: dateKeySchema, + to: dateKeySchema, + dataFrom: dateKeySchema + .nullable() + .describe( + "The local day of the marker's first numeric reading; null when it has none. `all` starts here.", + ), + }), + series: timelineLabSeries, + }) + .meta({ id: "BiomarkerSeries" }); + const biomarkerMergeRequest = biomarkerMergeSchema.meta({ id: "BiomarkerMergeRequest", description: @@ -325,6 +347,38 @@ export const biomarkerPaths: NonNullable = { }, }, }, + "/api/biomarkers/{id}/series": { + get: { + tags: ["Labs"], + summary: "One marker's lab line", + description: + "The line `labSeries` on `GET /api/timeline` draws for this marker, cut by the same `zoom`, `from` and `to` rules: every numeric reading in the window, oldest first, one point each (never averaged), readings in a unit that does not convert counted in `excluded`, and the band to draw behind them. `all` starts at the marker's first reading. Gated on the `labs` module, not on `timeline`. A point names the report it was read from only when the caller may see documents. Cross-user ids answer 404; a merged marker answers 404 with `meta.mergedInto`.", + requestParams: { + path: z.object({ id: z.string() }), + query: labSeriesQuerySchema, + }, + responses: { + ...recordRefusal( + "`module.disabled` with `meta.module = labs`: the record has the labs module switched off, or the operator turned it off server-wide.", + ), + "200": { + description: "The marker's line over the window.", + content: { + "application/json": { + schema: dataEnvelope(biomarkerSeries, "BiomarkerSeriesEnvelope"), + }, + }, + }, + ...notFound, + ...stdResponses, + "422": { + description: + "`labs.series.invalid`: an unknown `zoom`, a malformed date, a `range` without both ends or longer than 15 years, or `from` after `to`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/biomarkers/{id}/merge": { post: { tags: ["Labs"], diff --git a/src/lib/timeline/__tests__/resolve-window.test.ts b/src/lib/timeline/__tests__/resolve-window.test.ts new file mode 100644 index 000000000..412ce0bec --- /dev/null +++ b/src/lib/timeline/__tests__/resolve-window.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTimelineWindow } from "../load-timeline"; + +describe("resolveTimelineWindow", () => { + const today = "2026-11-04"; + + it("starts `all` at the first day with data, or a year back without any", () => { + expect(resolveTimelineWindow({ zoom: "all" }, today, "2019-03-02")).toEqual( + { from: "2019-03-02", to: today }, + ); + expect(resolveTimelineWindow({ zoom: "all" }, today, null)).toEqual({ + from: "2025-11-05", + to: today, + }); + // Data that starts after `to` does not invert the window. + expect( + resolveTimelineWindow( + { zoom: "all", to: "2020-01-01" }, + today, + "2021-01-01", + ), + ).toEqual({ from: "2019-01-02", to: "2020-01-01" }); + }); + + it("reaches back the fixed span for `year` and `quarter`", () => { + expect( + resolveTimelineWindow({ zoom: "year" }, today, "2001-01-01"), + ).toEqual({ from: "2025-11-05", to: today }); + expect( + resolveTimelineWindow({ zoom: "quarter" }, today, "2001-01-01"), + ).toEqual({ from: "2026-08-06", to: today }); + }); + + it("keeps a chosen `range` exactly", () => { + expect( + resolveTimelineWindow( + { zoom: "range", from: "2024-02-01", to: "2024-03-01" }, + today, + "2001-01-01", + ), + ).toEqual({ from: "2024-02-01", to: "2024-03-01" }); + }); +}); diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index 8de5bc5c1..de0d49f63 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -106,6 +106,30 @@ const ZOOM_DAYS: Readonly< quarter: 91, }; +/** + * The `[from, to]` a zoom reads, as local day keys. `to` defaults to today; + * `all` (and a `range`, which names both ends) starts at `dataFrom`, the + * earliest day there is anything to show, or a year back when there is + * nothing; `year` and `quarter` reach back their fixed span from `to`. The + * timeline and one marker's lab line (`/api/biomarkers/{id}/series`, #1342) + * both cut their window here. + */ +export function resolveTimelineWindow( + query: { zoom: TimelineZoom; from?: string; to?: string }, + today: string, + dataFrom: string | null, +): { from: string; to: string } { + const to = query.to ?? today; + const from = + query.from ?? + (query.zoom === "all" || query.zoom === "range" + ? dataFrom !== null && dataFrom < to + ? dataFrom + : shiftDateKey(to, -364) + : shiftDateKey(to, -(ZOOM_DAYS[query.zoom] - 1))); + return { from, to }; +} + const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( Object.values(MeasurementTypes), ); @@ -241,14 +265,7 @@ export async function loadTimeline(args: { if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz)); const dataFrom = starts.length > 0 ? starts.sort()[0] : null; - const to = query.to ?? today; - const from = - query.from ?? - (query.zoom === "all" || query.zoom === "range" - ? dataFrom !== null && dataFrom < to - ? dataFrom - : shiftDateKey(to, -364) - : shiftDateKey(to, -(ZOOM_DAYS[query.zoom] - 1))); + const { from, to } = resolveTimelineWindow(query, today, dataFrom); const lanes: TimelineResponse["lanes"] = []; const standing: StandingItem[] = []; diff --git a/tests/integration/biomarker-series.test.ts b/tests/integration/biomarker-series.test.ts new file mode 100644 index 000000000..2ba76c6bc --- /dev/null +++ b/tests/integration/biomarker-series.test.ts @@ -0,0 +1,191 @@ +/** + * `GET /api/biomarkers/{id}/series` (#1342), against Postgres. + * + * The marker's line must be the timeline's `labSeries` for that marker to + * the byte, under the same window, because both come from one loader; it + * must answer with the labs module on and the timeline off (the reason the + * route exists), refuse with labs off, and seal another record's marker. + * Every call goes through the contract check. All data invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getSeries } from "@/app/api/biomarkers/[id]/series/route"; +import { GET as getTimeline } from "@/app/api/timeline/route"; +import type { TimelineLabSeries, TimelineResponse } from "@/lib/day/contract"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY_MS = 86_400_000; + +function daysAgo(n: number): string { + return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10); +} + +interface SeriesBody { + zoom: string; + range: { from: string; to: string; dataFrom: string | null }; + series: TimelineLabSeries; +} + +async function seedFerritin(userId: string) { + const marker = await prisma.biomarker.create({ + data: { + userId, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + panel: "Iron", + analyteKey: "ferritin", + }, + }); + const at = (day: string, value: number, unit = "ng/mL") => + prisma.labResult.create({ + data: { + userId, + biomarkerId: marker.id, + analyte: "Ferritin", + value, + unit, + takenAt: new Date(`${day}T12:00:00.000Z`), + }, + }); + await at(daysAgo(900), 18); + await at(daysAgo(500), 85); + await at(daysAgo(60), 120); + // A unit that does not convert into ng/mL: counted, not drawn. + await at(daysAgo(59), 260, "pmol/L"); + return marker; +} + +async function series( + auth: RouteAuth, + id: string, + query: Record = {}, +) { + return callRoute(getSeries, { + method: "GET", + route: "/api/biomarkers/{id}/series", + params: { id }, + query, + auth, + }); +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("one marker's lab line", () => { + it("is the timeline's labSeries for the marker, window for window", async () => { + const user = await makeUser({ + timezone: "Europe/Berlin", + modulePreferencesJson: { timeline: true, labs: true }, + }); + const owner = await makeSession(user); + const marker = await seedFerritin(user.id); + + const windows: Array> = [ + { zoom: "range", from: daysAgo(1000), to: daysAgo(1) }, + { zoom: "year" }, + { zoom: "quarter" }, + ]; + for (const query of windows) { + const timeline = await callRoute(getTimeline, { + method: "GET", + route: "/api/timeline", + query: { ...query, values: `lab:${marker.id}` }, + auth: owner, + }); + expect(timeline.status).toBe(200); + const res = await series(owner, marker.id, query); + expect(res.status, query.zoom).toBe(200); + expect(res.data.series, query.zoom).toEqual(timeline.data.labSeries[0]); + expect(res.data.range.from, query.zoom).toBe(timeline.data.range.from); + expect(res.data.range.to, query.zoom).toBe(timeline.data.range.to); + } + + const year = await series(owner, marker.id, { zoom: "year" }); + expect(year.data.series.points.map((p) => p.value)).toEqual([120]); + expect(year.data.series.excluded).toBe(1); + }); + + it("starts `all` at the marker's first reading and draws every one", async () => { + const user = await makeUser({ + timezone: "Europe/Berlin", + modulePreferencesJson: { labs: true }, + }); + const owner = await makeSession(user); + const marker = await seedFerritin(user.id); + + const res = await series(owner, marker.id); + expect(res.status).toBe(200); + expect(res.data.zoom).toBe("all"); + expect(res.data.range.dataFrom).toBe(daysAgo(900)); + expect(res.data.range.from).toBe(daysAgo(900)); + expect(res.data.series.points.map((p) => p.value)).toEqual([18, 85, 120]); + expect(res.data.series.band).toEqual({ low: 30, high: 400 }); + }); + + it("answers with the timeline off, and refuses with labs off", async () => { + const user = await makeUser({ + timezone: "Europe/Berlin", + modulePreferencesJson: { timeline: false, labs: true }, + }); + const owner = await makeSession(user); + const marker = await seedFerritin(user.id); + + const timeline = await callRoute(getTimeline, { + method: "GET", + route: "/api/timeline", + query: { values: `lab:${marker.id}` }, + auth: owner, + }); + expect(timeline.status).toBe(403); + expect((await series(owner, marker.id)).status).toBe(200); + + await prisma.user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { timeline: false, labs: false } }, + }); + const off = await series(owner, marker.id); + expect(off.status).toBe(403); + expect(off.meta).toMatchObject({ + errorCode: "module.disabled", + module: "labs", + }); + }); + + it("seals another record's marker and refuses a malformed window", async () => { + const user = await makeUser({ modulePreferencesJson: { labs: true } }); + const other = await makeUser({ modulePreferencesJson: { labs: true } }); + const owner = await makeSession(user); + const theirs = await seedFerritin(other.id); + const mine = await seedFerritin(user.id); + + const sealed = await series(owner, theirs.id); + expect(sealed.status).toBe(404); + expect(sealed.meta).toMatchObject({ + errorCode: "labs.biomarker.notFound", + }); + expect((await series(owner, "does-not-exist")).status).toBe(404); + + const noEnd = await series(owner, mine.id, { + zoom: "range", + from: daysAgo(10), + }); + expect(noEnd.status).toBe(422); + const backwards = await series(owner, mine.id, { + zoom: "range", + from: daysAgo(1), + to: daysAgo(10), + }); + expect(backwards.status).toBe(422); + expect(backwards.meta).toMatchObject({ + errorCode: "labs.series.invalid", + }); + }); +});