Put both link families behind one service

Three narrow link tables landed with the visit record, which is a step
away from the one-shared-service direction the polymorphic-linking work
argues for. This bounds that step to a single module: every caller now
speaks one vocabulary — a source, a target kind, a list of target ids —
so replacing the storage underneath later is a change in one file rather
than a sweep across every route that files something.

The document-condition links move behind the same signature, so there
are two families and one call surface. Behaviour is unchanged and the
existing vault tests are the proof; where a test double had to follow
the new query shape, it says why in the diff.

The rules that used to live at each call site now live in the module and
are proven against real Postgres rather than a fake that ignores a
where clause: both ends re-read scoped to the owner before a row is
written, a repeated link is a success that changes nothing, a call is
capped at a hundred targets, and a tombstoned record is not linkable on
either end. Unlinking one deliberately still works after a delete, or a
filing nobody can clear would be left behind.

Reads return a resolved label and date rather than a bare id, so a
client rendering a list never re-derives what the server already knows.
The restore is the one documented exemption and writes the tables
directly: it rebuilds an account rather than filing something in one.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:26:14 +02:00
1 parent 24425e9aff
commit ba21c3a9bc
9 files changed
+1159 -51

No files matched your search

@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: {
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
findFirstOrThrow: vi.fn(),
},
@@ -131,6 +132,11 @@ beforeEach(() => {
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
// The link service re-narrows both ends before it writes: the document it is
// filing, and the episodes it is filing against.
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
{ id: "doc-1" },
] as never);
vi.mocked(prisma.documentConditionLink.deleteMany).mockResolvedValue({
count: 0,
} as never);
@@ -14,16 +14,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({
const {
txQueryRaw,
txCreate,
txCreateMany,
txDocumentFindMany,
txEpisodeFindMany,
} = vi.hoisted(() => ({
txQueryRaw: vi.fn(),
txCreate: vi.fn(),
txCreateMany: vi.fn(),
// The link service re-narrows BOTH ends inside the transaction before it
// writes, so the transaction double has to answer for them.
txDocumentFindMany: vi.fn(),
txEpisodeFindMany: vi.fn(),
}));
vi.mock("@/lib/db", () => {
const tx = {
$queryRaw: txQueryRaw,
inboundDocument: { create: txCreate },
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
illnessEpisode: { findMany: txEpisodeFindMany },
documentConditionLink: { createMany: txCreateMany },
};
return {
@@ -186,6 +197,8 @@ beforeEach(() => {
txQueryRaw.mockResolvedValue([{ used: BigInt(0) }]);
txCreate.mockResolvedValue(docRow() as never);
txCreateMany.mockResolvedValue({ count: 0 } as never);
txDocumentFindMany.mockResolvedValue([{ id: "doc-1" }] as never);
txEpisodeFindMany.mockResolvedValue([] as never);
});
describe("POST /api/documents/inbound (vault upload)", () => {
@@ -294,10 +307,14 @@ describe("POST /api/documents/inbound (vault upload)", () => {
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1" },
] as never);
txEpisodeFindMany.mockResolvedValue([{ id: "ep-1" }] as never);
let res = await post(mkUpload({ episodeIds: ["ep-1"] }));
expect(res.status).toBe(201);
expect(txCreateMany).toHaveBeenCalledWith({
data: [{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }],
// The link service is idempotent by construction, so a retried upload
// that reaches the same pair succeeds instead of hitting the unique index.
skipDuplicates: true,
});
// Foreign/unknown episode id → 404-shaped refusal, nothing persisted.
@@ -433,12 +450,13 @@ describe("GET /api/documents/inbound (list)", () => {
{ documentId: "d2", status: "APPROVED", _count: { _all: 1 } },
{ documentId: "d2", status: "REJECTED", _count: { _all: 3 } },
] as never);
// The link read resolves labels in a second query rather than through a
// relation join, so both halves are mocked.
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([
{
documentId: "d1",
episodeId: "ep-1",
episode: { label: "Knie" },
},
{ documentId: "d1", episodeId: "ep-1" },
] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1", label: "Knie", onsetAt: new Date("2026-06-01T00:00:00Z") },
] as never);
const res = await get(
+13 -5
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { linkTargets, unlinkTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isP2002 } from "@/lib/prisma-errors";
@@ -116,15 +117,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
results.push({ id, ok: false, error: "notFound" });
continue;
}
await prisma.documentConditionLink.createMany({
data: [{ documentId: id, episodeId: episodeId!, userId: user.id }],
skipDuplicates: true,
await linkTargets(prisma, {
userId: user.id,
sourceKind: "document",
sourceId: id,
targetKind: "conditionEpisode",
targetIds: [episodeId!],
});
break;
}
case "unlinkEpisode": {
await prisma.documentConditionLink.deleteMany({
where: { documentId: id, episodeId: episodeId!, userId: user.id },
await unlinkTargets(prisma, {
userId: user.id,
sourceKind: "document",
sourceId: id,
targetKind: "conditionEpisode",
targetIds: [episodeId!],
});
break;
}
+7 -6
View File
@@ -56,6 +56,7 @@ import {
} from "@/lib/documents/upload-policy";
import { withIdempotency } from "@/lib/idempotency";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { linkTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isP2002 } from "@/lib/prisma-errors";
@@ -337,12 +338,12 @@ async function processUpload(
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId: created.id,
episodeId,
userId: user.id,
})),
await linkTargets(tx, {
userId: user.id,
sourceKind: "document",
sourceId: created.id,
targetKind: "conditionEpisode",
targetIds: episodeIds,
});
}
return created;
+7 -7
View File
@@ -15,6 +15,7 @@ import {
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
import { listDistinctTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
@@ -50,11 +51,10 @@ export const GET = apiHandler(async () => {
// bar's condition chips. Sourced here (not from the loaded corpus) so
// a chip exists even when its documents sit pages deep in the
// timeline; one indexed grouped query, no blobs.
prisma.documentConditionLink.findMany({
where: { userId: user.id, document: { deletedAt: null } },
select: { episodeId: true, episode: { select: { label: true } } },
distinct: ["episodeId"],
orderBy: { episodeId: "asc" },
listDistinctTargets(prisma, {
userId: user.id,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
@@ -85,8 +85,8 @@ export const GET = apiHandler(async () => {
maxFileBytes: limits.maxFileBytes,
acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS],
linkedEpisodes: linkRows.map((row) => ({
episodeId: row.episodeId,
name: row.episode.label,
episodeId: row.id,
name: row.label,
})),
assistAvailable: capability.available,
contentIndex: {
+28 -26
View File
@@ -5,7 +5,9 @@
* 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 } from "@/lib/validations/inbound-documents";
/**
@@ -18,19 +20,21 @@ export async function loadConditionLinks(
): Promise<Map<string, DocumentConditionLinkDto[]>> {
const map = new Map<string, DocumentConditionLinkDto[]>();
if (documentIds.length === 0) return map;
const links = await prisma.documentConditionLink.findMany({
where: { userId, documentId: { in: documentIds } },
select: {
documentId: true,
episodeId: true,
episode: { select: { label: true } },
},
orderBy: { createdAt: "asc" },
// `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 link of links) {
const entry = map.get(link.documentId) ?? [];
entry.push({ episodeId: link.episodeId, name: link.episode.label });
map.set(link.documentId, entry);
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => ({ episodeId: target.id, name: target.label })),
);
}
return map;
}
@@ -59,25 +63,23 @@ export async function narrowOwnedEpisodeIds(
/**
* 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` MUST already be owner-narrowed (`narrowOwnedEpisodeIds`).
*
* `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: Pick<typeof prisma, "documentConditionLink">,
tx: Prisma.TransactionClient,
userId: string,
documentId: string,
episodeIds: string[],
): Promise<void> {
await tx.documentConditionLink.deleteMany({
where: { userId, documentId, episodeId: { notIn: episodeIds } },
await replaceTargets(tx, {
userId,
sourceKind: "document",
sourceId: documentId,
targetKind: "conditionEpisode",
targetIds: episodeIds,
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId,
episodeId,
userId,
})),
skipDuplicates: true,
});
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* One service for every link between two of an account's own records.
*
* Two families live behind it today: a visit's links to the documents, lab
* results and conditions it produced, and a document's links to the conditions
* it is filed against. They are separate tables and one call signature.
*
* The reason for the shape rather than four more call sites: the standing
* lesson from the polymorphic-linking work is "one shared service", and this
* release adds three narrow link tables, which is a bounded step the other way.
* Bounded by this module. Every caller speaks one vocabulary — a source, a
* target kind and a list of target ids — so replacing the storage underneath
* later is a change here and not a sweep across every route that files
* something.
*
* Rules the module owns, so no call site re-implements them:
*
* - **Ownership is verified here.** Both ends are re-read scoped by `userId`
* before a row is written. Passing another account's document id links
* nothing; the id comes back in `unknownTargetIds` and the caller decides
* whether that is a refusal or a silent no-op.
* - **Idempotent.** Linking a pair that already exists succeeds and changes
* nothing, matching the document bulk route's behaviour today.
* - **Bounded.** At most {@link MAX_TARGETS_PER_CALL} ids per call.
* - **Soft-deleted rows are not linkable**, on either end. A tombstoned
* document cannot be filed against a visit, and a deleted visit cannot
* collect new links.
*
* One documented exemption: `src/lib/export/visits-backup.ts` writes the link
* tables directly. A restore is rebuilding an account rather than filing
* something in one — it preserves ids and creation instants, and it must not
* re-run an ownership narrowing against rows it is in the middle of creating.
* It is the same exemption the wipe plan and the key rotation hold, for the
* same reason, and it is the only one.
*/
export {
MAX_TARGETS_PER_CALL,
linkTargets,
unlinkTargets,
replaceTargets,
listTargets,
listTargetsBySource,
listDistinctTargets,
type LinkSourceKind,
type LinkTargetKind,
type LinkRequest,
type LinkResult,
type LinkedTarget,
} from "@/lib/links/link-service";
+561
View File
@@ -0,0 +1,561 @@
/**
* The link service itself. See `index.ts` for why it exists and what it
* guarantees; this file is the implementation and the table of link kinds.
*/
import type { Prisma } from "@/generated/prisma/client";
/**
* A bound matching the document bulk route's own cap. A link call is a
* user-driven filing action, not an import path, so a request naming more ids
* than this is a client defect rather than a large but legitimate batch.
*/
export const MAX_TARGETS_PER_CALL = 100;
/** The record a link hangs off. */
export type LinkSourceKind = "encounter" | "document";
/** The record a link points at. */
export type LinkTargetKind = "document" | "labResult" | "conditionEpisode";
export interface LinkRequest {
userId: string;
sourceKind: LinkSourceKind;
sourceId: string;
targetKind: LinkTargetKind;
targetIds: string[];
}
export interface LinkResult {
/**
* Rows actually written or removed.
*
* Zero is a success, not a failure: linking a pair that already exists, or
* unlinking one that never did, is the idempotent no-op the callers rely on.
*/
changed: number;
/**
* Ids that named nothing this account owns, or a row it has deleted.
*
* Returned rather than thrown. A bulk filing action treats one unknown id as
* one failed row and keeps going; an upload treats it as a refusal of the
* whole request. Both are legitimate and neither belongs in here.
*/
unknownTargetIds: string[];
/** True when the source itself is unknown, foreign or deleted. */
unknownSource: boolean;
}
/** One resolved link target, ready for a DTO. Never just an id. */
export interface LinkedTarget {
id: string;
label: string;
/** The date that makes the target sortable and recognisable, ISO-8601. */
date: string | null;
}
/**
* Prisma's generated delegates are structurally compatible with the narrow
* shapes below, but each model's argument types are nominally distinct, so a
* table keyed by link kind cannot be written without one narrowing per
* accessor. The casts are confined to {@link LINK_TABLES} and every call in
* this file goes through it.
*/
interface LinkRowDelegate {
createMany(args: {
data: Array<Record<string, string>>;
skipDuplicates?: boolean;
}): Promise<{ count: number }>;
deleteMany(args: {
where: Record<string, unknown>;
}): Promise<{ count: number }>;
findMany(args: {
where: Record<string, unknown>;
select: Record<string, boolean | Record<string, unknown>>;
orderBy?: Record<string, unknown>;
}): Promise<Array<Record<string, unknown>>>;
}
interface OwnedRowDelegate {
findMany(args: {
where: Record<string, unknown>;
select: Record<string, boolean>;
}): Promise<Array<Record<string, unknown>>>;
}
interface EndpointSpec {
/** The owning model's delegate, for the ownership re-read. */
delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate;
/** Columns the label and date are derived from. */
select: Record<string, boolean>;
label: (row: Record<string, unknown>) => string;
date: (row: Record<string, unknown>) => string | null;
}
interface LinkTableSpec {
delegate: (tx: Prisma.TransactionClient) => LinkRowDelegate;
sourceColumn: string;
targetColumn: string;
target: EndpointSpec;
}
/**
* The one narrowing point.
*
* Prisma generates a distinct argument type per model, so a table keyed by
* link kind cannot hold them without erasing the difference. Going through
* `unknown` here rather than at each accessor keeps the unsafety countable:
* every delegate in this file is narrowed by this function and nowhere else,
* and the column names it is narrowed FOR are declared beside it in
* {@link LINK_TABLES}.
*/
function narrow<T>(delegate: unknown): T {
return delegate as T;
}
function asDate(value: unknown): string | null {
return value instanceof Date ? value.toISOString() : null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
/** Where a source record lives, and how to prove the caller owns a live one. */
const SOURCES: Record<
LinkSourceKind,
{
delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate;
/** The link row's relation field back to the source, for a joined filter. */
relationField: string;
}
> = {
encounter: {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.encounter),
relationField: "encounter",
},
document: {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.inboundDocument),
relationField: "document",
},
};
const DOCUMENT_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.inboundDocument),
select: {
id: true,
title: true,
filename: true,
documentDate: true,
reportDate: true,
createdAt: true,
},
// A vault document may carry neither a title nor a filename; the id is the
// last honest thing left rather than an invented placeholder.
label: (row) =>
asString(row.title) ?? asString(row.filename) ?? String(row.id),
date: (row) =>
asDate(row.documentDate) ?? asDate(row.reportDate) ?? asDate(row.createdAt),
};
const LAB_RESULT_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.labResult),
select: { id: true, analyte: true, panel: true, takenAt: true },
label: (row) => {
const panel = asString(row.panel);
const analyte = String(row.analyte);
return panel ? `${panel} · ${analyte}` : analyte;
},
date: (row) => asDate(row.takenAt),
};
const CONDITION_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.illnessEpisode),
select: { id: true, label: true, onsetAt: true },
label: (row) => String(row.label),
date: (row) => asDate(row.onsetAt),
};
/**
* Every legal (source, target) pair and the table behind it.
*
* A pair absent from here is not linkable, and asking for one is a programming
* error rather than a user error — hence the throw in {@link tableFor} rather
* than a result field. Three is the number of encounter link tables this
* design allows; a fourth is the point at which this stops being a small set
* of named relations and starts being a general edge store.
*/
const LINK_TABLES: Partial<
Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec>
> = {
"encounter:document": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterDocumentLink),
sourceColumn: "encounterId",
targetColumn: "documentId",
target: DOCUMENT_ENDPOINT,
},
"encounter:labResult": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterLabLink),
sourceColumn: "encounterId",
targetColumn: "labResultId",
target: LAB_RESULT_ENDPOINT,
},
"encounter:conditionEpisode": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterConditionLink),
sourceColumn: "encounterId",
targetColumn: "episodeId",
target: CONDITION_ENDPOINT,
},
"document:conditionEpisode": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.documentConditionLink),
sourceColumn: "documentId",
targetColumn: "episodeId",
target: CONDITION_ENDPOINT,
},
};
function tableFor(
sourceKind: LinkSourceKind,
targetKind: LinkTargetKind,
): LinkTableSpec {
const spec = LINK_TABLES[`${sourceKind}:${targetKind}`];
if (!spec) {
throw new Error(
`No link table joins a ${sourceKind} to a ${targetKind}. The legal pairs ` +
`are declared in src/lib/links/link-service.ts; adding one is a schema ` +
`decision, not a call-site decision.`,
);
}
return spec;
}
function assertWithinCap(targetIds: string[]): void {
if (targetIds.length > MAX_TARGETS_PER_CALL) {
throw new Error(
`A link call names ${targetIds.length} targets, above the cap of ` +
`${MAX_TARGETS_PER_CALL}.`,
);
}
}
/** Does the caller own a LIVE source record with this id? */
async function ownsSource(
tx: Prisma.TransactionClient,
{
userId,
sourceKind,
sourceId,
}: Omit<LinkRequest, "targetKind" | "targetIds">,
): Promise<boolean> {
const rows = await SOURCES[sourceKind].delegate(tx).findMany({
where: { id: sourceId, userId, deletedAt: null },
select: { id: true },
});
return rows.length === 1;
}
/** The subset of `targetIds` the caller owns and has not deleted. */
async function ownedTargetIds(
tx: Prisma.TransactionClient,
userId: string,
spec: LinkTableSpec,
targetIds: string[],
): Promise<Set<string>> {
if (targetIds.length === 0) return new Set();
const rows = await spec.target.delegate(tx).findMany({
where: { id: { in: targetIds }, userId, deletedAt: null },
select: { id: true },
});
return new Set(rows.map((row) => String(row.id)));
}
/**
* Split a requested id list into the ones that may be written and the ones
* that named nothing. Deduplicated first: a list naming the same document
* twice is one link, and reporting it as two would overstate what happened.
*/
async function resolveTargets(
tx: Prisma.TransactionClient,
request: LinkRequest,
spec: LinkTableSpec,
): Promise<{ owned: string[]; unknown: string[] }> {
const unique = [...new Set(request.targetIds)];
const owned = await ownedTargetIds(tx, request.userId, spec, unique);
return {
owned: unique.filter((id) => owned.has(id)),
unknown: unique.filter((id) => !owned.has(id)),
};
}
/**
* Add links, leaving any that already exist untouched.
*
* `skipDuplicates` is what makes a re-post a success that changed nothing,
* which is the contract the document bulk route already offers and the one a
* client retrying a request depends on.
*/
export async function linkTargets(
tx: Prisma.TransactionClient,
request: LinkRequest,
): Promise<LinkResult> {
assertWithinCap(request.targetIds);
const spec = tableFor(request.sourceKind, request.targetKind);
if (!(await ownsSource(tx, request))) {
return { changed: 0, unknownTargetIds: [], unknownSource: true };
}
const { owned, unknown } = await resolveTargets(tx, request, spec);
if (owned.length === 0) {
return { changed: 0, unknownTargetIds: unknown, unknownSource: false };
}
const created = await spec.delegate(tx).createMany({
data: owned.map((targetId) => ({
[spec.sourceColumn]: request.sourceId,
[spec.targetColumn]: targetId,
userId: request.userId,
})),
skipDuplicates: true,
});
return {
changed: created.count,
unknownTargetIds: unknown,
unknownSource: false,
};
}
/**
* Remove links.
*
* Ownership of the TARGET is deliberately not required here. A document the
* account has since deleted still has a link row, and refusing to unlink it
* would leave a filing nobody can clear. The link rows are scoped by `userId`
* regardless, so nothing another account owns is reachable.
*/
export async function unlinkTargets(
tx: Prisma.TransactionClient,
request: LinkRequest,
): Promise<LinkResult> {
assertWithinCap(request.targetIds);
const spec = tableFor(request.sourceKind, request.targetKind);
const unique = [...new Set(request.targetIds)];
if (unique.length === 0) {
return { changed: 0, unknownTargetIds: [], unknownSource: false };
}
const removed = await spec.delegate(tx).deleteMany({
where: {
userId: request.userId,
[spec.sourceColumn]: request.sourceId,
[spec.targetColumn]: { in: unique },
},
});
return {
changed: removed.count,
unknownTargetIds: [],
unknownSource: false,
};
}
/**
* Make the link set exactly `targetIds`: drop what is no longer named, add
* what is missing.
*
* The delete runs before the ownership check on purpose. Clearing a link is
* always allowed (see {@link unlinkTargets}), and doing it first means a
* request naming one unknown id still applies the part of itself that was
* valid, rather than leaving the set in a state the caller did not ask for.
*/
export async function replaceTargets(
tx: Prisma.TransactionClient,
request: LinkRequest,
): Promise<LinkResult> {
assertWithinCap(request.targetIds);
const spec = tableFor(request.sourceKind, request.targetKind);
if (!(await ownsSource(tx, request))) {
return { changed: 0, unknownTargetIds: [], unknownSource: true };
}
const { owned, unknown } = await resolveTargets(tx, request, spec);
const removed = await spec.delegate(tx).deleteMany({
where: {
userId: request.userId,
[spec.sourceColumn]: request.sourceId,
[spec.targetColumn]: { notIn: owned },
},
});
let created = 0;
if (owned.length > 0) {
const result = await spec.delegate(tx).createMany({
data: owned.map((targetId) => ({
[spec.sourceColumn]: request.sourceId,
[spec.targetColumn]: targetId,
userId: request.userId,
})),
skipDuplicates: true,
});
created = result.count;
}
return {
changed: removed.count + created,
unknownTargetIds: unknown,
unknownSource: false,
};
}
/**
* The resolved targets currently linked to one source.
*
* Returns labels and dates, never bare ids: a client that had to fetch each
* target to render a list would be re-deriving something the server already
* knows, and the two would drift.
*/
export async function listTargets(
tx: Prisma.TransactionClient,
request: Omit<LinkRequest, "targetIds">,
): Promise<LinkedTarget[]> {
const spec = tableFor(request.sourceKind, request.targetKind);
const links = await spec.delegate(tx).findMany({
where: {
userId: request.userId,
[spec.sourceColumn]: request.sourceId,
},
select: { [spec.targetColumn]: true },
orderBy: { createdAt: "asc" },
});
const targetIds = links.map((link) => String(link[spec.targetColumn]));
if (targetIds.length === 0) return [];
// Re-read the targets rather than joining through the link's relation: the
// soft-delete filter belongs on the target, and a tombstoned row must drop
// out of the list even though its link row survives the soft delete.
const rows = await spec.target.delegate(tx).findMany({
where: { id: { in: targetIds }, userId: request.userId, deletedAt: null },
select: spec.target.select,
});
const byId = new Map(rows.map((row) => [String(row.id), row]));
// Ordered by the link's own creation, so the list reads in the order the
// person filed things rather than in whatever order the id lookup returned.
return targetIds.flatMap((id) => {
const row = byId.get(id);
if (!row) return [];
return [{ id, label: spec.target.label(row), date: spec.target.date(row) }];
});
}
/**
* The resolved targets for MANY sources at once, as a map keyed by source id.
*
* One grouped query for the links and one for the targets, so a page of
* documents costs two round trips rather than two per row. Sources with no
* links are absent from the map rather than present with an empty array.
*
* `includeDeletedTargets` exists for one caller and is off by default. The
* document vault has always rendered a condition chip through a relation join
* with no tombstone filter, so a document filed against a condition the person
* later deleted still shows it. Whether that is right is a product question
* this refactor does not answer; changing it silently while moving the query
* would be the wrong place to answer it.
*/
export async function listTargetsBySource(
tx: Prisma.TransactionClient,
request: {
userId: string;
sourceKind: LinkSourceKind;
sourceIds: string[];
targetKind: LinkTargetKind;
includeDeletedTargets?: boolean;
},
): Promise<Map<string, LinkedTarget[]>> {
const map = new Map<string, LinkedTarget[]>();
const sourceIds = [...new Set(request.sourceIds)];
if (sourceIds.length === 0) return map;
const spec = tableFor(request.sourceKind, request.targetKind);
const links = await spec.delegate(tx).findMany({
where: {
userId: request.userId,
[spec.sourceColumn]: { in: sourceIds },
},
select: { [spec.sourceColumn]: true, [spec.targetColumn]: true },
orderBy: { createdAt: "asc" },
});
if (links.length === 0) return map;
const targetIds = [
...new Set(links.map((link) => String(link[spec.targetColumn]))),
];
const rows = await spec.target.delegate(tx).findMany({
where: {
id: { in: targetIds },
userId: request.userId,
...(request.includeDeletedTargets ? {} : { deletedAt: null }),
},
select: spec.target.select,
});
const byId = new Map(rows.map((row) => [String(row.id), row]));
for (const link of links) {
const targetId = String(link[spec.targetColumn]);
const row = byId.get(targetId);
if (!row) continue;
const sourceId = String(link[spec.sourceColumn]);
const entry = map.get(sourceId) ?? [];
entry.push({
id: targetId,
label: spec.target.label(row),
date: spec.target.date(row),
});
map.set(sourceId, entry);
}
return map;
}
/**
* Every distinct target this account has linked at least one LIVE source to.
*
* What a filter bar needs: the chips exist because something is filed under
* them, whether or not that something is on the page currently loaded. The
* source's tombstone is what "live" means here — a condition whose only
* documents were all deleted is not offered as a filter.
*/
export async function listDistinctTargets(
tx: Prisma.TransactionClient,
request: {
userId: string;
sourceKind: LinkSourceKind;
targetKind: LinkTargetKind;
},
): Promise<LinkedTarget[]> {
const spec = tableFor(request.sourceKind, request.targetKind);
const sourceRelation = SOURCES[request.sourceKind].relationField;
const links = await spec.delegate(tx).findMany({
where: {
userId: request.userId,
[sourceRelation]: { deletedAt: null },
},
select: { [spec.targetColumn]: true },
orderBy: { [spec.targetColumn]: "asc" },
});
const targetIds = [
...new Set(links.map((link) => String(link[spec.targetColumn]))),
];
if (targetIds.length === 0) return [];
const rows = await spec.target.delegate(tx).findMany({
where: { id: { in: targetIds }, userId: request.userId },
select: spec.target.select,
});
const byId = new Map(rows.map((row) => [String(row.id), row]));
return targetIds.flatMap((id) => {
const row = byId.get(id);
if (!row) return [];
return [{ id, label: spec.target.label(row), date: spec.target.date(row) }];
});
}
+463
View File
@@ -0,0 +1,463 @@
/**
* The link service against real Postgres.
*
* Almost nothing asserted here is unit-testable, and that is the reason the
* file exists rather than a mocked sibling. Ownership refusal is a `where`
* clause; the duplicate no-op is a unique index plus `skipDuplicates`; the
* soft-delete exclusion is another `where`. A fake client that ignored `where`
* would report every one of them as working, which is precisely the shape of
* green test this repository has been burned by.
*
* Two accounts are seeded for every case, because "scoped to the owner" is a
* claim about the SECOND account and a single-account fixture cannot see it.
*/
import { beforeEach, describe, expect, it } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import {
MAX_TARGETS_PER_CALL,
linkTargets,
listTargets,
listDistinctTargets,
listTargetsBySource,
replaceTargets,
unlinkTargets,
} from "@/lib/links";
import { getPrismaClient, truncateAllTables } from "./setup";
const OWNER = "link-owner";
const STRANGER = "link-stranger";
async function seedAccount(id: string) {
const prisma = getPrismaClient();
await prisma.user.create({
data: { id, username: id, email: `${id}@example.test` },
});
const encounter = await prisma.encounter.create({
data: {
userId: id,
occurredAt: new Date("2026-07-01T09:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
},
});
const document = await prisma.inboundDocument.create({
data: {
userId: id,
kind: "LAB_RESULT",
title: `${id} report`,
mimeType: "application/pdf",
byteSize: 12,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: new Date("2026-06-30T00:00:00.000Z"),
},
});
const labResult = await prisma.labResult.create({
data: {
userId: id,
analyte: "Ferritin",
panel: "Iron panel",
value: 91,
unit: "ng/mL",
takenAt: new Date("2026-06-30T09:00:00.000Z"),
},
});
const episode = await prisma.illnessEpisode.create({
data: {
userId: id,
label: `${id} condition`,
type: "INFECTION",
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
},
});
return { encounter, document, labResult, episode };
}
let owner: Awaited<ReturnType<typeof seedAccount>>;
let stranger: Awaited<ReturnType<typeof seedAccount>>;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
owner = await seedAccount(OWNER);
stranger = await seedAccount(STRANGER);
});
describe("link service — ownership", () => {
it("refuses a target the caller does not own, and writes nothing", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [stranger.document.id],
});
expect(result.changed).toBe(0);
expect(result.unknownTargetIds).toEqual([stranger.document.id]);
// The refusal has to be visible in the table, not only in the return
// value: a service that reported zero and wrote the row anyway would
// satisfy the assertion above.
expect(await prisma.encounterDocumentLink.count()).toBe(0);
});
it("refuses a source the caller does not own", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: stranger.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownSource).toBe(true);
expect(await prisma.encounterDocumentLink.count()).toBe(0);
});
it("links the owned ids and names the foreign ones in the same call", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id, stranger.document.id],
});
expect(result.changed).toBe(1);
expect(result.unknownTargetIds).toEqual([stranger.document.id]);
expect(await prisma.encounterDocumentLink.count()).toBe(1);
});
});
describe("link service — idempotency", () => {
it("linking the same pair twice succeeds and changes nothing the second time", async () => {
const prisma = getPrismaClient();
const request = {
userId: OWNER,
sourceKind: "encounter" as const,
sourceId: owner.encounter.id,
targetKind: "labResult" as const,
targetIds: [owner.labResult.id],
};
const first = await linkTargets(prisma, request);
const second = await linkTargets(prisma, request);
expect(first.changed).toBe(1);
expect(second.changed).toBe(0);
expect(await prisma.encounterLabLink.count()).toBe(1);
});
it("unlinking a pair that was never linked is a success", async () => {
const prisma = getPrismaClient();
const result = await unlinkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "labResult",
targetIds: [owner.labResult.id],
});
expect(result.changed).toBe(0);
});
it("counts a duplicated id in one call once", async () => {
const prisma = getPrismaClient();
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id, owner.episode.id],
});
expect(result.changed).toBe(1);
expect(await prisma.encounterConditionLink.count()).toBe(1);
});
});
describe("link service — the cap", () => {
it("refuses a call naming more targets than the cap", async () => {
const prisma = getPrismaClient();
const tooMany = Array.from(
{ length: MAX_TARGETS_PER_CALL + 1 },
(_, i) => `target-${i}`,
);
await expect(
linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: tooMany,
}),
).rejects.toThrow(/above the cap/);
});
it("accepts a call exactly at the cap", async () => {
const prisma = getPrismaClient();
const atCap = Array.from(
{ length: MAX_TARGETS_PER_CALL },
(_, i) => `target-${i}`,
);
// Every id is unknown, so nothing is written — the point is that the cap
// itself does not reject a call sitting on the boundary.
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: atCap,
});
expect(result.unknownTargetIds).toHaveLength(MAX_TARGETS_PER_CALL);
});
});
describe("link service — soft deletes", () => {
it("will not link a tombstoned target", async () => {
const prisma = getPrismaClient();
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownTargetIds).toEqual([owner.document.id]);
expect(await prisma.encounterDocumentLink.count()).toBe(0);
});
it("will not link onto a tombstoned source", async () => {
const prisma = getPrismaClient();
await prisma.encounter.update({
where: { id: owner.encounter.id },
data: { deletedAt: new Date() },
});
const result = await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.unknownSource).toBe(true);
});
it("still unlinks a target the account has since deleted", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
// Refusing here would leave a filing nobody can clear.
const result = await unlinkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
expect(result.changed).toBe(1);
});
it("drops a tombstoned target out of the resolved list", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
});
expect(listed).toEqual([]);
});
});
describe("link service — replace", () => {
it("drops what is no longer named and adds what is missing", async () => {
const prisma = getPrismaClient();
const second = await prisma.illnessEpisode.create({
data: {
userId: OWNER,
label: "Second condition",
type: "INFECTION",
onsetAt: new Date("2026-06-25T00:00:00.000Z"),
},
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id],
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [second.id],
});
const linked = await prisma.encounterConditionLink.findMany({
where: { encounterId: owner.encounter.id },
select: { episodeId: true },
});
expect(linked.map((row) => row.episodeId)).toEqual([second.id]);
});
it("clears every link when the set is empty", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id],
});
await replaceTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "conditionEpisode",
targetIds: [],
});
expect(await prisma.encounterConditionLink.count()).toBe(0);
});
});
describe("link service — resolved reads", () => {
it("returns a label and a date, never a bare id", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "labResult",
targetIds: [owner.labResult.id],
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "labResult",
});
expect(listed).toEqual([
{
id: owner.labResult.id,
label: "Iron panel · Ferritin",
date: "2026-06-30T09:00:00.000Z",
},
]);
});
it("keys a batched read by source and omits sources with no links", async () => {
const prisma = getPrismaClient();
const other = await prisma.encounter.create({
data: {
userId: OWNER,
occurredAt: new Date("2026-07-02T09:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
},
});
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: owner.encounter.id,
targetKind: "document",
targetIds: [owner.document.id],
});
const bySource = await listTargetsBySource(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceIds: [owner.encounter.id, other.id],
targetKind: "document",
});
expect(bySource.get(owner.encounter.id)).toHaveLength(1);
expect(bySource.has(other.id)).toBe(false);
});
it("offers a distinct target only while a live source still points at it", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "document",
sourceId: owner.document.id,
targetKind: "conditionEpisode",
targetIds: [owner.episode.id],
});
expect(
await listDistinctTargets(prisma, {
userId: OWNER,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
).toHaveLength(1);
await prisma.inboundDocument.update({
where: { id: owner.document.id },
data: { deletedAt: new Date() },
});
expect(
await listDistinctTargets(prisma, {
userId: OWNER,
sourceKind: "document",
targetKind: "conditionEpisode",
}),
).toEqual([]);
});
it("does not leak another account's links into a read", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {
userId: STRANGER,
sourceKind: "encounter",
sourceId: stranger.encounter.id,
targetKind: "document",
targetIds: [stranger.document.id],
});
const listed = await listTargets(prisma, {
userId: OWNER,
sourceKind: "encounter",
sourceId: stranger.encounter.id,
targetKind: "document",
});
expect(listed).toEqual([]);
});
});