feat(share): serve the record a link already implies, and say when there is none

Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.

The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.

The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.

Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.

The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:45:08 +02:00
1 parent f42934487d
commit 9e68ff525f
10 files changed
+586 -38

No files matched your search

@@ -21,7 +21,7 @@ import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { savedReportProfileSchema } from "@/lib/report-selection/saved-profile";
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
import { selectionFromRequest } from "@/lib/report-selection/selection";
export const dynamic = "force-dynamic";
@@ -47,6 +47,7 @@ vi.mock("@/lib/cycle/export-data", () => ({
currentPhase: "FOLLICULAR",
})),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -80,6 +81,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
import { prisma } from "@/lib/db";
import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
import { decrypt } from "@/lib/crypto";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -723,3 +725,59 @@ describe("POST /api/export/health-record — what the route remembers", () => {
);
});
});
/**
* The insurance number is its own leaf, and it is the most sensitive value on
* this path: encrypted at rest, and the one identifier a share link may never
* carry at all.
*
* Mutation check: drop the `selection.has("INSURANCE")` condition on the
* decrypt and "never decrypts the insurance number" goes red.
*/
describe("POST /api/export/health-record — the insurance leaf", () => {
beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
dateOfBirth: null,
gender: null,
heightCm: null,
glucoseUnit: null,
thresholdsJson: null,
fullName: null,
insurerName: "Sample Insurer",
insurerIkNumber: "123456789",
insuranceNumberEncrypted: Buffer.from("ciphertext"),
} as never);
});
it("never decrypts the insurance number when the leaf was not chosen", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq({ format: "fhir", selection: sel("WEIGHT", "PATIENT_IDENTITY") }),
);
expect(res.status).toBe(200);
// Not decrypted, so it never entered this process's memory — the stronger
// statement than "it was decrypted and then left out".
expect(decrypt).not.toHaveBeenCalled();
const bundle = (await res.json()) as {
entry: { resource: { resourceType: string } }[];
};
expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain(
"Coverage",
);
});
it("decrypts and carries it when the leaf was chosen by name", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq({
format: "fhir",
selection: sel("WEIGHT", "PATIENT_IDENTITY", "INSURANCE"),
}),
);
expect(res.status).toBe(200);
expect(decrypt).toHaveBeenCalled();
const text = JSON.stringify(await res.json());
expect(text).toContain("A123456789");
});
});
@@ -0,0 +1,278 @@
/**
* The two machine-format downloads on a share link.
*
* A practice opens the link, unlocks it, and saves the file into their system.
* That is what the removed FHIR toggle promised and never served. The gate has
* to be the document route's, step for step — the same live check, the same
* token-scoped unlock cookie, a rate limit before any aggregation, and the same
* blunt 404 for every miss class, so a probe cannot tell "no such link" from
* "revoked" from "documents-only".
*
* The load-bearing scope property: the download resolves the SAME frozen
* selection the page renders from, so it can never be wider than the page.
*
* Mutation checks:
* - remove the unlock-cookie check in `resolveShareReportDownload` →
* "refuses a protected link without the unlock cookie" goes red.
* - remove the rate-limit check → "refuses over the rate limit" goes red.
* - drop the `view.documentOnly` guard → "refuses a documents-only link"
* goes red.
* - change the bucket key to the document route's `share-documents:` →
* "uses its own rate bucket" goes red.
* - drop the `auditLog` call → "records an audit row per download" goes red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next/headers", () => ({ cookies: vi.fn() }));
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
resolveShareGateState: vi.fn(),
resolveShareToken: vi.fn(),
}));
vi.mock("@/lib/clinician-share/unlock-cookie", () => ({
unlockCookieName: (hash: string) => `hls_unlock_${hash}`,
verifyUnlockValue: vi.fn(),
}));
vi.mock("@/lib/clinician-share/share-view-data", () => ({
loadShareViewData: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
vi.mock("@/lib/i18n/server-translator", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/i18n/server-translator")>();
return actual;
});
import { cookies } from "next/headers";
import {
resolveShareGateState,
resolveShareToken,
} from "@/lib/clinician-share/resolve-share-token";
import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { selectionFromLeaves } from "@/lib/report-selection/selection";
import type { DoctorReportData } from "@/lib/doctor-report-data";
const TOKEN = "hls_deadbeef";
function report(): DoctorReportData {
return {
period: {
days: 30,
since: "2026-01-01",
start: "2026-01-01T00:00:00.000Z",
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: null,
dateOfBirth: null,
gender: null,
heightCm: null,
},
practiceName: null,
measurements: {},
stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-01-31T00:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
medications: [],
mood: null,
wellnessScores: null,
} as DoctorReportData;
}
function req(): Request {
return new Request(`http://localhost/c/${TOKEN}/fhir`);
}
const params = { params: Promise.resolve({ token: TOKEN }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: "hash-1",
passphraseHash: "pass-hash",
} as never);
vi.mocked(cookies).mockResolvedValue({
get: () => ({ value: "unlock-value" }),
} as never);
vi.mocked(verifyUnlockValue).mockReturnValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: new Date(),
} as never);
vi.mocked(resolveShareToken).mockResolvedValue({
shareLinkId: "link-1",
ownerUserId: "owner-1",
} as never);
vi.mocked(loadShareViewData).mockResolvedValue({
report: report(),
selection: selectionFromLeaves(["WEIGHT"]),
documents: [],
documentOnly: false,
} as never);
});
describe("GET /c/{token}/fhir", () => {
it("serves the link's frozen scope as a document Bundle", async () => {
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
expect(res.headers.get("content-disposition")).toContain("attachment");
expect(res.headers.get("cache-control")).toBe("no-store");
const bundle = (await res.json()) as {
resourceType: string;
type: string;
};
expect(bundle.resourceType).toBe("Bundle");
expect(bundle.type).toBe("document");
});
it("carries no Coverage — the insurance leaf cannot reach this path", async () => {
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
const bundle = (await res.json()) as {
entry: { resource: { resourceType: string } }[];
};
expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain(
"Coverage",
);
});
it("refuses a protected link without the unlock cookie", async () => {
vi.mocked(verifyUnlockValue).mockReturnValue(false);
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
expect(res.status).toBe(404);
expect(loadShareViewData).not.toHaveBeenCalled();
});
it("refuses an unknown, revoked or expired token with the same flat 404", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue(null as never);
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
expect(res.status).toBe(404);
expect(checkRateLimit).not.toHaveBeenCalled();
});
it("refuses a documents-only link even if a report came back with it", async () => {
// The flag is authoritative on its own. `loadShareViewData` returns a null
// report alongside it today, so the two conditions agree — this fixture
// separates them so the flag's own check is provably load-bearing rather
// than carried by its neighbour.
vi.mocked(loadShareViewData).mockResolvedValue({
report: report(),
selection: selectionFromLeaves(["WEIGHT"]),
documents: [],
documentOnly: true,
} as never);
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
expect(res.status).toBe(404);
});
it("refuses a link that produced no report", async () => {
vi.mocked(loadShareViewData).mockResolvedValue({
report: null,
selection: selectionFromLeaves([]),
documents: [],
documentOnly: false,
} as never);
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
expect(res.status).toBe(404);
});
it("refuses over the rate limit, before any aggregation", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
count: 21,
resetAt: new Date(),
} as never);
const { GET } = await import("../fhir/route");
const res = await GET(req(), params);
expect(res.status).toBe(429);
expect(loadShareViewData).not.toHaveBeenCalled();
});
it("uses its own rate bucket, not the document route's", async () => {
const { GET } = await import("../fhir/route");
await GET(req(), params);
// Generating a bundle is far more expensive than serving a stored blob and
// must not share a budget with it.
expect(checkRateLimit).toHaveBeenCalledWith(
"report-download:hash-1",
20,
60 * 60 * 1000,
);
});
it("records an audit row per download, scoped to the owner", async () => {
const { GET } = await import("../fhir/route");
await GET(req(), params);
expect(auditLog).toHaveBeenCalledWith(
"share-link.report.download",
expect.objectContaining({
userId: "owner-1",
details: expect.objectContaining({
shareLinkId: "link-1",
format: "fhir",
leafCount: 1,
}),
}),
);
});
});
describe("GET /c/{token}/report.pdf", () => {
it("serves the link's frozen scope as a PDF", async () => {
const { GET } = await import("../report.pdf/route");
const res = await GET(
new Request(`http://localhost/c/${TOKEN}/report.pdf`),
params,
);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
expect(res.headers.get("content-disposition")).toContain("attachment");
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-");
});
it("runs the same gate as the bundle beside it", async () => {
vi.mocked(verifyUnlockValue).mockReturnValue(false);
const { GET } = await import("../report.pdf/route");
const res = await GET(
new Request(`http://localhost/c/${TOKEN}/report.pdf`),
params,
);
expect(res.status).toBe(404);
});
it("records its own format in the audit row", async () => {
const { GET } = await import("../report.pdf/route");
await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params);
expect(auditLog).toHaveBeenCalledWith(
"share-link.report.download",
expect.objectContaining({
details: expect.objectContaining({ format: "pdf" }),
}),
);
});
});
@@ -54,7 +54,7 @@ import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import {
parseSavedProfile,
SAVED_PROFILE_FALLBACK,
} from "@/lib/report-selection/saved-profile";
} from "@/lib/report-selection/profile-shape";
import {
STANDARD_TEMPLATE_LABEL_KEY,
STANDARD_TEMPLATE_LEAVES,
@@ -82,7 +82,7 @@ export function HealthRecordExportPanel() {
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [practiceName, setPracticeName] = useState("");
const [includeCharts, setIncludeCharts] = useState(
const [includeCharts, setIncludeCharts] = useState<boolean>(
SAVED_PROFILE_FALLBACK.includeCharts,
);
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
@@ -287,6 +287,84 @@ describe("per-leaf gating sweep", () => {
}, 30_000);
});
describe("the derived figures ride their own leaf", () => {
it("withholds the BMI figure while the weight series stays", async () => {
// The sweep alone cannot isolate this: BODY_MASS_INDEX also gates its own
// measurement rows, so the payload differs either way. The figure needs
// its own assertion, because weight rows stay readable for exactly this
// computation and it would be easy to leave the result ungated.
const withBmi = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["WEIGHT", "BODY_MASS_INDEX"]),
{ moduleMap: moduleMap() },
);
expect(withBmi.bmi).not.toBeNull();
const withoutBmi = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["WEIGHT"]),
{ moduleMap: moduleMap() },
);
expect(withoutBmi.bmi).toBeNull();
expect(withoutBmi.stats.WEIGHT).toBeDefined();
});
it("computes the BMI figure without carrying the weight series", async () => {
// The other half: choosing the figure and not the series still needs the
// weight rows read, and must not smuggle the series out with them.
const payload = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["BODY_MASS_INDEX"]),
{ moduleMap: moduleMap() },
);
expect(payload.bmi).not.toBeNull();
expect(payload.stats.WEIGHT).toBeUndefined();
expect(payload.measurements.WEIGHT).toBeUndefined();
});
it("withholds the GLP-1 block while its inputs stay readable", async () => {
const on = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["GLP1_THERAPY"]),
{ moduleMap: moduleMap() },
);
expect(on.glp1).not.toBeNull();
const off = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["MEDICATION_LIST", "MEDICATION_COMPLIANCE"]),
{ moduleMap: moduleMap() },
);
expect(off.glp1).toBeNull();
expect(off.medications.length).toBeGreaterThan(0);
});
it("withholds the glucose panel while the raw series stays", async () => {
const withPanel = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["BLOOD_GLUCOSE", "GLUCOSE_PANEL"]),
{ moduleMap: moduleMap() },
);
expect(Object.keys(withPanel.glucoseStats).length).toBeGreaterThan(0);
const seriesOnly = await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(["BLOOD_GLUCOSE"]),
{ moduleMap: moduleMap() },
);
expect(seriesOnly.glucoseStats).toEqual({});
expect(seriesOnly.glucoseClinical.readingCount).toBe(0);
expect(seriesOnly.stats.BLOOD_GLUCOSE).toBeDefined();
});
});
describe("fail-closed exclusion", () => {
it("excludes a measurement type the catalogue does not know", () => {
const gate = resolveReportGate(
@@ -0,0 +1,86 @@
/**
* The doctor-visit summariser, and what it says when there is nothing to say.
*
* The MCP surfaces cannot ask a human, so they replay the owner's saved report
* selection. An account that never saved one resolves to the empty selection
* and the aggregator returns an empty payload — which has to read as "nothing
* was recorded for this", not as a record whose every section happens to be
* missing. The `{ present: false, reason }` sentinel is the same one the other
* MCP reads use.
*
* Mutation checks:
* - make `present` unconditionally `true` again → "reports absence for an
* empty scope" goes red.
* - drop `data.bmi !== null` from the `hasContent` test → "counts a lone BMI
* figure as content" goes red.
*/
import { describe, it, expect } from "vitest";
import { summariseForVisit } from "../doctor-visit-summary";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DoctorReportData } from "@/lib/doctor-report-data";
function payload(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
return {
period: {
days: 90,
since: "2026-01-01",
start: "2026-01-01T00:00:00.000Z",
end: "2026-04-01T00:00:00.000Z",
},
patient: {
username: null,
dateOfBirth: null,
gender: null,
heightCm: null,
},
practiceName: null,
measurements: {},
stats: {},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-04-01T00:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
medications: [],
wellnessScores: null,
...overrides,
} as DoctorReportData;
}
describe("summariseForVisit", () => {
it("reports absence for an empty scope, with the period it looked at", () => {
const summary = summariseForVisit(payload());
expect(summary.present).toBe(false);
expect(summary.reason).toBe("empty_report_selection");
expect(summary.period).toEqual({
days: 90,
start: "2026-01-01T00:00:00.000Z",
end: "2026-04-01T00:00:00.000Z",
});
// Nothing is zero-filled in its place.
expect(summary).not.toHaveProperty("vitals");
expect(summary).not.toHaveProperty("medications");
});
it("reports presence as soon as one metric survives the gate", () => {
const summary = summariseForVisit(
payload({
stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
}),
);
expect(summary.present).toBe(true);
expect(summary.vitals).toHaveLength(1);
});
it("counts a lone BMI figure as content", () => {
// The BMI figure can be the only thing chosen — the weight series stays
// withheld — and a summary carrying it is not an empty one.
const summary = summariseForVisit(payload({ bmi: 24.5 }));
expect(summary.present).toBe(true);
expect(summary.bmi).toBe(24.5);
});
});
+3 -1
View File
@@ -256,7 +256,9 @@ describe("healthlog://report/doctor-visit", () => {
vi.mocked(collectDoctorReportData).mockResolvedValue({
period: { days: 90, since: "", start: "", end: "" },
patient: { dateOfBirth: "1990-01-01", gender: "female", heightCm: 170 },
stats: {},
// One metric survived the gate, so the record is present; everything
// else is absent and must stay absent rather than being zero-filled.
stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
glucoseClinical: { stillLearning: true },
bmi: null,
compliance: {},
+27
View File
@@ -112,6 +112,33 @@ export function summariseForVisit(
resolvedAt: e.resolvedAt,
}));
// Absence is honest here too. An account whose owner never saved a report
// selection resolves to the empty one on this surface, and an empty scope
// produces an empty payload — which must read as "nothing was recorded for
// this", not as a record with every section quietly missing. The
// `{ present: false, reason }` sentinel is the same one the other MCP reads
// use, so an assistant already knows how to read it.
const hasContent =
vitals.length > 0 ||
medications.length > 0 ||
compliance.length > 0 ||
labs.length > 0 ||
wellness.length > 0 ||
illness.length > 0 ||
data.mood != null ||
data.bmi != null;
if (!hasContent) {
return {
present: false,
reason: "empty_report_selection",
period: {
days: data.period.days,
start: data.period.start,
end: data.period.end,
},
};
}
return {
present: true,
period: {
+43
View File
@@ -0,0 +1,43 @@
/**
* The shape of the owner's saved report profile, without the read that
* fetches it.
*
* The export panel is a client component and needs to parse the blob that
* rides the `/api/auth/me` payload. Keeping the schema here means it can do
* that without pulling the Prisma client into the browser bundle — the same
* separation `saved-profile.ts` documents on the server side.
*/
import { z } from "zod/v4";
import { reportSelectionSchema } from "./selection";
/**
* The stored profile: the leaf list plus the render choices the panel
* restores. `format` / `rangeDays` / `includeCharts` are presentation, not
* scope — no gate reads them — so they ride alongside rather than inside the
* selection.
*/
export const savedReportProfileSchema = reportSelectionSchema.extend({
format: z.enum(["pdf", "fhir", "package"]),
rangeDays: z.number().int().min(1).max(365),
includeCharts: z.boolean(),
});
export type SavedReportProfile = z.infer<typeof savedReportProfileSchema>;
/**
* The render choices the panel starts from when the account has never saved a
* profile. Presentation only — this is deliberately NOT a scope fallback;
* there is no such thing here, because a scope nobody chose is the defect.
*/
export const SAVED_PROFILE_FALLBACK = {
format: "pdf",
rangeDays: 90,
includeCharts: true,
} as const;
/** Parse a stored profile blob, or `null` when there is none this build reads. */
export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
const parsed = savedReportProfileSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
+10 -34
View File
@@ -1,5 +1,5 @@
/**
* The owner's saved report selection — "my selection".
* The owner's saved report selection — "my selection" — read from the row.
*
* Persisted on `User.reportSelectionJson` and written after a successful
* generation, the same way the practice name is. Two kinds of caller read it:
@@ -15,41 +15,23 @@
* account that has never saved one resolves to the EMPTY selection, so an
* assistant asking for a record the owner never scoped gets nothing — not a
* template, not a guess.
*
* The shape itself lives in `./profile-shape` so the client panel can parse
* the blob without pulling the Prisma client into the browser bundle.
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
EMPTY_REPORT_SELECTION,
reportSelectionSchema,
selectionFromStoredBlob,
type ReportSelection,
} from "./selection";
/**
* The stored profile: the leaf list plus the render choices the panel
* restores. `format` / `rangeDays` / `includeCharts` are presentation, not
* scope — no gate reads them — so they ride alongside rather than inside the
* selection.
*/
export const savedReportProfileSchema = reportSelectionSchema.extend({
format: z.enum(["pdf", "fhir", "package"]),
rangeDays: z.number().int().min(1).max(365),
includeCharts: z.boolean(),
});
export type SavedReportProfile = z.infer<typeof savedReportProfileSchema>;
/**
* The render choices the panel starts from when the account has never saved a
* profile. Presentation only — this is deliberately NOT a scope fallback;
* there is no such thing here, because a scope nobody chose is the defect.
*/
export const SAVED_PROFILE_FALLBACK = {
format: "pdf",
rangeDays: 90,
includeCharts: true,
} as const;
export {
savedReportProfileSchema,
parseSavedProfile,
SAVED_PROFILE_FALLBACK,
type SavedReportProfile,
} from "./profile-shape";
/**
* Resolve the owner's saved selection.
@@ -68,9 +50,3 @@ export async function resolveSavedSelection(
if (!row) return EMPTY_REPORT_SELECTION;
return selectionFromStoredBlob(row.reportSelectionJson);
}
/** Parse a stored profile blob for the panel, or null when there is none. */
export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
const parsed = savedReportProfileSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}