05-documentswave: W2filled12 citations

Document Management — Patient Documents and Attachments

Audiences: doctor, clinical-buyer, developer

Document Management — Patient Documents and Attachments

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.getPatientDocuments returns UnifiedPatientDocument[] + 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)

diagram
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 via PatientDocumentDrawerController), 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, the BulkUploadContext (context/BulkUploadContext.tsx) — SSE channel for per-file progress, used by BulkUploadStatusClient.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):

typescript
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 unified GET /patient-documents/:patientId endpoint 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

LayerChoiceWhy
Source of truthddx-api ResourceFacade (Prisma + FHIR + MinIO aggregate)Web NEVER writes any of the three directly; one server, one transaction boundary.
Unified mergeGET /patient-documents/:patientId returns merged + sourceBreakdownOne round trip; three sources; deterministic source labels for filters.
Gallery stateTanStack Query (useQuery)Cache by queryKeys.patientUnifiedDocuments; refetch on bulk-upload completion.
Drawer over navPatientDocumentDrawer opens over the galleryPreserves the gallery's filters/scroll while reading a document.
Upload UXMulti-step ingestion + localStorage persistenceA refresh mid-upload doesn't lose the queue.
Bulk progressSSE via useBulkIngestionSSEPer-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 headersframe-ancestors 'self' set by BFFBrowser will not embed PDFs without it; defense vs clickjacking still in place.
File ingestion typesingestion-types.ts + ingestion-utils.tsSingle source of valid MIME types; reject unsupported in browser before upload.
Native <embed>/<object>Banned for PDF inline viewerProject 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

  1. Doctor opens /<locale>/portal/doctor/patients/<id>/documents.
  2. requireRoleAccess('DOCTOR_ONLY', locale) passes.
  3. PatientDocumentsPageClient renders; useQuery calls patientDocumentsApi.getPatientDocuments(patientId, { pageSize: 100 }).
  4. Browser → BFF (/api/v1/patient-documents/:patientId) → ddx-api.
  5. ddx-api's patient-documents service queries Prisma for direct attachments, Visit-scoped attachments, and Generated Documents; merges into UnifiedPatientDocument[] with source discriminator and sourceBreakdown counts.
  6. Gallery renders PatientDocumentsGrid with source-filter chips.

(2) Upload a new document

  1. Doctor drops a PDF into IngestionDropzone.
  2. Client computes preview, validates type via ingestion-utils, persists to localStorage queue.
  3. POST multipart/form-data → BFF → ddx-api attachment-processing.
  4. ddx-api: virus scan → MIME detection → store object to MinIO → write Prisma PatientAttachment → optionally create FHIR DocumentReference → optionally trigger OCR (doxing/) → return the new UnifiedPatientDocument record.
  5. TanStack Query invalidates patientUnifiedDocuments(patientId) → gallery refetches → new card appears.

(3) View a document inline

  1. Click a card → PatientDocumentDrawer opens.
  2. DocumentDetailPanel renders metadata + a viewer.
  3. Viewer (@react-pdf-viewer/core per [[project_pdf_viewer_migration]]) loads /api/v1/documents/<id>/preview.
  4. BFF removes X-Frame-Options, sets frame-ancestors 'self'.
  5. PDF renders inline; user can scroll, zoom, copy text.
  6. Closing the drawer leaves the gallery filters/scroll intact.

(4) Patient self-service

  1. Patient logs in to /<locale>/portal/patient/documents.
  2. requireRoleAccess('PATIENT_ONLY', locale).
  3. <DocumentsClient /> calls the same patient-documents endpoint (the BFF carries X-Gateway-User-Role: PATIENT + X-Gateway-User-FHIR-Patient-ID).
  4. ddx-api scopes results to the patient's own documents and ACL permissions (see [[document-acl]]).
  5. PatientDocumentDrawerController renders 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; consumes BulkUploadContext.
  • 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; documentsQuery returns { documents, sourceBreakdown }.
  • ddx-web/src/components/ddx/documents/PatientDocumentDrawer.tsx — drawer + PatientDocumentDrawerController (used at portal/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.tspatientDocumentsApi.getPatientDocuments API client + UnifiedPatientDocument, SourceBreakdown types.
  • 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 navigationPatientDocumentDrawer is opened via PatientDocumentDrawerController mounted 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/core migration in flight — five surfaces are being migrated from native <iframe>/<object> PDF embedding to the React viewer (project memory project_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 at ddx-deepsearch/src/app/api/v1/[...path]/route.ts:99-108 is the reference; replicate any new media type explicitly.
  • localStorage upload queueuseBulkUpload.tsx persists 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 finaldocument-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 linkdocument-store/page.tsx:61-68 shows 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= under ddx-web/src and ddx-deepsearch/src; replace with @react-pdf-viewer/core.
  • 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).
    Document Management — Patient Documents and Attachments — Dudoxx Docs | Dudoxx