mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'worktree-agent-aad9f8f93f1f3817b' into release/v1.30.19
This commit is contained in:
commit
aed541910c
17 files changed
+1083
-41
No files matched your search
@@ -118,7 +118,7 @@ export {
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
WIDGET_MODULE_BY_ID,
|
||||
SUMMARY_TYPE_MODULE,
|
||||
disabledSummaryTypes,
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
@@ -965,10 +965,9 @@ function gateSummariesByModules(
|
||||
summaries: Record<string, DataSummary>;
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
} {
|
||||
const dropped = new Set<string>();
|
||||
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
|
||||
}
|
||||
// The drop decision is shared with `GET /api/dashboard/summary` — see
|
||||
// `disabledSummaryTypes`. Only the filtering below is snapshot-shaped.
|
||||
const dropped = disabledSummaryTypes(modules);
|
||||
if (dropped.size === 0) return { summaries, lastSeenByType };
|
||||
const outSummaries: Record<string, DataSummary> = {};
|
||||
for (const [type, summary] of Object.entries(summaries)) {
|
||||
|
||||
@@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
// on the `nutrients` module like the rest of that store's surfaces.
|
||||
NUTRIENT_WATER: "nutrients",
|
||||
};
|
||||
|
||||
/**
|
||||
* The summary types a module map turns off. Lifted out of
|
||||
* `gateSummariesByModules` in `@/lib/dashboard/snapshot` so the two
|
||||
* dashboard aggregates that both surface per-metric data — the snapshot
|
||||
* builder and the iOS `GET /api/dashboard/summary` payload — decide from
|
||||
* ONE map instead of each carrying its own copy.
|
||||
*
|
||||
* The two callers shape their payloads differently (the snapshot keys
|
||||
* summaries by `MeasurementType`; the summary route emits an array of
|
||||
* metric cards keyed by an iOS `MetricKind`), so what is shared is this
|
||||
* decision, not the filtering itself.
|
||||
*/
|
||||
export function disabledSummaryTypes(
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric
|
||||
* card) → the `MeasurementType` it is built from. The summary route emits
|
||||
* cards by kind, but module membership is defined per measurement type in
|
||||
* `SUMMARY_TYPE_MODULE` above; this map is the join between the two so the
|
||||
* summary payload gates off the SAME source of truth as the snapshot rather
|
||||
* than a second, drift-prone kind→module list.
|
||||
*
|
||||
* Kinds whose type carries no `SUMMARY_TYPE_MODULE` entry (weight, blood
|
||||
* pressure, pulse, body fat, steps, body water, bone mass, SpO₂) are core
|
||||
* vitals and always pass through — they are listed anyway so a reader can
|
||||
* see the full emitted set and so a new card cannot be added without
|
||||
* deciding which type backs it.
|
||||
*/
|
||||
export const SUMMARY_METRIC_TYPE_BY_KIND: Record<string, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the metric cards whose backing measurement type belongs to a module
|
||||
* the account turned off. A card whose kind is absent from
|
||||
* `SUMMARY_METRIC_TYPE_BY_KIND` is kept — an unmapped kind is a core metric
|
||||
* or a new one, and silently hiding it would be worse than surfacing it.
|
||||
*/
|
||||
export function gateMetricCardsByModules<T extends { kind: string }>(
|
||||
cards: ReadonlyArray<T>,
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): T[] {
|
||||
const dropped = disabledSummaryTypes(modules);
|
||||
if (dropped.size === 0) return [...cards];
|
||||
return cards.filter((card) => {
|
||||
const type = SUMMARY_METRIC_TYPE_BY_KIND[card.kind];
|
||||
return !type || !dropped.has(type);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `loadFhirContext` — doctorReport module backstop.
|
||||
*
|
||||
* The loader is the one door to the whole-record aggregate the FHIR face
|
||||
* serves, including the decrypted insurance number. Every `/api/fhir/*` data
|
||||
* route carries its own `requireModuleEnabled` gate (that is what produces the
|
||||
* clean 403); this test pins the SECOND layer — the loader refuses outright
|
||||
* rather than trusting that every present and future caller remembered.
|
||||
*
|
||||
* The gate resolver is exercised for real; only its data sources are stubbed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/doctor-report-data", () => ({
|
||||
collectDoctorReportData: vi.fn(),
|
||||
normaliseDateRange: vi.fn(() => ({ from: new Date(0), to: new Date() })),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
|
||||
|
||||
import { loadFhirContext } from "@/lib/fhir/rest";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function setDoctorReportModule(enabled: boolean): void {
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
|
||||
select?: Record<string, boolean>;
|
||||
}) => {
|
||||
// The gate's own read and the loader's identity read hit the same model.
|
||||
if (args?.select?.modulePreferencesJson) {
|
||||
return {
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: enabled ? {} : { doctorReport: false },
|
||||
};
|
||||
}
|
||||
return { insuranceNumberEncrypted: "cipher", locale: "de" };
|
||||
}) as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("loadFhirContext — doctorReport backstop", () => {
|
||||
it("refuses to assemble the record when the module is off", async () => {
|
||||
setDoctorReportModule(false);
|
||||
|
||||
await expect(loadFhirContext("user-1")).rejects.toThrow(
|
||||
/doctorReport.*disabled/,
|
||||
);
|
||||
|
||||
// Nothing was aggregated and nothing was decrypted.
|
||||
expect(collectDoctorReportData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assembles the record when the module is on", async () => {
|
||||
setDoctorReportModule(true);
|
||||
|
||||
const ctx = await loadFhirContext("user-1");
|
||||
expect(collectDoctorReportData).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.identity.insuranceNumber).toBe("A123456789");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type DoctorReportData,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import type {
|
||||
FhirBundleEntry,
|
||||
FhirBundleLink,
|
||||
@@ -182,12 +183,28 @@ export function operationOutcome(
|
||||
* The window matches the document export's default (`normaliseDateRange`
|
||||
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
|
||||
* single row omits the identifier rather than 500-ing the read.
|
||||
*
|
||||
* MODULE BACKSTOP. Every `/api/fhir/*` data route calls
|
||||
* `requireModuleEnabled(userId, "doctorReport")` itself, which is what
|
||||
* produces the clean 403 envelope. This assertion is the second layer: the
|
||||
* loader is the one door to the whole-record aggregate (including the
|
||||
* decrypted insurance number), so it refuses outright rather than trusting
|
||||
* that every present and future caller remembered the gate. It throws
|
||||
* instead of returning an envelope because reaching it means a caller is
|
||||
* missing its gate — a bug to surface, not a flow to serve.
|
||||
*/
|
||||
export async function loadFhirContext(userId: string): Promise<{
|
||||
data: DoctorReportData;
|
||||
identity: { insuranceNumber: string | null };
|
||||
germanAtc: boolean;
|
||||
}> {
|
||||
if (!(await isModuleEnabled(userId, "doctorReport"))) {
|
||||
throw new Error(
|
||||
'loadFhirContext called with the "doctorReport" module disabled — ' +
|
||||
"the calling route is missing its requireModuleEnabled gate",
|
||||
);
|
||||
}
|
||||
|
||||
const range = normaliseDateRange(undefined);
|
||||
const [data, userRow] = await Promise.all([
|
||||
collectDoctorReportData(userId, range, {}),
|
||||
|
||||
Reference in new issue
Block a user