Merge branch 'worktree-agent-aad9f8f93f1f3817b' into release/v1.30.19

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 01:53:18 +02:00
commit aed541910c
17 files changed
+1083 -41

No files matched your search

+4 -5
View File
@@ -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)) {
+67
View File
@@ -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");
});
});
+17
View File
@@ -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, {}),