fix(encounters): the visit list carries its links

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 15:06:10 +02:00
1 parent bdfea7dd85
commit 2d69842994
9 files changed
+218 -18

No files matched your search

+4 -2
View File
@@ -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:
+16 -3
View File
@@ -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);
});
@@ -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");
@@ -120,6 +120,34 @@ describe("<VisitsSection>", () => {
);
});
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"');
-9
View File
@@ -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<Encounter>(`${BASE}/${id}`),
enabled: Boolean(id),
});
}
/**
* "Does this belong to a visit?" for one anchor date.
*
+11 -2
View File
@@ -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[];
+71 -1
View File
@@ -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<Map<string, EncounterLinksDTO>> {
const out = new Map<string, EncounterLinksDTO>();
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<string, { id: string; label: string; date: string | null }[]>,
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.
*
+1 -1
View File
@@ -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
@@ -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<Response> {
return suggestEncounter(