feat(documents): view and download the original inbound document

The inbound-document store keeps the uploaded doctor letter encrypted at
rest (InboundDocument.contentEncrypted, AES-256-GCM), but there was no way
to read the original back — the decrypt helper had been dropped as unused.

- Restore decryptDocumentFromBytes in the document store, fail-closed on a
  bad / missing key id.
- Add GET /api/documents/inbound/[id]/original: owner-scoped (userId-narrowed
  query), module-gated, returns the decrypted bytes with the stored MIME
  type. A PDF / image is served inline so the browser can render it; other
  types download as an attachment. The filename is sanitised before it
  reaches the Content-Disposition header. The bytes are PHI — never logged,
  never served cross-user, and a decrypt error fails closed (500) rather than
  returning ciphertext. Cache-Control is private, no-store.
- Surface a 'View original' action in the document review screen.
- Document the route in the OpenAPI registry and regenerate the spec.
- Cover the route with a test: owner-scoped success, attachment fallback,
  404 for another user's document, and fail-closed on decrypt error.

i18n: viewOriginal across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 20:33:29 +02:00
1 parent 6e204d054a
commit 08314c7c09
12 files changed
+418 -10

No files matched your search

+34
View File
@@ -8696,6 +8696,40 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/documents/inbound/{id}/original:
get:
tags:
- Documents
summary: View / download the original document
description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under
`InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. The response
carries the stored `mimeType`: a PDF / image is served inline (`Content-Disposition: inline`) so the browser can
render it; any other type downloads as an attachment. Fails closed (500) on a decrypt error — it never returns
the ciphertext."
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The decrypted original document bytes with the stored MIME type.
content:
application/pdf:
schema:
type: string
format: binary
image/*:
schema:
type: string
format: binary
text/plain:
schema:
type: string
"401": *a1
"422": *a3
"429": *a4
/api/documents/inbound/{id}/facts/{factId}:
patch:
tags:
+1
View File
@@ -7819,6 +7819,7 @@
"review": {
"title": "Erkannte Angaben prüfen",
"discard": "Verwerfen",
"viewOriginal": "Original ansehen",
"confirm": "Bestätigte Angaben speichern",
"confirming": "Wird gespeichert …",
"needsReview": "Prüfung nötig",
+1
View File
@@ -7819,6 +7819,7 @@
"review": {
"title": "Review extracted facts",
"discard": "Discard",
"viewOriginal": "View original",
"confirm": "Save approved facts",
"confirming": "Saving…",
"needsReview": "Needs review",
+1
View File
@@ -7819,6 +7819,7 @@
"review": {
"title": "Revisar datos extraídos",
"discard": "Descartar",
"viewOriginal": "Ver original",
"confirm": "Guardar datos aprobados",
"confirming": "Guardando…",
"needsReview": "Requiere revisión",
+1
View File
@@ -7819,6 +7819,7 @@
"review": {
"title": "Vérifier les données extraites",
"discard": "Écarter",
"viewOriginal": "Voir l'original",
"confirm": "Enregistrer les données approuvées",
"confirming": "Enregistrement…",
"needsReview": "À vérifier",
+1
View File
@@ -7819,6 +7819,7 @@
"review": {
"title": "Rivedi i dati estratti",
"discard": "Scarta",
"viewOriginal": "Vedi originale",
"confirm": "Salva i dati approvati",
"confirming": "Salvataggio…",
"needsReview": "Da rivedere",
+1
View File
@@ -7819,6 +7819,7 @@
"review": {
"title": "Przejrzyj wyodrębnione dane",
"discard": "Odrzuć",
"viewOriginal": "Zobacz oryginał",
"confirm": "Zapisz zatwierdzone dane",
"confirming": "Zapisywanie…",
"needsReview": "Wymaga przeglądu",
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
/**
* v1.25.1 (W-DOCS-IN) — `GET /api/documents/inbound/[id]/original`.
*
* The route decrypts and serves the raw uploaded document. Load-bearing
* behaviour under test: it is owner-scoped (the query narrows on the session
* userId, so another user's document is a 404), it returns the decrypted bytes
* with the stored MIME type, and it fails closed on a decrypt error.
*/
// A reversible crypto stub so encryptDocumentToBytes / decryptDocumentFromBytes
// round-trip without real keys. `decrypt` is overridden per-test for the
// fail-closed path.
const decryptMock = vi.fn((s: string) =>
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
);
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`,
decrypt: (s: string) => decryptMock(s),
}));
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { encryptDocumentToBytes } from "@/lib/documents/store";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
type Ctx = { params: Promise<{ id: string }> };
const callGet = GET as unknown as (
req: NextRequest,
ctx: Ctx,
) => Promise<Response>;
function makeReq(id: string): NextRequest {
return new NextRequest(
new URL(`http://localhost/api/documents/inbound/${id}/original`),
);
}
function ctx(id: string): Ctx {
return { params: Promise.resolve({ id }) };
}
const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body");
beforeEach(() => {
vi.clearAllMocks();
decryptMock.mockImplementation((s: string) =>
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/documents/inbound/[id]/original", () => {
it("returns the decrypted original with the stored MIME type, owner-scoped", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
filename: "report.pdf",
mimeType: "application/pdf",
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
} as never);
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/pdf");
expect(res.headers.get("Content-Disposition")).toContain("inline");
expect(res.headers.get("Content-Disposition")).toContain("report.pdf");
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
const out = Buffer.from(await res.arrayBuffer());
expect(out.equals(PDF_BYTES)).toBe(true);
// The query is narrowed on the session userId + live rows only.
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0]
?.where as { id: string; userId: string; deletedAt: null };
expect(where.userId).toBe("user-1");
expect(where.id).toBe("doc-1");
expect(where.deletedAt).toBeNull();
});
it("serves a non-inline type (text) as an attachment", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-2",
filename: null,
mimeType: "text/plain",
contentEncrypted: encryptDocumentToBytes(Buffer.from("plain text")),
} as never);
const res = await callGet(makeReq("doc-2"), ctx("doc-2"));
expect(res.status).toBe(200);
const disposition = res.headers.get("Content-Disposition") ?? "";
expect(disposition).toContain("attachment");
// No filename → generated fallback keyed by id + extension.
expect(disposition).toContain("document-doc-2.txt");
});
it("404s for another user's document (the userId narrows it out)", async () => {
// The owner-scoped where clause yields no row for a non-owner.
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
null as never,
);
const res = await callGet(makeReq("other-doc"), ctx("other-doc"));
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBeTruthy();
});
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-3",
filename: "report.pdf",
mimeType: "application/pdf",
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
} as never);
decryptMock.mockImplementation(() => {
throw new Error("unknown key id");
});
const res = await callGet(makeReq("doc-3"), ctx("doc-3"));
expect(res.status).toBe(500);
expect(res.headers.get("Content-Type")).toContain("application/json");
const body = (await res.json()) as { error: string };
expect(body.error).toBeTruthy();
});
});
@@ -0,0 +1,136 @@
/**
* v1.25.1 (W-DOCS-IN) — view / download the original uploaded document.
*
* The raw doctor report / discharge letter is stored encrypted at rest
* (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY
* path that decrypts and serves it, so the user can re-read the source the
* extracted facts came from. Owner-scoped + module-gated; the bytes are PHI:
* never logged, never served cross-user, fail-closed on a decrypt error.
*
* The response carries the stored `mimeType`. A PDF / image is served inline
* (`Content-Disposition: inline`) so the browser can render it in a tab; any
* other type (e.g. the text-mode upload) is sent as an attachment. The
* filename is sanitised before it reaches the header.
*/
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { decryptDocumentFromBytes } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
type RouteParams = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/** MIME types we render inline; everything else downloads as an attachment. */
const INLINE_MIME_TYPES = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
/** Extension fallback when the stored filename is absent / unusable. */
const MIME_EXTENSION: Record<string, string> = {
"application/pdf": "pdf",
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"text/plain": "txt",
};
/**
* Build a header-safe ASCII filename. Strips control characters (incl. CR/LF,
* the header-injection vector), quotes, and path separators (path traversal),
* caps the length, and falls back to a generated name keyed by the document
* id + MIME type when nothing usable remains.
*/
function safeDownloadName(
filename: string | null,
id: string,
mimeType: string,
): string {
const ext = MIME_EXTENSION[mimeType] ?? "bin";
const cleaned = (filename ?? "")
// Strip control chars (incl. CR/LF — header injection), quotes, and path
// separators (path traversal) before the value reaches the header.
.replace(/[\u0000-\u001f"\\\/]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
return cleaned.length > 0 ? cleaned : `document-${id}.${ext}`;
}
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
// Owner-scoped: the userId narrows the row so a cuid guess cannot reach
// another user's document. A cookie session and a Bearer token both
// resolve through requireAuth(); neither can cross the userId boundary.
where: { id, userId: user.id, deletedAt: null },
select: {
id: true,
filename: true,
mimeType: true,
contentEncrypted: true,
},
});
if (!document) {
return apiError("Document not found", 404, {
errorCode: "documents.inbound.notFound",
});
}
let bytes: Buffer;
try {
bytes = decryptDocumentFromBytes(document.contentEncrypted);
} catch {
// Fail closed — never fall back to the raw ciphertext. The error reason
// (bad / missing key id) is logged by the annotation, never the bytes.
annotate({
action: { name: "documents.inbound.original.decryptFailed" },
meta: { documentId: document.id },
});
return apiError("Could not read the stored document", 500, {
errorCode: "documents.inbound.decryptFailed",
});
}
const inline = INLINE_MIME_TYPES.has(document.mimeType);
const downloadName = safeDownloadName(
document.filename,
document.id,
document.mimeType,
);
annotate({
action: { name: "documents.inbound.original.get" },
meta: {
documentId: document.id,
mimeType: document.mimeType,
bytes: bytes.byteLength,
disposition: inline ? "inline" : "attachment",
},
});
return new NextResponse(bytes as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": document.mimeType,
"Content-Length": String(bytes.byteLength),
"Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${downloadName}"`,
// Private PHI — never cache in a shared / disk cache.
"Cache-Control": "private, no-store",
},
});
},
);
@@ -12,7 +12,13 @@
import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, FileScan, Loader2, Upload } from "lucide-react";
import {
AlertTriangle,
ExternalLink,
FileScan,
Loader2,
Upload,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -283,14 +289,29 @@ function DocumentReview({
<CardTitle className="text-base">
{t("documents.review.title")}
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => discard.mutate()}
disabled={discard.isPending}
>
{t("documents.review.discard")}
</Button>
<div className="flex items-center gap-1">
{/* View the original uploaded document. The route is same-origin and
cookie-authenticated, so a new tab carries the session; a PDF /
image renders inline, other types download. */}
<Button asChild variant="ghost" size="sm">
<a
href={`/api/documents/inbound/${documentId}/original`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" aria-hidden />
{t("documents.review.viewOriginal")}
</a>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => discard.mutate()}
disabled={discard.isPending}
>
{t("documents.review.discard")}
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{pending.length === 0 ? (
+12 -1
View File
@@ -9,7 +9,7 @@
import { Buffer } from "node:buffer";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encrypt } from "@/lib/crypto";
import { decrypt, encrypt } from "@/lib/crypto";
import type {
ExtractedFact,
ExtractedFactStatus,
@@ -35,6 +35,17 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
return out;
}
/**
* Decrypt a stored document back to its raw bytes. Throws on a bad / missing
* key id (fail-closed) — the caller must treat a throw as "cannot serve" and
* never fall back to returning the ciphertext. The original document is PHI;
* the only path that reads it is the owner-scoped download route.
*/
export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
const base64 = decrypt(Buffer.from(buf).toString("utf8"));
return Buffer.from(base64, "base64");
}
/**
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
* schema stores. The structured clinical values (diagnosis text, lab values,
+34
View File
@@ -238,6 +238,40 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/documents/inbound/{id}/original": {
get: {
tags: ["Documents"],
summary: "View / download the original document",
description:
"Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. The response carries the stored `mimeType`: a PDF / image is served inline (`Content-Disposition: inline`) so the browser can render it; any other type downloads as an attachment. Fails closed (500) on a decrypt error — it never returns the ciphertext.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description:
"The decrypted original document bytes with the stored MIME type.",
content: {
"application/pdf": {
schema: { type: "string", format: "binary" },
},
"image/*": {
schema: { type: "string", format: "binary" },
},
"text/plain": {
schema: { type: "string" },
},
},
},
...stdResponses,
},
},
},
"/api/documents/inbound/{id}/facts/{factId}": {
patch: {
tags: ["Documents"],