Document Management — Patient Documents and Attachments
Audiences: doctor, clinical-buyer, developer
The patient-document surface — a unified gallery that pulls (a) patient attachments (direct uploads), (b) visit attachments, and (c) AI-generated documents into one timeline — with role-scoped views (doctor sees by-patient
- document-store + generation; patient sees their own gallery), an ACL-respecting drawer-based viewer, bulk upload with SSE progress, and iframe-safe inline preview through the BFF.
Business Purpose
A patient document is not a "file in a folder". It is one of three things at
the same time: (1) a Prisma row with metadata (who uploaded, what visit, what
type, what FHIR encounter, retention rules); (2) a FHIR DocumentReference
when the document is clinically significant; (3) a MinIO object holding the
bytes. Multiple sources (direct upload, visit attachment, generated letter,
OCR'd scan) must merge into one chronological view per patient — otherwise
the clinician sees three half-views and cannot answer "did I send the
discharge letter?".
The web layer's job is never to be the source of truth. It is the unified explorer + viewer + uploader on top of the backend's ResourceFacade aggregate (see minio-prisma-fhir-aggregate-storage). When the doctor uploads a PDF, the BFF forwards the multipart body to ddx-api, which writes the Prisma row, stores the MinIO object, optionally creates the FHIR DocumentReference, then returns the merged record. The web layer's contribution is the unified gallery, the drawer-based detail, the bulk upload UX with SSE progress, and iframe-safe inline preview.
Audiences
- Doctor: opens a patient and sees the document timeline; uploads new scans/letters; reviews AI-generated documents pending acceptance; jumps from a deepsearch chunk back to the source PDF.
- Clinical buyer (clinic operator): the surface that proves the HMS is a document system of record, not a thin chart. RBAC + ACL keeps documents scoped (a patient sees only their own gallery; staff see per-role subsets — see document-acl).
- Developer/partner: shows the three-document-source merge
(
patientDocumentsApi.getPatientDocumentsreturnsUnifiedPatientDocument[]+sourceBreakdown), the bulk-upload SSE hook (useBulkIngestionSSE), and the iframe-safe preview path. - Internal (ops/support): storage problems (missing MinIO object, orphan Prisma row) and viewer failures (CSP rejecting iframe) trace back to specific surfaces here.
Architecture
Doctor surfaces (three distinct pages)
portal/doctor/(with-nav)/ ├── document-store/ → all-clinic document explorer │ ├── page.tsx → entry: virtualised list, search, filter │ ├── DocumentsListClient.tsx → list + collection filter │ ├── DocumentStoreLayoutClient.tsx → sidebar + header chrome │ ├── DocumentHubHeader.tsx → header with quick filters │ ├── DocumentHubSidebar.tsx → collection navigator │ ├── collections/, documents/, knowledge/, overview/ │ ├── patient/, MyDocumentsScope.tsx, PatientDocumentsScope.tsx, │ │ OrganizationScope.tsx → scope-switcher views │ ├── upload/ → drag-drop with type detection │ ├── uploads/BulkUploadStatusClient.tsx → bulk upload progress ├── document-generation/ → AI-generated docs (letters/certs) │ ├── generated/, sections/, templates/ └── patients/[id]/documents/ → per-patient unified gallery
Patient surface
ddx-web/src/app/[locale]/portal/patient/documents/page.tsx:13-29 is a
clean two-component render — <DocumentsClient /> + a controllable drawer
controller — gated by requireRoleAccess('PATIENT_ONLY', locale). The
patient sees only documents the ACL permits.
Shared component library (components/ddx/documents/)
The 31-file library at ddx-web/src/components/ddx/documents/ provides:
- Unified gallery:
PatientDocumentsClient.tsx,PatientDocumentsPageClient.tsx,PatientDocumentsGrid.tsx,PatientDocumentCard.tsx— drives the per-patient view. - Detail drawer:
PatientDocumentDrawer.tsx(controllable viaPatientDocumentDrawerController),DocumentDetailPanel.tsx,DocumentSectionRenderer.tsx— drawer rather than navigation so the document opens over the gallery, preserving filter context. - Upload UX:
UploadDialog.tsx,IngestionDropzone.tsx,IngestionFileList.tsx,IngestionPersistedFiles.tsx,PersistedFileItem.tsx,FileItem.tsx,ingestion-types.ts,ingestion-utils.ts— multi-step ingestion with localStorage-persisted state (so a refresh mid-upload does not lose the queue). - Bulk upload + SSE:
hooks/useBulkIngestionSSE.ts, theBulkUploadContext(context/BulkUploadContext.tsx) — SSE channel for per-file progress, used byBulkUploadStatusClient.tsx. - Collections:
DDXDocumentCollections.tsx,CollectionSelectorCard.tsx,DocumentGrid.tsx,DocumentCard.tsx— knowledge-base style collections (doctor only). - Generation:
GeneratedDocumentViewer.tsx,DocumentExportButton.tsx,DDXDocumentIngestion.tsx,DDXDocumentQuery.tsx,ocr-templates/— AI-generated document flow. - ACL:
acl/,AccessControlPanel.tsx— per-document ACL editor (see document-acl).
Unified data shape
patientDocumentsApi.getPatientDocuments(patientId, { pageSize: 100 })
returns { documents: UnifiedPatientDocument[]; sourceBreakdown: SourceBreakdown } — the unified record merges Patient Attachments
(direct uploads), Visit Attachments (documents attached during visits),
and Generated Documents (AI-generated from templates) into one timeline.
The sourceBreakdown powers the source filter chips at the top of the
gallery.
PatientDocumentsPageClient.tsx:48-80 (excerpt):
const documentsQuery = useQuery({
queryKey: queryKeys.patientUnifiedDocuments(patientId, { pageSize: 100 }),
queryFn: async () => {
const response = await patientDocumentsApi.getPatientDocuments(
patientId, { pageSize: 100 }
);
if (response.error) throw new Error(response.error);
const data = response.body as {
documents?: UnifiedPatientDocument[]; sourceBreakdown?: SourceBreakdown
};
return { documents: data.documents ?? [],
sourceBreakdown: data.sourceBreakdown ?? null };
},
...
});
Backend surface (consumer)
ddx-api groups documents into four modules under
ddx-api/src/documents/:
documents-core/— generic CRUD on the ResourceFacade.patient-documents/— the unifiedGET /patient-documents/:patientIdendpoint and the source-merge service.attachment-processing/— multipart upload pipeline (virus scan, type detection, OCR triage).attachment-summarization/— LLM summarization of new attachments.document-generation/— letter/certificate/referral template renderer.doxing/— OCR + chunking + RAG ingestion (covered in doxing).intelligence/,pdf-engine/,treatment-plan-template/,ocr/.
Iframe-safe preview
/api/v1/documents/<id>/preview returns the binary content type
(application/pdf, image/*, etc.). The BFF's binary pass-through
(see proxy-gateway §route.ts:227-253, and DeepSearch's
catch-all :99-108) removes X-Frame-Options and sets
Content-Security-Policy: frame-ancestors 'self' so
<iframe src="/api/v1/documents/<id>/preview"> inside the drawer works
without same-origin browser blocks.
Bulk upload — SSE progress
useBulkUpload.tsx + BulkUploadContext + useBulkIngestionSSE deliver
the bulk-upload progress UX: queue persisted in localStorage (survives
refresh mid-upload — ddx-web/CLAUDE.md:188-193), per-file progress
streamed via SSE, status surfaced in
uploads/BulkUploadStatusClient.tsx.
Tech Stack & Choices
| Layer | Choice | Why |
|---|---|---|
| Source of truth | ddx-api ResourceFacade (Prisma + FHIR + MinIO aggregate) | Web NEVER writes any of the three directly; one server, one transaction boundary. |
| Unified merge | GET /patient-documents/:patientId returns merged + sourceBreakdown | One round trip; three sources; deterministic source labels for filters. |
| Gallery state | TanStack Query (useQuery) | Cache by queryKeys.patientUnifiedDocuments; refetch on bulk-upload completion. |
| Drawer over nav | PatientDocumentDrawer opens over the gallery | Preserves the gallery's filters/scroll while reading a document. |
| Upload UX | Multi-step ingestion + localStorage persistence | A refresh mid-upload doesn't lose the queue. |
| Bulk progress | SSE via useBulkIngestionSSE | Per-file progress, real-time, low overhead vs polling. |
| Preview | <iframe src="/api/v1/documents/.../preview"> | Same-origin via BFF; binary pass-through preserves content type. |
| Iframe headers | frame-ancestors 'self' set by BFF | Browser will not embed PDFs without it; defense vs clickjacking still in place. |
| File ingestion types | ingestion-types.ts + ingestion-utils.ts | Single source of valid MIME types; reject unsupported in browser before upload. |
Native <embed>/<object> | Banned for PDF inline viewer | Project memory project_pdf_viewer_migration — migration to @react-pdf-viewer/core is in flight across 5 surfaces. |
Rejected: per-source galleries (doctors fragmented across three tabs);
opening documents via <Link> navigation (would discard gallery state and
double the load time vs the drawer); direct MinIO browser uploads (would
require pre-signed URLs, bypassing virus scan and OCR triage); native
<object>/<embed> PDF viewer (poor mobile support, inconsistent
behaviour across browsers — see project memory note).
Data Flow
(1) Open a patient's documents tab
- Doctor opens
/<locale>/portal/doctor/patients/<id>/documents. requireRoleAccess('DOCTOR_ONLY', locale)passes.PatientDocumentsPageClientrenders;useQuerycallspatientDocumentsApi.getPatientDocuments(patientId, { pageSize: 100 }).- Browser → BFF (
/api/v1/patient-documents/:patientId) → ddx-api. - ddx-api's
patient-documentsservice queries Prisma for direct attachments, Visit-scoped attachments, and Generated Documents; merges intoUnifiedPatientDocument[]withsourcediscriminator andsourceBreakdowncounts. - Gallery renders
PatientDocumentsGridwith source-filter chips.
(2) Upload a new document
- Doctor drops a PDF into
IngestionDropzone. - Client computes preview, validates type via
ingestion-utils, persists tolocalStoragequeue. - POST
multipart/form-data→ BFF → ddx-apiattachment-processing. - ddx-api: virus scan → MIME detection → store object to MinIO → write
Prisma
PatientAttachment→ optionally create FHIRDocumentReference→ optionally trigger OCR (doxing/) → return the newUnifiedPatientDocumentrecord. - TanStack Query invalidates
patientUnifiedDocuments(patientId)→ gallery refetches → new card appears.
(3) View a document inline
- Click a card →
PatientDocumentDraweropens. DocumentDetailPanelrenders metadata + a viewer.- Viewer (
@react-pdf-viewer/coreper project_pdf_viewer_migration) loads/api/v1/documents/<id>/preview. - BFF removes
X-Frame-Options, setsframe-ancestors 'self'. - PDF renders inline; user can scroll, zoom, copy text.
- Closing the drawer leaves the gallery filters/scroll intact.
(4) Patient self-service
- Patient logs in to
/<locale>/portal/patient/documents. requireRoleAccess('PATIENT_ONLY', locale).<DocumentsClient />calls the samepatient-documentsendpoint (the BFF carriesX-Gateway-User-Role: PATIENT+X-Gateway-User-FHIR-Patient-ID).- ddx-api scopes results to the patient's own documents and ACL permissions (see document-acl).
PatientDocumentDrawerControllerrenders the drawer over the gallery for inline reads.
Implicated Code
ddx-web/src/app/[locale]/portal/patient/documents/page.tsx:13-29— patient gallery entry (requireRoleAccess('PATIENT_ONLY')).ddx-web/src/app/[locale]/portal/doctor/(with-nav)/document-store/page.tsx:40-94— doctor document-store entry with virtualised list + suspense skeleton;(with-nav)is mandatory (see doctor-portal).ddx-web/src/app/[locale]/portal/doctor/(with-nav)/document-store/DocumentsListClient.tsx— virtualised list with search/sort/pagination.ddx-web/src/app/[locale]/portal/doctor/(with-nav)/document-store/uploads/BulkUploadStatusClient.tsx— bulk upload status surface; consumesBulkUploadContext.ddx-web/src/app/[locale]/portal/doctor/(with-nav)/document-generation/— AI-generated documents (generated,sections,templates).ddx-web/src/components/ddx/documents/PatientDocumentsPageClient.tsx:39-80— unified gallery query;documentsQueryreturns{ documents, sourceBreakdown }.ddx-web/src/components/ddx/documents/PatientDocumentDrawer.tsx— drawer +PatientDocumentDrawerController(used atportal/patient/documents/page.tsx:26).ddx-web/src/components/ddx/documents/IngestionDropzone.tsx,IngestionFileList.tsx,IngestionPersistedFiles.tsx,ingestion-types.ts,ingestion-utils.ts— multi-step upload UX with persisted queue.ddx-web/src/components/ddx/documents/context/BulkUploadContext.tsx— global bulk-upload state.ddx-web/src/components/ddx/documents/hooks/useBulkIngestionSSE.ts— SSE listener for per-file ingestion progress.ddx-web/src/lib/hooks/useBulkUpload.tsx— bulk upload hook with localStorage persistence (cf.ddx-web/CLAUDE.md:188-193).ddx-web/src/lib/api/clients/patient/patient-documents.ts—patientDocumentsApi.getPatientDocumentsAPI client +UnifiedPatientDocument,SourceBreakdowntypes.ddx-web/src/components/ddx/documents/acl/,AccessControlPanel.tsx— ACL editor surfaces (see document-acl).ddx-web/src/components/ddx/documents/GeneratedDocumentViewer.tsx— rendering of AI-generated documents pending acceptance.ddx-api/src/documents/patient-documents/— unified patient-documents endpoint (the source-merge service).ddx-api/src/documents/documents-core/— ResourceFacade-backed CRUD.ddx-api/src/documents/attachment-processing/— multipart upload pipeline.ddx-api/src/documents/document-generation/— letter/certificate template renderer.
Operational Notes
- Drawer over navigation —
PatientDocumentDraweris opened viaPatientDocumentDrawerControllermounted alongside the gallery (cf.portal/patient/documents/page.tsx:25-27). Navigating to a route to open a document is a regression — discards filter state. @react-pdf-viewer/coremigration in flight — five surfaces are being migrated from native<iframe>/<object>PDF embedding to the React viewer (project memoryproject_pdf_viewer_migration). Do not introduce new native embeds; prefer the React viewer in any new document surface.frame-ancestors 'self'required — without the BFF header rewrite for binary preview content types, the iframe is silently blocked. The DeepSearch BFF atddx-deepsearch/src/app/api/v1/[...path]/route.ts:99-108is the reference; replicate any new media type explicitly.- localStorage upload queue —
useBulkUpload.tsxpersists the queue; if a user clears site data mid-upload the in-progress items are abandoned silently. There's no recovery path; document this in user- facing copy when the upload UX is touched. - Source breakdown matters — the gallery's source-filter chips
(
Patient Attachments,Visit Attachments,Generated) drive clinician trust: if a missing source is silently filtered, the doctor concludes "the document doesn't exist". Show zero-count chips too. - RBAC + ACL are layered — RBAC gates the route
(
requireRoleAccess), then ACL gates per-document visibility (see document-acl). Both run; never collapse. - AI-generated documents are NOT final —
document-generation/generated/shows AI documents pending acceptance. The doctor must approve before the document is promoted to the patient timeline; do not auto-publish. - TUCAN Intelligence link —
document-store/page.tsx:61-68shows the migration notice linking to/portal/doctor/tucan-intelligence; the document-store is being progressively migrated under TUCAN. - Common bug: native PDF
<embed>left in code — search for<embed,<object data=underddx-web/srcandddx-deepsearch/src; replace with@react-pdf-viewer/core.
Related Topics
- Doxing — OCR & Chunking — the ingestion pipeline that feeds RAG from uploaded documents.
- Document ACL — per-document access rules layered on top of RBAC.
- Document Generation — AI-generated document
workflow consumed in
document-generation/(and the templates surface). - MinIO + Prisma + FHIR Aggregate Storage — the backend ResourceFacade that web consumes.
- DeepSearch — search UI built on top of the same documents (chunks + vector search).
- Patient Portal — the patient's view of their gallery.
- Doctor Portal — the
doctor's three document surfaces (
(with-nav)/document-store,(with-nav)/document-generation,(with-nav)/patients/[id]/documents).