Files
HealthLog/src/lib/documents/links.ts
T
Marc-André Bombeck be9c79974f feat(documents): a document can say which visit it belongs to
Every arm here is the same shape as the one that already exists for
conditions, because the vault's filing model is settled and a second shape
would be the thing to explain rather than the thing to use: encounterIds on
upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter
beside the episode pair in the bulk action, and an encounterId list filter.

Both directions ride ONE table. The visit's sheet writes encounter_document_links
from its end and the document reads it from the other, declared as a second
direction in the link service rather than a fourth table. A visit's kind
crosses the wire as the enum constant rather than a rendered name: unlike a
condition label, which is the person's own words, a kind is one of eight closed
values whose human name the reader's own bundle owns.

The suggestion is offered where the document is being looked at, resolved by
the shared rule rather than by this surface: one candidate pre-selects with an
undo, two offer a picker with nothing pre-selected, none offers nothing at all.
It resolves with no AI provider configured, and it renders nothing once the
document already names a visit.

The bulk verb has a caller — the vault's selection bar files a whole selection
against a visit, mirroring the condition verb and hiding itself on an account
with no visits. Nothing here is one-ended.

OpenAPI regenerated in the same commit.
2026-08-08 13:41:42 +02:00

165 lines
5.3 KiB
TypeScript

/**
* Document ⇄ condition link helpers shared by the vault routes.
*
* One grouped query per page (no N+1), owner-scoped everywhere: every episode
* id a client sends is re-narrowed against the caller's live episodes before
* it can land in `document_condition_links`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { listTargetsBySource, replaceTargets } from "@/lib/links";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
} from "@/lib/validations/inbound-documents";
/**
* Load the condition links for a page of documents in ONE grouped query.
* Returns a map documentId → link DTOs (documents without links are absent).
*/
export async function loadConditionLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentConditionLinkDto[]>> {
const map = new Map<string, DocumentConditionLinkDto[]>();
if (documentIds.length === 0) return map;
// `includeDeletedTargets` keeps the long-standing behaviour: the chip has
// always come off a relation join with no tombstone filter, so a document
// filed against a condition the person later deleted still shows it.
const byDocument = await listTargetsBySource(prisma, {
userId,
sourceKind: "document",
sourceIds: documentIds,
targetKind: "conditionEpisode",
includeDeletedTargets: true,
});
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => ({ episodeId: target.id, name: target.label })),
);
}
return map;
}
/**
* Load the VISIT links for a page of documents in ONE grouped query.
*
* Unlike the condition chips this filters tombstoned visits out: the condition
* behaviour is a long-standing one this refactor deliberately did not change,
* while a visit link is new and starts from the honest default — a deleted
* visit is not something the document still belongs to.
*/
export async function loadDocumentEncounterLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentEncounterLinkDto[]>> {
const map = new Map<string, DocumentEncounterLinkDto[]>();
if (documentIds.length === 0) return map;
const byDocument = await listTargetsBySource(prisma, {
userId,
sourceKind: "document",
sourceIds: documentIds,
targetKind: "encounter",
});
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => ({
encounterId: target.id,
kind: target.label,
occurredAt: target.date,
})),
);
}
return map;
}
/**
* Narrow a client-sent visit-id list to the caller's LIVE visits.
*
* Same shape and same refusal as {@link narrowOwnedEpisodeIds}: null when any
* id names nothing, so an attacker probing another account's visit ids learns
* only "not found".
*/
export async function narrowOwnedEncounterIds(
userId: string,
encounterIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(encounterIds)];
if (unique.length === 0) return [];
const owned = await prisma.encounter.findMany({
where: { id: { in: unique }, userId, deletedAt: null },
select: { id: true },
});
if (owned.length !== unique.length) return null;
return unique;
}
/**
* Replace-set a document's visit links inside the given client.
*
* Goes through the link service in the `document → encounter` direction, which
* is the same table the visit's own sheet writes from the other end, so the two
* surfaces cannot disagree about what is filed where.
*/
export async function replaceEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
documentId: string,
encounterIds: string[],
): Promise<void> {
await replaceTargets(tx, {
userId,
sourceKind: "document",
sourceId: documentId,
targetKind: "encounter",
targetIds: encounterIds,
});
}
/**
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
* Returns the deduplicated owned ids, or null when any id is unknown /
* foreign / deleted — the caller answers with a 404-shaped refusal (an
* attacker probing another user's episode ids learns nothing beyond
* "not found").
*/
export async function narrowOwnedEpisodeIds(
userId: string,
episodeIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(episodeIds)];
if (unique.length === 0) return [];
const owned = await prisma.illnessEpisode.findMany({
where: { id: { in: unique }, userId, deletedAt: null },
select: { id: true },
});
if (owned.length !== unique.length) return null;
return unique;
}
/**
* Replace-set a document's condition links inside the given transaction-ish
* client: delete links no longer in the set, insert the missing ones.
*
* `episodeIds` should already be owner-narrowed (`narrowOwnedEpisodeIds`), so
* the route can answer a foreign id with the 404-shaped refusal it does today.
* The link service narrows them again regardless — the routes decide the reply,
* the service decides what reaches the table.
*/
export async function replaceConditionLinks(
tx: Prisma.TransactionClient,
userId: string,
documentId: string,
episodeIds: string[],
): Promise<void> {
await replaceTargets(tx, {
userId,
sourceKind: "document",
sourceId: documentId,
targetKind: "conditionEpisode",
targetIds: episodeIds,
});
}