diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index ecd92e89e..6adcd7b5b 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 078e5ec00..4b4f21999 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -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( diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts index 0a9fe10b9..35c141934 100644 --- a/src/app/api/documents/inbound/bulk/route.ts +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -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; } diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 864c20229..1a743ad68 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -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; diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index 0b4a57c66..5aee8c7ba 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -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: { diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 92515b6eb..dfd9ed170 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -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> { const map = new Map(); 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, + tx: Prisma.TransactionClient, userId: string, documentId: string, episodeIds: string[], ): Promise { - 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, - }); - } } diff --git a/src/lib/links/index.ts b/src/lib/links/index.ts new file mode 100644 index 000000000..3de5c1e27 --- /dev/null +++ b/src/lib/links/index.ts @@ -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"; diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts new file mode 100644 index 000000000..e52c226d3 --- /dev/null +++ b/src/lib/links/link-service.ts @@ -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>; + skipDuplicates?: boolean; + }): Promise<{ count: number }>; + deleteMany(args: { + where: Record; + }): Promise<{ count: number }>; + findMany(args: { + where: Record; + select: Record>; + orderBy?: Record; + }): Promise>>; +} + +interface OwnedRowDelegate { + findMany(args: { + where: Record; + select: Record; + }): Promise>>; +} + +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; + label: (row: Record) => string; + date: (row: Record) => 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(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(tx.encounter), + relationField: "encounter", + }, + document: { + delegate: (tx) => narrow(tx.inboundDocument), + relationField: "document", + }, +}; + +const DOCUMENT_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(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(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(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(tx.encounterDocumentLink), + sourceColumn: "encounterId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, + "encounter:labResult": { + delegate: (tx) => narrow(tx.encounterLabLink), + sourceColumn: "encounterId", + targetColumn: "labResultId", + target: LAB_RESULT_ENDPOINT, + }, + "encounter:conditionEpisode": { + delegate: (tx) => narrow(tx.encounterConditionLink), + sourceColumn: "encounterId", + targetColumn: "episodeId", + target: CONDITION_ENDPOINT, + }, + "document:conditionEpisode": { + delegate: (tx) => narrow(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, +): Promise { + 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> { + 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 { + 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 { + 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 { + 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, +): Promise { + 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> { + const map = new Map(); + 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 { + 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) }]; + }); +} diff --git a/tests/integration/link-service.test.ts b/tests/integration/link-service.test.ts new file mode 100644 index 000000000..19b12f81f --- /dev/null +++ b/tests/integration/link-service.test.ts @@ -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>; +let stranger: Awaited>; + +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([]); + }); +});