diff --git a/.env.production.example b/.env.production.example index cda623ef2..5ac450ad8 100644 --- a/.env.production.example +++ b/.env.production.example @@ -62,6 +62,17 @@ API_TOKEN_HMAC_KEY="" # BACKUP_ENCRYPTION_KEY="" +# ----------------------------------------------------------------------------- +# FHIR health-record export -- optional +# ----------------------------------------------------------------------------- +# Per-export ceiling on the number of MedicationAdministration rows in the +# FHIR Bundle. The report window already scopes the set; this is a coarse +# safety ceiling on the serialised Bundle size. Bounded 1..50000; any unset +# or out-of-range value falls back to the default of 5000. Raise it only for +# an unusual multi-year, many-medication workload. +# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000" + + # ----------------------------------------------------------------------------- # Dashboard snapshot -- BUILD-TIME toggle (default ON) # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 3d621b511..53ffc0a2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,11 @@ services: BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" + # Per-export ceiling on MedicationAdministration rows in the FHIR + # health-record Bundle. Bounded 1..50000, default 5000. Listed here so + # an operator override in .env actually reaches the container (the + # `environment:` block is a whitelist). + FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}" depends_on: db: condition: service_healthy diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index ea3100292..7047219ad 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index dc217cb1d..68e72d47a 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -207,19 +207,46 @@ const MAX_RANGE_DAYS = 730; const DEFAULT_RANGE_DAYS = 90; /** - * v1.9.0 — hard ceiling on the number of `MedicationAdministration` - * source rows materialised per export. The report window is already - * bounded at {@link MAX_RANGE_DAYS} (730 days), but a chronic medication - * dosed several times a day across that window — multiplied by several - * such medications — can still produce thousands of administration - * resources in a single Bundle (e.g. 4×/day × 730 days ≈ 2 920 per med). - * Cap at the MOST-RECENT 1 000 acted intakes overall: that preserves the - * recent-adherence picture a clinician cares about while bounding the - * in-memory array and the serialised Bundle. When the cap trims rows the - * aggregator flags it so the FHIR narrative can disclose the truncation - * rather than silently dropping history. + * Default ceiling on the number of `MedicationAdministration` source rows + * materialised per export, and the bounds an operator override is clamped + * to. The report window is already bounded at {@link MAX_RANGE_DAYS} + * (730 days), but a chronic medication dosed several times a day across + * that window — multiplied by several such medications — can still produce + * thousands of administration resources in a single Bundle. The default of + * 5 000 covers ~3.5 years at four doses a day, so it effectively never bites + * on a realistic report period while still bounding a pathological export. */ -const MAX_MEDICATION_ADMINISTRATIONS = 1000; +const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000; +const MIN_MEDICATION_ADMINISTRATIONS = 1; +const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000; + +/** + * Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`. + * Accepts a positive integer within `[1, 50000]`; any unset / non-integer / + * out-of-range value falls back to the default. Exported as a pure function so + * the resolution can be tested without import-time env coupling. + */ +export function resolveMaxMedicationAdministrations( + raw: string | undefined, +): number { + const parsed = Number(raw); + return Number.isInteger(parsed) && + parsed >= MIN_MEDICATION_ADMINISTRATIONS && + parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING + ? parsed + : DEFAULT_MAX_MEDICATION_ADMINISTRATIONS; +} + +/** + * The cap is a coarse safety ceiling resolved once at module load, not a + * per-request knob — the report window is the natural bound; this only + * guards a pathological multi-year, many-medication export. When it trims + * rows the aggregator flags it so the FHIR narrative discloses the + * truncation rather than silently dropping history. + */ +const MAX_MEDICATION_ADMINISTRATIONS = resolveMaxMedicationAdministrations( + process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS, +); export interface DoctorReportRange { /** Start of the reporting window (UTC, inclusive). */