mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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
This commit is contained in:
1 parent
a79fc4168a
commit
a35b77c147
11 files changed
+869
-234
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+329
-193
@@ -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:<biomarkerId>`, 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:<biomarkerId>`, 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:
|
||||
|
||||
@@ -940,6 +940,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
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<string, string> = {
|
||||
* 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` —
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
+58
-32
@@ -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:<biomarkerId>` (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:<biomarkerId>`, as named in `values`."),
|
||||
biomarkerId: z.string(),
|
||||
|
||||
@@ -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<BiomarkerSeries | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -273,6 +273,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"labs.biomarker.update.invalid",
|
||||
"labs.biomarker.update.referenceRangeInvalid",
|
||||
"labs.create.invalid",
|
||||
"labs.series.invalid",
|
||||
"labs.link.invalid",
|
||||
"labs.ocr.budgetExceeded",
|
||||
"labs.ocr.extractFailed",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<string> = 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[] = [];
|
||||
|
||||
@@ -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<string, string> = {},
|
||||
) {
|
||||
return callRoute<SeriesBody>(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<Record<string, string>> = [
|
||||
{ zoom: "range", from: daysAgo(1000), to: daysAgo(1) },
|
||||
{ zoom: "year" },
|
||||
{ zoom: "quarter" },
|
||||
];
|
||||
for (const query of windows) {
|
||||
const timeline = await callRoute<TimelineResponse>(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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user