From 3e176db5b769051447a0f16f376ed26a7071c92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:39:46 +0200 Subject: [PATCH] fix(encounters): stop offering the visit edit sheet to a delegate who may not save it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VisitsSection` asked the grant about its add button and nothing else. Every row underneath stayed tappable into the edit sheet, whose Save is `PATCH /api/encounters/{id}` and whose Delete is `DELETE` on the same path — both `("manage", "profile")`. So a READ delegate opened a form that could not be saved, and a WRITE delegate, who may file a new visit, opened one that could not be changed. Reachable wherever the checkups page is, which is every `profile`-scoped grant. The row now takes its open callback only when `canManageDomain("profile")` answers, and the card renders its content as content rather than as a button when it does not. The add button keeps asking the write question, because that is a different question with a different answer. Four legs in the affordances suite over the same scoped fixtures the immunization log uses: the owner opens the sheet, a READ and a WRITE delegate see the row and cannot open it, a WRITE delegate still gets the add button, and a MANAGE delegate opens it only while holding the section. Two of them fail against the previous section. --- .../delegated-write-affordances.test.tsx | 81 +++++++++++++++++++ src/components/encounters/visit-card.tsx | 46 +++++++++-- src/components/encounters/visits-section.tsx | 12 ++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index c802640c5..e433653cd 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -174,6 +174,8 @@ import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsor import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { LedgerRowItem } from "@/components/medications/dose-history-ledger"; import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import type { EncounterDTO } from "@/lib/encounters/dto"; import { queryKeys } from "@/lib/query-keys"; import type { DailyDigest } from "@/lib/daily/digest"; import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; @@ -740,3 +742,82 @@ describe("the immunization log's add, edit and delete", () => { ).not.toContain('data-slot="vaccination-add-empty"'); }); }); + +/* -------------------------------------------------------------------------- */ +/* The visits list */ +/* -------------------------------------------------------------------------- */ + +describe("a visit row's edit sheet", () => { + const VISIT: EncounterDTO = { + id: "visit-1", + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + }; + + const seedList = (client: QueryClient) => { + client.setQueryData( + queryKeys.encounterList(null, null, undefined, undefined), + { + upcoming: [], + past: [VISIT], + }, + ); + }; + + /** The row's content is a button only while it opens something. */ + const rowIsTappable = (html: string) => + html.includes('data-slot="visit-card-open"'); + + it("opens for the owner", () => { + expect(rowIsTappable(render(OWN_RECORD, , seedList))).toBe( + true, + ); + }); + + it("does not open for a READ or a WRITE delegate", () => { + // The add button was the only thing asked, and it asks the right question + // — `POST /api/encounters` is WRITE. The row underneath opens a sheet + // whose Save is `PATCH` and whose Delete is `DELETE`, both MANAGE. + for (const level of ["read", "write"] as const) { + const html = render( + grant(level, ["profile"]), + , + seedList, + ); + expect(html, `${level} still sees the row`).toContain( + 'data-slot="visit-card"', + ); + expect(rowIsTappable(html), `${level} may open the sheet`).toBe(false); + } + }); + + it("keeps the add button for a WRITE delegate", () => { + // The two answers are different questions and must not collapse into one. + expect( + render(grant("write", ["profile"]), , seedList), + ).toContain('data-slot="visits-add"'); + expect( + render(grant("read", ["profile"]), , seedList), + ).not.toContain('data-slot="visits-add"'); + }); + + it("opens for a MANAGE delegate holding the section, and not one outside it", () => { + expect( + rowIsTappable( + render(grant("manage", ["profile"]), , seedList), + ), + ).toBe(true); + expect( + rowIsTappable( + render(grant("manage", ["labs"]), , seedList), + ), + ).toBe(false); + }); +}); diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 7f4ecdf32..0baaf512e 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -35,7 +35,14 @@ export function VisitCard({ onOpen, }: { encounter: Encounter; - onOpen: (encounter: Encounter) => void; + /** + * Opens the edit sheet. Omitted when the caller may not change the visit — + * `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`, + * so a READ or WRITE delegate gets the card as a card and not as a button. + * A row that reads as tappable and answers 403 is the affordance saying + * something about the grant that is not true. + */ + onOpen?: (encounter: Encounter) => void; }) { const { t } = useTranslations(); const format = useFormatters(); @@ -71,11 +78,7 @@ export function VisitCard({ className="gap-2 py-3 md:py-4" > - + ); } + +/** + * The row's content, tappable or not. + * + * One element either way rather than two copies of the content: the read-only + * form keeps the same spacing the button had, so a card that loses its edit + * affordance does not also change shape. + */ +function Body({ + onOpen, + encounter, + children, +}: { + onOpen?: (encounter: Encounter) => void; + encounter: Encounter; + children: React.ReactNode; +}) { + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 2af5b67d0..3b2ad8c9a 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card"; export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); - const { canWriteDomain } = useRecordCapabilities(); - // `POST /api/encounters` answers at WRITE under the profile section. + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/encounters` answers at WRITE under the profile section; the + // edit and the delete behind the row are both MANAGE. Only the add was + // asked, so every row stayed tappable into a sheet whose Save and Delete + // the server refuses. const canAddVisit = canWriteDomain("profile"); + const canManageVisits = canManageDomain("profile"); const list = useEncounters(enabled); // `null` closed, `"new"` create, a row edits it. @@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} @@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))}