fix(body-sites): skip the site suggestions read without the visits section

A delegate who may keep the illness journal but not read the visits
would be refused by GET /api/body-sites on every open of the condition
form, and each refusal writes an access-denied row. The field now asks
only when the grant covers the visits' section, and the condition page
shows the site as text rather than a link in that case.

Refs #1025
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 22:08:30 +02:00
1 parent 14f0fc4d4f
commit 645032ec97
4 files changed
+41 -7

No files matched your search

@@ -8,10 +8,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
*
* - the suggestions are the server's list, in its order;
* - the site already typed is not suggested back to itself;
* - no list (a refused or failed read) is a plain field, not an empty list.
* - no list (a refused or failed read) is a plain field, not an empty list;
* - a delegate whose grant does not include the visits' section is not
* sent to a read that would refuse them.
*
* Mutation check (run, seen red): drop the `list` attribute from the input →
* "offers the sites the record holds" goes red.
* Mutation checks (each run, each seen red): drop the `list` attribute from
* the input → "offers the sites the record holds" goes red; enable the read
* unconditionally in `canReadBodySites` → "does not ask for the list" goes
* red.
*/
vi.mock("@/lib/api/api-fetch", async () => {
@@ -24,6 +28,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BodySiteListDTO } from "@/lib/body-sites/dto";
import { canReadBodySites } from "@/hooks/use-body-sites";
import { BodySiteFields } from "../body-site-fields";
function render(data: BodySiteListDTO | null, typed = ""): string {
@@ -77,4 +82,12 @@ describe("<BodySiteFields>", () => {
expect(html).not.toContain("<datalist");
expect(html).not.toContain(" list=");
});
it("does not ask for the list without the visits' section", () => {
expect(canReadBodySites(null)).toBe(true);
expect(canReadBodySites(undefined)).toBe(true);
expect(canReadBodySites(["profile", "illness"])).toBe(true);
expect(canReadBodySites(["illness"])).toBe(false);
expect(canReadBodySites([])).toBe(false);
});
});
+11 -3
View File
@@ -10,14 +10,18 @@
* types. The list comes from the server (`useBodySiteSuggestions`), which
* decrypts and folds the sites; the browser's own `datalist` does the
* narrowing, so a suggestion is a nudge and never a restriction. A caller whose
* grant cannot read the list gets a plain text field.
* grant cannot read the list gets a plain text field and no request.
*/
import { useId } from "react";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useBodySiteSuggestions } from "@/hooks/use-body-sites";
import {
canReadBodySites,
useBodySiteSuggestions,
} from "@/hooks/use-body-sites";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import {
LATERALITIES,
@@ -46,7 +50,11 @@ export function BodySiteFields({
const listId = `${useId()}-body-sites`;
const typed = bodySite.trim().toLowerCase();
// The exact site already typed is not suggested back to itself.
const suggestions = useBodySiteSuggestions().filter(
// The list is read in the visits' section. A delegate without it (say, one
// who may only keep the illness journal) is not sent to a read that would
// refuse them on every open; the field simply offers nothing.
const { sections } = useRecordCapabilities();
const suggestions = useBodySiteSuggestions(canReadBodySites(sections)).filter(
(site) => site.toLowerCase() !== typed,
);
@@ -22,6 +22,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { bodySiteText } from "@/components/encounters/encounter-labels";
import { canReadBodySites } from "@/hooks/use-body-sites";
import { IllnessCorrelationCard } from "./illness-correlation-card";
import { IllnessDayTimeline } from "./illness-day-timeline";
@@ -46,7 +47,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const { canManageDomain, sections } = useRecordCapabilities();
// The body-site view is read in the visits' section; a grant without it
// gets the site as text rather than a link that would be refused.
const canOpenBodySites = sections === null || sections.includes("profile");
const canOpenBodySites = canReadBodySites(sections);
const canManageIllness = canManageDomain("illness");
const fmt = useFormatters();
const {
+12
View File
@@ -40,6 +40,18 @@ export function useBodySites(
});
}
/**
* Whether the caller may ask for the list at all. It is read in the visits'
* section (`profile`), so a grant without it would be refused on every open;
* `null` or absent sections mean the whole record (one's own, or an
* entire-record grant).
*/
export function canReadBodySites(
sections: readonly string[] | null | undefined,
): boolean {
return !sections || sections.includes("profile");
}
/**
* The site names a body-site field suggests: every site the record holds,
* most used first. A failed or refused read (a grant without the visits