Files
HealthLog/src/lib/doctor-report-data.ts
T
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00

628 lines
21 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 type { GlucoseContext } from "@/generated/prisma/client";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
export interface DoctorReportStats {
avg: number;
min: number;
max: number;
count: number;
latest: number;
}
export interface DoctorReportCompliance {
total: number;
taken: number;
skipped: number;
missed: number;
}
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;
};
/**
* 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;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
name: string;
dose: string;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | 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;
}
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;
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>;
}
/**
* 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.
const sections: DoctorReportPrefs = {
...DEFAULT_DOCTOR_REPORT_PREFS,
...(options.sections ?? {}),
};
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.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: { medication: { select: { name: 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.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,
},
}),
]);
// Group measurements by type.
const byType: Record<
string,
Array<{ value: number; measuredAt: string }>
> = {};
for (const m of measurements) {
if (!byType[m.type]) byType[m.type] = [];
byType[m.type].push({
value: m.value,
measuredAt: m.measuredAt.toISOString(),
});
}
// 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],
};
}
// Medication compliance.
const compliance: Record<string, DoctorReportCompliance> = {};
for (const event of intakeEvents) {
const name = event.medication.name;
if (!compliance[name]) {
compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
}
compliance[name].total++;
if (event.takenAt) {
compliance[name].taken++;
} else if (event.skipped) {
compliance[name].skipped++;
} else {
compliance[name].missed++;
}
}
// 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 }> = {};
const glucoseRows = 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 };
}
}
// `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);
const filteredStats = filterMeasurementKeys(stats, sections);
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: 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 })),
};
}
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,
},
practiceName,
measurements: filteredByType,
stats: filteredStats,
glucoseStats,
glucoseRanges,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: filteredBmi,
compliance: filteredCompliance,
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
})),
})),
mood,
glp1,
};
}
/**
* 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 Marc'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",
};
function filterMeasurementKeys<T>(
map: Record<string, T>,
sections: DoctorReportPrefs,
): 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;
out[type] = value;
}
return out;
}