diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1f9fd5403..e7162f99e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -27639,12 +27639,20 @@ components: type: string minLength: 1 maxLength: 40 + vaccinationIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 description: "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the - caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the - Prisma `where` with the row id." + caller's visits), `vaccinationIds` (the same replace-set semantics over the caller's vaccination doses, up to + 100 so one childhood record can be filed against a whole primary schedule). At least one field required. No + `userId` field — narrowed from the session and fed to the Prisma `where` with the row id." InboundExtractRequest: anyOf: - type: object @@ -30203,20 +30211,20 @@ components: documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid, - encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment - — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; - export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; - feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, - health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, - illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, - illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — - ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, - insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — - labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, - labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, - labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, - labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound; encounter — encounter.invalid, + encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, + encounter.reminder-not-found; environment — environment.invalid, environment.no_home, + environment.range_too_large, environment.travel.not_found; export — export.selection.empty, + export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; + health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — + illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, + illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — + import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, + insights.generate.outboundScreened, insights.generate.profileScopeChanged, + insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate, + labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, + labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — @@ -62810,6 +62818,15 @@ components: type: array items: $ref: "#/components/schemas/ExtractedFact" + vaccinationLinks: + anyOf: + - type: array + items: + $ref: "#/components/schemas/DocumentVaccinationLink" + - type: "null" + description: "Vaccination doses this document is filed against. Null when the caller acts inside somebody else's record + under a grant that does not cover the health background the doses live in: the page is shared, the doses are + not, and an empty list would claim the page records none." summary: anyOf: - type: string @@ -62851,6 +62868,7 @@ components: - createdAt - updatedAt - facts + - vaccinationLinks - summary - summaryGeneratedAt - summaryState @@ -62939,6 +62957,33 @@ components: with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited). + DocumentVaccinationLink: + type: object + properties: + vaccinationId: + type: string + occurredAt: + anyOf: + - type: string + - type: "null" + catalogSlug: + anyOf: + - type: string + - type: "null" + vaccineName: + anyOf: + - type: string + - type: "null" + required: + - vaccinationId + - occurredAt + - catalogSlug + - vaccineName + additionalProperties: false + description: A link to one of the caller's vaccination doses — "this page records that dose". `catalogSlug` is the + catalogue entry when this release still knows the dose's slug, for the reader's own bundle to name; null is the + signal to show `vaccineName`, the person's own wording. The same table the dose's own + `/api/vaccinations/{id}/links` writes from the other end. InboundDocumentUpdateEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 89affb4d7..1d7950b25 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "Hinzugefügt am {date} · {size}", "share": "Teilen", "labValuesLink": "{count} Laborwerte ansehen", - "visitsLabel": "Gehört zu Termin" + "visitsLabel": "Gehört zu Termin", + "vaccinationsLabel": "Impfungen", + "noVisitsToLink": "Noch keine Termine erfasst.", + "noVaccinationsToLink": "Noch keine Impfungen erfasst." }, "dropOverlay": { "title": "Zum Hochladen ablegen", diff --git a/messages/en.json b/messages/en.json index 3b6c0da04..9649ed33a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "Added {date} · {size}", "share": "Share", "labValuesLink": "View {count} lab values", - "visitsLabel": "Belongs to visit" + "visitsLabel": "Belongs to visit", + "vaccinationsLabel": "Vaccinations", + "noVisitsToLink": "No visits recorded yet.", + "noVaccinationsToLink": "No vaccinations recorded yet." }, "dropOverlay": { "title": "Drop files to upload", diff --git a/messages/es.json b/messages/es.json index 23bf967d6..12d133552 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "Añadido el {date} · {size}", "share": "Compartir", "labValuesLink": "Ver {count} valores de laboratorio", - "visitsLabel": "Pertenece a la cita" + "visitsLabel": "Pertenece a la cita", + "vaccinationsLabel": "Vacunas", + "noVisitsToLink": "Aún no hay consultas registradas.", + "noVaccinationsToLink": "Aún no hay vacunas registradas." }, "dropOverlay": { "title": "Suelta los archivos para subirlos", diff --git a/messages/fr.json b/messages/fr.json index ca61704d8..d357e27ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "Ajouté le {date} · {size}", "share": "Partager", "labValuesLink": "Voir {count} valeurs de laboratoire", - "visitsLabel": "Rattaché au rendez-vous" + "visitsLabel": "Rattaché au rendez-vous", + "vaccinationsLabel": "Vaccinations", + "noVisitsToLink": "Aucune consultation enregistrée pour l’instant.", + "noVaccinationsToLink": "Aucune vaccination enregistrée pour l’instant." }, "dropOverlay": { "title": "Déposez les fichiers pour les téléverser", diff --git a/messages/it.json b/messages/it.json index 2eda19ed3..9ed5eff0a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "Aggiunto il {date} · {size}", "share": "Condividi", "labValuesLink": "Vedi {count} valori di laboratorio", - "visitsLabel": "Appartiene all'appuntamento" + "visitsLabel": "Appartiene all'appuntamento", + "vaccinationsLabel": "Vaccinazioni", + "noVisitsToLink": "Nessuna visita registrata finora.", + "noVaccinationsToLink": "Nessuna vaccinazione registrata finora." }, "dropOverlay": { "title": "Rilascia i file per caricarli", diff --git a/messages/ko.json b/messages/ko.json index 1d86c656e..9f6e1bbfd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "추가 {date} · {size}", "share": "공유", "labValuesLink": "검사 수치 {count}개 보기", - "visitsLabel": "연결된 진료" + "visitsLabel": "연결된 진료", + "vaccinationsLabel": "예방접종", + "noVisitsToLink": "아직 기록된 진료가 없습니다.", + "noVaccinationsToLink": "아직 기록된 예방접종이 없습니다." }, "dropOverlay": { "title": "파일을 놓아 업로드", diff --git a/messages/pl.json b/messages/pl.json index 5fa6e6c98..017568820 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9881,7 +9881,10 @@ "uploadedMeta": "Dodano {date} · {size}", "share": "Udostępnij", "labValuesLink": "Zobacz {count} wyników laboratoryjnych", - "visitsLabel": "Należy do wizyty" + "visitsLabel": "Należy do wizyty", + "vaccinationsLabel": "Szczepienia", + "noVisitsToLink": "Nie zapisano jeszcze żadnych wizyt.", + "noVaccinationsToLink": "Nie zapisano jeszcze żadnych szczepień." }, "dropOverlay": { "title": "Upuść pliki, aby je przesłać", 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 d4c5e77ef..3b1f74874 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({ deleteMany: vi.fn(), createMany: vi.fn(), }, + vaccinationDocumentLink: { + findMany: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + vaccinationRecord: { + findMany: vi.fn(), + }, documentContentIndex: { findUnique: vi.fn(), }, @@ -73,6 +81,10 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// Owner by default (every section open); the redaction case narrows it. +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: vi.fn(async () => () => true), +})); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -89,10 +101,12 @@ vi.mock("next/headers", () => ({ })), })); -import { PATCH } from "../route"; +import { GET, PATCH } from "../route"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -141,6 +155,16 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.vaccinationDocumentLink.deleteMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.vaccinationDocumentLink.createMany).mockResolvedValue({ + count: 0, + } as never); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); @@ -266,4 +290,141 @@ describe("PATCH /api/documents/inbound/[id]", () => { expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled(); expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled(); }); + + it("replace-sets vaccination links from vaccinationIds and audits the change", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([ + { id: "dose-1" }, + { id: "dose-2" }, + ] as never); + vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue( + docRow() as never, + ); + + const res = await PATCH( + mkReq("doc-1", { vaccinationIds: ["dose-1", "dose-2"] }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + // The owner-narrowing read is scoped by the session user and the + // tombstone, never by anything the body carried. + const narrowWhere = vi.mocked(prisma.vaccinationRecord.findMany).mock + .calls[0]![0]!.where!; + expect(narrowWhere).toMatchObject({ userId: "user-1", deletedAt: null }); + expect(prisma.vaccinationDocumentLink.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + documentId: "doc-1", + vaccinationId: { notIn: ["dose-1", "dose-2"] }, + }, + }); + expect(prisma.vaccinationDocumentLink.createMany).toHaveBeenCalledWith({ + data: [ + { documentId: "doc-1", vaccinationId: "dose-1", userId: "user-1" }, + { documentId: "doc-1", vaccinationId: "dose-2", userId: "user-1" }, + ], + skipDuplicates: true, + }); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "documents.inbound.update", + expect.objectContaining({ + details: expect.objectContaining({ fields: ["vaccinationIds"] }), + }), + ); + const body = await res.json(); + expect(body.data.vaccinationLinks).toEqual([]); + }); + + it("refuses a foreign dose with a 404-shaped response and writes nothing", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + + const res = await PATCH( + mkReq("doc-1", { vaccinationIds: ["dose-foreign"] }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(404); + expect(prisma.vaccinationDocumentLink.deleteMany).not.toHaveBeenCalled(); + expect(prisma.vaccinationDocumentLink.createMany).not.toHaveBeenCalled(); + }); + + it("refuses more dose links than one request may carry", async () => { + const ids = Array.from({ length: 101 }, (_, i) => `dose-${i}`); + const res = await PATCH( + mkReq("doc-1", { vaccinationIds: ids }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(422); + expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/documents/inbound/[id] — vaccination links", () => { + function getReq(id: string): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}`), + ); + } + + beforeEach(() => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + docRow() as never, + ); + vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue([ + { documentId: "doc-1", vaccinationId: "dose-1" }, + { documentId: "doc-1", vaccinationId: "dose-2" }, + ] as never); + // First read: the link service's target re-read; second: the identity + // read that lets the client name each dose. + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([ + { + id: "dose-1", + antigenSlug: "tetanus", + vaccineName: null, + occurredAt: new Date("1991-04-02T00:00:00.000Z"), + }, + { + id: "dose-2", + antigenSlug: "no-longer-in-catalogue", + vaccineName: "DTP", + occurredAt: new Date("1991-06-02T00:00:00.000Z"), + }, + ] as never); + }); + + it("lists every dose the page is filed against, named for the reader's bundle", async () => { + const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.vaccinationLinks).toEqual([ + { + vaccinationId: "dose-1", + occurredAt: "1991-04-02T00:00:00.000Z", + catalogSlug: "tetanus", + vaccineName: null, + }, + { + vaccinationId: "dose-2", + occurredAt: "1991-06-02T00:00:00.000Z", + catalogSlug: null, + vaccineName: "DTP", + }, + ]); + }); + + it("withholds the doses from a grant that does not cover the health background", async () => { + vi.mocked(actingDomainVisibility).mockResolvedValueOnce( + (domain) => domain !== "profile", + ); + const res = await GET(getReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.vaccinationLinks).toBeNull(); + expect(prisma.vaccinationDocumentLink.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index ee87db643..8876d279c 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -23,11 +23,15 @@ import { prisma } from "@/lib/db"; import { loadConditionLinks, loadDocumentEncounterLinks, + loadDocumentVaccinationLinks, narrowOwnedEncounterIds, narrowOwnedEpisodeIds, + narrowOwnedVaccinationIds, replaceConditionLinks, replaceEncounterLinks, + replaceVaccinationLinks, } from "@/lib/documents/links"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -81,7 +85,7 @@ async function enforceWriteRateLimit(userId: string): Promise { */ export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read", "documents"); + const { user, grantId } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; @@ -97,18 +101,26 @@ export const GET = apiHandler( }); } - const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ - loadConditionLinks(user.id, [document.id]), - loadDocumentEncounterLinks(user.id, [document.id]), - prisma.documentContentIndex.findUnique({ - where: { documentId: document.id }, - select: { source: true }, - }), - prisma.documentThumbnail.findUnique({ - where: { documentId: document.id }, - select: { id: true }, - }), - ]); + // The doses a page records live in the health background, not in the + // vault. A grant that opened the vault was never consent for that side + // of the seam, so the list is withheld (null) rather than read. + const visible = await actingDomainVisibility(prisma, grantId); + const [links, visitLinks, doseLinks, contentIndex, thumbnail] = + await Promise.all([ + loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), + visible("profile") + ? loadDocumentVaccinationLinks(user.id, [document.id]) + : Promise.resolve(null), + prisma.documentContentIndex.findUnique({ + where: { documentId: document.id }, + select: { source: true }, + }), + prisma.documentThumbnail.findUnique({ + where: { documentId: document.id }, + select: { id: true }, + }), + ]); annotate({ action: { name: "documents.inbound.get" }, @@ -124,6 +136,7 @@ export const GET = apiHandler( toContentIndexSource(contentIndex?.source), thumbnail !== null, visitLinks.get(document.id) ?? [], + doseLinks === null ? null : (doseLinks.get(document.id) ?? []), ), ); }, @@ -196,6 +209,22 @@ export const PATCH = apiHandler( nextEncounterIds = narrowed; } + // Replace-set vaccination links, same semantics and same refusal. The + // table is the one the dose's own form writes from the other end. + let nextVaccinationIds: string[] | undefined; + if (parsed.data.vaccinationIds !== undefined) { + const narrowed = await narrowOwnedVaccinationIds( + user.id, + parsed.data.vaccinationIds, + ); + if (narrowed === null) { + return apiError("Vaccination not found", 404, { + errorCode: "documents.inbound.vaccinationNotFound", + }); + } + nextVaccinationIds = narrowed; + } + // No mass assignment — each editable column is set explicitly only when // the client sent it. `userId` is never a body field. const data: Prisma.InboundDocumentUpdateInput = {}; @@ -225,28 +254,42 @@ export const PATCH = apiHandler( ); } + if (nextVaccinationIds !== undefined) { + await replaceVaccinationLinks( + prisma, + user.id, + existing.id, + nextVaccinationIds, + ); + } + const document = await prisma.inboundDocument.findFirstOrThrow({ where: { id: existing.id }, omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ - loadConditionLinks(user.id, [document.id]), - loadDocumentEncounterLinks(user.id, [document.id]), - prisma.documentContentIndex.findUnique({ - where: { documentId: document.id }, - select: { source: true }, - }), - prisma.documentThumbnail.findUnique({ - where: { documentId: document.id }, - select: { id: true }, - }), - ]); + // PATCH is owner-only (bare `requireAuth()`), so every section is the + // caller's own and the dose list is always readable here. + const [links, visitLinks, doseLinks, contentIndex, thumbnail] = + await Promise.all([ + loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), + loadDocumentVaccinationLinks(user.id, [document.id]), + prisma.documentContentIndex.findUnique({ + where: { documentId: document.id }, + select: { source: true }, + }), + prisma.documentThumbnail.findUnique({ + where: { documentId: document.id }, + select: { id: true }, + }), + ]); const touched = [ ...Object.keys(data), ...(nextEpisodeIds !== undefined ? ["episodeIds"] : []), ...(nextEncounterIds !== undefined ? ["encounterIds"] : []), + ...(nextVaccinationIds !== undefined ? ["vaccinationIds"] : []), ]; await auditLog("documents.inbound.update", { @@ -272,6 +315,7 @@ export const PATCH = apiHandler( toContentIndexSource(contentIndex?.source), thumbnail !== null, visitLinks.get(document.id) ?? [], + doseLinks.get(document.id) ?? [], ), ); }, diff --git a/src/components/documents/__tests__/document-facts-review.test.tsx b/src/components/documents/__tests__/document-facts-review.test.tsx index 75dfb3f7b..7ab4798ef 100644 --- a/src/components/documents/__tests__/document-facts-review.test.tsx +++ b/src/components/documents/__tests__/document-facts-review.test.tsx @@ -82,6 +82,7 @@ function doc( pendingCount: 1, conditionLinks: [], encounterLinks: [], + vaccinationLinks: [], servingClass: "inline", hasContentIndex: true, contentIndexSource: "vision", diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx new file mode 100644 index 000000000..c93b34c62 --- /dev/null +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { modules: { vaccinations: true } } }), +})); +vi.mock("@/hooks/use-encounters", () => ({ + useEncounters: () => ({ + data: { upcoming: [], past: [] }, + isPending: false, + isError: false, + refetch: vi.fn(), + }), +})); +vi.mock("@/components/vaccinations/use-vaccinations", () => ({ + useVaccinations: () => ({ + data: { + vaccinations: [ + { + id: "dose-1", + occurredAt: "1991-04-02T00:00:00.000Z", + catalogEntry: { + slug: "tetanus", + atc: "J07AM01", + category: "standard", + }, + vaccineName: null, + }, + ], + }, + isPending: false, + isError: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; +import { DocumentRecordLinks, recordOptions } from "../document-record-links"; + +function doc( + over: Partial = {}, +): InboundDocumentDetailDto { + return { + id: "doc-1", + kind: "VACCINATION", + title: "Childhood record", + filename: null, + mimeType: "application/pdf", + byteSize: 1, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "1991-04-02", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [], + encounterLinks: [], + servingClass: "inline", + hasContentIndex: false, + contentIndexSource: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, + hasThumbnail: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + facts: [], + vaccinationLinks: [], + summary: null, + summaryGeneratedAt: null, + summaryState: "NONE", + ...over, + } as InboundDocumentDetailDto; +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("recordOptions", () => { + it("puts records near the document first and groups the rest by year", () => { + const options = recordOptions( + [ + { + id: "late", + label: "Tetanus", + occurredAt: "2010-06-01T00:00:00.000Z", + }, + { id: "near", label: "Polio", occurredAt: "1991-04-05T00:00:00.000Z" }, + ], + "1991-04-02", + { suggested: "Around this date", date: (iso) => iso.slice(0, 10) }, + ); + expect(options.map((o) => o.id)).toEqual(["near", "late"]); + expect(options[0]!.group?.key).toBe("suggested"); + expect(options[1]!.group).toEqual({ key: "2010", label: "2010" }); + }); +}); + +describe("", () => { + it("lets a manager link the page to doses and visits", () => { + const html = render( + undefined} />, + ); + expect(html).toContain('data-slot="document-vaccination-links"'); + expect(html).toContain('data-slot="document-vaccination-links-add"'); + expect(html).toContain('data-slot="document-visit-links"'); + }); + + it("shows a read-only reader the doses the page is filed against", () => { + const html = render( + undefined} + />, + ); + expect(html).toContain('data-slot="document-vaccination-link"'); + expect(html).toContain("DTP"); + expect(html).not.toContain('data-slot="document-vaccination-links-add"'); + }); + + it("shows no dose block at all when the grant withholds the doses", () => { + const html = render( + undefined} + />, + ); + expect(html).not.toContain("document-vaccination-links"); + }); +}); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index b04899f93..1ee941f67 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -61,9 +61,7 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; -import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { EncounterKind } from "@/generated/prisma/client"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { @@ -80,6 +78,7 @@ import { } from "@/lib/validations/inbound-documents"; import { DocumentAiSection } from "./document-ai-section"; import { DocumentEncounterSuggestion } from "./document-encounter-suggestion"; +import { DocumentRecordLinks } from "./document-record-links"; import { DocumentFactsSection } from "./document-facts-review"; import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion"; import { DocumentSummaryBlock } from "./document-summary-block"; @@ -102,6 +101,7 @@ type PatchInput = { documentDate?: string | null; episodeIds?: string[]; encounterIds?: string[]; + vaccinationIds?: string[]; }; /** @@ -408,6 +408,8 @@ export function DocumentDetailSheet({ const indexDoc = useIndexDocument(); const [mutationError, setMutationError] = useState(null); + /** Bumped on a failed write; remounts the link block onto server state. */ + const [linkResets, setLinkResets] = useState(0); const [shareOpen, setShareOpen] = useState(false); const [editingTitle, setEditingTitle] = useState(false); /** @@ -463,11 +465,22 @@ export function DocumentDetailSheet({ `/api/documents/inbound/${documentId}`, input, ), - onSuccess: () => { + onSuccess: (_data, input) => { setMutationError(null); - void invalidateKeys(queryClient, [queryKeys.documents()]); + // A link written from this side changes what the visit's and the + // dose's own views list, so their reads refresh with the vault's. + void invalidateKeys(queryClient, [ + queryKeys.documents(), + ...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []), + ...(input.vaccinationIds !== undefined + ? [queryKeys.vaccinations()] + : []), + ]); + }, + onError: () => { + setMutationError(t("documents.detail.saveError")); + setLinkResets((n) => n + 1); }, - onError: () => setMutationError(t("documents.detail.saveError")), }); const restore = useMutation({ @@ -1074,36 +1087,16 @@ export function DocumentDetailSheet({ /> ) : null} - {/* "Belongs to visit" — read-only here on purpose. The filing - happens at the moment the document arrives, or from the - visit's own sheet; a second editor for the same link would - be a second place the set can be changed. Each row deep-links - to the vault filtered to that visit. */} - {doc.encounterLinks.length > 0 ? ( -
-

