mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Publish share-selection group membership in capabilities
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.
This commit is contained in:
1 parent
b767cff187
commit
c9c824ca4d
6 files changed
+139
-11
No files matched your search
+18
-2
@@ -16096,8 +16096,24 @@ components:
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Selection group ids, in presentation order.
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Group id.
|
||||
leaves:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Member leaf ids, in the order the panel renders them.
|
||||
sensitive:
|
||||
description: "Present and true only on the fenced tier: no single control may switch on more than one of its leaves."
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- leaves
|
||||
additionalProperties: false
|
||||
description: Selection groups, in presentation order, each carrying its member leaf ids.
|
||||
leaves:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -61,6 +61,7 @@ import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
REPORT_GROUP_ORDER,
|
||||
SENSITIVE_GROUP_ID,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -96,7 +97,7 @@ type CapabilitiesBody = {
|
||||
maxDays: number;
|
||||
reportDownload: string[];
|
||||
selectionVersion: number;
|
||||
groups: string[];
|
||||
groups: { id: string; leaves: string[]; sensitive?: boolean }[];
|
||||
leaves: string[];
|
||||
};
|
||||
};
|
||||
@@ -187,12 +188,37 @@ describe("GET /api/meta/capabilities — drift guards", () => {
|
||||
// that was never built.
|
||||
expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]);
|
||||
expect(body.data.share.selectionVersion).toBe(2);
|
||||
expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]);
|
||||
expect(body.data.share.groups.map((g) => g.id)).toEqual([
|
||||
...REPORT_GROUP_ORDER,
|
||||
]);
|
||||
expect([...body.data.share.leaves].sort()).toEqual(
|
||||
[...ALL_LEAF_IDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("share.groups carries membership: every leaf in exactly one group, fence flagged once", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const group of body.data.share.groups) {
|
||||
for (const leaf of group.leaves) {
|
||||
expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe(
|
||||
false,
|
||||
);
|
||||
seen.add(leaf);
|
||||
}
|
||||
}
|
||||
// Every leaf in the flat set is in exactly one group, and no group holds
|
||||
// a leaf absent from the flat set — the invariant that makes a native
|
||||
// client's fence trustworthy.
|
||||
expect([...seen].sort()).toEqual([...body.data.share.leaves].sort());
|
||||
|
||||
const sensitiveGroups = body.data.share.groups.filter((g) => g.sensitive);
|
||||
expect(sensitiveGroups).toHaveLength(1);
|
||||
expect(sensitiveGroups[0]?.id).toBe(SENSITIVE_GROUP_ID);
|
||||
});
|
||||
|
||||
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
@@ -46,10 +46,7 @@ import {
|
||||
FHIR_SEARCH_PARAMS,
|
||||
} from "@/lib/fhir/rest";
|
||||
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
REPORT_GROUP_ORDER,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import { ALL_LEAF_IDS, SHARE_GROUPS } from "@/lib/report-selection/catalogue";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -135,7 +132,12 @@ export const GET = apiHandler(async () => {
|
||||
maxDays: SHARE_LINK_MAX_DAYS,
|
||||
reportDownload: ["fhir", "pdf"],
|
||||
selectionVersion: 2,
|
||||
groups: REPORT_GROUP_ORDER,
|
||||
// Each group carries its own member leaves, and the fenced tier carries
|
||||
// `sensitive: true` — the membership the panel, the PDF and the guard
|
||||
// tests already enforce, published so the native client can mirror the
|
||||
// fence instead of guessing which leaves it may bundle behind one
|
||||
// control. `leaves` stays the flat total set for back-compat.
|
||||
groups: SHARE_GROUPS,
|
||||
leaves: ALL_LEAF_IDS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,8 +113,25 @@ const capabilitiesResponse = z
|
||||
.int()
|
||||
.describe("Version of the leaf-selection grammar this build speaks."),
|
||||
groups: z
|
||||
.array(z.string())
|
||||
.describe("Selection group ids, in presentation order."),
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe("Group id."),
|
||||
leaves: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Member leaf ids, in the order the panel renders them.",
|
||||
),
|
||||
sensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Present and true only on the fenced tier: no single control may switch on more than one of its leaves.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Selection groups, in presentation order, each carrying its member leaf ids.",
|
||||
),
|
||||
leaves: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
REPORT_GROUPS,
|
||||
REPORT_GROUP_LABEL_KEYS,
|
||||
REPORT_GROUP_ORDER,
|
||||
SENSITIVE_GROUP_ID,
|
||||
SENSITIVE_LEAF_IDS,
|
||||
SHARE_GROUPS,
|
||||
STRUCTURED_LEAF_IDS,
|
||||
leafLabelKey,
|
||||
} from "../catalogue";
|
||||
@@ -80,6 +82,49 @@ describe("report selection catalogue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `SHARE_GROUPS` is what `GET /api/meta/capabilities` publishes as
|
||||
* `share.groups` — the membership a native client needs to mirror the
|
||||
* sensitive-tier fence itself. It is derived from `REPORT_GROUPS`, so these
|
||||
* checks are the invariant that makes that fence trustworthy: every leaf in
|
||||
* the flat `ALL_LEAF_IDS` set lands in exactly one group, no group carries a
|
||||
* leaf the flat set doesn't have, and exactly one group — the one whose id is
|
||||
* `SENSITIVE_GROUP_ID` — carries `sensitive: true`.
|
||||
*
|
||||
* Mutation check (verified red, then reverted): push a leaf into two groups
|
||||
* in `SHARE_GROUPS` by hand → the "exactly one group" assertion goes red
|
||||
* naming the duplicated leaf.
|
||||
*/
|
||||
describe("share-group membership (GET /api/meta/capabilities share.groups)", () => {
|
||||
it("covers exactly the flat leaf set, no leaf in more than one group", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const group of SHARE_GROUPS) {
|
||||
for (const leaf of group.leaves) {
|
||||
expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe(
|
||||
false,
|
||||
);
|
||||
seen.add(leaf);
|
||||
}
|
||||
}
|
||||
expect([...seen].sort()).toEqual([...ALL_LEAF_IDS].sort());
|
||||
});
|
||||
|
||||
it("flags sensitive: true on exactly the fenced group", () => {
|
||||
const flagged = SHARE_GROUPS.filter((g) => g.sensitive);
|
||||
expect(flagged).toHaveLength(1);
|
||||
expect(flagged[0]?.id).toBe(SENSITIVE_GROUP_ID);
|
||||
});
|
||||
|
||||
it("orders groups and leaves identically to REPORT_GROUPS", () => {
|
||||
expect(SHARE_GROUPS.map((g) => g.id)).toEqual(
|
||||
REPORT_GROUPS.map((g) => g.id),
|
||||
);
|
||||
for (const [i, group] of SHARE_GROUPS.entries()) {
|
||||
expect(group.leaves).toEqual(REPORT_GROUPS[i]!.leaves);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template purity", () => {
|
||||
it("keeps the shipped template clear of the fenced tier", () => {
|
||||
const overlap = STANDARD_TEMPLATE_LEAVES.filter((leaf) =>
|
||||
|
||||
@@ -344,6 +344,28 @@ 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. */
|
||||
|
||||
Reference in new issue
Block a user