From e7a1950f3a6f05f46a4f8c4c1258d29979ca7532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 11:02:32 +0200 Subject: [PATCH] refactor(sharing): retire the coarse "may this person add" answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `canWrite` is the grant's LEVEL and carries no scope term. Its docblock said "May this caller ADD to that record", and the client hook republished it as `canAdd`, so fourteen controls across measurements, medications, labs, illness and the dashboard gated a create on a boolean that answers `true` for a WRITE grant scoped to the document vault — a grant whose writable-section list is empty, because the vault takes no delegated write at any level. Nothing shipped was visibly wrong before the capture picker, because the nav drops a section a grant does not open. That is a property of one surface, not of the answer, and the answer is the thing many controls read. So the coarse answer is gone from the hook, and every consumer asks `canWriteDomain` for its own section instead: `measurements` on the two measurement surfaces and the dashboard's empty state, `medications` on the medication list, table, ledger, card menu and side effects, `labs` on the lab list and biomarker detail, `illness` on the episode list. Each of those is the section its own route declares. `canWrite` keeps its name and its wire shape — native clients read it — and its docblock now says what it is: whether the grant is a reading one, with the per-section lists beside it for the question a control actually has. The hook table gained the vault-scoped WRITE grant as its own leg: level true, every section false. The take-all-due fixture described a grant the server never mints — `canWrite: true` over an empty writable list — and now derives its list from the same table the server derives it from. --- .../sharing-legacy-client-contract.test.ts | 9 ++-- src/app/labs/page.tsx | 5 +- src/app/measurements/page.tsx | 9 ++-- .../take-all-due-affordance.test.tsx | 13 ++++- src/app/medications/page-client.tsx | 17 ++++--- src/app/page-client.tsx | 5 +- .../delegated-write-affordances.test.tsx | 11 ++-- .../__tests__/cycle-view-error-state.test.tsx | 2 +- src/components/illness/illness-view.tsx | 7 +-- src/components/labs/lab-biomarker-detail.tsx | 7 +-- src/components/labs/lab-list.tsx | 5 +- .../__tests__/capture-picker-discard.test.tsx | 1 - .../layout/__tests__/capture-picker.test.tsx | 1 - src/components/layout/capture-picker.tsx | 9 ++-- ...rsorge-dashboard-card-error-state.test.tsx | 2 +- .../measurements/measurement-list.tsx | 5 +- .../card-parts/medication-intake-actions.tsx | 5 +- .../medications/dose-history-add-dialog.tsx | 2 +- .../medications/dose-history-ledger.tsx | 12 +++-- .../medications/medication-card-menu.tsx | 5 +- .../medications/medication-table.tsx | 5 +- .../medications/side-effects-section.tsx | 5 +- .../__tests__/use-record-capabilities.test.ts | 50 ++++++++++++++----- src/hooks/use-record-capabilities.ts | 23 +++++---- src/lib/sharing/account-access-view.ts | 19 +++++-- 25 files changed, 146 insertions(+), 88 deletions(-) diff --git a/src/__tests__/sharing-legacy-client-contract.test.ts b/src/__tests__/sharing-legacy-client-contract.test.ts index 67eaa3d0b..63dd4f71e 100644 --- a/src/__tests__/sharing-legacy-client-contract.test.ts +++ b/src/__tests__/sharing-legacy-client-contract.test.ts @@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => { expect(capabilities.sections).toEqual(fixture.sections); expect(capabilities.recordKind).toBe(fixture.recordKind); expect(capabilities.inSharedRecord).toBe(true); - // Resolved server-side and rendered, never re-derived: `canAdd` follows - // `canWrite`, and `canManage` follows the published `manageableDomains` - // list rather than the level — a delegate's management reach is the - // server's answer, section by section. + // Resolved server-side and rendered, never re-derived: `canWrite` is the + // published level, and both per-section answers follow the published + // lists rather than the level — a delegate's reach is the server's + // answer, section by section. expect(capabilities.canWrite).toBe(fixture.canWrite); - expect(capabilities.canAdd).toBe(fixture.canWrite); const manageable = active?.manageableDomains ?? []; expect(capabilities.canManage).toBe(manageable.length > 0); expect(manageable.length > 0).toBe(fixture.level === "manage"); diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 768d7e5b5..8d6710f6e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -44,7 +44,8 @@ export default function LabsPage() { // Scanning a report is not: it files a document and commits through the OCR // route, and neither is delegated. So a WRITE delegate keeps the manual add // and loses the choice menu around it. - const { canAdd, inSharedRecord } = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); @@ -152,7 +153,7 @@ export default function LabsPage() { - ) : canAdd ? ( + ) : canAddLab ? ( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 2f077bee4..e11c4ed98 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); - const { canAdd } = useRecordCapabilities(); + const { canWriteDomain } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const { prefs } = useModuleListPrefs("labs"); // v1.22 — a short, factual line under each marker heading describing what the @@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { title={t("labs.emptyTitle")} description={t("labs.emptyDescription")} action={ - onAddFirst && canAdd ? ( + onAddFirst && canAddLab ? ( ) : undefined } diff --git a/src/components/layout/__tests__/capture-picker-discard.test.tsx b/src/components/layout/__tests__/capture-picker-discard.test.tsx index a2c0b3819..f701b3a94 100644 --- a/src/components/layout/__tests__/capture-picker-discard.test.tsx +++ b/src/components/layout/__tests__/capture-picker-discard.test.tsx @@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/__tests__/capture-picker.test.tsx b/src/components/layout/__tests__/capture-picker.test.tsx index 52a3fb878..ecb0d3463 100644 --- a/src/components/layout/__tests__/capture-picker.test.tsx +++ b/src/components/layout/__tests__/capture-picker.test.tsx @@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 8e52a9318..6232e6f3d 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -55,11 +55,10 @@ type CaptureKind = "measurement" | "medication" | "mood"; * * One question per kind, asked of that kind's own section. The picker used to * ask a coarse one as well — a closed list of kinds a WRITE grant admits, - * offered whenever `canAdd` was true — and `canAdd` is bound to the grant's - * LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` - * published `canAdd: true` and got a weight form the server refuses; one - * scoped to `["documents"]`, which takes no delegated write at any level, got - * both. `writableDomains` is already the level × scope × route-table + * offered whenever the grant could write anywhere — and that answer is the + * grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` + * read as true and got a weight form the server refuses; one scoped to + * `["documents"]`, which takes no delegated write at any level, got both. `writableDomains` is already the level × scope × route-table * intersection, so asking it per section answers both cases and the mood * entry's MANAGE-only create as well. */ diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx index 584e016f4..74b48d6a2 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ canManage: true, - canAdd: true, + canWriteDomain: () => true, canManageDomain: () => true, }), })); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index efa88d127..d85ad6a8c 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -292,8 +292,9 @@ export function MeasurementList({ // v1.36.x — inside somebody else's record, only the add path survives: // entering a reading is an admitted delegated write, editing and deleting // one are not. The row controls are dropped rather than disabled. - const { canAdd, canManage, inSharedRecord, canWrite } = + const { canWriteDomain, canManage, inSharedRecord, canWrite } = useRecordCapabilities(); + const canAddMeasurement = canWriteDomain("measurements"); const activeRecord = user?.accountAccess?.active ?? null; // Named only when the sentence below is owed: a delegate who may add but // not change. In the caller's own record, and for a read-only delegate who @@ -1068,7 +1069,7 @@ export function MeasurementList({ > {t("measurements.emptyResetFilter")} - ) : onAddFirst && canAdd ? ( + ) : onAddFirst && canAddMeasurement ? (