From 15cce33a286c7ad3d485844527a328e3947e864b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:15:36 +0200 Subject: [PATCH] fix(documents): keep the link pickers from deleting a link made elsewhere A dose or a visit can link documents from its own form, and a document's sheet can link doses and visits; both ends write the same table, and the sheet writes the whole set on every tap. Dose and visit writes left the document reads alone, and the sheet seeded its pickers from a detail cached for five minutes, so the first tap there after linking from the dose's form wrote a set without the new link and removed it. Dose and visit writes now evict the document root, the sheet reads the document again on every open, and its pickers wait for that read before they offer a change. --- .../__tests__/document-record-links.test.tsx | 25 ++++++++++++++- .../documents/document-detail-sheet.tsx | 22 +++++++++++++ .../documents/document-record-links.tsx | 32 +++++++++++++++---- .../__tests__/link-dependent-keys.test.ts | 25 +++++++++++++++ src/lib/query-keys/index.ts | 8 +++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 src/lib/query-keys/__tests__/link-dependent-keys.test.ts diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index c93b34c62..d06b0653e 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -103,7 +103,12 @@ describe("recordOptions", () => { describe("", () => { it("lets a manager link the page to doses and visits", () => { const html = render( - undefined} />, + undefined} + />, ); expect(html).toContain('data-slot="document-vaccination-links"'); expect(html).toContain('data-slot="document-vaccination-links-add"'); @@ -124,6 +129,7 @@ describe("", () => { ], })} canManage={false} + seedFresh onChange={() => undefined} />, ); @@ -137,9 +143,26 @@ describe("", () => { undefined} />, ); expect(html).not.toContain("document-vaccination-links"); }); + + it("offers no picker until the document has been read since the sheet opened", () => { + // Each change is a replace-set write seeded from the document's links. + // Seeded from a cached copy that predates a link made on the dose's or + // the visit's own form, the first tap would silently delete that link. + const html = render( + undefined} + />, + ); + expect(html).not.toContain('data-slot="document-vaccination-links-add"'); + expect(html).not.toContain('data-slot="document-visit-links-add"'); + }); }); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c1b216982..780105bfa 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -353,11 +353,30 @@ export function DocumentDetailSheet({ const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId ?? "none"), enabled: open && documentId !== null, + // Always read again on open: the link pickers below write replace-sets + // seeded from this copy (see `seedFresh`). + staleTime: 0, queryFn: () => apiGet(`/api/documents/inbound/${documentId}`), }); const doc = detail.data; + // The detail's timestamp when the sheet opened on this document. A read + // that lands after it is fresh enough to seed the link pickers from; a + // cached copy from before the sheet opened is not. + const [openedOn, setOpenedOn] = useState<{ + id: string; + seenAt: number; + } | null>(null); + const openKey = open && documentId !== null ? documentId : null; + if (openKey !== (openedOn?.id ?? null)) { + setOpenedOn( + openKey === null ? null : { id: openKey, seenAt: detail.dataUpdatedAt }, + ); + } + const linkSeedFresh = + openedOn !== null && detail.dataUpdatedAt > openedOn.seenAt; + // 2026-07-17 UX/IA audit M5 / F5-5 — the document → lab-values direction // had no UI link at all: an auto-staged (PENDING) or OCR-confirmed // (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already @@ -1111,6 +1130,9 @@ export function DocumentDetailSheet({ key={`${doc.id}:${linkResets}`} doc={doc} canManage={canManageDocuments} + seedFresh={linkSeedFresh} + seedError={!linkSeedFresh && detail.isRefetchError} + onRetrySeed={() => void detail.refetch()} onChange={(part) => patch.mutate(part)} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index 7899443c2..cbf9e0b0a 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -91,10 +91,24 @@ function doseName( export function DocumentRecordLinks({ doc, canManage, + seedFresh, + seedError = false, + onRetrySeed, onChange, }: { doc: InboundDocumentDetailDto; canManage: boolean; + /** + * Whether `doc` was read from the server since the sheet opened. Every + * change is a replace-set write seeded from `doc`'s links, so a seed from a + * cached copy that predates a link made on the dose's or the visit's own + * form would delete that link on the first tap. The pickers wait for a + * fresh read instead. + */ + seedFresh: boolean; + /** That fresh read failed; the pickers show the retry row instead. */ + seedError?: boolean; + onRetrySeed?: () => void; onChange: (part: { encounterIds?: string[]; vaccinationIds?: string[]; @@ -155,10 +169,13 @@ export function DocumentRecordLinks({ icon={CalendarDays} title={t("documents.detail.visitsLabel")} slot="document-visit-links" - pending={encounters.isPending} - error={encounters.isError} + pending={encounters.isPending || !seedFresh} + error={encounters.isError || seedError} errorLabel={t("links.picker.loadError")} - onRetry={() => void encounters.refetch()} + onRetry={() => { + if (encounters.isError) void encounters.refetch(); + if (seedError) onRetrySeed?.(); + }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} onChange={(ids) => { setVisitIds(ids); @@ -199,10 +216,13 @@ export function DocumentRecordLinks({ icon={Syringe} title={t("documents.detail.vaccinationsLabel")} slot="document-vaccination-links" - pending={vaccinations.isPending} - error={vaccinations.isError} + pending={vaccinations.isPending || !seedFresh} + error={vaccinations.isError || seedError} errorLabel={t("links.picker.loadError")} - onRetry={() => void vaccinations.refetch()} + onRetry={() => { + if (vaccinations.isError) void vaccinations.refetch(); + if (seedError) onRetrySeed?.(); + }} selected={ doseIds ?? (doc.vaccinationLinks ?? []).map((link) => link.vaccinationId) diff --git a/src/lib/query-keys/__tests__/link-dependent-keys.test.ts b/src/lib/query-keys/__tests__/link-dependent-keys.test.ts new file mode 100644 index 000000000..e6654cc6e --- /dev/null +++ b/src/lib/query-keys/__tests__/link-dependent-keys.test.ts @@ -0,0 +1,25 @@ +/** + * A visit or a dose written from its own form can link or unlink documents, + * and the document's sheet seeds its replace-set pickers from its cached + * links. A record write that left the document reads alone kept that seed + * stale, and the next tap on the sheet deleted the new link. Both record + * bundles therefore evict the document root, and the document's own link + * write evicts the record roots (pinned in `document-detail-sheet`). + */ +import { describe, expect, it } from "vitest"; + +import { + encounterDependentKeys, + queryKeys, + vaccinationDependentKeys, +} from "@/lib/query-keys"; + +describe("record writes reach the document reads", () => { + it("a dose write evicts the documents root", () => { + expect(vaccinationDependentKeys).toContainEqual(queryKeys.documents()); + }); + + it("a visit write evicts the documents root", () => { + expect(encounterDependentKeys).toContainEqual(queryKeys.documents()); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index ed83c7676..861b4b276 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -281,6 +281,10 @@ export const encounterDependentKeys = [ // `invalidateReminderReads` helper, which carries it) — the digest is // typically unmounted while the user is on the visits surface. queryKeys.dailyDigest(), + // A visit's form links and unlinks documents, and the document's sheet + // seeds its replace-set visit picker from its own cached links: left + // stale, the next tap there would delete the link just made. + queryKeys.documents(), ]; /** @@ -300,6 +304,10 @@ export const encounterDependentKeys = [ export const vaccinationDependentKeys = [ queryKeys.vaccinations(), queryKeys.measurementReminders(), + // A dose's form links and unlinks documents, and the document's sheet + // seeds its replace-set dose picker from its own cached links: left stale, + // the next tap there would delete the link just made. + queryKeys.documents(), ]; /**