mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The PHQ-9/GAD-7 totals flowed into the doctor-report / FHIR export unconditionally while the AI/MCP surfaces were correctly fenced off. Map the two `*_SCORE` measurement types to the mental-health module so the totals export only once the account opts the module in (default OFF — privacy by default), consistent with how mood/sleep/glucose gate. Item-level answers are never a measurement, so they can never leak through this path.
1545 lines
60 KiB
TypeScript
1545 lines
60 KiB
TypeScript
/**
|
||
* Server-side aggregator for doctor-report data.
|
||
*
|
||
* Single source of truth for the aggregated payload consumed by both
|
||
* `/api/doctor-report` (JSON, client renders PDF) and
|
||
* `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
|
||
* structurally identical so visual parity between client- and server-rendered
|
||
* PDFs is guaranteed by construction, not by drift-prone copy-paste.
|
||
*/
|
||
|
||
import { prisma } from "@/lib/db";
|
||
import {
|
||
getEffectiveRange,
|
||
type ThresholdOverridesJson,
|
||
} from "@/lib/analytics/effective-range";
|
||
import {
|
||
resolveGlucoseUnit,
|
||
thresholdMetricForContext,
|
||
type GlucoseUnit,
|
||
} from "@/lib/glucose";
|
||
import {
|
||
computeGlucoseClinicalMetrics,
|
||
type GlucoseClinicalMetrics,
|
||
} from "@/lib/analytics/glucose-metrics";
|
||
import type {
|
||
GlucoseContext,
|
||
MeasurementSource,
|
||
MeasurementType,
|
||
} from "@/generated/prisma/client";
|
||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||
import { readNote } from "@/lib/crypto/note-cipher";
|
||
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
||
import { userDayKey } from "@/lib/tz/resolver";
|
||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||
import {
|
||
reconstructSleepNights,
|
||
type SleepStageRow,
|
||
} from "@/lib/analytics/sleep-night";
|
||
import {
|
||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||
type DoctorReportPrefs,
|
||
} from "@/lib/validations/doctor-report-prefs";
|
||
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
|
||
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
|
||
import {
|
||
buildComplianceMedicationContext,
|
||
lastNonSkippedTakenAt,
|
||
tallyComplianceFromLedger,
|
||
type ComplianceSchedule,
|
||
type IntakeEvent,
|
||
type MedicationPauseEraLike,
|
||
} from "@/lib/analytics/compliance";
|
||
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
|
||
|
||
export interface DoctorReportStats {
|
||
avg: number;
|
||
min: number;
|
||
max: number;
|
||
count: number;
|
||
latest: number;
|
||
}
|
||
|
||
export interface DoctorReportCompliance {
|
||
total: number;
|
||
taken: number;
|
||
skipped: number;
|
||
missed: number;
|
||
}
|
||
|
||
/**
|
||
* Canonical adherence-rate rounding for the doctor-report export surfaces.
|
||
*
|
||
* The PDF table, the PDF clinical-summary headline, the FHIR adherence
|
||
* Observation, and the GLP-1 block all derive the rate from the SAME ledger
|
||
* denominator (`taken / total`, `total = taken + missed`). They MUST round it
|
||
* the same way the app does so a clinician comparing the export to the in-app
|
||
* card never sees a presentational divergence (87 % on the card, 87.3 % on the
|
||
* PDF). The app convention is `round(100 · taken / denominator)` capped at 100
|
||
* (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an
|
||
* integer percent. This is the one source of truth for every export surface.
|
||
*
|
||
* Returns `null` when `total <= 0` so callers render the "no expected dose"
|
||
* placeholder instead of a misleading `0` or `100`.
|
||
*/
|
||
export function adherenceRatePercent(
|
||
taken: number,
|
||
total: number,
|
||
): number | null {
|
||
if (total <= 0) return null;
|
||
return Math.min(100, Math.round((taken / total) * 100));
|
||
}
|
||
|
||
export interface DoctorReportMood {
|
||
avg: number;
|
||
min: number;
|
||
max: number;
|
||
count: number;
|
||
distribution: Record<number, number>;
|
||
}
|
||
|
||
export interface DoctorReportData {
|
||
/**
|
||
* Reporting period bounds. `start` and `end` are ISO timestamps; `days` is
|
||
* the inclusive day-count between the two (rounded up) so existing callers
|
||
* that displayed "last N days" continue to work without a UI rewrite.
|
||
*/
|
||
period: { days: number; since: string; start: string; end: string };
|
||
patient: {
|
||
username: string | null;
|
||
dateOfBirth: string | null;
|
||
gender: string | null;
|
||
heightCm: number | null;
|
||
// v1.7.0 — optional patient-identity fields for the export cover +
|
||
// FHIR Patient. `fullName` + `insurerName` are plaintext columns; the
|
||
// KVNR is NOT carried here (it's encrypted at rest and decrypted by
|
||
// the route, then handed to the builders separately). Optional in the
|
||
// type so pre-v1.7.0 fixtures that omit them still typecheck — the
|
||
// renderer treats absent and null identically.
|
||
fullName?: string | null;
|
||
insurerName?: string | null;
|
||
// v1.8.6 — German insurer institution number (IKNR), plaintext. Carried
|
||
// here for the FHIR `Coverage` payor Organization identifier. Optional
|
||
// so pre-v1.8.6 fixtures still typecheck.
|
||
insurerIkNumber?: string | null;
|
||
};
|
||
/**
|
||
* Free-text practice / clinic name printed on the cover. `null` omits the
|
||
* line entirely. Persisted as a user preference (see
|
||
* `User.lastReportPracticeName`).
|
||
*/
|
||
practiceName: string | null;
|
||
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
|
||
stats: Record<string, DoctorReportStats>;
|
||
glucoseStats: Record<string, DoctorReportStats>;
|
||
glucoseRanges: Record<string, { min: number; max: number }>;
|
||
glucoseUnit: GlucoseUnit;
|
||
/**
|
||
* v1.17.0 — server-authoritative glucose clinical panel over the report
|
||
* period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI
|
||
* tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export
|
||
* carries the SAME numbers the insights panel and the coach show. Values are
|
||
* canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried
|
||
* as a spot-reading estimate (`isSpotEstimate: true`), gated by
|
||
* `stillLearning` when the period is too thin to assert clinically.
|
||
*/
|
||
glucoseClinical: GlucoseClinicalMetrics;
|
||
bmi: number | null;
|
||
compliance: Record<string, DoctorReportCompliance>;
|
||
medications: Array<{
|
||
name: string;
|
||
dose: string;
|
||
// v1.9.0 — optional user/clinician-asserted drug-classification
|
||
// codes. Carried here so the FHIR exporter can emit a coded
|
||
// `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL
|
||
// when no code was captured — the exporter then emits the pre-v1.9.0
|
||
// text-only concept. Optional in the type so pre-v1.9.0 fixtures
|
||
// still typecheck.
|
||
atcCode?: string | null;
|
||
rxNormCode?: string | null;
|
||
schedules: Array<{
|
||
windowStart: string;
|
||
windowEnd: string;
|
||
label: string | null;
|
||
}>;
|
||
}>;
|
||
/**
|
||
* v1.9.0 — acted medication-intake events over the report window, fed
|
||
* to the FHIR `MedicationAdministration` builder. One entry per intake
|
||
* row the user actually actioned (taken OR explicitly skipped);
|
||
* pending / missed / soft-deleted rows are excluded upstream so the
|
||
* exporter never asserts an administration that did not happen. The
|
||
* `dose` is the medication's structured dose-in-effect at
|
||
* `effectiveAt` (from `MedicationDoseChange`) when one exists, else
|
||
* NULL. Optional so pre-v1.9.0 fixtures still typecheck.
|
||
*/
|
||
medicationAdministrations?: Array<{
|
||
medicationName: string;
|
||
/** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */
|
||
effectiveAt: string;
|
||
status: "completed" | "not-done";
|
||
/** Free-text dose label (the medication's `dose`), mirrors the statement. */
|
||
doseText: string | null;
|
||
/** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */
|
||
dose: { value: number; unit: string } | null;
|
||
/** Injection site, when the row recorded one. */
|
||
injectionSite: string | null;
|
||
/** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */
|
||
atcCode: string | null;
|
||
rxNormCode: string | null;
|
||
/** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */
|
||
deliveryForm: string | null;
|
||
}>;
|
||
/**
|
||
* v1.9.0 — set when the administration cap (see
|
||
* {@link resolveMaxMedicationAdministrations}) trimmed the administration
|
||
* set. `total` is the full acted-intake count over the
|
||
* window; `included` is how many (the most-recent) survived the cap.
|
||
* Null/absent when nothing was trimmed. The FHIR builder discloses
|
||
* this in the document narrative so the export is honest about the
|
||
* omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck.
|
||
*/
|
||
medicationAdministrationsTruncation?: {
|
||
total: number;
|
||
included: number;
|
||
} | null;
|
||
mood: DoctorReportMood | null;
|
||
/**
|
||
* v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at
|
||
* least one active GLP-1 medication. Null/absent when the user has no
|
||
* GLP-1 prescription (the vast majority of accounts). When set the
|
||
* PDF gains a "GLP-1 Therapy" section listing dose history, weight
|
||
* change over the therapy window, side-effect counts, and compliance.
|
||
* Optional in the type so legacy callers + test fixtures that omit
|
||
* the field continue to work; the renderer treats absent and null
|
||
* identically.
|
||
*/
|
||
glp1?: {
|
||
medications: Array<{
|
||
name: string;
|
||
currentDose: { value: number; unit: string; since: string } | null;
|
||
doseHistory: Array<{
|
||
value: number;
|
||
unit: string;
|
||
effectiveFrom: string;
|
||
note: string | null;
|
||
}>;
|
||
lastInjection: { date: string; site: string | null } | null;
|
||
compliance: { taken: number; total: number };
|
||
}>;
|
||
weightDeltaKg: number | null;
|
||
weightStartKg: number | null;
|
||
weightEndKg: number | null;
|
||
sideEffects: Array<{ tag: string; count: number }>;
|
||
} | null;
|
||
/**
|
||
* v1.10.0 — the server-derived nightly wellness scores
|
||
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the
|
||
* report window. These are 0–100 DESCRIPTIVE composites, NOT clinical
|
||
* vitals — they are rendered in a clearly-labelled "Wellness summary"
|
||
* section separate from the clinical vitals table, each with a
|
||
* "descriptive, not a clinical assessment" note. Null/absent when the
|
||
* user has no computed scores in the window (the renderer + FHIR builder
|
||
* then skip the section). Optional so pre-v1.10 fixtures still typecheck.
|
||
*/
|
||
wellnessScores?: Array<{
|
||
/** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */
|
||
type: string;
|
||
latest: number;
|
||
avg: number;
|
||
min: number;
|
||
max: number;
|
||
count: number;
|
||
/** ISO timestamp of the latest score. */
|
||
latestAt: string;
|
||
}> | null;
|
||
/**
|
||
* v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg
|
||
* length + variability, current phase). Populated ONLY when the `cycle`
|
||
* section toggle is explicitly ON (privacy default OFF — a user sharing a
|
||
* BP report with a cardiologist must not auto-leak reproductive data) AND
|
||
* the user has at least one observed cycle. Statistics only — no free-text
|
||
* notes ever reach this surface. Null/absent otherwise; both the PDF and
|
||
* FHIR builders then skip the section. Optional so pre-v1.15 fixtures
|
||
* still typecheck.
|
||
*/
|
||
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
|
||
/**
|
||
* v1.17.1 — structured lab results recorded over the report window.
|
||
* Populated when the `labs` section toggle is ON (default) and the user
|
||
* recorded at least one result in the period. Statistics + the single
|
||
* latest reading per analyte; free-text notes never reach this surface.
|
||
* Each entry carries the lab's reference bounds so the PDF can print an
|
||
* in/out-of-range marker and the FHIR exporter can emit `referenceRange`.
|
||
* Null/absent otherwise; both the PDF and FHIR builders skip the section.
|
||
* Optional so pre-v1.17.1 fixtures still typecheck.
|
||
*/
|
||
labResults?: Array<{
|
||
/** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */
|
||
panel: string | null;
|
||
/** Biomarker name as the user recorded it. */
|
||
analyte: string;
|
||
/**
|
||
* Latest numeric value for this analyte over the window; null for a
|
||
* qualitative reading (see `valueText`). v1.18.9.
|
||
*/
|
||
value: number | null;
|
||
/**
|
||
* v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a
|
||
* numeric reading. Exactly one of `value` / `valueText` is set.
|
||
*/
|
||
valueText: string | null;
|
||
unit: string;
|
||
referenceLow: number | null;
|
||
referenceHigh: number | null;
|
||
/** ISO timestamp of the latest reading. */
|
||
takenAt: string;
|
||
/** Count of readings for this analyte in the window (≥ 1). */
|
||
count: number;
|
||
}> | null;
|
||
/**
|
||
* v1.18.1 P4 — illness / condition episodes overlapping the report window.
|
||
* Populated ONLY when the `illness` module is enabled (default-on, opt-out);
|
||
* the journal is on and shared deliberately, so the privacy stance matches
|
||
* labs (ON when the module is on), not mood / cycle.
|
||
* Statistics + labels only — the free-text note is NEVER read here, so no
|
||
* encrypted plaintext can leak into the report. Each entry feeds a FHIR
|
||
* `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
|
||
* module is off or no episode overlaps the window; both builders then skip
|
||
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
|
||
*/
|
||
illnessEpisodes?: Array<{
|
||
/** User-facing condition label (e.g. "Erkältung"). */
|
||
label: string;
|
||
/** Broad condition class (INFECTION / ALLERGY / INJURY / …). */
|
||
type: string;
|
||
/** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */
|
||
lifecycle: string;
|
||
/** ISO onset instant. */
|
||
onsetAt: string;
|
||
/** ISO resolution instant, or null while ongoing. */
|
||
resolvedAt: string | null;
|
||
}> | null;
|
||
}
|
||
|
||
/** The three persisted score types surfaced in the wellness summary. */
|
||
export const WELLNESS_SCORE_REPORT_TYPES = [
|
||
"RECOVERY_SCORE",
|
||
"STRESS_SCORE",
|
||
"STRAIN_SCORE",
|
||
] as const;
|
||
|
||
/** One wellness-summary row (latest + range over the report window). */
|
||
interface WellnessScoreSummary {
|
||
type: string;
|
||
latest: number;
|
||
avg: number;
|
||
min: number;
|
||
max: number;
|
||
count: number;
|
||
latestAt: string;
|
||
}
|
||
|
||
/** A measurement row as the doctor-report aggregator reads it (subset). */
|
||
interface RecoveryMeasurementRow {
|
||
type: string;
|
||
value: number;
|
||
measuredAt: Date;
|
||
source: MeasurementSource;
|
||
}
|
||
|
||
/** Minimal Measurement shape the canonical-source collapse consults. */
|
||
export interface CanonicalCollapseRow {
|
||
type: string;
|
||
value: number;
|
||
measuredAt: Date;
|
||
source: MeasurementSource;
|
||
deviceType?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Collapse a window's raw Measurement rows to one canonical source per metric
|
||
* before the doctor report computes per-type avg/min/max. Without this the PDF
|
||
* + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate
|
||
* day would average two readings), while the dashboard / insights aggregator
|
||
* already collapse to the ladder-canonical source per day. This routes the rows
|
||
* through the SAME picker (`pickCanonicalSourceRows`, keyed by
|
||
* `metricKeyForType` + the user's day key + source-priority ladder), so the two
|
||
* surfaces report identical numbers.
|
||
*
|
||
* SLEEP_DURATION is passed through untouched: it carries per-stage rows that
|
||
* `reconstructSleepNights` resolves with its own source de-dup downstream, so a
|
||
* pre-collapse here would drop stages the night reconstruction needs. A type
|
||
* with no ladder, or a day with a single source, passes through unchanged via
|
||
* the picker's documented fallback. Exported for unit tests.
|
||
*/
|
||
export function collapseMeasurementsToCanonical<T extends CanonicalCollapseRow>(
|
||
measurements: readonly T[],
|
||
timezone: string,
|
||
sourcePriorityJson: unknown,
|
||
): T[] {
|
||
const dayKey = (d: Date) => userDayKey(d, timezone);
|
||
const byType = new Map<string, T[]>();
|
||
for (const m of measurements) {
|
||
const slot = byType.get(m.type);
|
||
if (slot) slot.push(m);
|
||
else byType.set(m.type, [m]);
|
||
}
|
||
const out: T[] = [];
|
||
for (const [type, rows] of byType) {
|
||
const metricKey =
|
||
type === "SLEEP_DURATION"
|
||
? null
|
||
: metricKeyForType(type as MeasurementType);
|
||
if (!metricKey) {
|
||
out.push(...rows);
|
||
continue;
|
||
}
|
||
const { canonicalRows } = pickCanonicalSourceRows(
|
||
rows.map((m) => ({
|
||
...m,
|
||
type: m.type as MeasurementType,
|
||
})),
|
||
metricKey,
|
||
sourcePriorityJson,
|
||
dayKey,
|
||
);
|
||
// `pickCanonicalSourceRows` returns the spread copies; re-key back to the
|
||
// original rows by reference identity is unnecessary because the spread
|
||
// preserves every field the caller reads (value / measuredAt / type).
|
||
out.push(...(canonicalRows as unknown as T[]));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
|
||
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
|
||
* doctor PDF never blends the proxy and the native value into one min/avg/max.
|
||
* Returns null when no recovery row exists. Exported for unit tests.
|
||
*/
|
||
export function summariseCanonicalRecovery(
|
||
measurements: readonly RecoveryMeasurementRow[],
|
||
timezone?: string | null,
|
||
): WellnessScoreSummary | null {
|
||
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
|
||
if (recovery.length === 0) return null;
|
||
const canonical = resolveCanonicalRecovery(
|
||
recovery.map((m) => ({
|
||
value: m.value,
|
||
measuredAt: m.measuredAt,
|
||
source: m.source,
|
||
})),
|
||
timezone,
|
||
);
|
||
if (canonical.length === 0) return null;
|
||
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
|
||
// latest value + the window range over the canonical set.
|
||
const values = canonical.map((r) => r.value);
|
||
const latestRow = canonical[0];
|
||
return {
|
||
type: "RECOVERY_SCORE",
|
||
latest: Math.round(latestRow.value),
|
||
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
|
||
min: Math.round(Math.min(...values)),
|
||
max: Math.round(Math.max(...values)),
|
||
count: values.length,
|
||
latestAt: latestRow.measuredAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||
"FASTING",
|
||
"POSTPRANDIAL",
|
||
"RANDOM",
|
||
"BEDTIME",
|
||
];
|
||
|
||
/**
|
||
* Validate and normalise the requested reporting window.
|
||
* Accepts an unknown value (typically `body.days`); falls back to 90.
|
||
*/
|
||
export function normaliseDays(value: unknown, fallback = 90): number {
|
||
if (
|
||
typeof value === "number" &&
|
||
Number.isInteger(value) &&
|
||
value >= 1 &&
|
||
value <= 365
|
||
) {
|
||
return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
const MIN_RANGE_DAYS = 1;
|
||
/**
|
||
* Maximum span between `startDate` and `endDate`. Two years balances the
|
||
* "give me everything I have" case (chronic condition follow-up) against
|
||
* unbounded server work; the existing `days` fallback is capped at 365 so a
|
||
* caller asking for a 730-day window must come through the explicit-range
|
||
* surface and proves they want it.
|
||
*/
|
||
const MAX_RANGE_DAYS = 730;
|
||
const DEFAULT_RANGE_DAYS = 90;
|
||
|
||
/**
|
||
* 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 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;
|
||
}
|
||
|
||
export interface DoctorReportRange {
|
||
/** Start of the reporting window (UTC, inclusive). */
|
||
start: Date;
|
||
/** End of the reporting window (UTC, inclusive). */
|
||
end: Date;
|
||
/** Inclusive day-count between `start` and `end` (rounded up). */
|
||
days: number;
|
||
}
|
||
|
||
/** Hard cap on the printed cover line — protects PDF layout from runaway input. */
|
||
const PRACTICE_NAME_MAX_LENGTH = 120;
|
||
|
||
/**
|
||
* Trim, collapse whitespace, drop control characters, and length-cap a
|
||
* caller-provided practice name. Returns `null` if the result is empty so
|
||
* the cover line is omitted entirely (rather than rendering a blank label).
|
||
*/
|
||
export function sanitisePracticeName(value: unknown): string | null {
|
||
if (typeof value !== "string") return null;
|
||
// Strip ASCII C0 + DEL controls (0x00..0x1F + 0x7F) which jsPDF can't
|
||
// render and which break PDF text streams. Then collapse whitespace + trim.
|
||
const cleaned = value
|
||
.replace(/[\x00-\x1F\x7F]/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
if (cleaned.length === 0) return null;
|
||
return cleaned.slice(0, PRACTICE_NAME_MAX_LENGTH);
|
||
}
|
||
|
||
function parseIsoDate(value: unknown): Date | null {
|
||
if (typeof value !== "string" || value.length === 0) return null;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return null;
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated
|
||
* inclusive reporting window.
|
||
*
|
||
* Resolution order:
|
||
* 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with
|
||
* `endDate >= startDate` and span <= 730 days → use them.
|
||
* 2. Neither/invalid range, but a valid `days` integer (1..365) is provided
|
||
* → fall back to "last `days` days ending now".
|
||
* 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default).
|
||
*
|
||
* The function is intentionally tolerant: invalid input never throws — bad
|
||
* shapes silently fall through to the next tier so a malformed request
|
||
* still produces a useful report rather than a 422.
|
||
*/
|
||
export function normaliseDateRange(
|
||
value: unknown,
|
||
now: Date = new Date(),
|
||
): DoctorReportRange {
|
||
const body =
|
||
typeof value === "object" && value !== null
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const startCandidate = parseIsoDate(body.startDate);
|
||
const endCandidate = parseIsoDate(body.endDate);
|
||
|
||
if (startCandidate && endCandidate) {
|
||
const startMs = startCandidate.getTime();
|
||
const endMs = endCandidate.getTime();
|
||
if (endMs >= startMs) {
|
||
const spanMs = endMs - startMs;
|
||
const spanDays = Math.ceil(spanMs / 86_400_000) || 1;
|
||
if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) {
|
||
return {
|
||
start: startCandidate,
|
||
end: endCandidate,
|
||
days: spanDays,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
const days = normaliseDays(body.days, DEFAULT_RANGE_DAYS);
|
||
const end = now;
|
||
const start = new Date(end);
|
||
start.setDate(start.getDate() - days);
|
||
return { start, end, days };
|
||
}
|
||
|
||
export interface CollectDoctorReportOptions {
|
||
/** Free-text practice/clinic name to print on the cover. Optional. */
|
||
practiceName?: string | null;
|
||
/**
|
||
* v1.4.25 W6c — per-section visibility toggles. When omitted the
|
||
* documented defaults apply (every section ON except mood). When
|
||
* `sections.mood = false` the aggregator does NOT query
|
||
* `MoodEntry` at all — the mood data never leaves the DB row
|
||
* (privacy-by-default). Other sections are stripped from the
|
||
* returned payload so downstream renderers (PDF + JSON consumer)
|
||
* don't accidentally print a section the user opted out of.
|
||
*/
|
||
sections?: Partial<DoctorReportPrefs>;
|
||
/**
|
||
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
|
||
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
|
||
* module forces its report section / FHIR resources out of the payload,
|
||
* regardless of the user's per-report `sections` toggles: a user who turned
|
||
* off the Sleep module exports no sleep series, mood Observations, glucose
|
||
* panel, recovery/strain scores, cycle summary, or lab results for the
|
||
* modules they no longer keep. Core clinical sections (weight / BP / pulse /
|
||
* medications) carry no module key and are always included. Injectable for
|
||
* tests so the module ↔ section mapping can be asserted without a DB.
|
||
*/
|
||
moduleMap?: Record<ModuleKey, boolean>;
|
||
}
|
||
|
||
/**
|
||
* v1.17 W1a — the medication row shape the ledger compliance builder needs.
|
||
* Carries the cadence-engine context fields (course window + creation +
|
||
* archived schedule eras) so the same band minter the detail page uses can
|
||
* reconstruct expected slots.
|
||
*/
|
||
export interface DoctorReportComplianceMedication {
|
||
id: string;
|
||
name: string;
|
||
asNeeded: boolean;
|
||
startsOn: Date | null;
|
||
endsOn: Date | null;
|
||
oneShot: boolean;
|
||
createdAt: Date;
|
||
schedules: ComplianceSchedule[];
|
||
scheduleRevisions?: ScheduleRevisionLike[];
|
||
/** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */
|
||
pauseEras?: MedicationPauseEraLike[];
|
||
}
|
||
|
||
/** A window-bounded intake row keyed to its medication. */
|
||
export interface DoctorReportComplianceIntake extends IntakeEvent {
|
||
medicationId: string;
|
||
}
|
||
|
||
/**
|
||
* v1.17 W1a — doctor-report medication adherence through the dose-ledger
|
||
* authority.
|
||
*
|
||
* The previous implementation tallied RAW intake rows: `taken = takenAt != null`,
|
||
* else `skipped`, else `missed`, with the denominator being every row. That
|
||
* double-counted cross-source duplicate rows (a REMINDER row + an API row for
|
||
* the same slot both counted as taken), ignored band timing (a pre-window or
|
||
* ad-hoc take counted as a plain "taken", a pending row counted as "missed"
|
||
* regardless of the miss cutoff), and honoured no cadence (rolling / cyclic /
|
||
* RRULE off-weeks could enter the tally). A clinician comparing the PDF % to
|
||
* the app detail page could see two different adherence numbers for one drug.
|
||
*
|
||
* Route the report through the SAME ledger the detail page uses: per
|
||
* medication, mint the cadence-aware bands over the report window
|
||
* `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped`
|
||
* from the unified dose-history ledger. `total = taken + missed` so the
|
||
* renderers' `taken / total` rate equals the ledger rate the detail page
|
||
* shows. PRN / as-needed medications are excluded exactly as before (no
|
||
* schedule, no expected dose — a fabricated 100 % on a clinical report).
|
||
*
|
||
* Pure / synchronous — bands come from pre-fetched schedules + intake
|
||
* instants. Keyed by medication name to match the renderer contract.
|
||
*/
|
||
export function buildLedgerCompliance(
|
||
medications: DoctorReportComplianceMedication[],
|
||
intakeEvents: DoctorReportComplianceIntake[],
|
||
userTz: string,
|
||
start: Date,
|
||
end: Date,
|
||
now: Date,
|
||
): Record<string, DoctorReportCompliance> {
|
||
const eventsByMedId = new Map<string, DoctorReportComplianceIntake[]>();
|
||
for (const event of intakeEvents) {
|
||
const list = eventsByMedId.get(event.medicationId);
|
||
if (list) list.push(event);
|
||
else eventsByMedId.set(event.medicationId, [event]);
|
||
}
|
||
|
||
const compliance: Record<string, DoctorReportCompliance> = {};
|
||
for (const med of medications) {
|
||
// PRN / as-needed carry no schedule and no expected dose — excluded so
|
||
// the report never prints a fabricated 100 %.
|
||
if (med.asNeeded) continue;
|
||
if (med.schedules.length === 0) continue;
|
||
|
||
const events = eventsByMedId.get(med.id) ?? [];
|
||
const ctx = buildComplianceMedicationContext(
|
||
med,
|
||
lastNonSkippedTakenAt(events),
|
||
userTz,
|
||
);
|
||
const tally = tallyComplianceFromLedger(
|
||
events,
|
||
med.schedules,
|
||
ctx,
|
||
start,
|
||
end,
|
||
now,
|
||
);
|
||
// `total = taken + missed` (the ledger denominator) so the renderer's
|
||
// `taken / total` equals `tally.rate` — the exact number the detail page
|
||
// shows. Deliberate skips are reported separately but stay out of `total`.
|
||
compliance[med.name] = {
|
||
total: tally.denominator,
|
||
taken: tally.taken,
|
||
skipped: tally.skipped,
|
||
missed: tally.missed,
|
||
};
|
||
}
|
||
return compliance;
|
||
}
|
||
|
||
/**
|
||
* Aggregate the doctor-report payload for a user over a `[start, end]` range.
|
||
* Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
|
||
*
|
||
* `range` is validated via `normaliseDateRange()` upstream; this function
|
||
* trusts it and uses both bounds in the Prisma `where` clause so a custom
|
||
* window (not just "last N days") filters correctly.
|
||
*/
|
||
export async function collectDoctorReportData(
|
||
userId: string,
|
||
range: DoctorReportRange,
|
||
options: CollectDoctorReportOptions = {},
|
||
): Promise<DoctorReportData> {
|
||
const { start, end, days } = range;
|
||
|
||
// Resolve section toggles up-front. Mood is privacy-sensitive: when
|
||
// it's off we don't issue the `MoodEntry` findMany at all so the data
|
||
// never lands in this process's memory. Other sections are queried
|
||
// either way (the queries are cheap on indexed columns) and stripped
|
||
// from the returned payload below — keeping a single source of
|
||
// truth for the data shape regardless of toggle state.
|
||
// v1.18.0 — resolve the per-user module map up-front (once per build). A
|
||
// disabled module's section/resources are excluded from the export so the
|
||
// PDF + FHIR bundle reflect only the modules the user keeps. The map is
|
||
// ANDed over the user's per-report `sections` toggles: a disabled module
|
||
// forces its section off even if the report toggle says on (you can't share
|
||
// data for a module you turned off), while leaving the others untouched.
|
||
// Core clinical sections (weight / BP / pulse / medications) have no module
|
||
// key and stay unconditional. Injectable for tests.
|
||
const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId));
|
||
|
||
const sections: DoctorReportPrefs = {
|
||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||
...(options.sections ?? {}),
|
||
};
|
||
// Module gate: a disabled module wins over the report toggle. Each report
|
||
// section maps to its owning module key; core sections (bp/weight/pulse/
|
||
// bmi/compliance) carry no key and are never gated here.
|
||
if (moduleMap.mood === false) sections.mood = false;
|
||
if (moduleMap.sleep === false) sections.sleep = false;
|
||
if (moduleMap.cycle === false) sections.cycle = false;
|
||
if (moduleMap.labs === false) sections.labs = false;
|
||
// Module gates with no `sections` key — applied directly to the data slices
|
||
// below (glucose panel, recovery/strain wellness scores, workout series).
|
||
const glucoseEnabled = moduleMap.glucose !== false;
|
||
const recoveryEnabled = moduleMap.recovery !== false;
|
||
const workoutsEnabled = moduleMap.workouts !== false;
|
||
|
||
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
|
||
await Promise.all([
|
||
prisma.measurement.findMany({
|
||
// v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
|
||
// the doctor-report aggregator so deleted rows never leak into
|
||
// the JSON payload or the server-rendered PDF.
|
||
where: {
|
||
userId,
|
||
measuredAt: { gte: start, lte: end },
|
||
deletedAt: null,
|
||
},
|
||
orderBy: { measuredAt: "asc" },
|
||
}),
|
||
prisma.medication.findMany({
|
||
where: { userId, active: true },
|
||
include: {
|
||
schedules: true,
|
||
// v1.17 W1a — archived schedule eras so the ledger compliance
|
||
// builder segments expected-slot expansion against the schedule
|
||
// that was live on each past day (matches the detail page).
|
||
scheduleRevisions: {
|
||
orderBy: { validFrom: "asc" },
|
||
select: {
|
||
id: true,
|
||
validFrom: true,
|
||
validUntil: true,
|
||
payload: true,
|
||
supersededByRevisionId: true,
|
||
},
|
||
},
|
||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||
// v1.4.25 W4d — eager-load dose history + recent intake site
|
||
// for any active medication. Generic meds carry empty arrays
|
||
// so the legacy data path is byte-identical.
|
||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||
intakeEvents: {
|
||
where: { takenAt: { not: null } },
|
||
orderBy: { takenAt: "desc" },
|
||
take: 1,
|
||
select: { takenAt: true, injectionSite: true },
|
||
},
|
||
},
|
||
}),
|
||
prisma.medicationIntakeEvent.findMany({
|
||
// v1.7.0 sync — exclude tombstoned rows from the doctor report.
|
||
where: {
|
||
userId,
|
||
deletedAt: null,
|
||
scheduledFor: { gte: start, lte: end },
|
||
},
|
||
include: {
|
||
// v1.9.0 — carry the medication identity + codes + delivery
|
||
// form so the FHIR MedicationAdministration builder can emit a
|
||
// self-describing `medicationCodeableConcept` and a route.
|
||
medication: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
dose: true,
|
||
atcCode: true,
|
||
rxNormCode: true,
|
||
deliveryForm: true,
|
||
asNeeded: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: { scheduledFor: "asc" },
|
||
}),
|
||
// Mood data: zero DB read when the user opted out. This is the
|
||
// privacy-by-default contract — the JSON payload + audit log
|
||
// both reflect "mood was never fetched", not "mood was fetched
|
||
// and then dropped".
|
||
sections.mood
|
||
? prisma.moodEntry.findMany({
|
||
// v1.7.0 sync — exclude tombstoned rows from the doctor report.
|
||
where: {
|
||
userId,
|
||
deletedAt: null,
|
||
moodLoggedAt: { gte: start, lte: end },
|
||
},
|
||
orderBy: { moodLoggedAt: "asc" },
|
||
})
|
||
: Promise.resolve([]),
|
||
prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: {
|
||
username: true,
|
||
dateOfBirth: true,
|
||
gender: true,
|
||
heightCm: true,
|
||
glucoseUnit: true,
|
||
thresholdsJson: true,
|
||
// v1.17 W1a — the user timezone anchors the ledger compliance
|
||
// band minter (matches the detail page's dose-day attribution).
|
||
timezone: true,
|
||
// v1.17.1 — source-priority feeds the per-night sleep reconstruction
|
||
// so the report's SLEEP_DURATION resolves the same canonical night
|
||
// (multi-source de-dup) the dashboard + iOS feed show.
|
||
sourcePriorityJson: true,
|
||
// v1.7.0 — patient-identity fields for the export cover + FHIR
|
||
// Patient. KVNR is encrypted (and not selected here) — the route
|
||
// decrypts it and hands it to the builders.
|
||
fullName: true,
|
||
insurerName: true,
|
||
insurerIkNumber: true,
|
||
},
|
||
}),
|
||
]);
|
||
|
||
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
|
||
|
||
// v1.18.0 — collapse each multi-source metric to its CANONICAL source before
|
||
// grouping, so the report's per-type avg/min/max match the dashboard rather
|
||
// than blending overlapping sources (e.g. WHOOP + Apple Watch resting-heart-
|
||
// rate summed into one inflated mean). See `collapseMeasurementsToCanonical`.
|
||
const canonicalMeasurements = collapseMeasurementsToCanonical(
|
||
measurements,
|
||
reportTz,
|
||
userProfile?.sourcePriorityJson ?? null,
|
||
);
|
||
|
||
// Group measurements by type (canonical-source-collapsed).
|
||
const byType: Record<
|
||
string,
|
||
Array<{ value: number; measuredAt: string }>
|
||
> = {};
|
||
for (const m of canonicalMeasurements) {
|
||
if (!byType[m.type]) byType[m.type] = [];
|
||
byType[m.type].push({
|
||
value: m.value,
|
||
measuredAt: m.measuredAt.toISOString(),
|
||
});
|
||
}
|
||
// measuredAt-ascending within each type so `latest` (last element) is the
|
||
// most recent reading after the canonical collapse reorders by bucket.
|
||
for (const entries of Object.values(byType)) {
|
||
entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
|
||
}
|
||
|
||
// v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a
|
||
// 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface
|
||
// (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed)
|
||
// shows the per-night reconstructed asleep total via `summarizeSleepNights`,
|
||
// and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route
|
||
// the report's sleep value through the same engine — one number, one surface
|
||
// — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`.
|
||
const sleepRows = measurements.filter(
|
||
(m) => m.type === "SLEEP_DURATION",
|
||
) as unknown as SleepStageRow[];
|
||
if (sleepRows.length > 0) {
|
||
const nights = reconstructSleepNights(
|
||
sleepRows,
|
||
reportTz,
|
||
userProfile?.sourcePriorityJson ?? null,
|
||
).filter((n) => n.asleepMinutes > 0);
|
||
// Per-night asleep totals, ascending by night, replace the raw per-stage
|
||
// rows so the clinical vitals table reads time-asleep hours, not stage-row
|
||
// minutes. Empty (no scorable night) drops SLEEP_DURATION entirely.
|
||
if (nights.length > 0) {
|
||
byType.SLEEP_DURATION = nights.map((n) => ({
|
||
value: n.asleepMinutes,
|
||
measuredAt: n.measuredAt.toISOString(),
|
||
}));
|
||
} else {
|
||
delete byType.SLEEP_DURATION;
|
||
}
|
||
}
|
||
|
||
// Per-type stats.
|
||
const stats: Record<string, DoctorReportStats> = {};
|
||
for (const [type, entries] of Object.entries(byType)) {
|
||
const values = entries.map((e) => e.value);
|
||
stats[type] = {
|
||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||
min: Math.min(...values),
|
||
max: Math.max(...values),
|
||
count: values.length,
|
||
latest: values[values.length - 1],
|
||
};
|
||
}
|
||
|
||
// v1.17 W1a — medication compliance through the dose-ledger authority (the
|
||
// same engine the detail page uses), NOT a raw-row tally. Routes each
|
||
// scheduled medication's intake rows through the cadence-aware band minter
|
||
// over the report window so the PDF / FHIR adherence % matches the app's
|
||
// detail page (slot dedup, band timing, cadence honoured). As-needed / PRN
|
||
// medications are excluded — no schedule, no expected dose, no fabricated
|
||
// 100 % on a clinical report. The medication itself stays on the list.
|
||
const compliance = buildLedgerCompliance(
|
||
medications.map((m) => ({
|
||
id: m.id,
|
||
name: m.name,
|
||
asNeeded: m.asNeeded,
|
||
startsOn: m.startsOn,
|
||
endsOn: m.endsOn,
|
||
oneShot: m.oneShot,
|
||
createdAt: m.createdAt,
|
||
schedules: m.schedules,
|
||
scheduleRevisions: m.scheduleRevisions,
|
||
pauseEras: m.pauseEras,
|
||
})),
|
||
intakeEvents.map((e) => ({
|
||
medicationId: e.medicationId,
|
||
scheduledFor: e.scheduledFor,
|
||
takenAt: e.takenAt,
|
||
skipped: e.skipped,
|
||
autoMissed: e.autoMissed ?? false,
|
||
attributionSource: e.attributionSource ?? undefined,
|
||
})),
|
||
reportTz,
|
||
start,
|
||
end,
|
||
end,
|
||
);
|
||
|
||
// v1.9.0 — MedicationAdministration source rows. One entry per acted
|
||
// intake (taken OR explicitly skipped); pending / missed rows are
|
||
// dropped so the FHIR export never asserts an administration that did
|
||
// not happen, and tombstoned rows are already excluded by the query
|
||
// `deletedAt: null` predicate. The structured `dose` is resolved from
|
||
// the medication's `MedicationDoseChange` history — the latest change
|
||
// effective at or before the administration instant — when one exists.
|
||
//
|
||
// Dose-change history is loaded on the `medications` array (active
|
||
// meds only); a per-medicationId index lets the resolver run in O(1)
|
||
// lookups + a short linear scan over the (typically small) change list.
|
||
const doseChangesByMedId = new Map<
|
||
string,
|
||
Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
|
||
>();
|
||
for (const m of medications) {
|
||
doseChangesByMedId.set(
|
||
m.id,
|
||
m.doseChanges.map((dc) => ({
|
||
effectiveFrom: dc.effectiveFrom,
|
||
doseValue: dc.doseValue,
|
||
doseUnit: dc.doseUnit,
|
||
})),
|
||
);
|
||
}
|
||
const resolveDoseInEffect = (
|
||
medicationId: string,
|
||
at: Date,
|
||
): { value: number; unit: string } | null => {
|
||
const changes = doseChangesByMedId.get(medicationId);
|
||
if (!changes || changes.length === 0) return null;
|
||
// `doseChanges` are loaded ordered by `effectiveFrom asc`; take the
|
||
// last one whose effectiveFrom is <= the administration instant.
|
||
let inEffect: { value: number; unit: string } | null = null;
|
||
for (const c of changes) {
|
||
if (c.effectiveFrom.getTime() <= at.getTime()) {
|
||
inEffect = { value: c.doseValue, unit: c.doseUnit };
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
return inEffect;
|
||
};
|
||
|
||
const medicationAdministrations: NonNullable<
|
||
DoctorReportData["medicationAdministrations"]
|
||
> = [];
|
||
for (const event of intakeEvents) {
|
||
// Only acted rows: a taken dose (completed) or an explicit skip
|
||
// (not-done). A scheduled-but-unconfirmed ("missed") slot is not an
|
||
// administration event and is omitted entirely.
|
||
const isTaken = event.takenAt !== null;
|
||
if (!isTaken && !event.skipped) continue;
|
||
const effectiveAt = isTaken ? (event.takenAt as Date) : event.scheduledFor;
|
||
medicationAdministrations.push({
|
||
medicationName: event.medication.name,
|
||
effectiveAt: effectiveAt.toISOString(),
|
||
status: isTaken ? "completed" : "not-done",
|
||
doseText: event.medication.dose || null,
|
||
// Structured dose only meaningful for a taken dose with a
|
||
// dose-change history; a skip records no dose consumed.
|
||
dose: isTaken
|
||
? resolveDoseInEffect(event.medication.id, effectiveAt)
|
||
: null,
|
||
injectionSite: event.injectionSite ?? null,
|
||
atcCode: event.medication.atcCode ?? null,
|
||
rxNormCode: event.medication.rxNormCode ?? null,
|
||
deliveryForm: event.medication.deliveryForm ?? null,
|
||
});
|
||
}
|
||
|
||
// v1.9.0 — bound the administration set. `intakeEvents` is ordered
|
||
// `scheduledFor: asc`, so the most-recent acted rows are at the tail;
|
||
// keep the last N and flag the trim so the FHIR narrative can disclose
|
||
// it. The omitted rows are the OLDEST in the window — recent adherence
|
||
// is preserved intact. The cap is a coarse safety ceiling (the report
|
||
// window is the natural bound; this only guards a pathological
|
||
// multi-year, many-medication export) — resolved per call from
|
||
// `FHIR_MAX_MEDICATION_ADMINISTRATIONS` so an operator override takes
|
||
// effect without a code change.
|
||
const maxMedicationAdministrations = resolveMaxMedicationAdministrations(
|
||
process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
|
||
);
|
||
const totalAdministrations = medicationAdministrations.length;
|
||
const medicationAdministrationsTruncated =
|
||
totalAdministrations > maxMedicationAdministrations;
|
||
const cappedAdministrations = medicationAdministrationsTruncated
|
||
? medicationAdministrations.slice(
|
||
totalAdministrations - maxMedicationAdministrations,
|
||
)
|
||
: medicationAdministrations;
|
||
|
||
// Mood summary.
|
||
const moodScores = moodEntries.map((e) => e.score);
|
||
const mood: DoctorReportMood | null =
|
||
moodScores.length > 0
|
||
? {
|
||
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
|
||
min: Math.min(...moodScores),
|
||
max: Math.max(...moodScores),
|
||
count: moodScores.length,
|
||
distribution: {
|
||
1: moodScores.filter((s) => s === 1).length,
|
||
2: moodScores.filter((s) => s === 2).length,
|
||
3: moodScores.filter((s) => s === 3).length,
|
||
4: moodScores.filter((s) => s === 4).length,
|
||
5: moodScores.filter((s) => s === 5).length,
|
||
},
|
||
}
|
||
: null;
|
||
|
||
// BMI from latest weight + profile height.
|
||
const weightStats = stats.WEIGHT;
|
||
const bmiRaw =
|
||
weightStats && userProfile?.heightCm
|
||
? weightStats.latest / (userProfile.heightCm / 100) ** 2
|
||
: null;
|
||
const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
|
||
|
||
// Per-context glucose stats + effective ranges (canonical mg/dL).
|
||
const glucoseStats: Record<string, DoctorReportStats> = {};
|
||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||
// v1.18.0 — when the glucose module is disabled, no glucose row enters the
|
||
// panel: stats, ranges, and the clinical metrics all collapse to empty so the
|
||
// PDF + FHIR Observations carry no glucose for this user.
|
||
const glucoseRows = glucoseEnabled
|
||
? measurements.filter((m) => m.type === "BLOOD_GLUCOSE")
|
||
: [];
|
||
const overrides = (userProfile?.thresholdsJson ??
|
||
null) as ThresholdOverridesJson | null;
|
||
const profileForRange = {
|
||
heightCm: userProfile?.heightCm ?? null,
|
||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||
gender: userProfile?.gender ?? null,
|
||
};
|
||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||
if (rows.length === 0) continue;
|
||
const values = rows.map((r) => r.value);
|
||
glucoseStats[ctx] = {
|
||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||
min: Math.min(...values),
|
||
max: Math.max(...values),
|
||
count: values.length,
|
||
latest: values[values.length - 1],
|
||
};
|
||
const eff = getEffectiveRange(
|
||
thresholdMetricForContext(ctx),
|
||
profileForRange,
|
||
overrides,
|
||
);
|
||
if (eff.range) {
|
||
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
|
||
}
|
||
}
|
||
|
||
// v1.17.0 — clinical panel over the WHOLE report period (all contexts
|
||
// pooled), computed by the one literature-locked engine the insights panel
|
||
// and the coach also consume. `windowDays` is the report's own period so the
|
||
// TIR / GMI / eA1C / CV% reflect exactly the readings the rest of the report
|
||
// tabulates; `now: end` anchors the window to the report's upper bound. The
|
||
// learning gate keeps a thin period from asserting a clinical AGP off spot
|
||
// data. Values stay canonical mg/dL; the renderer converts with `glucoseUnit`.
|
||
const glucoseClinical = computeGlucoseClinicalMetrics(
|
||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||
{ windowDays: days, now: end },
|
||
);
|
||
|
||
// `practiceName` is sanitised to a single-line string with a hard length
|
||
// cap. The PDF cover prints it verbatim — never let unbounded input land in
|
||
// a layout-sensitive header.
|
||
const practiceName = sanitisePracticeName(options.practiceName);
|
||
|
||
// Apply section toggles. Removing the type's keys from `byType`,
|
||
// `stats`, and `compliance` lets the PDF renderer treat "section
|
||
// disabled" identically to "section had no rows" — both paths skip
|
||
// the table. Mood is already null when disabled (we never queried).
|
||
const filteredByType = filterMeasurementKeys(byType, sections, moduleMap);
|
||
const filteredStats = filterMeasurementKeys(stats, sections, moduleMap);
|
||
const filteredCompliance = sections.compliance ? compliance : {};
|
||
const filteredBmi = sections.bmi ? bmi : null;
|
||
|
||
// v1.4.25 W4d — GLP-1 therapy section. Only assembled when the user
|
||
// has at least one active GLP-1 medication AND the compliance toggle
|
||
// is on (per the privacy contract: a doctor-report dose history needs
|
||
// the compliance section enabled to be useful). Generic accounts
|
||
// get `glp1: null` which the PDF renderer skips entirely.
|
||
const glp1Meds = medications.filter((m) => m.treatmentClass === "GLP1");
|
||
let glp1: DoctorReportData["glp1"] = null;
|
||
if (sections.compliance && glp1Meds.length > 0) {
|
||
const weightSorted = (byType.WEIGHT ?? []).slice();
|
||
const weightStartKg = weightSorted[0]?.value ?? null;
|
||
const weightEndKg = weightSorted[weightSorted.length - 1]?.value ?? null;
|
||
const weightDeltaKg =
|
||
weightStartKg !== null && weightEndKg !== null
|
||
? Math.round((weightEndKg - weightStartKg) * 10) / 10
|
||
: null;
|
||
|
||
// Side-effect tag counts (only when mood section is enabled — we
|
||
// already gated the moodEntries read on `sections.mood`, so this
|
||
// collapses to "no tags" when the user opted out).
|
||
const sideEffectTags = new Set([
|
||
"nausea",
|
||
"constipation",
|
||
"diarrhea",
|
||
"fatigue",
|
||
"appetite-loss",
|
||
"heartburn",
|
||
"headache",
|
||
"übelkeit",
|
||
"verstopfung",
|
||
"durchfall",
|
||
"müdigkeit",
|
||
"appetitlosigkeit",
|
||
"sodbrennen",
|
||
"kopfschmerzen",
|
||
]);
|
||
const sideEffectCounts = new Map<string, number>();
|
||
for (const mood of moodEntries) {
|
||
const rawTags = mood.tags ?? "";
|
||
let tags: string[];
|
||
try {
|
||
const parsed = JSON.parse(rawTags);
|
||
tags = Array.isArray(parsed)
|
||
? parsed
|
||
.map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
|
||
.filter(Boolean)
|
||
: [];
|
||
} catch {
|
||
tags = rawTags
|
||
.split(",")
|
||
.map((p) => p.trim())
|
||
.filter(Boolean);
|
||
}
|
||
for (const tag of tags) {
|
||
const lower = tag.toLowerCase();
|
||
if (!sideEffectTags.has(lower)) continue;
|
||
sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
|
||
}
|
||
}
|
||
|
||
glp1 = {
|
||
medications: glp1Meds.map((m) => {
|
||
const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
|
||
const lastIntake = m.intakeEvents[0] ?? null;
|
||
const comp = compliance[m.name] ?? {
|
||
taken: 0,
|
||
total: 0,
|
||
skipped: 0,
|
||
missed: 0,
|
||
};
|
||
return {
|
||
name: m.name,
|
||
currentDose: latest
|
||
? {
|
||
value: latest.doseValue,
|
||
unit: latest.doseUnit,
|
||
since: latest.effectiveFrom.toISOString(),
|
||
}
|
||
: null,
|
||
doseHistory: m.doseChanges.map((dc) => ({
|
||
value: dc.doseValue,
|
||
unit: dc.doseUnit,
|
||
effectiveFrom: dc.effectiveFrom.toISOString(),
|
||
note: readNote(dc.noteEncrypted, dc.note),
|
||
})),
|
||
lastInjection:
|
||
lastIntake && lastIntake.takenAt
|
||
? {
|
||
date: lastIntake.takenAt.toISOString(),
|
||
site: lastIntake.injectionSite,
|
||
}
|
||
: null,
|
||
compliance: { taken: comp.taken, total: comp.total },
|
||
};
|
||
}),
|
||
weightStartKg,
|
||
weightEndKg,
|
||
weightDeltaKg,
|
||
sideEffects: Array.from(sideEffectCounts.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([tag, count]) => ({ tag, count })),
|
||
};
|
||
}
|
||
|
||
// v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
|
||
// in `byType`/`stats` (they're only filtered out of the clinical vitals
|
||
// table). Summarise each present score type for the separate "Wellness
|
||
// summary" section. Empty array → null so the renderer + FHIR builder skip
|
||
// the section entirely.
|
||
//
|
||
// RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
|
||
// the same day; mixing them into one min/avg/max would blend two distinct
|
||
// series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
|
||
// the SAME value the tile + iOS feed show — one number, one engine.
|
||
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
|
||
// v1.18.0 — module gate per score type. Recovery + stress are the
|
||
// recovery/readiness signals (recovery module); strain is the
|
||
// training-load signal derived from the workouts table (workouts module).
|
||
// A disabled owning module drops the score from the wellness summary and,
|
||
// since the FHIR exporter sources these Observations from
|
||
// `data.wellnessScores`, from the bundle too.
|
||
if (type === "STRAIN_SCORE" && !workoutsEnabled) return [];
|
||
if (
|
||
(type === "RECOVERY_SCORE" || type === "STRESS_SCORE") &&
|
||
!recoveryEnabled
|
||
) {
|
||
return [];
|
||
}
|
||
if (type === "RECOVERY_SCORE") {
|
||
const summary = summariseCanonicalRecovery(measurements, reportTz);
|
||
return summary ? [summary] : [];
|
||
}
|
||
const s = stats[type];
|
||
const rows = byType[type];
|
||
if (!s || !rows || rows.length === 0) return [];
|
||
return [
|
||
{
|
||
type,
|
||
latest: Math.round(s.latest),
|
||
avg: Math.round(s.avg),
|
||
min: Math.round(s.min),
|
||
max: Math.round(s.max),
|
||
count: s.count,
|
||
latestAt: rows[rows.length - 1].measuredAt,
|
||
},
|
||
];
|
||
});
|
||
const wellnessScores =
|
||
wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
|
||
|
||
// v1.15.0 — cycle summary. Privacy default OFF: only read + assemble when
|
||
// the `cycle` section toggle is explicitly ON. Statistics only — the
|
||
// helper never touches `notesEncrypted`, so no plaintext free-text can
|
||
// leak through this surface. `null` when disabled or no observed cycle.
|
||
let cycle: DoctorReportData["cycle"] = null;
|
||
if (sections.cycle) {
|
||
cycle = await buildCycleExportSummary(
|
||
userId,
|
||
end.toISOString().slice(0, 10),
|
||
);
|
||
}
|
||
|
||
// v1.17.1 — structured lab results over the window. ON by default; the
|
||
// user recorded these specifically to share with a clinician, so the
|
||
// privacy stance matches BP / weight, not mood / cycle. We reduce to the
|
||
// latest reading per analyte (with a count) so the report is a concise
|
||
// panel, not a raw dump; notes are never read here.
|
||
let labResults: DoctorReportData["labResults"] = null;
|
||
if (sections.labs) {
|
||
const labRows = await prisma.labResult.findMany({
|
||
where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
|
||
orderBy: { takenAt: "asc" },
|
||
select: {
|
||
panel: true,
|
||
analyte: true,
|
||
value: true,
|
||
valueText: true,
|
||
unit: true,
|
||
referenceLow: true,
|
||
referenceHigh: true,
|
||
takenAt: true,
|
||
},
|
||
});
|
||
// Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold
|
||
// together; rows are ascending so the last seen wins as the latest.
|
||
const byAnalyte = new Map<
|
||
string,
|
||
NonNullable<DoctorReportData["labResults"]>[number]
|
||
>();
|
||
for (const r of labRows) {
|
||
const key = r.analyte.toLowerCase();
|
||
const prev = byAnalyte.get(key);
|
||
byAnalyte.set(key, {
|
||
panel: r.panel,
|
||
analyte: r.analyte,
|
||
value: r.value,
|
||
valueText: r.valueText,
|
||
unit: r.unit,
|
||
referenceLow: r.referenceLow,
|
||
referenceHigh: r.referenceHigh,
|
||
takenAt: r.takenAt.toISOString(),
|
||
count: (prev?.count ?? 0) + 1,
|
||
});
|
||
}
|
||
const collapsed = Array.from(byAnalyte.values());
|
||
labResults = collapsed.length > 0 ? collapsed : null;
|
||
}
|
||
|
||
// v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
|
||
// on the illness module (default-on, opt-out): when off, no read, no section.
|
||
// An episode overlaps when it began on or before the window end AND is
|
||
// either still ongoing or resolved on or after the window start. Labels +
|
||
// lifecycle + dates only — the encrypted note is never selected, so no
|
||
// free-text leaks into the clinical export.
|
||
let illnessEpisodes: DoctorReportData["illnessEpisodes"] = null;
|
||
if (moduleMap.illness !== false) {
|
||
const episodeRows = await prisma.illnessEpisode.findMany({
|
||
where: {
|
||
userId,
|
||
deletedAt: null,
|
||
onsetAt: { lte: end },
|
||
OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }],
|
||
},
|
||
orderBy: { onsetAt: "asc" },
|
||
select: {
|
||
label: true,
|
||
type: true,
|
||
lifecycle: true,
|
||
onsetAt: true,
|
||
resolvedAt: true,
|
||
},
|
||
});
|
||
const mapped = episodeRows.map((e) => ({
|
||
label: e.label,
|
||
type: e.type,
|
||
lifecycle: e.lifecycle,
|
||
onsetAt: e.onsetAt.toISOString(),
|
||
resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
|
||
}));
|
||
illnessEpisodes = mapped.length > 0 ? mapped : null;
|
||
}
|
||
|
||
return {
|
||
period: {
|
||
days,
|
||
// `since` is preserved for backwards compatibility with any in-flight
|
||
// clients that read the old field name; mirrors `start`.
|
||
since: start.toISOString(),
|
||
start: start.toISOString(),
|
||
end: end.toISOString(),
|
||
},
|
||
patient: {
|
||
username: userProfile?.username ?? null,
|
||
dateOfBirth: userProfile?.dateOfBirth
|
||
? userProfile.dateOfBirth.toISOString()
|
||
: null,
|
||
gender: userProfile?.gender ?? null,
|
||
heightCm: userProfile?.heightCm ?? null,
|
||
fullName: userProfile?.fullName ?? null,
|
||
insurerName: userProfile?.insurerName ?? null,
|
||
insurerIkNumber: userProfile?.insurerIkNumber ?? null,
|
||
},
|
||
practiceName,
|
||
measurements: filteredByType,
|
||
stats: filteredStats,
|
||
glucoseStats,
|
||
glucoseRanges,
|
||
// Surfaced by the insights glucose views; the doctor-PDF / FHIR rendering of
|
||
// these clinical metrics is staged for a later release, so no report
|
||
// consumer reads this field yet — it is forward-populated, not dead.
|
||
glucoseClinical,
|
||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||
bmi: filteredBmi,
|
||
compliance: filteredCompliance,
|
||
medications: medications.map((m) => ({
|
||
name: m.name,
|
||
dose: m.dose,
|
||
// v1.9.0 — drug-classification codes for the coded FHIR concept.
|
||
atcCode: m.atcCode,
|
||
rxNormCode: m.rxNormCode,
|
||
schedules: m.schedules.map((s) => ({
|
||
windowStart: s.windowStart,
|
||
windowEnd: s.windowEnd,
|
||
label: s.label,
|
||
})),
|
||
})),
|
||
medicationAdministrations: cappedAdministrations,
|
||
medicationAdministrationsTruncation: medicationAdministrationsTruncated
|
||
? { total: totalAdministrations, included: cappedAdministrations.length }
|
||
: null,
|
||
mood,
|
||
glp1,
|
||
wellnessScores,
|
||
cycle,
|
||
labResults,
|
||
illnessEpisodes,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Per-measurement-type → section-toggle mapping. Keys not listed here
|
||
* (e.g., glucose contexts, body fat, oxygen saturation) bypass the
|
||
* toggles for now — those sections will get their own flags in a
|
||
* future iteration. The current scope per the maintainer's directive is the
|
||
* "big five" plus mood + compliance.
|
||
*/
|
||
const MEASUREMENT_TYPE_SECTION: Record<string, keyof DoctorReportPrefs> = {
|
||
BLOOD_PRESSURE_SYS: "bp",
|
||
BLOOD_PRESSURE_DIA: "bp",
|
||
WEIGHT: "weight",
|
||
PULSE: "pulse",
|
||
SLEEP_DURATION: "sleep",
|
||
};
|
||
|
||
/**
|
||
* v1.18.0 — per-measurement-type → owning module mapping for the types that
|
||
* carry no `sections` toggle but DO belong to a toggleable module. When the
|
||
* module is disabled, the type is stripped from `measurements` + `stats` so the
|
||
* series never reaches the PDF tables or a FHIR Observation.
|
||
*
|
||
* - `workouts` owns the activity / movement series (steps, active energy,
|
||
* distance, flights, the walking-gait + stair metrics) and the
|
||
* training-load `STRAIN_SCORE`.
|
||
* - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`.
|
||
* - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is
|
||
* gated where it is assembled; this strips the raw rows too).
|
||
*
|
||
* Core clinical types (BP / weight / pulse / body-composition / vitals) carry
|
||
* no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride
|
||
* the `sections` mechanism instead.
|
||
*/
|
||
const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
|
||
ACTIVITY_STEPS: "workouts",
|
||
ACTIVE_ENERGY_BURNED: "workouts",
|
||
FLIGHTS_CLIMBED: "workouts",
|
||
WALKING_RUNNING_DISTANCE: "workouts",
|
||
WALKING_SPEED: "workouts",
|
||
WALKING_ASYMMETRY: "workouts",
|
||
WALKING_STEP_LENGTH: "workouts",
|
||
WALKING_DOUBLE_SUPPORT: "workouts",
|
||
SIX_MINUTE_WALK_DISTANCE: "workouts",
|
||
STAIR_ASCENT_SPEED: "workouts",
|
||
STAIR_DESCENT_SPEED: "workouts",
|
||
STRAIN_SCORE: "workouts",
|
||
RECOVERY_SCORE: "recovery",
|
||
STRESS_SCORE: "recovery",
|
||
BLOOD_GLUCOSE: "glucose",
|
||
// v1.25.0 — the PHQ-9 / GAD-7 screener TOTALS ride the doctor-report / FHIR
|
||
// export only when the opt-in mental-health module is on (default OFF →
|
||
// excluded by default, privacy-by-default). Item-level answers are never a
|
||
// Measurement, so they can never leak through this path regardless. The total
|
||
// is the intended clinical artefact (total + band per clinical-instruments.md
|
||
// §4); gating it on the module keeps the export consistent with how mood /
|
||
// sleep / glucose are gated and avoids exporting a screener the account never
|
||
// opted into.
|
||
PHQ9_SCORE: "mentalHealth",
|
||
GAD7_SCORE: "mentalHealth",
|
||
};
|
||
|
||
function filterMeasurementKeys<T>(
|
||
map: Record<string, T>,
|
||
sections: DoctorReportPrefs,
|
||
moduleMap: Record<ModuleKey, boolean>,
|
||
): Record<string, T> {
|
||
const out: Record<string, T> = {};
|
||
for (const [type, value] of Object.entries(map)) {
|
||
const sectionKey = MEASUREMENT_TYPE_SECTION[type];
|
||
if (sectionKey && sections[sectionKey] === false) continue;
|
||
const moduleKey = MEASUREMENT_TYPE_MODULE[type];
|
||
if (moduleKey && moduleMap[moduleKey] === false) continue;
|
||
out[type] = value;
|
||
}
|
||
return out;
|
||
}
|