From 2d69842994da853f698542c0ee58619dbe3c94a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 15:06:10 +0200 Subject: [PATCH] fix(encounters): the visit list carries its links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects behind one omission. The card counts what a visit produced, so a list that dropped the links made every card read zero for a visit with three documents. And the edit sheet seeds its pickers from the row it was handed: an edit opened from a linkless list row would save a replace-set of three empty arrays and silently unfile everything the visit had collected. The links now ride the list, resolved in three grouped queries for the whole page through the batched arm of the same service the detail uses, with the same per-family redaction from the same predicate. The single-visit read hook had no caller and is gone with it — the list is where the surfaces read from. Both defects get a test that would have caught them: the card counts assert against a populated links object, the draft asserts the arrays survive a round trip through draftToBody, and the integration file asserts the list response itself carries every family. --- docs/api/openapi.yaml | 6 +- src/app/api/encounters/route.ts | 19 ++++- .../__tests__/encounter-form.test.tsx | 34 +++++++++ .../__tests__/visits-section.test.tsx | 28 ++++++++ src/hooks/use-encounters.ts | 9 --- src/lib/encounters/dto.ts | 13 +++- src/lib/encounters/service.ts | 72 ++++++++++++++++++- src/lib/openapi/routes/encounters.ts | 2 +- .../encounter-phase2-surfaces.test.ts | 53 ++++++++++++++ 9 files changed, 218 insertions(+), 18 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9c44ed2fb..060568132 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34915,8 +34915,10 @@ components: - labResults - conditions additionalProperties: false - description: The three link families of one visit, each ordered by when it was filed. Present on the detail response, - absent from the list. + description: "The three link families of one visit, each ordered by when it was filed. Present on BOTH the list and the + detail: a card shows counts of what the visit produced, and an edit opened from a list row seeds its pickers + from that row — a list without them would make the first read zero and the second unfile everything on save. The + list resolves them in three grouped queries for the whole page, not three per row." EncounterLinkedTarget: type: object properties: diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index a5bd7d2ba..85797154e 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -36,6 +36,7 @@ import { ENCOUNTER_INCLUDE, actingDomainVisibility, applyEncounterLinks, + loadEncounterLinksForMany, closeCheckupForVisit, resolveClosableReminder, resolveOwnedPractitioner, @@ -50,7 +51,7 @@ import { } from "@/lib/export/restore-skips"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read", "profile"); + const { user, grantId } = await requireRecordAuth("read", "profile"); const params = new URL(request.url).searchParams; const parsed = encounterListQuerySchema.safeParse({ @@ -124,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }), ]); + // The links ride the list. The card counts what a visit produced, and the + // edit sheet seeds its pickers from the row it was handed — a list without + // them makes the first read zero and the second unfile everything on save. + // Three grouped queries for the page, not three per row. + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ); + annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, meta: { upcoming: upcoming.length, past: past.length }, }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row)), - past: past.map((row) => toEncounterDTO(row)), + upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), + past: past.map((row) => toEncounterDTO(row, links.get(row.id))), }; return apiSuccess(body); }); diff --git a/src/components/encounters/__tests__/encounter-form.test.tsx b/src/components/encounters/__tests__/encounter-form.test.tsx index ee7b9a04b..c7011f645 100644 --- a/src/components/encounters/__tests__/encounter-form.test.tsx +++ b/src/components/encounters/__tests__/encounter-form.test.tsx @@ -45,6 +45,7 @@ vi.mock("@/lib/api/api-fetch", async () => { import { I18nProvider } from "@/lib/i18n/context"; import { EncounterForm, + draftFromEncounter, derivedStatus, draftInstant, draftToBody, @@ -118,6 +119,39 @@ describe("nothing but the date is required", () => { }); }); +describe("editing never unfiles what the visit already collected", () => { + it("seeds the link arrays from the row's own links", () => { + // The defect this pins: an edit seeded from a row that did not know its + // links would save a replace-set of three EMPTY arrays and silently + // unfile everything. The list carries the links so this cannot happen. + const draft = draftFromEncounter({ + id: "e1", + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + links: { + documents: [{ id: "d1", label: "Letter", date: null, redacted: false }], + labResults: [{ id: "l1", label: "LDL", date: null, redacted: false }], + conditions: [{ id: "c1", label: "Knee", date: null, redacted: false }], + }, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + }); + expect(draft.documentIds).toEqual(["d1"]); + expect(draft.labResultIds).toEqual(["l1"]); + expect(draft.episodeIds).toEqual(["c1"]); + + const body = draftToBody(draft, { isEdit: true }); + expect(body?.documentIds).toEqual(["d1"]); + expect(body?.labResultIds).toEqual(["l1"]); + expect(body?.episodeIds).toEqual(["c1"]); + }); +}); + describe("the status follows the date on a new visit", () => { const now = Date.parse("2026-08-08T12:00:00.000Z"); diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index ef056ff28..fe8dd4ffb 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -120,6 +120,34 @@ describe("", () => { ); }); + it("counts what a visit produced, from the links the LIST carried", () => { + // The counts come off `links`, and the list used to omit them — every card + // then read zero for a visit with three documents. The fixture carries a + // populated `links` because the response now does. + const html = render({ + upcoming: [], + past: [ + visit({ + id: "linked", + links: { + documents: [ + { id: "d1", label: "Letter", date: null, redacted: false }, + { id: "d2", label: "Scan", date: null, redacted: false }, + ], + labResults: [ + { id: "l1", label: "LDL", date: null, redacted: false }, + ], + conditions: [], + }, + }), + ], + }); + expect(html).toContain("2 linked documents"); + expect(html).toContain("1 linked lab results"); + // The empty family renders no counter rather than a zero. + expect(html).not.toContain("0 linked conditions"); + }); + it("offers the address book beside the add action", () => { const html = render({ upcoming: [], past: [] }); expect(html).toContain('href="/checkups/practitioners"'); diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index a46a7c5b3..df26b6dfb 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -75,15 +75,6 @@ export function useEncounters( }); } -/** One visit with its three link families resolved. */ -export function useEncounter(id: string | null) { - return useQuery({ - queryKey: queryKeys.encounter(id ?? ""), - queryFn: () => apiGet(`${BASE}/${id}`), - enabled: Boolean(id), - }); -} - /** * "Does this belong to a visit?" for one anchor date. * diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index 9361d2591..41bd509c8 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -64,7 +64,15 @@ export interface EncounterDTO { * happened, or once the one-shot reminder is spent. */ reminderNextDueAt: string | null; - /** Present on the detail response; omitted from the list. */ + /** + * The three link families, resolved. + * + * Present on the list as well as the detail. Omitting them from the list + * would break two things at once: the card counts what a visit produced, and + * the edit sheet seeds its pickers from the row it was handed — an edit + * opened from a linkless row would save three empty arrays and unfile + * everything. The list pays three grouped queries for the whole page. + */ links?: EncounterLinksDTO; /** * What the write could not do, named. @@ -130,7 +138,8 @@ export function toEncounterDTO( * Two arrays rather than one sorted list, resolved server-side: "what is * coming up" and "what happened" are read in opposite directions, and making * the client split and re-sort one array would put that decision in two - * places. + * places. Each row carries its links, for the reasons stated at + * `EncounterDTO.links`. */ export interface EncounterListDTO { upcoming: EncounterDTO[]; diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 70600950a..7951e187e 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -12,7 +12,12 @@ import { satisfyReminder, type SatisfiableReminder, } from "@/lib/measurement-reminders/satisfy"; -import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links"; +import { + listTargets, + listTargetsBySource, + replaceTargets, + type LinkTargetKind, +} from "@/lib/links"; import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { EncounterLinksDTO } from "@/lib/encounters/dto"; @@ -144,6 +149,71 @@ export async function loadEncounterLinks( return { documents, labResults, conditions }; } +/** + * The same three families for a PAGE of visits, in three grouped queries. + * + * Two grouped reads per family rather than two per visit — `listTargetsBySource` + * is the batched arm of the same service, so the list pays six round trips for + * a page instead of six per row. + * + * The list carries links for two reasons that are both defects when it does + * not. The card shows counts of what a visit produced, and a card reading zero + * for a visit with three documents is a lie the person cannot see past. And the + * edit sheet seeds its pickers from the row it was handed: an edit opened from a + * list row that did not know its links would save a replace-set of three empty + * arrays and silently unfile everything the visit had collected. + * + * Redaction is the same rule, applied per family, from the same predicate. + */ +export async function loadEncounterLinksForMany( + tx: Prisma.TransactionClient, + userId: string, + encounterIds: string[], + visible: (domain: ShareDomain) => boolean, +): Promise> { + const out = new Map(); + if (encounterIds.length === 0) return out; + + const read = async (targetKind: LinkTargetKind) => + listTargetsBySource(tx, { + userId, + sourceKind: "encounter", + sourceIds: encounterIds, + targetKind, + }); + const [documents, labResults, conditions] = await Promise.all([ + read("document"), + read("labResult"), + read("conditionEpisode"), + ]); + + const family = ( + map: Map, + id: string, + targetKind: LinkTargetKind, + ) => { + const rows = map.get(id) ?? []; + if (visible(TARGET_DOMAIN[targetKind])) { + return rows.map((row) => ({ ...row, redacted: false })); + } + return rows.map((row) => ({ + id: row.id, + label: null, + date: null, + redacted: true, + })); + }; + + for (const id of encounterIds) { + out.set(id, { + documents: family(documents, id, "document"), + labResults: family(labResults, id, "labResult"), + conditions: family(conditions, id, "conditionEpisode"), + }); + } + return out; +} + /** * Apply whichever of the three link arrays the request actually named. * diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 2e6313277..395f28a29 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -127,7 +127,7 @@ const encounterLinks = z .meta({ id: "EncounterLinks", description: - "The three link families of one visit, each ordered by when it was filed. Present on the detail response, absent from the list.", + "The three link families of one visit, each ordered by when it was filed. Present on BOTH the list and the detail: a card shows counts of what the visit produced, and an edit opened from a list row seeds its pickers from that row — a list without them would make the first read zero and the second unfile everything on save. The list resolves them in three grouped queries for the whole page, not three per row.", }); const skipReport = z diff --git a/tests/integration/encounter-phase2-surfaces.test.ts b/tests/integration/encounter-phase2-surfaces.test.ts index a71fa7998..7e3e5687f 100644 --- a/tests/integration/encounter-phase2-surfaces.test.ts +++ b/tests/integration/encounter-phase2-surfaces.test.ts @@ -414,6 +414,59 @@ describe("the visit list's condition filter", () => { }); }); +describe("the visit list carries its links", () => { + it("resolves every family on a list row, not only on the detail", async () => { + const prisma = getPrismaClient(); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Knee", + type: "INJURY", + onsetAt: daysFromNow(-40), + }, + }); + const document = await prisma.inboundDocument.create({ + data: { + userId: OWNER_ID, + kind: "OTHER", + filename: "letter.pdf", + mimeType: "application/pdf", + byteSize: 4, + status: "STORED", + contentEncrypted: Buffer.from("test"), + contentCodec: "v1", + }, + }); + + await post({ + occurredAt: daysFromNow(-3).toISOString(), + status: "DONE", + episodeIds: [episode.id], + documentIds: [document.id], + }); + + const list = await json<{ + past: Array<{ + links?: { + documents: unknown[]; + labResults: unknown[]; + conditions: unknown[]; + }; + }>; + }>( + await listEncounters( + new Request("http://localhost/api/encounters") as never, + ), + ); + expect(list.past).toHaveLength(1); + // Without this the card counts read zero for a visit that produced two + // things, and an edit opened from the row would unfile both on save. + expect(list.past[0]!.links?.documents).toHaveLength(1); + expect(list.past[0]!.links?.conditions).toHaveLength(1); + expect(list.past[0]!.links?.labResults).toHaveLength(0); + }); +}); + describe("the suggestion, over real rows", () => { function suggest(anchor: string): Promise { return suggestEncounter(