fix(encounters): stop offering the visit edit sheet to a delegate who may not save it

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 10:39:46 +02:00
1 parent 7c7e621fd9
commit 3e176db5b7
3 files changed
+128 -11

No files matched your search

@@ -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, <VisitsSection />, 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"]),
<VisitsSection />,
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"]), <VisitsSection />, seedList),
).toContain('data-slot="visits-add"');
expect(
render(grant("read", ["profile"]), <VisitsSection />, 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"]), <VisitsSection />, seedList),
),
).toBe(true);
expect(
rowIsTappable(
render(grant("manage", ["labs"]), <VisitsSection />, seedList),
),
).toBe(false);
});
});
+39 -7
View File
@@ -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"
>
<CardContent className="space-y-2">
<button
type="button"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
<Body onOpen={onOpen} encounter={encounter}>
<div className="flex items-start gap-2">
<CalendarClock
className="text-foreground mt-0.5 size-5 shrink-0"
@@ -120,8 +123,37 @@ export function VisitCard({
))}
</div>
) : null}
</button>
</Body>
</CardContent>
</Card>
);
}
/**
* 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 <div className="w-full space-y-2">{children}</div>;
return (
<button
type="button"
data-slot="visit-card-open"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
{children}
</button>
);
}
+8 -4
View File
@@ -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 }) {
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>
@@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>