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:
Marc-André Bombeck committed 2026-11-04 21:24:12 +01:00
1 parent a79fc4168a
commit a35b77c147
11 files changed
+869 -234

No files matched your search

+3
View File
@@ -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
View File
@@ -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:
+5 -1
View File
@@ -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
View File
@@ -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(),
+61
View File
@@ -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 };
}
+1
View File
@@ -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",
+54
View File
@@ -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" });
});
});
+25 -8
View File
@@ -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[] = [];
+191
View File
@@ -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",
});
});
});