Document Viewer — Steer Document¶
Feature: Patient Document Viewer (presigned URL preview + download) Version: 1.0 Date: April 2026 Author: Srikanth Donthi (CPO/CTO) Status: Proposed Issue: #119
1. Problem Statement¶
Patients upload medical reports (PDFs, lab results, imaging) via the presigned URL flow to Cloudflare R2, but have no way to view or download them afterward. The DocsPanel (DocsPanel.tsx) shows document names and statuses (uploaded, analyzed, has_issues) but provides no view or download action. The RequirementsChecklist shows requirement-level status but likewise offers no way to open a previously uploaded document.
This is a gap in the patient experience: they upload files, the system processes them, but they cannot verify what was uploaded, re-read their own reports, or download copies. This matters because:
- Patients need to verify the correct document was uploaded (especially when names are auto-generated or abbreviated).
- Patients may need to share their uploaded documents with other parties (family, local doctors).
- There is no audit trail for document access today. If a presigned download URL were added without logging, there would be no record of who viewed what.
2. What Exists Today¶
Backend¶
| Component | File | What it does |
|---|---|---|
| Upload presign | app/routers/documents.py |
POST .../presign generates presigned PUT URL for R2 upload |
| Upload confirm | app/routers/documents.py |
POST .../confirm creates DocumentReference record + queues OCR |
| List documents | app/routers/documents.py |
GET .../documents returns DocumentRead[] with metadata |
| Verify file | app/routers/documents.py |
GET .../verify/{storage_key} HEAD-checks R2 for file existence |
| SSE stream | app/routers/documents.py |
GET .../stream pushes real-time OCR/analysis status updates |
| Download URL generator | app/integrations/r2_client.py |
generate_download_url(key, expires_in=900) -- exists but is unused by any endpoint |
| File download | app/integrations/r2_client.py |
download_file(key) downloads bytes from R2 (used only by OCR pipeline) |
| Document model | app/models/document.py |
DocumentReference with storage_key, mime_type, original_filename, tenant_id, patient_id |
| Document service | app/services/document_service.py |
get_document() retrieves a single doc by ID (tenant-scoped), list_documents() retrieves all for a patient |
| Event model | app/models/event.py |
Append-only events table with event_type, tenant_id, actor_id, patient_id, payload |
| Audit log model | app/models/audit.py |
AuditLog with actor_id, action, resource_type, resource_id, tenant_id, changes |
Frontend¶
| Component | File | What it does |
|---|---|---|
| DocsPanel | DocsPanel.tsx |
Shows document checklist grouped by lab tests / imaging records. Each RequirementCard displays filename, status icon, report date, validation issues. No view/download action. |
| RequirementsChecklist | RequirementsChecklist.tsx |
Shows procedure-level test requirements with priority, validity, source acceptance. Lists requirement status but no action to view uploaded documents. |
| ConversationApp | ConversationApp.tsx |
Renders document_processing and document_analysis_result rich cards inline. References documents by ID for SSE progress tracking. No view action on completed documents. |
Key observation¶
The R2 client already has generate_download_url() implemented and tested. The backend has get_document() which validates ownership (patient_id + tenant_id + not deleted). The missing piece is an endpoint that connects them and a frontend component that calls it.
3. Design Decisions¶
3.1 Presigned GET URL Approach (Not Byte Proxying)¶
Decision: The download endpoint returns a presigned R2 GET URL. The browser fetches the file directly from R2. The API server never proxies file bytes.
Rationale: - R2 has zero egress costs (Cloudflare's pricing model). No bandwidth penalty for direct serving. - Eliminates API server memory pressure from large files (PDFs can be 50MB per the upload limit). - Presigned URL pattern is already established for uploads. Symmetric approach for downloads. - Stateless endpoint -- naturally extractable to a separate microservice per the architecture plan.
Rejected alternative: Proxying bytes through FastAPI (StreamingResponse from R2 download). This would consume server RAM proportional to file size, add latency, and create a bottleneck under concurrent requests. The only benefit would be hiding the R2 URL from the client, which is not a meaningful security gain given presigned URL expiry.
3.2 Short-Lived URLs (15 Minutes)¶
Decision: Presigned download URLs expire after 900 seconds (15 minutes), consistent with the upload presign expiry. Each view request generates a fresh URL.
Rationale:
- Minimizes the window of exposure if a URL is shared or leaked.
- 15 minutes is long enough for the browser to start rendering (even large PDFs begin streaming immediately).
- Regeneration on each view request means expired links are never a UX problem -- patient clicks "View" again and gets a fresh URL.
- Consistent with the existing generate_presign flow which uses expires_in_seconds=900.
3.3 Access Control: Patient Ownership + Tenant Isolation¶
Decision: A patient can only view documents belonging to their own patient_id within their tenant_id. The get_document() service function already enforces this triple filter (document_id + patient_id + tenant_id + is_deleted == False).
Implementation: The download endpoint reuses the existing _require_patient() guard (validates patient exists in tenant) and document_service.get_document() (validates document belongs to patient). No new authorization logic needed.
Future: Provider/coordinator access to patient documents is out of scope for this iteration. It will be gated behind the provider_document_access feature flag when implemented.
3.4 Audit Logging: document.viewed Event¶
Decision: Every successful download URL generation writes a document.viewed event to the events table and an AuditLog entry.
Event payload:
{
"event_type": "document.viewed",
"tenant_id": "tenant-apollo-001",
"actor_id": "patient-uuid",
"patient_id": "patient-uuid",
"payload": {
"document_id": "doc-uuid",
"content_type": "application/pdf",
"filename": "knee-xray-2026.pdf",
"access_type": "presigned_url"
}
}
Rationale:
- Completes the document lifecycle audit trail: document.uploaded (exists today) -> document.viewed (new).
- Supports future compliance reporting (who accessed what medical records, when).
- Matches the existing pattern in confirm_upload() which writes both an Event and an AuditLog.
3.5 PDF Preview: Browser-Native First¶
Decision: PDFs are displayed in an inline <iframe> on desktop. On mobile (viewport < 768px), the presigned URL opens in a new tab. No PDF.js dependency.
Rationale: - All modern desktop browsers (Chrome, Firefox, Safari, Edge) have built-in PDF renderers. - An iframe avoids the complexity and bundle size of PDF.js (~500KB gzipped). - Mobile browsers handle PDFs inconsistently in iframes. A new tab delegates to the OS-level PDF viewer (which is universally excellent on iOS and Android). - If browser-native rendering proves insufficient for specific use cases (annotations, page navigation), PDF.js can be added later behind a feature flag.
3.6 Image Preview: Inline with Lightbox¶
Decision: JPEG, PNG, and TIFF images display inline in a modal/drawer with zoom and pan capabilities (lightbox pattern). The image src is the presigned URL.
Rationale:
- Medical images (X-rays, scanned reports) benefit from zoom to inspect details.
- A lightbox keeps the patient in the conversation context (same as the FullEHRDrawer pattern).
- CSS object-fit: contain ensures images display correctly regardless of aspect ratio.
3.7 Supported Formats¶
| Format | MIME Type | Preview Method |
|---|---|---|
application/pdf |
iframe (desktop) / new tab (mobile) | |
| JPEG | image/jpeg |
inline <img> with lightbox |
| PNG | image/png |
inline <img> with lightbox |
| TIFF | image/tiff |
inline <img> with lightbox (browser-dependent; fallback to download) |
These match the upload validation rules in guardrails.yaml and file_validator.py.
3.8 DICOM: Out of Scope¶
Decision: DICOM files (.dcm, application/dicom) are not viewable in the browser. They require conversion via pydicom + a DICOM web viewer (Cornerstone.js or OHIF). This is tracked separately and will be gated behind a dicom_viewer_enabled feature flag.
For now, DICOM files show a "Download" button only (no inline preview). A tooltip explains: "DICOM files require specialized viewing software."
3.9 Provider/Coordinator Access: Future, Feature-Flagged¶
Decision: This iteration is patient-only. Provider and coordinator access to patient documents will be implemented behind the provider_document_access feature flag. It requires:
- Role-based access (provider can only view documents for patients who have consented to share with that provider).
- Consent verification before generating the presigned URL.
- Different audit event (document.viewed_by_provider).
3.10 Dark Mode Compatibility¶
Decision: The DocumentViewer component uses CSS variable tokens from the existing design system (chat-surface, chat-text, chat-border, etc.). No hardcoded colors. The component inherits dark mode behavior from the parent theme.
3.11 Microservices Readiness¶
Decision: The download endpoint is stateless -- it reads a document record from PostgreSQL, calls R2 for a presigned URL, writes an event, and returns. No session state, no file caching. This makes it naturally extractable to an independent Document Service during the microservices extraction phases documented in CLAUDE.md.
4. Risks¶
4.1 Presigned URL Forwarding¶
Risk: A patient can copy the presigned URL and share it with anyone. The URL works for anyone within the 15-minute window without authentication.
Assessment: Acceptable. This is the same risk as email attachments -- once a patient has their own medical document, they can share it by any means. The short expiry window limits exposure. The audit log records who generated the URL.
4.2 R2 Rate Limiting on Repeated Views¶
Risk: A patient rapidly clicking "View" generates many presigned URL requests. While the presigned URL generation is a local signing operation (no R2 API call), the subsequent browser GET requests to R2 could hit rate limits under extreme use.
Mitigation: Cloudflare R2 free tier allows 10 million Class B (read) operations per month. A patient viewing documents 100 times/day is well within limits. No rate limiting needed at MVP.
4.3 CORS Configuration¶
Risk: The browser fetching the presigned URL directly from R2 requires CORS to be configured on the R2 bucket. If Access-Control-Allow-Origin does not include the frontend domain (app.curaway.ai), the browser will block the request.
Mitigation: The R2 bucket already has CORS configured for uploads (PUT via presigned URL). The same CORS rules should cover GET requests. Verify during implementation and add GET to allowed methods if not already present.
4.4 Content-Type Mismatch¶
Risk: If the Content-Type stored in the document metadata does not match the actual file, the browser may misrender or refuse to display it.
Mitigation: The presigned GET URL should set Content-Type from the document's mime_type field (stored at upload time). The Content-Type is set via the ResponseContentType parameter in the presigned URL generation.
5. Connection to Existing Specs¶
- FullEHRDrawer (#103): The full EHR view could embed document preview links for documents referenced in the health record. When a lab result section shows "Source: CBC Report (uploaded 2026-04-01)", that text could link to the DocumentViewer.
- DocsPanel: Gets "View" buttons on each document row. This is the primary entry point.
- RequirementsChecklist: Gets a view action on requirements that have an uploaded document matched.
- ConversationApp: The
document_analysis_resultrich card could add a "View Original" link that opens the DocumentViewer for the source document.
6. Success Criteria¶
- A patient can click "View" on any uploaded document in the DocsPanel and see its contents.
- PDFs render inline on desktop and open in a new tab on mobile.
- Images render inline with zoom/pan capability.
- Every view generates a
document.viewedevent in the events table. - The presigned URL expires after 15 minutes and a new one is generated on each view.
- A patient cannot view documents belonging to another patient or another tenant.
- The component works in both light and dark mode.