mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(documents): link a document to visits and vaccinations from its own sheet
A document could show its visits read-only and its vaccinations not at all, so a childhood record covering a dozen doses had to be attached from a dozen dose forms. The document's detail sheet now lists the visits and vaccinations it is filed against and, for someone who may manage the vault, links and unlinks them in one place, with the records dated near the document on top. Both ends write the same link tables through the link service, so the dose's form and the document's sheet cannot disagree about what is filed where. The document detail response carries the linked doses, named by catalogue slug or the person's own wording. It is null rather than empty for a grant that opens the vault but not the health background, so a shared page never claims to record no doses. The update route takes a replace-set of vaccination ids, up to 100 so one record can cover a whole primary schedule; ownership is re-checked, a foreign id answers 404 and the change lands in the audit log beside the other link fields. Linking from the dose suggestion now refreshes the sheet. Refs #1024, #1005
This commit is contained in:
1 parent
f46ed99def
commit
b480ac4543
23 files changed
+1012
-87
No files matched your search
+61
-16
@@ -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:
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -9881,7 +9881,10 @@
|
||||
"uploadedMeta": "추가 {date} · {size}",
|
||||
"share": "공유",
|
||||
"labValuesLink": "검사 수치 {count}개 보기",
|
||||
"visitsLabel": "연결된 진료"
|
||||
"visitsLabel": "연결된 진료",
|
||||
"vaccinationsLabel": "예방접종",
|
||||
"noVisitsToLink": "아직 기록된 진료가 없습니다.",
|
||||
"noVaccinationsToLink": "아직 기록된 예방접종이 없습니다."
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "파일을 놓아 업로드",
|
||||
|
||||
+4
-1
@@ -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ć",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Response | null> {
|
||||
*/
|
||||
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) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ function doc(
|
||||
pendingCount: 1,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
vaccinationLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: true,
|
||||
contentIndexSource: "vision",
|
||||
|
||||
@@ -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> = {},
|
||||
): 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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<DocumentRecordLinks>", () => {
|
||||
it("lets a manager link the page to doses and visits", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks doc={doc()} canManage onChange={() => 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(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({
|
||||
vaccinationLinks: [
|
||||
{
|
||||
vaccinationId: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogSlug: null,
|
||||
vaccineName: "DTP",
|
||||
},
|
||||
],
|
||||
})}
|
||||
canManage={false}
|
||||
onChange={() => 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(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({ vaccinationLinks: null })}
|
||||
canManage
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("document-vaccination-links");
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(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 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : 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. */}
|
||||
<DocumentRecordLinks
|
||||
key={`${doc.id}:${linkResets}`}
|
||||
doc={doc}
|
||||
canManage={canManageDocuments}
|
||||
onChange={(part) => patch.mutate(part)}
|
||||
/>
|
||||
|
||||
{/* Staged-facts review + the stored-text extract recovery. The
|
||||
existing extract/confirm chain finally gets its control on
|
||||
|
||||
@@ -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<typeof useTranslations>["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<string[] | null>(null);
|
||||
const [doseIds, setDoseIds] = useState<string[] | null>(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 ? (
|
||||
<EntityLinkPicker
|
||||
icon={CalendarDays}
|
||||
title={t("documents.detail.visitsLabel")}
|
||||
slot="document-visit-links"
|
||||
pending={encounters.isPending}
|
||||
error={encounters.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => 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 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDoses && canManage ? (
|
||||
<EntityLinkPicker
|
||||
icon={Syringe}
|
||||
title={t("documents.detail.vaccinationsLabel")}
|
||||
slot="document-vaccination-links"
|
||||
pending={vaccinations.isPending}
|
||||
error={vaccinations.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => 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 ? (
|
||||
<div className="space-y-1.5" data-slot="document-vaccination-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.vaccinationsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(doc.vaccinationLinks ?? []).map((link) => (
|
||||
<Link
|
||||
key={link.vaccinationId}
|
||||
href="/vaccinations"
|
||||
data-slot="document-vaccination-link"
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{doseName(t, link.catalogSlug, link.vaccineName)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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<Map<string, DocumentVaccinationLinkDto[]>> {
|
||||
const map = new Map<string, DocumentVaccinationLinkDto[]>();
|
||||
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<string[] | null> {
|
||||
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<void> {
|
||||
await replaceTargets(tx, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceId: documentId,
|
||||
targetKind: "vaccination",
|
||||
targetIds: vaccinationIds,
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -73,6 +73,8 @@ const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
|
||||
labResult: "labs",
|
||||
conditionEpisode: "illness",
|
||||
encounter: "profile",
|
||||
// A dose lives in the health background, like the visit itself.
|
||||
vaccination: "profile",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<LinkRowDelegate>(tx.vaccinationDocumentLink),
|
||||
sourceColumn: "documentId",
|
||||
targetColumn: "vaccinationId",
|
||||
target: VACCINATION_ENDPOINT,
|
||||
},
|
||||
};
|
||||
|
||||
function tableFor(
|
||||
|
||||
@@ -128,6 +128,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"documents.inbound.restoreGone",
|
||||
"documents.inbound.uploadBusy",
|
||||
"documents.inbound.uploadTimeout",
|
||||
"documents.inbound.vaccinationNotFound",
|
||||
],
|
||||
encounter: [
|
||||
"encounter.invalid",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.<slug>`); `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" },
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user