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

@@ -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 ? (