- {t("documents.detail.visitsLabel")} -

-
- {doc.encounterLinks.map((link) => ( - - - {encounterKindText(t, link.kind as EncounterKind)} - - {link.occurredAt ? ( - - {format.date(link.occurredAt)} - - ) : null} - - ))} -
-
- ) : null} + {/* Which visits and doses this page is filed against, and — for + someone who may manage the vault — the links themselves. + Keyed on the write-failure count so a refused write drops + the local selection back to what the server confirmed. */} + patch.mutate(part)} + /> {/* Staged-facts review + the stored-text extract recovery. The existing extract/confirm chain finally gets its control on diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx new file mode 100644 index 000000000..7899443c2 --- /dev/null +++ b/src/components/documents/document-record-links.tsx @@ -0,0 +1,246 @@ +"use client"; + +/** + * "What is this page filed against?" — the document side of the visit and + * vaccination links, on the vault's detail sheet. + * + * Both links already existed as tables, but only one end could write them: + * a visit's form picked its documents, a dose's form picked its pages, and + * the document itself could show its visits read-only and its doses not at + * all. A childhood record covering a dozen doses had to be attached dose by + * dose, from twelve forms. Here the page lists what it is filed against and, + * for someone who may manage the vault, links and unlinks it in one place. + * Both ends write the SAME table through the link service, so the dose's + * form and this sheet cannot disagree about what is filed where. + * + * Every name and date comes from the server: the visit kind as its enum + * constant for the reader's bundle, the dose as its catalogue slug or the + * person's own wording. The pick lists come from the visit and vaccination + * list routes, with the records dated near the document on top under the + * same seven-day window the upload suggestion uses. + * + * A module that is off leaves no block behind, and a grant that does not + * reach the doses (`vaccinationLinks: null`) shows no dose block rather than + * an empty one that would claim the page records none. + */ +import { useState } from "react"; +import Link from "next/link"; +import { CalendarDays, Syringe } from "lucide-react"; + +import { EntityLinkPicker } from "@/components/links/entity-link-picker"; +import type { EntityLinkOption } from "@/components/links/entity-link-picker"; +import { isNearAnchor } from "@/components/links/vault-document-options"; +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; +import { useEncounters } from "@/hooks/use-encounters"; +import { useAuth } from "@/hooks/use-auth"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { EncounterKind } from "@/generated/prisma/client"; +import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; + +type Translate = ReturnType["t"]; + +/** Records near the document first, then the rest by year, newest first. */ +export function recordOptions( + records: ReadonlyArray<{ + id: string; + label: string; + meta?: string | null; + occurredAt: string; + }>, + anchor: string | null, + labels: { suggested: string; date: (iso: string) => string }, +): EntityLinkOption[] { + const near: EntityLinkOption[] = []; + const rest: EntityLinkOption[] = []; + for (const record of records) { + const option = { + id: record.id, + label: record.label, + meta: record.meta ?? null, + dateLabel: labels.date(record.occurredAt), + }; + if (isNearAnchor(record.occurredAt, anchor)) { + near.push({ + ...option, + group: { key: "suggested", label: labels.suggested }, + }); + } else { + const year = record.occurredAt.slice(0, 4); + rest.push({ ...option, group: { key: year, label: year } }); + } + } + return [...near, ...rest]; +} + +/** A dose named the way the vaccination page names it. */ +function doseName( + t: Translate, + catalogSlug: string | null, + vaccineName: string | null, +): string { + if (catalogSlug) return t(`vaccinations.catalog.${catalogSlug}`); + return vaccineName ?? t("vaccinations.suggestion.unnamed"); +} + +/** + * The caller mounts this keyed on the document and on its write-failure + * count, so the local selection below starts over from the server's answer + * whenever the sheet moves to another document or a write fails. + */ +export function DocumentRecordLinks({ + doc, + canManage, + onChange, +}: { + doc: InboundDocumentDetailDto; + canManage: boolean; + onChange: (part: { + encounterIds?: string[]; + vaccinationIds?: string[]; + }) => void; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const { user } = useAuth(); + const vaccinationsOn = user?.modules?.vaccinations !== false; + const anchor = doc.documentDate ?? doc.reportDate; + + const encounters = useEncounters(canManage); + const vaccinations = useVaccinations( + null, + canManage && vaccinationsOn && doc.vaccinationLinks !== null, + ); + + // The selection the person is editing, ahead of the server. Each change is + // sent as the whole set (a replace-set PATCH), so a quick second tap never + // computes from a list the first tap has not refreshed yet. + const [visitIds, setVisitIds] = useState(null); + const [doseIds, setDoseIds] = useState(null); + + const labels = { + suggested: t("links.picker.suggested"), + date: (iso: string) => format.date(iso), + }; + + const visitOptions = recordOptions( + [ + ...(encounters.data?.upcoming ?? []), + ...(encounters.data?.past ?? []), + ].map((visit) => ({ + id: visit.id, + label: encounterKindText(t, visit.kind as EncounterKind), + meta: visit.practitioner?.name ?? null, + occurredAt: visit.occurredAt, + })), + anchor, + labels, + ); + const doseOptions = recordOptions( + (vaccinations.data?.vaccinations ?? []).map((dose) => ({ + id: dose.id, + label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName), + occurredAt: dose.occurredAt, + })), + anchor, + labels, + ); + + const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; + + return ( + <> + {canManage ? ( + void encounters.refetch()} + selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} + onChange={(ids) => { + setVisitIds(ids); + onChange({ encounterIds: ids }); + }} + options={visitOptions} + searchPlaceholder={t("links.picker.searchPlaceholder")} + emptyLabel={t("documents.detail.noVisitsToLink")} + /> + ) : doc.encounterLinks.length > 0 ? ( +
+

+ {t("documents.detail.visitsLabel")} +

+
+ {doc.encounterLinks.map((link) => ( + + + {encounterKindText(t, link.kind as EncounterKind)} + + {link.occurredAt ? ( + + {format.date(link.occurredAt)} + + ) : null} + + ))} +
+
+ ) : null} + + {showDoses && canManage ? ( + void vaccinations.refetch()} + selected={ + doseIds ?? + (doc.vaccinationLinks ?? []).map((link) => link.vaccinationId) + } + onChange={(ids) => { + setDoseIds(ids); + onChange({ vaccinationIds: ids }); + }} + options={doseOptions} + searchPlaceholder={t("links.picker.searchPlaceholder")} + emptyLabel={t("documents.detail.noVaccinationsToLink")} + /> + ) : showDoses && (doc.vaccinationLinks?.length ?? 0) > 0 ? ( +
+

+ {t("documents.detail.vaccinationsLabel")} +

+
+ {(doc.vaccinationLinks ?? []).map((link) => ( + + + {doseName(t, link.catalogSlug, link.vaccineName)} + + {link.occurredAt ? ( + + {format.date(link.occurredAt)} + + ) : null} + + ))} +
+
+ ) : null} + + ); +} diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 1f1065bd6..8cdd874d0 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -158,6 +158,9 @@ export function useLinkDocumentToVaccination() { onSuccess: () => Promise.all([ invalidateKeys(qc, vaccinationDependentKeys), + // The document's own sheet lists the doses it is filed against, so + // a link made from the suggestion there has to repaint it. + invalidateKeys(qc, [queryKeys.documents()]), // The linked-document badge can surface on the daily reads' checkup // items; force the unmounted snapshot/digest to refetch like the // sibling mutations do (via `invalidateReminderReads`). diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 79e981381..70e028ed4 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -11,7 +11,9 @@ import { listTargetsBySource, replaceTargets } from "@/lib/links"; import type { DocumentConditionLinkDto, DocumentEncounterLinkDto, + DocumentVaccinationLinkDto, } from "@/lib/validations/inbound-documents"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; /** * Load the condition links for a page of documents in ONE grouped query. @@ -162,3 +164,87 @@ export async function replaceConditionLinks( targetIds: episodeIds, }); } + +/** + * Load the VACCINATION links for one or more documents in two grouped + * queries: the links through the link service, then the doses' identity so + * the client can name each one from its own bundle. + * + * Tombstoned doses drop out, the same honest default the visit links take. + */ +export async function loadDocumentVaccinationLinks( + userId: string, + documentIds: string[], +): Promise> { + const map = new Map(); + if (documentIds.length === 0) return map; + const byDocument = await listTargetsBySource(prisma, { + userId, + sourceKind: "document", + sourceIds: documentIds, + targetKind: "vaccination", + }); + const doseIds = [ + ...new Set([...byDocument.values()].flat().map((target) => target.id)), + ]; + if (doseIds.length === 0) return map; + const doses = await prisma.vaccinationRecord.findMany({ + where: { id: { in: doseIds }, userId }, + select: { id: true, antigenSlug: true, vaccineName: true }, + }); + const doseById = new Map(doses.map((dose) => [dose.id, dose])); + for (const [documentId, targets] of byDocument) { + map.set( + documentId, + targets.map((target) => { + const dose = doseById.get(target.id); + return { + vaccinationId: target.id, + occurredAt: target.date, + catalogSlug: + resolveCatalogEntry(dose?.antigenSlug ?? null)?.slug ?? null, + vaccineName: dose?.vaccineName ?? null, + }; + }), + ); + } + return map; +} + +/** + * Narrow a client-sent dose-id list to the caller's LIVE doses. Same shape + * and same refusal as {@link narrowOwnedEncounterIds}. + */ +export async function narrowOwnedVaccinationIds( + userId: string, + vaccinationIds: string[], +): Promise { + const unique = [...new Set(vaccinationIds)]; + if (unique.length === 0) return []; + const owned = await prisma.vaccinationRecord.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 vaccination links through the link service, in + * the `document → vaccination` direction of the table the dose's own form + * writes. + */ +export async function replaceVaccinationLinks( + tx: Prisma.TransactionClient, + userId: string, + documentId: string, + vaccinationIds: string[], +): Promise { + await replaceTargets(tx, { + userId, + sourceKind: "document", + sourceId: documentId, + targetKind: "vaccination", + targetIds: vaccinationIds, + }); +} diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 7f322a15b..57c9cbbde 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -21,6 +21,7 @@ import { toIndexOutcome } from "@/lib/validations/inbound-documents"; import type { DocumentConditionLinkDto, DocumentEncounterLinkDto, + DocumentVaccinationLinkDto, DocumentContentIndexSourceValue, DocumentSummaryStateValue, ExtractedFactDto, @@ -275,6 +276,7 @@ export function serialiseDocumentDetail( contentIndexSource: DocumentContentIndexSourceValue | null = null, hasThumbnail = false, encounterLinks: DocumentEncounterLinkDto[] = [], + vaccinationLinks: DocumentVaccinationLinkDto[] | null = [], ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part @@ -324,6 +326,7 @@ export function serialiseDocumentDetail( encounterLinks, ), facts: facts.map(serialiseFact), + vaccinationLinks, summary, summaryGeneratedAt: doc.summaryGeneratedAt ? doc.summaryGeneratedAt.toISOString() diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 7f080ca56..88d3b311f 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -73,6 +73,8 @@ const TARGET_DOMAIN: Record = { labResult: "labs", conditionEpisode: "illness", encounter: "profile", + // A dose lives in the health background, like the visit itself. + vaccination: "profile", }; /** diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index d58a3b36f..766fcfab9 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -16,7 +16,7 @@ export type LinkSourceKind = "encounter" | "document" | "vaccination"; /** The record a link points at. */ export type LinkTargetKind = - "document" | "labResult" | "conditionEpisode" | "encounter"; + "document" | "labResult" | "conditionEpisode" | "encounter" | "vaccination"; export interface LinkRequest { userId: string; @@ -187,6 +187,18 @@ const ENCOUNTER_ENDPOINT: EndpointSpec = { date: (row) => asDate(row.occurredAt), }; +const VACCINATION_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.vaccinationRecord), + select: { id: true, antigenSlug: true, vaccineName: true, occurredAt: true }, + // The person's own wording first, then the catalogue slug. A caller that + // renders a dose resolves the slug through its own bundle instead (see + // `loadDocumentVaccinationLinks`); this label is the fallback every + // generic consumer of the service gets. + label: (row) => + asString(row.vaccineName) ?? asString(row.antigenSlug) ?? String(row.id), + date: (row) => asDate(row.occurredAt), +}; + const CONDITION_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.illnessEpisode), select: { id: true, label: true, onsetAt: true }, @@ -255,6 +267,18 @@ const LINK_TABLES: Partial< targetColumn: "documentId", target: DOCUMENT_ENDPOINT, }, + // The SAME table as `vaccination:document`, read from the other end, like + // `document:encounter` beside it. A childhood record covering a dozen doses + // is filed from the document's own sheet in one pass rather than dose by + // dose; the dose's form and the document's sheet write one table, so they + // cannot disagree about what is filed where. A second direction, not a + // second vaccination table. + "document:vaccination": { + delegate: (tx) => narrow(tx.vaccinationDocumentLink), + sourceColumn: "documentId", + targetColumn: "vaccinationId", + target: VACCINATION_ENDPOINT, + }, }; function tableFor( diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index daef007e4..1957d5bb0 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -128,6 +128,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "documents.inbound.restoreGone", "documents.inbound.uploadBusy", "documents.inbound.uploadTimeout", + "documents.inbound.vaccinationNotFound", ], encounter: [ "encounter.invalid", diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index c421df1db..3ee9bbb2a 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -98,7 +98,7 @@ const inboundFactEditRequest = inboundFactEditSchema.meta({ const documentUpdateRequest = documentUpdateSchema.meta({ id: "DocumentUpdateRequest", description: - "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.", + "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits), `vaccinationIds` (the same replace-set semantics over the caller's vaccination doses, up to 100 so one childhood record can be filed against a whole primary schedule). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.", }); const documentBulkRequest = documentBulkSchema.meta({ @@ -156,6 +156,19 @@ const encounterLink = z "A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the constant is what lets a German reader and an English one each see it named correctly.", }); +const vaccinationLink = z + .object({ + vaccinationId: z.string(), + occurredAt: z.string().nullable(), + catalogSlug: z.string().nullable(), + vaccineName: z.string().nullable(), + }) + .meta({ + id: "DocumentVaccinationLink", + description: + "A link to one of the caller's vaccination doses — \"this page records that dose\". `catalogSlug` is the catalogue entry when this release still knows the dose's slug, for the reader's own bundle to name; null is the signal to show `vaccineName`, the person's own wording. The same table the dose's own `/api/vaccinations/{id}/links` writes from the other end.", + }); + const inboundDocument = z .object({ id: z.string(), @@ -203,6 +216,10 @@ const inboundDocument = z const inboundDocumentDetail = inboundDocument .extend({ facts: z.array(extractedFact), + vaccinationLinks: z.array(vaccinationLink).nullable().meta({ + description: + "Vaccination doses this document is filed against. Null when the caller acts inside somebody else's record under a grant that does not cover the health background the doses live in: the page is shared, the doses are not, and an empty list would claim the page records none.", + }), summary: z.string().nullable(), summaryGeneratedAt: z.string().nullable(), summaryState: z.enum(DOCUMENT_SUMMARY_STATES), diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 29acd9af5..669c55f91 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -366,6 +366,24 @@ export interface DocumentEncounterLinkDto { occurredAt: string | null; } +/** + * One vaccination link on a document detail DTO — "this page records that + * dose". + * + * `catalogSlug` is the catalogue entry when this release still knows the + * dose's slug, so the reader's own bundle names it + * (`vaccinations.catalog.`); `null` is the signal to show + * `vaccineName`, the person's own wording. The same degrade the vaccination + * DTO's `catalogEntry: null` carries. + */ +export interface DocumentVaccinationLinkDto { + vaccinationId: string; + /** ISO-8601 instant of the dose (UTC midnight — a Pass carries dates). */ + occurredAt: string | null; + catalogSlug: string | null; + vaccineName: string | null; +} + /** The document DTO (list + detail). */ export interface InboundDocumentDto { id: string; @@ -429,6 +447,13 @@ export interface InboundDocumentDto { export interface InboundDocumentDetailDto extends InboundDocumentDto { facts: ExtractedFactDto[]; + /** + * Doses this document is filed against, newest link last. `null` when the + * caller acts inside somebody else's record under a grant that does not + * cover the health background the doses live in: the page is shared, the + * doses are not, and an empty list would claim the page records none. + */ + vaccinationLinks: DocumentVaccinationLinkDto[] | null; /** * Short (3-4 sentence) plain-language summary of WHAT the document is, * generated once in the background after upload when the `documentsAutoAiRead` @@ -619,6 +644,20 @@ const encounterIdList = z .array(z.string().trim().min(1).max(40)) .max(DOCUMENT_MAX_ENCOUNTER_LINKS); +/** + * Max vaccination links a single document may carry / receive per request. + * + * Higher than the visit and condition bounds on purpose. One childhood + * record routinely covers every dose of a whole primary schedule, which is + * well past twenty rows, and it is the normal case rather than an edge one. + * The link service's own per-call ceiling is the bound. + */ +export const DOCUMENT_MAX_VACCINATION_LINKS = 100; + +const vaccinationIdList = z + .array(z.string().trim().min(1).max(40)) + .max(DOCUMENT_MAX_VACCINATION_LINKS); + /** * The store-only upload metadata (the multipart form fields beside the file). * Every field is optional — a bare file upload is valid and lands as a STORED @@ -665,6 +704,11 @@ export const documentUpdateSchema = z episodeIds: episodeIdList.optional(), /** Replace-set visit links, same semantics as `episodeIds`. */ encounterIds: encounterIdList.optional(), + /** + * Replace-set vaccination links, same semantics. The same table the + * dose's own form writes from the other end. + */ + vaccinationIds: vaccinationIdList.optional(), }) .refine( (d) => @@ -672,7 +716,8 @@ export const documentUpdateSchema = z d.kind !== undefined || d.documentDate !== undefined || d.episodeIds !== undefined || - d.encounterIds !== undefined, + d.encounterIds !== undefined || + d.vaccinationIds !== undefined, { message: "Provide at least one field to update" }, ); diff --git a/tests/integration/link-service.test.ts b/tests/integration/link-service.test.ts index 94dc0dd1d..8f0453c30 100644 --- a/tests/integration/link-service.test.ts +++ b/tests/integration/link-service.test.ts @@ -645,3 +645,91 @@ describe("link service — the vaccination pair", () => { ).rejects.toThrow(/No link table joins a vaccination to a labResult/); }); }); + +describe("link service — a document filed against many doses (#1024)", () => { + async function seedDoses(userId: string, count: number) { + const prisma = getPrismaClient(); + const ids: string[] = []; + for (let i = 0; i < count; i++) { + const dose = await prisma.vaccinationRecord.create({ + data: { + userId, + occurredAt: new Date(Date.UTC(1991, i % 12, 1 + i)), + antigenSlug: i % 2 === 0 ? "dtap" : "polio", + }, + }); + ids.push(dose.id); + } + return ids; + } + + it("files one page against a whole childhood schedule from the document side", async () => { + const prisma = getPrismaClient(); + const doses = await seedDoses(OWNER, 12); + const result = await replaceTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "vaccination", + targetIds: doses, + }); + expect(result.changed).toBe(12); + // One table, read from either end: every dose now lists the page. + for (const dose of doses) { + const pages = await listTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: dose, + targetKind: "document", + }); + expect(pages.map((page) => page.id)).toEqual([owner.document.id]); + } + // And the rows landed in the right columns. + const row = await prisma.vaccinationDocumentLink.findFirstOrThrow({ + where: { vaccinationId: doses[0] }, + }); + expect(row.documentId).toBe(owner.document.id); + expect(row.userId).toBe(OWNER); + }); + + it("refuses another account's dose from the document side, and writes nothing", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "vaccination", + targetIds: [stranger.vaccination.id], + }); + expect(result.unknownTargetIds).toEqual([stranger.vaccination.id]); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("unlinking from the document side leaves the other doses filed", async () => { + const prisma = getPrismaClient(); + const doses = await seedDoses(OWNER, 3); + await replaceTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "vaccination", + targetIds: doses, + }); + await replaceTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "vaccination", + targetIds: [doses[0]!, doses[2]!], + }); + const linked = await listTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "vaccination", + }); + expect(linked.map((dose) => dose.id).sort()).toEqual( + [doses[0]!, doses[2]!].sort(), + ); + }); +});