mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
GET /api/meta/capabilities dropped group membership at the wire: it shipped a flat group-id list and a flat leaf list with no boundary between them, so a native client could not tell which leaves live under which group, or which group is the fenced sensitive tier. That made it unsafe for the client to offer a "select all" affordance for any group, since it could not mirror the server's fence against bundling more than one sensitive leaf behind a single control. Add SHARE_GROUPS to the report-selection catalogue, derived from the existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP maps: each group now carries its member leaf ids, and the group whose id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in the capabilities response now ships this shape instead of bare ids; share.leaves is unchanged. Update the OpenAPI schema and regenerate docs/api/openapi.yaml. Add an invariant test asserting every leaf in the flat leaves set appears in exactly one group and no group carries a leaf outside that set, plus a check that exactly one group is flagged sensitive.
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
/**
|
|
* The doctor-report leaf catalogue: every piece of the record a user can
|
|
* release or withhold, and the group it is presented under.
|
|
*
|
|
* A LEAF is the smallest releasable unit. There are exactly two kinds: every
|
|
* `MeasurementType` enum member, and the structured sections of the report
|
|
* payload that are not measurement series (identity, insurance, the glucose
|
|
* panel, labs, the three medication blocks, GLP-1, allergies, illness
|
|
* episodes, family history, mood, cycle).
|
|
*
|
|
* One catalogue drives the selection panel, the aggregator, the PDF, the
|
|
* clinician share view and the guard tests, so a control can never again exist
|
|
* without a gating path behind it, and a gate can never exist without a
|
|
* control.
|
|
*
|
|
* Two of the three new-metric locks live here:
|
|
*
|
|
* - `MEASUREMENT_LEAF_GROUP` is a `Record<MeasurementType, ReportGroupId>`.
|
|
* Adding an enum member without deciding its group fails `pnpm typecheck`,
|
|
* which runs in the pre-commit hook and in CI. The decision has to be made
|
|
* in the same commit as the enum member, and it has no effect on anyone's
|
|
* saved selection.
|
|
* - `STRUCTURED_LEAF_GROUP` does the same over the closed structured union.
|
|
*
|
|
* The third lock is set membership: a stored selection is a list of what was
|
|
* chosen, so a leaf that did not exist when it was saved is not in it. See
|
|
* `./selection.ts`.
|
|
*/
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import type { ModuleKey } from "@/lib/modules/registry";
|
|
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
|
|
|
/**
|
|
* The structured (non-measurement) leaves. A closed union, so
|
|
* `STRUCTURED_LEAF_GROUP` below is exhaustive by the compiler rather than by
|
|
* review.
|
|
*/
|
|
export type StructuredLeafId =
|
|
| "PATIENT_IDENTITY"
|
|
| "INSURANCE"
|
|
| "GLUCOSE_PANEL"
|
|
| "LAB_RESULTS"
|
|
| "MEDICATION_LIST"
|
|
| "MEDICATION_ADMINISTRATIONS"
|
|
| "MEDICATION_COMPLIANCE"
|
|
| "GLP1_THERAPY"
|
|
| "ALLERGIES"
|
|
| "ILLNESS_EPISODES"
|
|
| "FAMILY_HISTORY"
|
|
| "MOOD"
|
|
| "CYCLE"
|
|
| "ANAMNESIS";
|
|
|
|
/** Every leaf id: the measurement types plus closed structured sections. */
|
|
export type ReportLeafId = MeasurementType | StructuredLeafId;
|
|
|
|
/** The presentation groups, in the order the panel and the PDF render them. */
|
|
export const REPORT_GROUP_ORDER = [
|
|
"identity",
|
|
"vitals",
|
|
"cardio",
|
|
"body",
|
|
"sleepRecovery",
|
|
"activity",
|
|
"mobility",
|
|
"environment",
|
|
"glucose",
|
|
"labs",
|
|
"medications",
|
|
"history",
|
|
"sensitive",
|
|
] as const;
|
|
|
|
export type ReportGroupId = (typeof REPORT_GROUP_ORDER)[number];
|
|
|
|
/**
|
|
* The fenced tier. It renders below a separator with no group checkbox, so no
|
|
* single control can turn on more than one of these at a time, and it is never
|
|
* in the shipped template. Mental-health screening totals, mood and cycle are
|
|
* the user's own sensitive data; family history is third-party information
|
|
* about people who consented to nothing.
|
|
*/
|
|
export const SENSITIVE_GROUP_ID = "sensitive" satisfies ReportGroupId;
|
|
|
|
/**
|
|
* Measurement leaf → group. Exhaustive over the enum by construction.
|
|
*
|
|
* Two placements carry a stated reason. `PAIN_NRS` sits in vitals because pain
|
|
* is read as a vital sign in every intake, not because it is a device reading.
|
|
* The four screening totals are fenced because a depression or anxiety
|
|
* instrument is not something to hand over as part of a bundle.
|
|
*/
|
|
export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
|
|
// ── vitals ──
|
|
BLOOD_PRESSURE_SYS: "vitals",
|
|
BLOOD_PRESSURE_DIA: "vitals",
|
|
PULSE: "vitals",
|
|
OXYGEN_SATURATION: "vitals",
|
|
RESPIRATORY_RATE: "vitals",
|
|
BODY_TEMPERATURE: "vitals",
|
|
PAIN_NRS: "vitals",
|
|
|
|
// ── cardio ──
|
|
RESTING_HEART_RATE: "cardio",
|
|
HEART_RATE_VARIABILITY: "cardio",
|
|
HRV_RMSSD: "cardio",
|
|
VO2_MAX: "cardio",
|
|
WALKING_HEART_RATE_AVERAGE: "cardio",
|
|
AVERAGE_HEART_RATE: "cardio",
|
|
MAX_HEART_RATE: "cardio",
|
|
CARDIO_RECOVERY: "cardio",
|
|
CARDIO_LOAD: "cardio",
|
|
PULSE_WAVE_VELOCITY: "cardio",
|
|
VASCULAR_AGE: "cardio",
|
|
IRREGULAR_RHYTHM_NOTIFICATION: "cardio",
|
|
HIGH_HEART_RATE_EVENT: "cardio",
|
|
LOW_HEART_RATE_EVENT: "cardio",
|
|
|
|
// ── body ──
|
|
WEIGHT: "body",
|
|
BODY_MASS_INDEX: "body",
|
|
BODY_FAT: "body",
|
|
FAT_MASS: "body",
|
|
FAT_FREE_MASS: "body",
|
|
MUSCLE_MASS: "body",
|
|
LEAN_BODY_MASS: "body",
|
|
TOTAL_BODY_WATER: "body",
|
|
BONE_MASS: "body",
|
|
VISCERAL_FAT: "body",
|
|
WAIST_CIRCUMFERENCE: "body",
|
|
WAIST_TO_HEIGHT: "body",
|
|
|
|
// ── sleep & recovery ──
|
|
SLEEP_DURATION: "sleepRecovery",
|
|
SLEEP_SCORE: "sleepRecovery",
|
|
SLEEP_PERFORMANCE: "sleepRecovery",
|
|
SLEEP_EFFICIENCY: "sleepRecovery",
|
|
SLEEP_CONSISTENCY: "sleepRecovery",
|
|
SLEEP_NEED: "sleepRecovery",
|
|
SLEEP_DISTURBANCE_COUNT: "sleepRecovery",
|
|
BREATHING_DISTURBANCES: "sleepRecovery",
|
|
BREATHING_DISTURBANCE_EVENT: "sleepRecovery",
|
|
RECOVERY_SCORE: "sleepRecovery",
|
|
STRESS_SCORE: "sleepRecovery",
|
|
RESILIENCE: "sleepRecovery",
|
|
ANS_CHARGE: "sleepRecovery",
|
|
SKIN_TEMPERATURE: "sleepRecovery",
|
|
WRIST_TEMPERATURE: "sleepRecovery",
|
|
BODY_TEMPERATURE_DEVIATION: "sleepRecovery",
|
|
|
|
// ── activity ──
|
|
ACTIVITY_STEPS: "activity",
|
|
WALKING_RUNNING_DISTANCE: "activity",
|
|
FLIGHTS_CLIMBED: "activity",
|
|
ACTIVE_ENERGY_BURNED: "activity",
|
|
ENERGY_EXPENDITURE_KJ: "activity",
|
|
STRAIN_SCORE: "activity",
|
|
DAY_STRAIN: "activity",
|
|
WORKOUT_STRAIN: "activity",
|
|
|
|
// ── mobility & gait ──
|
|
WALKING_SPEED: "mobility",
|
|
WALKING_ASYMMETRY: "mobility",
|
|
WALKING_DOUBLE_SUPPORT: "mobility",
|
|
WALKING_STEP_LENGTH: "mobility",
|
|
WALKING_STEADINESS: "mobility",
|
|
WALKING_STEADINESS_EVENT: "mobility",
|
|
STAIR_ASCENT_SPEED: "mobility",
|
|
STAIR_DESCENT_SPEED: "mobility",
|
|
SIX_MINUTE_WALK_DISTANCE: "mobility",
|
|
FALL_COUNT: "mobility",
|
|
GRIP_STRENGTH: "mobility",
|
|
|
|
// ── environment ──
|
|
AUDIO_EXPOSURE_ENV: "environment",
|
|
AUDIO_EXPOSURE_HEADPHONE: "environment",
|
|
AUDIO_EXPOSURE_EVENT: "environment",
|
|
TIME_IN_DAYLIGHT: "environment",
|
|
|
|
// ── glucose ──
|
|
BLOOD_GLUCOSE: "glucose",
|
|
|
|
// ── fenced ──
|
|
PHQ9_SCORE: "sensitive",
|
|
GAD7_SCORE: "sensitive",
|
|
WHO5_SCORE: "sensitive",
|
|
SCI_SCORE: "sensitive",
|
|
};
|
|
|
|
/** Structured leaf → group. Exhaustive over the closed union. */
|
|
export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
|
PATIENT_IDENTITY: "identity",
|
|
INSURANCE: "identity",
|
|
GLUCOSE_PANEL: "glucose",
|
|
LAB_RESULTS: "labs",
|
|
MEDICATION_LIST: "medications",
|
|
MEDICATION_ADMINISTRATIONS: "medications",
|
|
MEDICATION_COMPLIANCE: "medications",
|
|
GLP1_THERAPY: "medications",
|
|
ALLERGIES: "history",
|
|
ILLNESS_EPISODES: "history",
|
|
FAMILY_HISTORY: "sensitive",
|
|
MOOD: "sensitive",
|
|
CYCLE: "sensitive",
|
|
ANAMNESIS: "sensitive",
|
|
};
|
|
|
|
/** i18n label keys for the 14 structured leaves. */
|
|
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
|
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
|
|
INSURANCE: "reportSelection.leafInsurance",
|
|
GLUCOSE_PANEL: "reportSelection.leafGlucosePanel",
|
|
LAB_RESULTS: "reportSelection.leafLabResults",
|
|
MEDICATION_LIST: "reportSelection.leafMedicationList",
|
|
MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
|
|
MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
|
|
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
|
|
ALLERGIES: "reportSelection.leafAllergies",
|
|
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
|
|
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
|
|
MOOD: "reportSelection.leafMood",
|
|
CYCLE: "reportSelection.leafCycle",
|
|
ANAMNESIS: "reportSelection.leafAnamnesis",
|
|
};
|
|
|
|
/** i18n label keys for the groups, including the fenced tier's heading. */
|
|
export const REPORT_GROUP_LABEL_KEYS: Record<ReportGroupId, string> = {
|
|
identity: "reportSelection.groupIdentity",
|
|
vitals: "reportSelection.groupVitals",
|
|
cardio: "reportSelection.groupCardio",
|
|
body: "reportSelection.groupBody",
|
|
sleepRecovery: "reportSelection.groupSleepRecovery",
|
|
activity: "reportSelection.groupActivity",
|
|
mobility: "reportSelection.groupMobility",
|
|
environment: "reportSelection.groupEnvironment",
|
|
glucose: "reportSelection.groupGlucose",
|
|
labs: "reportSelection.groupLabs",
|
|
medications: "reportSelection.groupMedications",
|
|
history: "reportSelection.groupHistory",
|
|
sensitive: "reportSelection.groupSensitive",
|
|
};
|
|
|
|
/**
|
|
* v1.18.0 — leaf → owning module. The module map still ANDs over the
|
|
* selection: a disabled module wins over a selected leaf, and a deselected
|
|
* leaf wins over an enabled module. Neither softens the other.
|
|
*
|
|
* Core clinical leaves (blood pressure, weight, pulse, body composition,
|
|
* identity) carry no module and are never gated here.
|
|
*/
|
|
export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
|
// Activity / movement series and the training-load scores.
|
|
ACTIVITY_STEPS: "workouts",
|
|
ACTIVE_ENERGY_BURNED: "workouts",
|
|
ENERGY_EXPENDITURE_KJ: "workouts",
|
|
FLIGHTS_CLIMBED: "workouts",
|
|
WALKING_RUNNING_DISTANCE: "workouts",
|
|
WALKING_SPEED: "workouts",
|
|
WALKING_ASYMMETRY: "workouts",
|
|
WALKING_STEP_LENGTH: "workouts",
|
|
WALKING_DOUBLE_SUPPORT: "workouts",
|
|
WALKING_STEADINESS: "workouts",
|
|
WALKING_STEADINESS_EVENT: "workouts",
|
|
SIX_MINUTE_WALK_DISTANCE: "workouts",
|
|
STAIR_ASCENT_SPEED: "workouts",
|
|
STAIR_DESCENT_SPEED: "workouts",
|
|
STRAIN_SCORE: "workouts",
|
|
DAY_STRAIN: "workouts",
|
|
WORKOUT_STRAIN: "workouts",
|
|
// Readiness signals.
|
|
RECOVERY_SCORE: "recovery",
|
|
STRESS_SCORE: "recovery",
|
|
RESILIENCE: "recovery",
|
|
ANS_CHARGE: "recovery",
|
|
// Sleep.
|
|
SLEEP_DURATION: "sleep",
|
|
SLEEP_SCORE: "sleep",
|
|
SLEEP_PERFORMANCE: "sleep",
|
|
SLEEP_EFFICIENCY: "sleep",
|
|
SLEEP_CONSISTENCY: "sleep",
|
|
SLEEP_NEED: "sleep",
|
|
SLEEP_DISTURBANCE_COUNT: "sleep",
|
|
BREATHING_DISTURBANCES: "sleep",
|
|
BREATHING_DISTURBANCE_EVENT: "sleep",
|
|
// Environment exposure.
|
|
AUDIO_EXPOSURE_ENV: "environment",
|
|
AUDIO_EXPOSURE_HEADPHONE: "environment",
|
|
AUDIO_EXPOSURE_EVENT: "environment",
|
|
TIME_IN_DAYLIGHT: "environment",
|
|
// Glucose: the raw series and the derived panel.
|
|
BLOOD_GLUCOSE: "glucose",
|
|
GLUCOSE_PANEL: "glucose",
|
|
// The screening totals ride the mental-health module as well as being
|
|
// fenced in the panel. Item-level answers are never a Measurement, so only
|
|
// the total — the intended clinical artefact — can reach this path at all.
|
|
PHQ9_SCORE: "mentalHealth",
|
|
GAD7_SCORE: "mentalHealth",
|
|
WHO5_SCORE: "mentalHealth",
|
|
SCI_SCORE: "mentalHealth",
|
|
// Structured sections with an owning module.
|
|
LAB_RESULTS: "labs",
|
|
MOOD: "mood",
|
|
CYCLE: "cycle",
|
|
ILLNESS_EPISODES: "illness",
|
|
MEDICATION_LIST: "medications",
|
|
MEDICATION_ADMINISTRATIONS: "medications",
|
|
MEDICATION_COMPLIANCE: "medications",
|
|
GLP1_THERAPY: "medications",
|
|
};
|
|
|
|
/** Every measurement leaf id, in enum order. */
|
|
export const MEASUREMENT_LEAF_IDS = Object.keys(
|
|
MEASUREMENT_LEAF_GROUP,
|
|
) as MeasurementType[];
|
|
|
|
/** Every structured leaf id. */
|
|
export const STRUCTURED_LEAF_IDS = Object.keys(
|
|
STRUCTURED_LEAF_GROUP,
|
|
) as StructuredLeafId[];
|
|
|
|
/**
|
|
* Every leaf id, grouped and ordered the way the panel renders them: group
|
|
* order first, structured leaves before measurement leaves inside a group.
|
|
* The PDF and the clinician view read the same order, so the panel is a table
|
|
* of contents for the artefact.
|
|
*/
|
|
export const REPORT_GROUPS: ReadonlyArray<{
|
|
id: ReportGroupId;
|
|
labelKey: string;
|
|
leaves: readonly ReportLeafId[];
|
|
}> = REPORT_GROUP_ORDER.map((id) => ({
|
|
id,
|
|
labelKey: REPORT_GROUP_LABEL_KEYS[id],
|
|
leaves: [
|
|
...STRUCTURED_LEAF_IDS.filter((leaf) => STRUCTURED_LEAF_GROUP[leaf] === id),
|
|
...MEASUREMENT_LEAF_IDS.filter(
|
|
(leaf) => MEASUREMENT_LEAF_GROUP[leaf] === id,
|
|
),
|
|
],
|
|
}));
|
|
|
|
/** The full ordered leaf list — the stable order a stored selection uses. */
|
|
export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap(
|
|
(group) => group.leaves,
|
|
);
|
|
|
|
/**
|
|
* The wire shape for `GET /api/meta/capabilities` `share.groups`: each group
|
|
* carries its own member leaves, and the fenced tier carries `sensitive:
|
|
* true`, derived from `SENSITIVE_GROUP_ID` rather than a second string
|
|
* literal. A native client that cannot see group membership cannot mirror the
|
|
* fence (no single control switching on more than one sensitive leaf), so
|
|
* this — not `REPORT_GROUP_ORDER` alone — is what the endpoint should ship.
|
|
*
|
|
* Order matches `REPORT_GROUPS` exactly: group order is `REPORT_GROUP_ORDER`,
|
|
* leaf order within a group is structured-then-measurement, so this shape and
|
|
* the flat `ALL_LEAF_IDS` list agree leaf-for-leaf.
|
|
*/
|
|
export const SHARE_GROUPS: ReadonlyArray<{
|
|
id: ReportGroupId;
|
|
leaves: readonly ReportLeafId[];
|
|
sensitive?: true;
|
|
}> = REPORT_GROUPS.map((group) => ({
|
|
id: group.id,
|
|
leaves: group.leaves,
|
|
...(group.id === SENSITIVE_GROUP_ID ? { sensitive: true as const } : {}),
|
|
}));
|
|
|
|
const ALL_LEAF_SET = new Set<string>(ALL_LEAF_IDS);
|
|
|
|
/** Whether a raw string is a leaf id this build knows. */
|
|
export function isReportLeafId(value: unknown): value is ReportLeafId {
|
|
return typeof value === "string" && ALL_LEAF_SET.has(value);
|
|
}
|
|
|
|
const STRUCTURED_LEAF_SET = new Set<string>(STRUCTURED_LEAF_IDS);
|
|
|
|
/** Whether a leaf id is one of the structured sections. */
|
|
export function isStructuredLeafId(
|
|
leaf: ReportLeafId,
|
|
): leaf is StructuredLeafId {
|
|
return STRUCTURED_LEAF_SET.has(leaf);
|
|
}
|
|
|
|
/** The fenced leaves, as a set, for the template-purity guard and the panel. */
|
|
export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
|
|
(leaf) =>
|
|
isStructuredLeafId(leaf)
|
|
? STRUCTURED_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID
|
|
: MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
|
|
);
|
|
|
|
/** The i18n label key for any leaf. */
|
|
export function leafLabelKey(leaf: ReportLeafId): string {
|
|
return isStructuredLeafId(leaf)
|
|
? STRUCTURED_LEAF_LABEL_KEYS[leaf]
|
|
: MEASUREMENT_TYPE_LABEL_KEYS[leaf];
|
|
}
|
|
|
|
/** The group a leaf belongs to. */
|
|
export function leafGroup(leaf: ReportLeafId): ReportGroupId {
|
|
return isStructuredLeafId(leaf)
|
|
? STRUCTURED_LEAF_GROUP[leaf]
|
|
: MEASUREMENT_LEAF_GROUP[leaf];
|
|
}
|