feature: HL7 FHIR R4 Integration

This commit is contained in:
voltsrage
2026-06-27 22:23:45 +08:00
parent 756cff332c
commit 5646dfddb4
27 changed files with 2658 additions and 16 deletions
+70 -14
View File
@@ -2,7 +2,7 @@
A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession. A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
**Implementation status:** Phases 19 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`) with role-specific views for intake, entry, verification, clinical approval, live capture, patient history, and supervisor dashboard. Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion deferral with exponential backoff retry, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 adds extended demo seed data (10 batches across all types, tracks, and statuses), the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`), and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)). Post-phase hardening includes health check endpoints, user management APIs, auth rate limiting, document access audit events, batch cancellation, list/queue sorting (`sortBy`/`sortDirection`), unified promotion retry logic, normalized patient deduplication, and assignment-time `IN_ENTRY` transitions. See [Implemented Phases](#implemented-phases) and the [gap analysis](docs/vigilcare-records-gap-analysis.md) for remaining work. **Implementation status:** Phases 110 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`) with role-specific views for intake, entry, verification, clinical approval, live capture, patient history, and supervisor dashboard. Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion deferral with exponential backoff retry, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 adds extended demo seed data (10 batches across all types, tracks, and statuses), the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`), and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)). Phase 10 adds barcode/QR cover sheets for high-volume backfill: generate printable cover pages with encoded batch type, track, optional patient, and optional entry-clerk pre-assignment; barcode-assisted upload auto-creates batches and skips manual classification. Post-phase hardening includes health check endpoints, user management APIs, auth rate limiting, document access audit events, batch cancellation, list/queue sorting (`sortBy`/`sortDirection`), unified promotion retry logic, normalized patient deduplication, and assignment-time `IN_ENTRY` transitions. Phase 11 (HL7 FHIR R4 read API) is planned next — see [docs/plans/phase-11-plan.md](docs/plans/phase-11-plan.md). See [Implemented Phases](#implemented-phases) and the [gap analysis](docs/vigilcare-records-gap-analysis.md) for remaining work.
## Domain Model — How It Maps to a Real Clinical System ## Domain Model — How It Maps to a Real Clinical System
@@ -45,7 +45,8 @@ Append-only audit log entry for every state transition, field-level correction,
## Features ## Features
- **Document Upload and Batch Creation** — multipart upload (PDF, JPEG, PNG; max 25 MB) with MinIO storage, SHA-256 integrity hash, presigned GET URLs (15-minute expiry); duplicate detection per patient within 24 hours by SHA-256 (`409 DUPLICATE_DOCUMENT`); cross-patient duplicate scans allowed; optional `supersedesBatchId` creates a correction batch linked to a promoted batch; batch created in `UPLOADED` status with `DigitizationEvent` audit trail - **Document Upload and Batch Creation** — multipart upload (PDF, JPEG, PNG; max 25 MB) with MinIO storage, SHA-256 integrity hash, presigned GET URLs (15-minute expiry); duplicate detection per patient within 24 hours by SHA-256 (`409 DUPLICATE_DOCUMENT`); cross-patient duplicate scans allowed; optional `supersedesBatchId` creates a correction batch linked to a promoted batch; batch created in `UPLOADED` status with `DigitizationEvent` audit trail; optional `coverSheetCode` looks up a cover sheet barcode, applies encoded batch type/track/patient, redeems the cover sheet on success (`409 COVER_SHEET_ALREADY_USED` on reuse), and auto-assigns the batch when the cover sheet has `assignToUserId` set
- **Cover Sheet System** — `POST /cover-sheets/generate` creates 1100 cover sheets with unique `VCR-CS-{8-hex}` codes encoding batch type, track, optional patient, and optional entry-clerk pre-assignment; `GET /cover-sheets/lookup/{code}` resolves a barcode for intake auto-fill; `GET /cover-sheets` lists sheets with `isUsed`/`patientId` filters; `POST /cover-sheets/{id}/pdf` and `POST /cover-sheets/batch-pdf` produce printable PDFs with QR codes (QRCoder); cover sheets are single-use and linked to the batch they create via `batchId`
- **Batch Assignment** — `PATCH /digitization-batches/:id/assign` assigns an entry clerk with a Redis lock (`SET batch:assign:{id} NX EX 3600`) to prevent double-assignment; transitions `UPLOADED → IN_ENTRY` immediately and writes an `entry_started` audit event; only `UPLOADED` batches can be assigned; `409 BATCH_ALREADY_ASSIGNED` on conflict - **Batch Assignment** — `PATCH /digitization-batches/:id/assign` assigns an entry clerk with a Redis lock (`SET batch:assign:{id} NX EX 3600`) to prevent double-assignment; transitions `UPLOADED → IN_ENTRY` immediately and writes an `entry_started` audit event; only `UPLOADED` batches can be assigned; `409 BATCH_ALREADY_ASSIGNED` on conflict
- **Batch Cancellation** — `POST /digitization-batches/:id/cancel` (administrator only) permanently cancels batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED` status with a mandatory reason (min 5 characters); releases the Redis assignment lock; `CANCELLED` is terminal - **Batch Cancellation** — `POST /digitization-batches/:id/cancel` (administrator only) permanently cancels batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED` status with a mandatory reason (min 5 characters); releases the Redis assignment lock; `CANCELLED` is terminal
- **Draft Data Entry** — full CRUD on draft payload: upsert patient demographics, upsert encounter context, add/edit/delete observation rows; plausibility validation on observation values at draft save time (catches decimal errors before verification); `DraftService` retains a fallback `UPLOADED`/`REJECTED → `IN_ENTRY` transition when entry begins without prior assignment; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`) - **Draft Data Entry** — full CRUD on draft payload: upsert patient demographics, upsert encounter context, add/edit/delete observation rows; plausibility validation on observation values at draft save time (catches decimal errors before verification); `DraftService` retains a fallback `UPLOADED`/`REJECTED → `IN_ENTRY` transition when entry begins without prior assignment; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`)
@@ -61,7 +62,7 @@ Append-only audit log entry for every state transition, field-level correction,
- **Batch Audit Trail API** — `GET /digitization-batches/:id/events` returns cursor-paginated digitization events with actor username and full name; accessible by administrator, verifier, and clinical approver roles - **Batch Audit Trail API** — `GET /digitization-batches/:id/events` returns cursor-paginated digitization events with actor username and full name; accessible by administrator, verifier, and clinical approver roles
- **User Directory and Management** — `GET /users?role=` lists active users for batch assignment; administrators can `POST /users` (create), `PATCH /users/:id` (update name, role, active flag), `POST /users/:id/reset-password`, and any authenticated user can `POST /users/me/change-password` with current-password verification - **User Directory and Management** — `GET /users?role=` lists active users for batch assignment; administrators can `POST /users` (create), `PATCH /users/:id` (update name, role, active flag), `POST /users/:id/reset-password`, and any authenticated user can `POST /users/me/change-password` with current-password verification
- **Document Access Audit** — `GET /digitization-batches/:id` writes a `document_accessed` digitization event (deduplicated per user within 5 minutes) whenever a presigned scan URL is issued - **Document Access Audit** — `GET /digitization-batches/:id` writes a `document_accessed` digitization event (deduplicated per user within 5 minutes) whenever a presigned scan URL is issued
- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing and nav (intake, entry, verification, clinical approval, live capture, patient history, supervisor dashboard); split-pane scan viewer with zoom/pan/rotate; draft entry with auto-save and batch-type-aware fields (allergies, medications, discharge diagnosis); field-level verification checkboxes; clinical approval queue with scan review, approve/reject, and retroactive alert toggle; live capture form for new or existing encounters with attestation and password confirm; patient history timeline with correction chain and audit trail; presigned URL refresh for long sessions; JWT refresh interceptor and toast notifications - **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing and nav (intake, cover sheets, entry, verification, clinical approval, live capture, patient history, supervisor dashboard); split-pane scan viewer with zoom/pan/rotate; cover sheet management view (generate, list, print PDF); barcode-assisted intake (scan/type cover sheet code to auto-fill batch type, track, patient, then upload); draft entry with auto-save and batch-type-aware fields (allergies, medications, discharge diagnosis); field-level verification checkboxes; clinical approval queue with scan review, approve/reject, and retroactive alert toggle; live capture form for new or existing encounters with attestation and password confirm; patient history timeline with correction chain and audit trail; presigned URL refresh for long sessions; JWT refresh interceptor and toast notifications
- **Live Capture (Track B)** — `POST /live-capture/encounters/{encounterId}/observations` and `POST /live-capture/encounters` for credentialed clinicians entering vitals at bedside; clinician attestation + password re-confirm replaces the dual-human verification gate; observations promote synchronously to live tables in a single transaction with `source = live_capture`; critical threshold evaluation runs before the response returns, with inline `criticalAlert` per observation, committed `ClinicalAlert` rows, and `observation.recorded` / `alert.generated` outbox events; `422 ATTESTATION_REQUIRED`, `422 PASSWORD_CONFIRM_INVALID`, `422 EMPTY_OBSERVATIONS`, `409 ENCOUNTER_NOT_ACTIVE`, `409 ACTIVE_ENCOUNTER_EXISTS` - **Live Capture (Track B)** — `POST /live-capture/encounters/{encounterId}/observations` and `POST /live-capture/encounters` for credentialed clinicians entering vitals at bedside; clinician attestation + password re-confirm replaces the dual-human verification gate; observations promote synchronously to live tables in a single transaction with `source = live_capture`; critical threshold evaluation runs before the response returns, with inline `criticalAlert` per observation, committed `ClinicalAlert` rows, and `observation.recorded` / `alert.generated` outbox events; `422 ATTESTATION_REQUIRED`, `422 PASSWORD_CONFIRM_INVALID`, `422 EMPTY_OBSERVATIONS`, `409 ENCOUNTER_NOT_ACTIVE`, `409 ACTIVE_ENCOUNTER_EXISTS`
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` and `CANCELLED` are terminal — corrections require a new batch with `supersedesBatchId` - **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` and `CANCELLED` are terminal — corrections require a new batch with `supersedesBatchId`
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing; login and refresh rate-limited to 10 requests per 5 minutes per client (`429`) - **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing; login and refresh rate-limited to 10 requests per 5 minutes per client (`429`)
@@ -99,6 +100,8 @@ HTTP request
├── UserDirectoryService (active user listing, create/update/deactivate, password reset/change) ├── UserDirectoryService (active user listing, create/update/deactivate, password reset/change)
├── AttestationService (clinician role + password re-confirm for live capture) ├── AttestationService (clinician role + password re-confirm for live capture)
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation) ├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
├── CoverSheetService (generate, lookup, redeem, list cover sheets)
├── CoverSheetPdfGenerator (printable PDF with QR codes)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs) ├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
├── PlausibilityValidator (per-code numeric range guard) ├── PlausibilityValidator (per-code numeric range guard)
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables) ├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
@@ -143,6 +146,7 @@ HTTP request
| Logging | Serilog + Seq sink | | Logging | Serilog + Seq sink |
| Metrics | Prometheus (`prometheus-net`) + Grafana | | Metrics | Prometheus (`prometheus-net`) + Grafana |
| Docs | Swagger / OpenAPI (Swashbuckle) | | Docs | Swagger / OpenAPI (Swashbuckle) |
| Barcode / PDF | QRCoder (cover sheet QR codes; raw PDF generation) |
| Testing | xUnit + FluentAssertions + WebApplicationFactory | | Testing | xUnit + FluentAssertions + WebApplicationFactory |
--- ---
@@ -157,6 +161,7 @@ VigilCareRecords/
│ ├── Controllers/ │ ├── Controllers/
│ │ ├── ApprovalController.cs # Batch approval, promotion deferral (202), promotion to live tables │ │ ├── ApprovalController.cs # Batch approval, promotion deferral (202), promotion to live tables
│ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile │ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
│ │ ├── CoverSheetController.cs # Cover sheet generate, lookup, list, PDF export
│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment, events, promote │ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment, events, promote
│ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit │ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
│ │ ├── PatientsController.cs # Patient search and digitization history │ │ ├── PatientsController.cs # Patient search and digitization history
@@ -175,13 +180,13 @@ VigilCareRecords/
│ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh │ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh
│ │ ├── stores/ # Pinia: auth, batches, liveCapture │ │ ├── stores/ # Pinia: auth, batches, liveCapture
│ │ ├── router/index.ts # Role-based routes and navigation guards │ │ ├── router/index.ts # Role-based routes and navigation guards
│ │ ├── views/ # Login, Intake, Entry, Verification, Approval, LiveCapture, PatientHistory, QueueDashboard │ │ ├── views/ # Login, Intake, CoverSheets, Entry, Verification, Approval, LiveCapture, PatientHistory, QueueDashboard
│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, ToastContainer, AssignClerkDialog │ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, ToastContainer, AssignClerkDialog
│ │ ├── composables/ # usePresignedUrl (URL refresh), useToast (notifications) │ │ ├── composables/ # usePresignedUrl (URL refresh), useToast (notifications)
│ │ └── types/index.ts # TypeScript interfaces matching API response shapes │ │ └── types/index.ts # TypeScript interfaces matching API response shapes
│ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217 │ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217
│ └── tailwind.config.js # Clinical color palette and layout component classes │ └── tailwind.config.js # Clinical color palette and layout component classes
├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 19) ├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 110)
├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217) ├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217)
├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana ├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana
├── scripts/ ├── scripts/
@@ -192,9 +197,11 @@ VigilCareRecords/
│ ├── run-vigilcare-records-phase-5-verification.sh │ ├── run-vigilcare-records-phase-5-verification.sh
│ ├── run-vigilcare-records-phase-6-verification.sh │ ├── run-vigilcare-records-phase-6-verification.sh
│ ├── run-vigilcare-records-phase-8-verification.sh # Prometheus metrics, overview, events, retry │ ├── run-vigilcare-records-phase-8-verification.sh # Prometheus metrics, overview, events, retry
── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test ── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
│ └── run-vigilcare-records-phase-10-verification.sh # Cover sheets, barcode upload, PDF, auto-assign
│ └── run-vigilcare-records-phase-11-verification.sh # FHIR metadata, read/search, $everything, integration tests
└── docs/ └── docs/
├── plans/ # Phase 19 implementation guides ├── plans/ # Phase 111 implementation guides
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference ├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog ├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog
└── vigilcare-records-prd.md # Product requirements and phase roadmap └── vigilcare-records-prd.md # Product requirements and phase roadmap
@@ -363,7 +370,7 @@ Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the
| Username | Password | Default route | | Username | Password | Default route |
|---|---|---| |---|---|---|
| `intake1` | `password` | `/intake` — upload scans, assign entry clerks | | `intake1` | `password` | `/intake` — upload scans, barcode-assisted cover sheet upload; `/cover-sheets` — generate and print cover sheets |
| `entry1` | `password` | `/entry` — data entry queue and split-pane form | | `entry1` | `password` | `/entry` — data entry queue and split-pane form |
| `verifier1` | `password` | `/verification` — field-level verification | | `verifier1` | `password` | `/verification` — field-level verification |
| `approver1` | `password` | `/approval` — clinical sign-off before promotion | | `approver1` | `password` | `/approval` — clinical sign-off before promotion |
@@ -401,6 +408,8 @@ Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 | | `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 |
| `LiveCaptureIntegrationTests` | 6 | Attestation and password confirm, synchronous promotion, critical low/high potassium alerts, mixed-batch alerting, open encounter + vitals, role and validation guards, audit events | | `LiveCaptureIntegrationTests` | 6 | Attestation and password confirm, synchronous promotion, critical low/high potassium alerts, mixed-batch alerting, open encounter + vitals, role and validation guards, audit events |
| `UserManagementTests` | 9 | User create/update/deactivate, admin password reset, self-service password change, duplicate username guard, weak password validation | | `UserManagementTests` | 9 | User create/update/deactivate, admin password reset, self-service password change, duplicate username guard, weak password validation |
| `CoverSheetBatchTests` | 10 | Cover sheet redeem on upload, reuse prevention, auto-assign from pre-assigned cover sheet |
| `CoverSheetPdfTests` | 10 | Single and batch PDF generation, valid PDF structure with QR metadata |
| `BatchOperationsTests` | — | Batch cancellation (status guards, Redis lock release), list/queue `sortBy`/`sortDirection` validation | | `BatchOperationsTests` | — | Batch cancellation (status guards, Redis lock release), list/queue `sortBy`/`sortDirection` validation |
### Verification Scripts ### Verification Scripts
@@ -416,6 +425,8 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts ./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts
./scripts/run-vigilcare-records-phase-8-verification.sh # Phase 8 — Prometheus metrics, work-queue overview, batch events, promotion retry ./scripts/run-vigilcare-records-phase-8-verification.sh # Phase 8 — Prometheus metrics, work-queue overview, batch events, promotion retry
./scripts/run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test ./scripts/run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
./scripts/run-vigilcare-records-phase-10-verification.sh # Phase 10 — cover sheets, barcode upload, PDF, auto-assign
./scripts/run-vigilcare-records-phase-11-verification.sh # Phase 11 — FHIR R4 read/search, $everything, LOINC, integration tests
``` ```
--- ---
@@ -501,10 +512,11 @@ Error response:
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `file` | binary | yes | PDF, JPEG, or PNG (max 25 MB) | | `file` | binary | yes | PDF, JPEG, or PNG (max 25 MB) |
| `batchType` | string | yes | `PATIENT_REGISTRATION`, `ENCOUNTER_SUMMARY`, `VITALS_SHEET`, `LAB_RESULTS`, `MEDICATION_LIST`, `ALLERGY_UPDATE`, `MIXED` | | `batchType` | string | yes* | `PATIENT_REGISTRATION`, `ENCOUNTER_SUMMARY`, `VITALS_SHEET`, `LAB_RESULTS`, `MEDICATION_LIST`, `ALLERGY_UPDATE`, `MIXED` — required unless `coverSheetCode` is provided; cover sheet values override when both are sent |
| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` | | `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` — overridden by cover sheet when `coverSheetCode` is set |
| `patientId` | Guid | no | Link to existing patient (enables duplicate detection) | | `patientId` | Guid | no | Link to existing patient (enables duplicate detection); inherited from cover sheet when set |
| `supersedesBatchId` | Guid | no | Links a correction batch to the promoted batch it will supersede on promotion | | `supersedesBatchId` | Guid | no | Links a correction batch to the promoted batch it will supersede on promotion |
| `coverSheetCode` | string | no | Cover sheet barcode (e.g. `VCR-CS-A3F7B2D1`); auto-applies batch type, track, and patient; redeems on success; auto-assigns when cover sheet has `assignToUserId` |
**Status codes:** **Status codes:**
@@ -512,8 +524,8 @@ Error response:
|---|---| |---|---|
| 201 | Batch created | | 201 | Batch created |
| 400 | Empty file or invalid MIME type | | 400 | Empty file or invalid MIME type |
| 404 | Superseded batch not found (`SUPERSEDED_BATCH_NOT_FOUND`) | | 404 | Superseded batch not found (`SUPERSEDED_BATCH_NOT_FOUND`); cover sheet not found (`COVER_SHEET_NOT_FOUND`) |
| 409 | Duplicate document (same SHA-256 for same patient within 24 hours); batch already superseded (`BATCH_ALREADY_SUPERSEDED`) | | 409 | Duplicate document (same SHA-256 for same patient within 24 hours); batch already superseded (`BATCH_ALREADY_SUPERSEDED`); cover sheet already used (`COVER_SHEET_ALREADY_USED`) |
| 422 | Superseded batch not in `PROMOTED` status (`SUPERSEDED_BATCH_NOT_PROMOTED`) | | 422 | Superseded batch not in `PROMOTED` status (`SUPERSEDED_BATCH_NOT_PROMOTED`) |
**PATCH `/digitization-batches/{id}/assign` body:** **PATCH `/digitization-batches/{id}/assign` body:**
@@ -530,6 +542,30 @@ Error response:
**Status codes:** `409 ILLEGAL_STATUS_TRANSITION` when the batch is not in a cancellable status. **Status codes:** `409 ILLEGAL_STATUS_TRANSITION` when the batch is not in a cancellable status.
### Cover Sheets
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/cover-sheets/generate` | Intake Clerk, Administrator | Generate 1100 cover sheets with unique barcode codes |
| GET | `/cover-sheets/lookup/{code}` | Any authenticated | Look up a cover sheet by barcode for intake auto-fill |
| GET | `/cover-sheets` | Intake Clerk, Administrator | List cover sheets; optional `isUsed`, `patientId` filters; paginated |
| POST | `/cover-sheets/{id}/pdf` | Intake Clerk, Administrator | Download a printable PDF with QR code for one cover sheet |
| POST | `/cover-sheets/batch-pdf` | Intake Clerk, Administrator | Download a multi-page PDF for a list of cover sheet IDs |
**POST `/cover-sheets/generate` body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `count` | int | yes | Number of cover sheets to generate (1100) |
| `batchType` | string | yes | Batch type encoded in the barcode |
| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` |
| `patientId` | Guid | no | Pre-link to an existing patient |
| `assignToUserId` | Guid | no | Pre-assign batches created with this cover sheet to an entry clerk |
**Cover sheet response fields:** `id`, `code` (`VCR-CS-{8-hex}`), `batchType`, `track`, `patientId`, `patientName`, `patientMrn`, `assignToUserId`, `assignToUserName`, `isUsed`, `batchId`, `createdAt`, `usedAt`.
**Status codes:** `404 PATIENT_NOT_FOUND`, `404 USER_NOT_FOUND`, `404 COVER_SHEET_NOT_FOUND`, `409 COVER_SHEET_ALREADY_USED`.
### Draft Data Entry ### Draft Data Entry
| Method | Path | Description | | Method | Path | Description |
@@ -829,6 +865,24 @@ Returns `503` when a required dependency is unhealthy.
## Data Models ## Data Models
### CoverSheet
Single-use barcode label that encodes batch metadata for high-volume backfill intake. Redeemed atomically when a batch is created with `coverSheetCode`.
```
id Guid PK
code string required, unique — VCR-CS-{8-hex} encoded in QR barcode
patientId Guid? optional pre-link to patient
batchType string encoded batch type
track string BACKFILL | LIVE_CAPTURE
assignToUserId Guid? optional entry clerk pre-assignment
generatedByUserId Guid FK → User who generated the cover sheet
isUsed bool default false — set true on batch creation
batchId Guid? FK → DigitizationBatch created from this cover sheet
createdAt DateTimeOffset
usedAt DateTimeOffset? set when redeemed
```
### DigitizationBatch ### DigitizationBatch
``` ```
@@ -1149,7 +1203,7 @@ Response shape:
## Implemented Phases ## Implemented Phases
Phases 19 are fully implemented and verified via integration tests and per-phase scripts. Post-phase hardening (health checks, user management, promotion retry unification, patient dedup normalization, assignment-time status transitions, document access audit, batch cancellation) is also in place. See [docs/vigilcare-records-gap-analysis.md](docs/vigilcare-records-gap-analysis.md) for the remaining backlog. Phases 110 are fully implemented and verified via integration tests and per-phase scripts. Post-phase hardening (health checks, user management, promotion retry unification, patient dedup normalization, assignment-time status transitions, document access audit, batch cancellation) is also in place. Phase 11 (HL7 FHIR R4 read API) is planned — see [docs/plans/phase-11-plan.md](docs/plans/phase-11-plan.md). See [docs/vigilcare-records-gap-analysis.md](docs/vigilcare-records-gap-analysis.md) for the remaining backlog.
| Phase | Feature | Status | | Phase | Feature | Status |
|---|---|---| |---|---|---|
@@ -1162,4 +1216,6 @@ Phases 19 are fully implemented and verified via integration tests and per-ph
| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing and nav, split-pane scan viewer, batch-type-aware draft entry (allergies, medications, discharge diagnosis), verification checkboxes, clinical approval view, live capture view (new/existing encounter), patient history timeline, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard, toast notifications | Done | | 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing and nav, split-pane scan viewer, batch-type-aware draft entry (allergies, medications, discharge diagnosis), verification checkboxes, clinical approval view, live capture view (new/existing encounter), patient history timeline, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard, toast notifications | Done |
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done | | 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
| 9 | Extended seed data (10 demo batches across all types/tracks/statuses), E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`), `UserManagementTests` | Done | | 9 | Extended seed data (10 demo batches across all types/tracks/statuses), E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`), `UserManagementTests` | Done |
| 10 | Barcode/QR cover sheet system: `CoverSheet` entity, generate/lookup/list/PDF APIs, `coverSheetCode` on batch upload with redeem and auto-assign, printable PDF with QRCoder, `/cover-sheets` and barcode-assisted `/intake` UI views, `CoverSheetBatchTests`, `CoverSheetPdfTests`, Phase 10 verification script | Done |
| — | Health probes (`/health/live`, `/health/ready`, `/health/startup`), user management CRUD + password endpoints, auth rate limiting, document access audit, batch cancellation, list/queue sorting, unified `ExecutePromotionCoreAsync` for approve + retry promote, assignment-time `IN_ENTRY` transition, CORS policy for production frontend origins | Done | | — | Health probes (`/health/live`, `/health/ready`, `/health/startup`), user management CRUD + password endpoints, auth rate limiting, document access audit, batch cancellation, list/queue sorting, unified `ExecutePromotionCoreAsync` for approve + retry promote, assignment-time `IN_ENTRY` transition, CORS policy for production frontend origins | Done |
| 11 | HL7 FHIR R4 read-only API for promoted clinical data (Patient, Encounter, Observation) | Planned |
@@ -0,0 +1,298 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Integration tests for FHIR R4 read/search endpoints (Phase 11 Step 7).
/// </summary>
[Collection("Database")]
public class FhirIntegrationTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private HttpClient _client = null!;
private HttpClient _anonymousClient = null!;
public FhirIntegrationTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedAsync(db);
await FhirClinicalSeedHelper.SeedAsync(db);
_client = await AuthHelper.LoginAsync(_fixture, "admin1");
_anonymousClient = _fixture.CreateClient();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Metadata_ReturnsCapabilityStatementWithSupportedResources()
{
var response = await GetFhirAsync("/fhir/metadata", authenticated: false);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
var resourceTypes = body.GetProperty("rest")[0]
.GetProperty("resource")
.EnumerateArray()
.Select(r => r.GetProperty("type").GetString())
.ToList();
resourceTypes.Should().Contain(new[] { "Patient", "Encounter", "Observation" });
}
[Fact]
public async Task ReadPatient_ReturnsFhirPatientWithMrnNameAndGender()
{
var response = await GetFhirAsync($"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("Patient");
body.GetProperty("id").GetString()
.Should().Be(FhirClinicalSeedHelper.Patient1Id.ToString());
var identifier = body.GetProperty("identifier")[0];
identifier.GetProperty("value").GetString().Should().Be("VCR-000001");
body.GetProperty("name")[0].GetProperty("text").GetString()
.Should().Be("MARIA SANTOS");
body.GetProperty("gender").GetString().Should().Be("female");
body.GetProperty("birthDate").GetString().Should().Be("1978-03-15");
}
[Fact]
public async Task SearchPatients_ByName_ReturnsMatchingBundle()
{
var response = await GetFhirAsync("/fhir/Patient?name=Santos");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("type").GetString().Should().Be("searchset");
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("name")[0].GetProperty("text").GetString()
.Should().Contain("SANTOS");
}
[Fact]
public async Task SearchPatients_ByIdentifier_ReturnsPatientByMrn()
{
var response = await GetFhirAsync("/fhir/Patient?identifier=VCR-000001");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("entry").GetArrayLength().Should().Be(1);
body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("identifier")[0].GetProperty("value").GetString()
.Should().Be("VCR-000001");
}
[Fact]
public async Task ReadEncounter_ReturnsFhirEncounterWithStatusAndPatientReference()
{
var response = await GetFhirAsync($"/fhir/Encounter/{FhirClinicalSeedHelper.Encounter1Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("Encounter");
body.GetProperty("status").GetString().Should().Be("in-progress");
body.GetProperty("subject").GetProperty("reference").GetString()
.Should().Be($"Patient/{FhirClinicalSeedHelper.Patient1Id}");
}
[Fact]
public async Task SearchEncounters_ByPatient_ReturnsMatchingEncounters()
{
var patientId = FhirClinicalSeedHelper.Patient1Id;
var response = await GetFhirAsync($"/fhir/Encounter?patient={patientId}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
body.GetProperty("entry").EnumerateArray().Should().AllSatisfy(entry =>
{
entry.GetProperty("resource").GetProperty("subject")
.GetProperty("reference").GetString()
.Should().Be($"Patient/{patientId}");
});
}
[Fact]
public async Task ReadObservation_ReturnsFhirObservationWithLoincCodeAndUcumUnit()
{
var response = await GetFhirAsync($"/fhir/Observation/{FhirClinicalSeedHelper.HeartRateObsId}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("Observation");
var coding = body.GetProperty("code").GetProperty("coding")[0];
coding.GetProperty("system").GetString().Should().Be("http://loinc.org");
coding.GetProperty("code").GetString().Should().Be("8867-4");
if (coding.TryGetProperty("display", out var display))
display.GetString().Should().Be("Heart rate");
var value = body.GetProperty("valueQuantity");
value.GetProperty("value").GetDecimal().Should().Be(88m);
value.GetProperty("unit").GetString().Should().Be("bpm");
value.GetProperty("code").GetString().Should().Be("/min");
}
[Fact]
public async Task SearchObservations_ByCategoryVitalSigns_ReturnsVitalSignObservationsOnly()
{
var patientId = FhirClinicalSeedHelper.Patient1Id;
var response = await GetFhirAsync(
$"/fhir/Observation?patient={patientId}&category=vital-signs");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
foreach (var entry in body.GetProperty("entry").EnumerateArray())
{
var category = entry.GetProperty("resource").GetProperty("category")[0]
.GetProperty("coding")[0].GetProperty("code").GetString();
category.Should().Be("vital-signs");
}
}
[Fact]
public async Task SearchObservations_ByLoincCode_ResolvesToHeartRate()
{
var response = await GetFhirAsync("/fhir/Observation?code=8867-4");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
var coding = body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("code").GetProperty("coding")[0];
coding.GetProperty("code").GetString().Should().Be("8867-4");
var value = body.GetProperty("entry")[0].GetProperty("resource")
.GetProperty("valueQuantity").GetProperty("value").GetDecimal();
value.Should().Be(88m);
}
[Fact]
public async Task SearchObservations_ByDateGreaterOrEqual_FiltersByRecordedAt()
{
var patientId = FhirClinicalSeedHelper.Patient1Id;
var response = await GetFhirAsync(
$"/fhir/Observation?patient={patientId}&date=ge2026-06-20");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
}
[Fact]
public async Task PatientEverything_ReturnsCompletePatientBundle()
{
var response = await GetFhirAsync(
$"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}/$everything");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("type").GetString().Should().Be("searchset");
body.GetProperty("total").GetInt32().Should().BeGreaterThan(1);
var resourceTypes = body.GetProperty("entry").EnumerateArray()
.Select(e => e.GetProperty("resource").GetProperty("resourceType").GetString())
.ToList();
resourceTypes.Should().Contain("Patient");
resourceTypes.Should().Contain("Encounter");
resourceTypes.Should().Contain("Observation");
var includeModes = body.GetProperty("entry").EnumerateArray()
.Where(e => e.GetProperty("resource").GetProperty("resourceType").GetString() != "Patient")
.Select(e => e.GetProperty("search").GetProperty("mode").GetString());
includeModes.Should().AllBe("include");
}
[Fact]
public async Task ReadPatient_NotFound_Returns404OperationOutcome()
{
var missingId = Guid.Parse("00000000-0000-0000-0000-000000000001");
var response = await GetFhirAsync($"/fhir/Patient/{missingId}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var body = await ParseJsonAsync(response);
body.GetProperty("resourceType").GetString().Should().Be("OperationOutcome");
var issue = body.GetProperty("issue")[0];
issue.GetProperty("severity").GetString().Should().Be("error");
issue.GetProperty("code").GetString().Should().Be("not-found");
issue.GetProperty("diagnostics").GetString()
.Should().Contain($"Patient/{missingId}");
}
[Fact]
public async Task ReadPatient_ReturnsApplicationFhirJsonContentType()
{
var response = await GetFhirAsync($"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/fhir+json");
}
[Fact]
public async Task SearchPatients_PaginationLinks_AreCorrect()
{
var response = await GetFhirAsync("/fhir/Patient?_count=1&_offset=0");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await ParseJsonAsync(response);
body.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(2);
body.GetProperty("entry").GetArrayLength().Should().Be(1);
var links = body.GetProperty("link").EnumerateArray()
.ToDictionary(l => l.GetProperty("relation").GetString()!, l => l.GetProperty("url").GetString());
links.Should().ContainKey("self");
links["self"].Should().Contain("_count=1");
links["self"].Should().Contain("_offset=0");
links.Should().ContainKey("next");
links["next"].Should().Contain("_count=1");
links["next"].Should().Contain("_offset=1");
}
private async Task<HttpResponseMessage> GetFhirAsync(string path, bool authenticated = true)
{
var client = authenticated ? _client : _anonymousClient;
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json"));
return await client.SendAsync(request);
}
private static async Task<JsonElement> ParseJsonAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
return JsonDocument.Parse(json).RootElement;
}
}
@@ -18,7 +18,8 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{ {
["ConnectionStrings:DefaultConnection"] = ["ConnectionStrings:DefaultConnection"] =
"Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password", "Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password",
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true" ["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true",
["Fhir:BaseUrl"] = "http://localhost/fhir"
}); });
}); });
} }
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Seeds promoted clinical records aligned with Phase 9 demo batches for FHIR tests.
/// DataSeeder creates digitization batches; this helper populates the clinical tables
/// that FhirService reads.
/// </summary>
public static class FhirClinicalSeedHelper
{
public static readonly Guid Patient1Id = Guid.Parse("b1000000-0000-0000-0000-000000000001");
public static readonly Guid Patient2Id = Guid.Parse("b1000000-0000-0000-0000-000000000002");
public static readonly Guid Encounter1Id = Guid.Parse("d1000000-0000-0000-0000-000000000001");
public static readonly Guid HeartRateObsId = Guid.Parse("e1000000-0000-0000-0000-000000000001");
public static readonly Guid WbcObsId = Guid.Parse("e1000000-0000-0000-0000-000000000002");
public static readonly Guid Batch1Id = Guid.Parse("c1000000-0000-0000-0000-000000000001");
public static async Task SeedAsync(AppDbContext db)
{
if (await db.Patients.AnyAsync())
return;
var now = DateTimeOffset.UtcNow;
var recordedAt = now.AddDays(-5);
db.Patients.AddRange(
new Patient
{
Id = Patient1Id,
Mrn = "VCR-000001",
FullName = "MARIA SANTOS",
DateOfBirth = new DateOnly(1978, 3, 15),
Sex = "female",
BloodType = BloodType.APos,
EmergencyContact = "Juan Santos - 555-0101",
NoKnownAllergies = false,
AllergiesJson = "[\"Penicillin\", \"Sulfa drugs\"]",
CreatedAt = now.AddDays(-3),
UpdatedAt = now.AddHours(-12),
},
new Patient
{
Id = Patient2Id,
Mrn = "VCR-000002",
FullName = "KENJI NAKAMURA",
DateOfBirth = new DateOnly(1952, 11, 8),
Sex = "male",
BloodType = BloodType.ONeg,
EmergencyContact = "Yuki Nakamura - 555-0202",
NoKnownAllergies = true,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddDays(-1),
});
db.Encounters.Add(new Encounter
{
Id = Encounter1Id,
PatientId = Patient1Id,
AdmissionDate = recordedAt,
Department = Department.InternalMedicine,
RoomBed = "2A-04",
AdmissionReason = "Pneumonia with elevated WBC",
Status = "active",
SourceBatchId = Batch1Id,
CreatedAt = now.AddDays(-3),
UpdatedAt = now.AddHours(-12),
});
db.Observations.AddRange(
new Observation
{
Id = HeartRateObsId,
EncounterId = Encounter1Id,
PatientId = Patient1Id,
ObservationCode = "HEART_RATE",
Value = 88m,
Unit = "bpm",
RecordedAt = recordedAt,
Source = "digitization_backfill",
SourceBatchId = Batch1Id,
CreatedAt = now.AddHours(-12),
},
new Observation
{
Id = WbcObsId,
EncounterId = Encounter1Id,
PatientId = Patient1Id,
ObservationCode = "WBC_K_UL",
Value = 14.2m,
Unit = "K/uL",
RecordedAt = recordedAt,
Source = "digitization_backfill",
SourceBatchId = Batch1Id,
CreatedAt = now.AddHours(-12),
});
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,9 @@
public class FhirOptions
{
public const string Section = "Fhir";
public string BaseUrl { get; set; } = "http://localhost:5217/fhir";
public string PublisherName { get; set; } = "VigilCare Records";
public string PublisherUrl { get; set; } = "https://vigilcare.local";
public string ServerVersion { get; set; } = "1.0.0";
}
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir/Encounter")]
[Produces("application/fhir+json")]
[Authorize]
public class FhirEncounterController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirEncounterController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR read: GET /fhir/Encounter/{id}
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> Read(string id)
{
var encounter = await _fhir.GetEncounterAsync(Guid.Parse(id));
if (encounter is null)
return NotFound(FhirErrorHelper.NotFound("Encounter", id));
return Ok(encounter);
}
/// <summary>
/// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z
/// Supports search by patient reference, status, and date range.
/// </summary>
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? patient,
[FromQuery] string? status,
[FromQuery] string? date,
[FromQuery(Name = "_count")] int count = 20,
[FromQuery(Name = "_offset")] int offset = 0)
{
var bundle = await _fhir.SearchEncountersAsync(patient, status, date, count, offset);
return Ok(bundle);
}
}
@@ -0,0 +1,27 @@
using Hl7.Fhir.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir")]
[Produces("application/fhir+json")]
public class FhirMetadataController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirMetadataController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR metadata: GET /fhir/metadata
/// Returns the server's CapabilityStatement describing supported
/// resources, interactions, and search parameters.
/// No authentication required (FHIR spec requirement).
/// </summary>
[HttpGet("metadata")]
[AllowAnonymous]
[ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)]
public IActionResult GetMetadata()
{
return Ok(_fhir.GetCapabilityStatement());
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir/Observation")]
[Produces("application/fhir+json")]
[Authorize]
public class FhirObservationController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirObservationController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR read: GET /fhir/Observation/{id}
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> Read(string id)
{
var observation = await _fhir.GetObservationAsync(Guid.Parse(id));
if (observation is null)
return NotFound(FhirErrorHelper.NotFound("Observation", id));
return Ok(observation);
}
/// <summary>
/// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W
/// Supports search by patient reference, LOINC code, date range, and category.
/// </summary>
[HttpGet]
public async Task<IActionResult> Search(
[FromQuery] string? patient,
[FromQuery] string? code,
[FromQuery] string? date,
[FromQuery] string? category,
[FromQuery] string? encounter,
[FromQuery(Name = "_count")] int count = 50,
[FromQuery(Name = "_offset")] int offset = 0)
{
var bundle = await _fhir.SearchObservationsAsync(
patient, code, date, category, encounter, count, offset);
return Ok(bundle);
}
}
@@ -0,0 +1,65 @@
using Hl7.Fhir.Model;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("fhir/Patient")]
[Produces("application/fhir+json")]
[Authorize]
public class FhirPatientController : ControllerBase
{
private readonly IFhirService _fhir;
public FhirPatientController(IFhirService fhir) => _fhir = fhir;
/// <summary>
/// FHIR read: GET /fhir/Patient/{id}
/// Returns a single Patient resource by logical ID.
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Hl7.Fhir.Model.OperationOutcome), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Read(string id)
{
var patient = await _fhir.GetPatientAsync(Guid.Parse(id));
if (patient is null)
return NotFound(FhirErrorHelper.NotFound("Patient", id));
return Ok(patient);
}
/// <summary>
/// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z
/// Supports search by name (contains), birthdate (exact), and MRN identifier.
/// Returns a FHIR Bundle of type searchset.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(Hl7.Fhir.Model.Bundle), StatusCodes.Status200OK)]
public async Task<IActionResult> Search(
[FromQuery] string? name,
[FromQuery] string? birthdate,
[FromQuery] string? identifier,
[FromQuery(Name = "_count")] int count = 20,
[FromQuery(Name = "_offset")] int offset = 0)
{
var bundle = await _fhir.SearchPatientsAsync(name, birthdate, identifier, count, offset);
return Ok(bundle);
}
/// <summary>
/// FHIR $everything: GET /fhir/Patient/{id}/$everything
/// Returns a Bundle containing the Patient resource, all Encounters,
/// and all Observations for the patient.
/// </summary>
[HttpGet("{id}/$everything")]
[ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Everything(string id)
{
var bundle = await _fhir.GetPatientEverythingAsync(Guid.Parse(id));
if (bundle is null)
return NotFound(FhirErrorHelper.NotFound("Patient", id));
return Ok(bundle);
}
}
@@ -0,0 +1,18 @@
using Hl7.Fhir.Model;
public static class FhirErrorHelper
{
public static OperationOutcome NotFound(string resourceType, string id) =>
new()
{
Issue =
{
new OperationOutcome.IssueComponent
{
Severity = OperationOutcome.IssueSeverity.Error,
Code = OperationOutcome.IssueType.NotFound,
Diagnostics = $"{resourceType}/{id} not found",
}
}
};
}
@@ -0,0 +1,31 @@
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Text;
using Task = System.Threading.Tasks.Task;
public class FhirJsonOutputFormatter : TextOutputFormatter
{
public FhirJsonOutputFormatter()
{
SupportedMediaTypes.Add("application/fhir+json");
SupportedMediaTypes.Add("application/json");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
{
return type != null && typeof(Resource).IsAssignableFrom(type);
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context, Encoding selectedEncoding)
{
var resource = context.Object as Resource;
if (resource is null) return;
var serializer = new FhirJsonSerializer(new SerializerSettings { Pretty = true });
var json = serializer.SerializeToString(resource);
await context.HttpContext.Response.WriteAsync(json, selectedEncoding);
}
}
@@ -0,0 +1,90 @@
using Hl7.Fhir.Model;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
public static class EncounterMapper
{
/// <summary>
/// Maps a VigilCare Encounter entity to a FHIR R4 Encounter resource.
///
/// Mapping decisions:
/// - VigilCare encounter Status ("active", "discharged") maps to FHIR
/// Encounter.Status (in-progress, finished).
/// - Department maps to Encounter.serviceType using a local CodeSystem.
/// Facilities should map to their own department OID or SNOMED CT codes.
/// - SourceBatchId is preserved as an extension for traceability.
/// </summary>
public static FhirEncounter ToFhir(Encounter entity, string baseUrl)
{
var encounter = new FhirEncounter
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.UpdatedAt,
},
Status = entity.Status?.ToLowerInvariant() switch
{
"active" => FhirEncounter.EncounterStatus.InProgress,
"discharged" => FhirEncounter.EncounterStatus.Finished,
"cancelled" => FhirEncounter.EncounterStatus.Cancelled,
_ => FhirEncounter.EncounterStatus.Unknown,
},
Class = new Coding(
"http://terminology.hl7.org/CodeSystem/v3-ActCode",
"IMP",
"inpatient encounter"),
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
};
if (entity.AdmissionDate.HasValue)
{
encounter.Period = new Period
{
StartElement = new FhirDateTime(entity.AdmissionDate.Value),
};
}
if (entity.Department.HasValue)
{
encounter.ServiceType = new CodeableConcept(
"urn:vigilcare:department",
entity.Department.Value.ToString(),
entity.Department.Value.ToString());
}
if (!string.IsNullOrEmpty(entity.RoomBed))
{
encounter.Location.Add(new FhirEncounter.LocationComponent
{
Location = new ResourceReference { Display = entity.RoomBed },
Status = FhirEncounter.EncounterLocationStatus.Active,
});
}
if (!string.IsNullOrEmpty(entity.AdmissionReason))
{
encounter.ReasonCode.Add(new CodeableConcept { Text = entity.AdmissionReason });
}
if (!string.IsNullOrEmpty(entity.DischargeDiagnosis))
{
encounter.Diagnosis.Add(new FhirEncounter.DiagnosisComponent
{
Condition = new ResourceReference { Display = entity.DischargeDiagnosis },
Use = new CodeableConcept(
"http://terminology.hl7.org/CodeSystem/diagnosis-role",
"DD", "Discharge diagnosis"),
});
}
if (entity.SourceBatchId.HasValue)
{
encounter.Extension.Add(new Extension(
"urn:vigilcare:source-batch-id",
new FhirString(entity.SourceBatchId.Value.ToString())));
}
return encounter;
}
}
@@ -0,0 +1,137 @@
using Hl7.Fhir.Model;
using FhirObservation = Hl7.Fhir.Model.Observation;
public static class ObservationMapper
{
private static readonly Dictionary<string, string> LoincToVigilCare = new()
{
["8867-4"] = "HEART_RATE",
["8310-5"] = "TEMP_C",
["8480-6"] = "BP_SYSTOLIC",
["8462-4"] = "BP_DIASTOLIC",
["9279-1"] = "RESP_RATE",
["2708-6"] = "SPO2",
["2345-7"] = "GLUCOSE_MG_DL",
["2823-3"] = "POTASSIUM_MEQ_L",
["2951-2"] = "SODIUM_MEQ_L",
["2524-7"] = "LACTATE_MMOL_L",
["6690-2"] = "WBC_K_UL",
["718-7"] = "HEMOGLOBIN_G_DL",
["2160-0"] = "CREATININE_MG_DL",
};
private static readonly HashSet<string> VitalSignCodes =
[
"HEART_RATE", "TEMP_C", "BP_SYSTOLIC", "BP_DIASTOLIC", "RESP_RATE", "SPO2"
];
/// <summary>
/// Maps a VigilCare Observation entity to a FHIR R4 Observation resource.
///
/// Mapping decisions:
/// - ObservationCode maps to LOINC codes where a standard mapping exists.
/// Unknown codes use a local CodeSystem with the original code as display.
/// - Value + Unit maps to Observation.valueQuantity with UCUM unit codes.
/// - RecordedAt maps to effectiveDateTime (when the observation was clinically
/// relevant), not issued (when the system recorded it).
/// - Source and SourceBatchId are preserved as extensions for provenance.
/// </summary>
public static FhirObservation ToFhir(Observation entity, string baseUrl)
{
var observation = new FhirObservation
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.CreatedAt,
},
Status = ObservationStatus.Final,
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
Encounter = new ResourceReference($"Encounter/{entity.EncounterId}"),
Effective = new FhirDateTime(entity.RecordedAt),
Issued = entity.CreatedAt,
};
observation.Code = MapObservationCode(entity.ObservationCode);
observation.Value = new Quantity
{
Value = entity.Value,
Unit = entity.Unit,
System = "http://unitsofmeasure.org",
Code = MapToUcum(entity.Unit),
};
var category = IsVitalSign(entity.ObservationCode) ? "vital-signs" : "laboratory";
observation.Category.Add(new CodeableConcept(
"http://terminology.hl7.org/CodeSystem/observation-category",
category));
if (!string.IsNullOrEmpty(entity.Note))
{
observation.Note.Add(new Annotation { Text = new Markdown(entity.Note) });
}
observation.Extension.Add(new Extension(
"urn:vigilcare:source",
new FhirString(entity.Source)));
if (entity.SourceBatchId.HasValue)
{
observation.Extension.Add(new Extension(
"urn:vigilcare:source-batch-id",
new FhirString(entity.SourceBatchId.Value.ToString())));
}
return observation;
}
/// <summary>
/// Reverse LOINC → VigilCare code mapping for FHIR search by LOINC code.
/// </summary>
public static IReadOnlyDictionary<string, string> GetReverseLoincMapping() => LoincToVigilCare;
/// <summary>
/// VigilCare observation codes that represent vital signs (used for category search).
/// </summary>
public static IReadOnlyCollection<string> GetVitalSignCodes() => VitalSignCodes;
private static CodeableConcept MapObservationCode(string code) => code switch
{
"HEART_RATE" => Loinc("8867-4", "Heart rate"),
"TEMP_C" => Loinc("8310-5", "Body temperature"),
"BP_SYSTOLIC" => Loinc("8480-6", "Systolic blood pressure"),
"BP_DIASTOLIC" => Loinc("8462-4", "Diastolic blood pressure"),
"RESP_RATE" => Loinc("9279-1", "Respiratory rate"),
"SPO2" => Loinc("2708-6", "Oxygen saturation"),
"GLUCOSE_MG_DL" => Loinc("2345-7", "Glucose [Mass/volume] in Serum or Plasma"),
"POTASSIUM_MEQ_L" => Loinc("2823-3", "Potassium [Moles/volume] in Serum or Plasma"),
"SODIUM_MEQ_L" => Loinc("2951-2", "Sodium [Moles/volume] in Serum or Plasma"),
"LACTATE_MMOL_L" => Loinc("2524-7", "Lactate [Moles/volume] in Serum or Plasma"),
"WBC_K_UL" => Loinc("6690-2", "Leukocytes [#/volume] in Blood"),
"HEMOGLOBIN_G_DL" => Loinc("718-7", "Hemoglobin [Mass/volume] in Blood"),
"CREATININE_MG_DL" => Loinc("2160-0", "Creatinine [Mass/volume] in Serum or Plasma"),
_ => new CodeableConcept("urn:vigilcare:observation-code", code, code),
};
private static CodeableConcept Loinc(string code, string display) =>
new("http://loinc.org", code, display);
private static string MapToUcum(string unit) => unit switch
{
"bpm" => "/min",
"C" => "Cel",
"mmHg" => "mm[Hg]",
"breaths/min" => "/min",
"%" => "%",
"mg/dL" => "mg/dL",
"mEq/L" => "meq/L",
"mmol/L" => "mmol/L",
"K/uL" => "10*3/uL",
"g/dL" => "g/dL",
_ => unit,
};
private static bool IsVitalSign(string code) => VitalSignCodes.Contains(code);
}
@@ -0,0 +1,83 @@
using Hl7.Fhir.Model;
using FhirPatient = Hl7.Fhir.Model.Patient;
public static class PatientMapper
{
/// <summary>
/// Maps a VigilCare Patient entity to a FHIR R4 Patient resource.
///
/// Mapping decisions:
/// - VigilCare stores full name as a single string; FHIR splits into family/given.
/// We use HumanName.Text for the full string and attempt to split on the last
/// space for family/given when possible.
/// - MRN maps to Identifier with system "urn:oid:2.16.840.1.113883.19.5" (example OID).
/// Facilities should configure their own OID.
/// - BloodType maps to an extension (no standard FHIR element for blood type).
/// - AllergiesJson is NOT mapped here — allergies should use AllergyIntolerance
/// resources, which are out of scope for Phase 11 v1.
/// </summary>
public static FhirPatient ToFhir(Patient entity, string baseUrl)
{
var patient = new FhirPatient
{
Id = entity.Id.ToString(),
Meta = new Meta
{
VersionId = "1",
LastUpdated = entity.UpdatedAt,
},
Active = true,
};
patient.Identifier.Add(new Identifier
{
System = "urn:oid:2.16.840.1.113883.19.5",
Value = entity.Mrn,
Use = Identifier.IdentifierUse.Official,
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR", "Medical Record Number"),
});
var name = new HumanName { Text = entity.FullName, Use = HumanName.NameUse.Official };
var parts = entity.FullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
name.Family = parts[^1];
name.Given = parts[..^1].ToList();
}
else
{
name.Family = entity.FullName;
}
patient.Name.Add(name);
if (entity.DateOfBirth.HasValue)
{
patient.BirthDate = entity.DateOfBirth.Value.ToString("yyyy-MM-dd");
}
if (!string.IsNullOrEmpty(entity.Sex))
{
patient.Gender = entity.Sex.ToLowerInvariant() switch
{
"male" => AdministrativeGender.Male,
"female" => AdministrativeGender.Female,
"other" => AdministrativeGender.Other,
_ => AdministrativeGender.Unknown,
};
}
if (!string.IsNullOrEmpty(entity.EmergencyContact))
{
patient.Contact.Add(new FhirPatient.ContactComponent
{
Relationship = new List<CodeableConcept>
{
new("http://terminology.hl7.org/CodeSystem/v2-0131", "C", "Emergency Contact")
},
Name = new HumanName { Text = entity.EmergencyContact },
});
}
return patient;
}
}
+7 -1
View File
@@ -51,6 +51,8 @@ try
builder.Services.Configure<PromotionRetryOptions>( builder.Services.Configure<PromotionRetryOptions>(
builder.Configuration.GetSection(PromotionRetryOptions.Section)); builder.Configuration.GetSection(PromotionRetryOptions.Section));
builder.Services.Configure<FhirOptions>(builder.Configuration.GetSection(FhirOptions.Section));
// JWT Authentication // JWT Authentication
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!; var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
@@ -122,6 +124,7 @@ try
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>(); builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
builder.Services.AddScoped<IBatchEventService, BatchEventService>(); builder.Services.AddScoped<IBatchEventService, BatchEventService>();
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>(); builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
builder.Services.AddScoped<IFhirService, FhirService>();
builder.Services.AddHostedService<MetricsCollectorService>(); builder.Services.AddHostedService<MetricsCollectorService>();
builder.Services.AddHostedService<PromotionRetryService>(); builder.Services.AddHostedService<PromotionRetryService>();
@@ -143,7 +146,10 @@ try
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<ValidationFilter>(); builder.Services.AddScoped<ValidationFilter>();
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
options.Filters.AddService<ValidationFilter>()); {
options.OutputFormatters.Insert(0, new FhirJsonOutputFormatter());
options.Filters.AddService<ValidationFilter>();
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddVigilCareRecordsSwagger(); builder.Services.AddVigilCareRecordsSwagger();
+360
View File
@@ -0,0 +1,360 @@
using Hl7.Fhir.Model;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Globalization;
using FhirPatient = Hl7.Fhir.Model.Patient;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
using FhirObservation = Hl7.Fhir.Model.Observation;
public class FhirService : IFhirService
{
private readonly AppDbContext _db;
private readonly FhirOptions _options;
public FhirService(AppDbContext db, IOptions<FhirOptions> options)
{
_db = db;
_options = options.Value;
}
// --- Patient ---
public async Task<FhirPatient?> GetPatientAsync(Guid id)
{
var entity = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
return entity is null ? null : PatientMapper.ToFhir(entity, _options.BaseUrl);
}
public async Task<Bundle> SearchPatientsAsync(
string? name, string? birthdate, string? identifier, int count, int offset)
{
count = Math.Clamp(count, 1, 100);
var query = _db.Patients.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(name))
query = query.Where(p => EF.Functions.ILike(p.FullName, $"%{name}%"));
if (!string.IsNullOrWhiteSpace(birthdate) && DateOnly.TryParse(birthdate, out var dob))
query = query.Where(p => p.DateOfBirth == dob);
if (!string.IsNullOrWhiteSpace(identifier))
query = query.Where(p => p.Mrn == identifier);
var total = await query.CountAsync();
var entities = await query.OrderBy(p => p.FullName).Skip(offset).Take(count).ToListAsync();
return BuildSearchBundle(
entities.Select(e => PatientMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
total, count, offset, "Patient");
}
// --- Encounter ---
public async Task<FhirEncounter?> GetEncounterAsync(Guid id)
{
var entity = await _db.Encounters.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
return entity is null ? null : EncounterMapper.ToFhir(entity, _options.BaseUrl);
}
public async Task<Bundle> SearchEncountersAsync(
string? patient, string? status, string? date, int count, int offset)
{
count = Math.Clamp(count, 1, 100);
var query = _db.Encounters.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(patient) && Guid.TryParse(patient, out var patientId))
query = query.Where(e => e.PatientId == patientId);
if (!string.IsNullOrWhiteSpace(status))
{
var fhirStatus = status.ToLowerInvariant();
query = fhirStatus switch
{
"in-progress" => query.Where(e => e.Status == "active"),
"finished" => query.Where(e => e.Status == "discharged"),
_ => query.Where(e => e.Status == status),
};
}
if (!string.IsNullOrWhiteSpace(date) && TryParseUtcDate(date, out var encounterDay))
{
var dayEnd = encounterDay.AddDays(1);
query = query.Where(e =>
e.AdmissionDate != null &&
e.AdmissionDate.Value >= encounterDay &&
e.AdmissionDate.Value < dayEnd);
}
var total = await query.CountAsync();
var entities = await query.OrderByDescending(e => e.AdmissionDate).Skip(offset).Take(count).ToListAsync();
return BuildSearchBundle(
entities.Select(e => EncounterMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
total, count, offset, "Encounter");
}
// --- Observation ---
public async Task<FhirObservation?> GetObservationAsync(Guid id)
{
var entity = await _db.Observations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id);
return entity is null ? null : ObservationMapper.ToFhir(entity, _options.BaseUrl);
}
public async Task<Bundle> SearchObservationsAsync(
string? patient, string? code, string? date,
string? category, string? encounter, int count, int offset)
{
count = Math.Clamp(count, 1, 200);
var query = _db.Observations.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(patient) && Guid.TryParse(patient, out var patientId))
query = query.Where(o => o.PatientId == patientId);
if (!string.IsNullOrWhiteSpace(encounter) && Guid.TryParse(encounter, out var encId))
query = query.Where(o => o.EncounterId == encId);
if (!string.IsNullOrWhiteSpace(code))
{
// Accept both LOINC codes (e.g., "8867-4") and VigilCare codes (e.g., "HEART_RATE")
var loincToVigilCare = ObservationMapper.GetReverseLoincMapping();
var vigilCareCode = loincToVigilCare.GetValueOrDefault(code, code);
query = query.Where(o => o.ObservationCode == vigilCareCode);
}
if (!string.IsNullOrWhiteSpace(category))
{
var isVitalSigns = category.Equals("vital-signs", StringComparison.OrdinalIgnoreCase);
var vitalCodes = ObservationMapper.GetVitalSignCodes();
query = isVitalSigns
? query.Where(o => vitalCodes.Contains(o.ObservationCode))
: query.Where(o => !vitalCodes.Contains(o.ObservationCode));
}
if (!string.IsNullOrWhiteSpace(date))
query = ApplyObservationDateFilter(query, date);
var total = await query.CountAsync();
var entities = await query.OrderByDescending(o => o.RecordedAt).Skip(offset).Take(count).ToListAsync();
return BuildSearchBundle(
entities.Select(e => ObservationMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
total, count, offset, "Observation");
}
public async Task<Bundle?> GetPatientEverythingAsync(Guid patientId)
{
var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == patientId);
if (patient is null) return null;
var encounters = await _db.Encounters.AsNoTracking()
.Where(e => e.PatientId == patientId)
.ToListAsync();
var observations = await _db.Observations.AsNoTracking()
.Where(o => o.PatientId == patientId)
.OrderByDescending(o => o.RecordedAt)
.ToListAsync();
var bundle = new Bundle
{
Type = Bundle.BundleType.Searchset,
Total = 1 + encounters.Count + observations.Count,
Meta = new Meta { LastUpdated = DateTimeOffset.UtcNow },
};
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/Patient/{patient.Id}",
Resource = PatientMapper.ToFhir(patient, _options.BaseUrl),
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Match },
});
foreach (var enc in encounters)
{
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/Encounter/{enc.Id}",
Resource = EncounterMapper.ToFhir(enc, _options.BaseUrl),
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Include },
});
}
foreach (var obs in observations)
{
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/Observation/{obs.Id}",
Resource = ObservationMapper.ToFhir(obs, _options.BaseUrl),
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Include },
});
}
return bundle;
}
// --- Bundle builder ---
private Bundle BuildSearchBundle(
List<Resource> resources, int total, int count, int offset, string resourceType)
{
var bundle = new Bundle
{
Type = Bundle.BundleType.Searchset,
Total = total,
Meta = new Meta { LastUpdated = DateTimeOffset.UtcNow },
};
foreach (var resource in resources)
{
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{_options.BaseUrl}/{resourceType}/{resource.Id}",
Resource = resource,
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Match },
});
}
// Pagination links
var selfUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={offset}";
bundle.Link.Add(new Bundle.LinkComponent { Relation = "self", Url = selfUrl });
if (offset + count < total)
{
var nextUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={offset + count}";
bundle.Link.Add(new Bundle.LinkComponent { Relation = "next", Url = nextUrl });
}
if (offset > 0)
{
var prevOffset = Math.Max(0, offset - count);
var prevUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={prevOffset}";
bundle.Link.Add(new Bundle.LinkComponent { Relation = "previous", Url = prevUrl });
}
return bundle;
}
// --- CapabilityStatement ---
public CapabilityStatement GetCapabilityStatement()
{
return new CapabilityStatement
{
Status = PublicationStatus.Active,
Date = "2026-06-27",
Kind = CapabilityStatementKind.Instance,
FhirVersion = FHIRVersion.N4_0_1,
Format = new[] { "json" },
Software = new CapabilityStatement.SoftwareComponent
{
Name = _options.PublisherName,
Version = _options.ServerVersion,
},
Implementation = new CapabilityStatement.ImplementationComponent
{
Description = "VigilCare Records FHIR R4 API — read-only access to promoted clinical data",
Url = _options.BaseUrl,
},
Rest = new List<CapabilityStatement.RestComponent>
{
new()
{
Mode = CapabilityStatement.RestfulCapabilityMode.Server,
Resource = new List<CapabilityStatement.ResourceComponent>
{
FhirResource("Patient", new[] { "read", "search-type" },
new[] { "name", "birthdate", "identifier" }),
FhirResource("Encounter", new[] { "read", "search-type" },
new[] { "patient", "status", "date" }),
FhirResource("Observation", new[] { "read", "search-type" },
new[] { "patient", "code", "date", "category", "encounter" }),
},
}
}
};
}
private static CapabilityStatement.ResourceComponent FhirResource(
string type, string[] interactions, string[] searchParams)
{
var resource = new CapabilityStatement.ResourceComponent
{
Type = type,
};
foreach (var interaction in interactions)
{
resource.Interaction.Add(new CapabilityStatement.ResourceInteractionComponent
{
Code = Enum.Parse<CapabilityStatement.TypeRestfulInteraction>(
interaction.Replace("-", ""), ignoreCase: true),
});
}
foreach (var param in searchParams)
{
resource.SearchParam.Add(new CapabilityStatement.SearchParamComponent
{
Name = param,
Type = SearchParamType.String,
});
}
return resource;
}
private static IQueryable<Observation> ApplyObservationDateFilter(
IQueryable<Observation> query, string date)
{
if (date.StartsWith("gt", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var gt))
{
return query.Where(o => o.RecordedAt > gt);
}
if (date.StartsWith("lt", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var lt))
{
return query.Where(o => o.RecordedAt < lt);
}
if (date.StartsWith("ge", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var ge))
{
return query.Where(o => o.RecordedAt >= ge);
}
if (date.StartsWith("le", StringComparison.OrdinalIgnoreCase)
&& TryParseUtcDate(date[2..], out var le))
{
return query.Where(o => o.RecordedAt <= le);
}
if (TryParseUtcDate(date, out var dayStart))
{
var dayEnd = dayStart.AddDays(1);
return query.Where(o => o.RecordedAt >= dayStart && o.RecordedAt < dayEnd);
}
return query;
}
private static bool TryParseUtcDate(string value, out DateTimeOffset utc)
{
if (DateTimeOffset.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out utc))
{
return true;
}
utc = default;
return false;
}
}
@@ -0,0 +1,22 @@
using Hl7.Fhir.Model;
using FhirPatient = Hl7.Fhir.Model.Patient;
using FhirEncounter = Hl7.Fhir.Model.Encounter;
using FhirObservation = Hl7.Fhir.Model.Observation;
public interface IFhirService
{
Task<FhirPatient?> GetPatientAsync(Guid id);
Task<Bundle> SearchPatientsAsync(string? name, string? birthdate, string? identifier, int count, int offset);
Task<FhirEncounter?> GetEncounterAsync(Guid id);
Task<Bundle> SearchEncountersAsync(string? patient, string? status, string? date, int count, int offset);
Task<FhirObservation?> GetObservationAsync(Guid id);
Task<Bundle> SearchObservationsAsync(
string? patient, string? code, string? date,
string? category, string? encounter, int count, int offset);
Task<Bundle?> GetPatientEverythingAsync(Guid patientId);
CapabilityStatement GetCapabilityStatement();
}
@@ -13,6 +13,7 @@
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
+6
View File
@@ -70,5 +70,11 @@
"MaxDelaySeconds": 900, "MaxDelaySeconds": 900,
"MaxRetryAttempts": 10, "MaxRetryAttempts": 10,
"BackoffMultiplier": 2.0 "BackoffMultiplier": 2.0
},
"Fhir": {
"BaseUrl": "http://localhost:5271/fhir",
"PublisherName": "VigilCare Records",
"PublisherUrl": "https://vigilcare.local",
"ServerVersion": "1.0.0"
} }
} }
+585
View File
@@ -0,0 +1,585 @@
#!/usr/bin/env bash
# Runs Phase 11 verification checks from docs/plans/phase-11-plan.md.
#
# Covers FHIR metadata, Patient/Encounter/Observation read & search,
# LOINC mapping, $everything, content-type negotiation, 404 OperationOutcome,
# and FhirIntegrationTests.
#
# Prerequisites:
# docker compose up -d (PostgreSQL, Redis, MinIO)
# dotnet ef database update --project VigilCareRecordsAPI
# dotnet run --project VigilCareRecordsAPI
# Phase 110 seed data (admin1)
#
# Environment overrides:
# VIGILCARE_API_URL default: http://localhost:5217
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
# VIGILCARE_PG_HOST default: localhost
# VIGILCARE_PG_PORT default: 5437
# VIGILCARE_PG_DB default: vigilcare_records
# VIGILCARE_PG_USER default: postgres
# VIGILCARE_PG_PASSWORD default: password
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL seed/assertions
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
#
# Usage:
# chmod +x scripts/run-vigilcare-records-phase-11-verification.sh
# ./scripts/run-vigilcare-records-phase-11-verification.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
FHIR_URL="${API_URL}/fhir"
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
COMPOSE=(docker compose -f "$COMPOSE_FILE")
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
PG_USER="${VIGILCARE_PG_USER:-postgres}"
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
# Deterministic clinical IDs (match FhirClinicalSeedHelper)
PATIENT1_ID="b1000000-0000-0000-0000-000000000001"
PATIENT2_ID="b1000000-0000-0000-0000-000000000002"
ENCOUNTER1_ID="d1000000-0000-0000-0000-000000000001"
HEART_RATE_OBS_ID="e1000000-0000-0000-0000-000000000001"
WBC_OBS_ID="e1000000-0000-0000-0000-000000000002"
BATCH1_ID="c1000000-0000-0000-0000-000000000001"
ADMIN_TOKEN=""
RESOLVED_PATIENT_ID=""
PASS_COUNT=0
FAIL_COUNT=0
FAILED_TESTS=()
log() {
printf '%s\n' "$*"
}
section() {
log ""
log "== $1 =="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
log " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED_TESTS+=("$1")
log " FAIL: $1"
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "ERROR: required command not found: $cmd"
exit 1
fi
}
http_code() {
curl -sS -o /dev/null -w '%{http_code}' "$@"
}
json_post() {
local url="$1"
local body="$2"
curl -sS -X POST "$url" \
-H 'Content-Type: application/json' \
-d "$body"
}
fhir_get() {
local path_query="$1"
local token="${2:-}"
if [[ -n "$token" ]]; then
curl -sS "${FHIR_URL}${path_query}" \
-H "Authorization: Bearer $token" \
-H 'Accept: application/fhir+json'
else
curl -sS "${FHIR_URL}${path_query}" \
-H 'Accept: application/fhir+json'
fi
}
fhir_get_status() {
local path_query="$1"
local token="$2"
local body_file http_status
body_file="$(mktemp)"
http_status="$(curl -sS -o "$body_file" -w '%{http_code}' \
"${FHIR_URL}${path_query}" \
-H "Authorization: Bearer $token" \
-H 'Accept: application/fhir+json')"
cat "$body_file"
rm -f "$body_file"
printf '\n__HTTP_STATUS__:%s' "$http_status"
}
assert_api_reachable() {
local code
code="$(http_code "$API_URL/swagger/index.html" || true)"
if [[ "$code" != "200" ]]; then
log "ERROR: API not reachable at $API_URL (HTTP $code)."
log "Start infrastructure with: docker compose up -d"
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
log "Start API with: dotnet run --project VigilCareRecordsAPI"
exit 1
fi
}
compose_service_running() {
local service="$1"
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
}
psql_available() {
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
compose_service_running postgres && return 0
command -v psql >/dev/null 2>&1 && return 0
return 1
}
psql_query() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
else
return 1
fi
}
psql_exec() {
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
return 1
fi
if compose_service_running postgres; then
"${COMPOSE[@]}" exec -T postgres \
psql -U "$PG_USER" -d "$PG_DB" -v ON_ERROR_STOP=1 -q -c "$1"
elif command -v psql >/dev/null 2>&1; then
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \
-v ON_ERROR_STOP=1 -q -c "$1"
else
return 1
fi
}
test_authentication() {
section "0. Authentication"
local admin_json
admin_json="$(json_post "$API_URL/api/v1/auth/login" \
'{"username":"admin1","password":"password"}')"
ADMIN_TOKEN="$(jq -er '.data.token // empty' <<<"$admin_json" 2>/dev/null || true)"
if [[ -n "$ADMIN_TOKEN" ]]; then
pass "admin1 login returns JWT"
else
log "ERROR: admin1 login failed."
log "Response: ${admin_json:-<empty>}"
exit 1
fi
}
ensure_fhir_clinical_seed() {
section "1. Clinical seed data for FHIR endpoints"
if ! psql_available; then
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
log " NOTE: FHIR curl tests require clinical.patients with MRN VCR-000001"
log " Promote a batch or run with postgres available to auto-seed."
return
fi
local patient_count
patient_count="$(psql_query "SELECT count(*) FROM clinical.patients WHERE mrn = 'VCR-000001';" || echo "0")"
if [[ "${patient_count:-0}" -ge 1 ]]; then
pass "clinical patient VCR-000001 already present"
return
fi
log " Seeding demo clinical records for FHIR verification..."
psql_exec "
INSERT INTO clinical.patients (
id, mrn, full_name, date_of_birth, sex, blood_type,
emergency_contact, allergies_json, no_known_allergies, created_at, updated_at
) VALUES
('${PATIENT1_ID}', 'VCR-000001', 'MARIA SANTOS', '1978-03-15', 'female', 'A+',
'Juan Santos - 555-0101', '[\"Penicillin\", \"Sulfa drugs\"]', false,
NOW() - interval '3 days', NOW() - interval '12 hours'),
('${PATIENT2_ID}', 'VCR-000002', 'KENJI NAKAMURA', '1952-11-08', 'male', 'O-',
'Yuki Nakamura - 555-0202', NULL, true,
NOW() - interval '1 day', NOW() - interval '1 day')
ON CONFLICT (id) DO NOTHING;
" || {
fail "seed clinical.patients for FHIR verification"
return
}
psql_exec "
INSERT INTO clinical.encounters (
id, patient_id, admission_date, department, room_bed, admission_reason,
status, source_batch_id, created_at, updated_at
) VALUES (
'${ENCOUNTER1_ID}', '${PATIENT1_ID}', NOW() - interval '5 days',
'Internal Medicine', '2A-04', 'Pneumonia with elevated WBC',
'active', '${BATCH1_ID}', NOW() - interval '3 days', NOW() - interval '12 hours'
)
ON CONFLICT (id) DO NOTHING;
" || {
fail "seed clinical.encounters for FHIR verification"
return
}
psql_exec "
INSERT INTO clinical.observations (
id, encounter_id, patient_id, observation_code, value, unit,
recorded_at, source, source_batch_id, created_at
) VALUES
('${HEART_RATE_OBS_ID}', '${ENCOUNTER1_ID}', '${PATIENT1_ID}',
'HEART_RATE', 88.000, 'bpm', NOW() - interval '5 days',
'digitization_backfill', '${BATCH1_ID}', NOW() - interval '12 hours'),
('${WBC_OBS_ID}', '${ENCOUNTER1_ID}', '${PATIENT1_ID}',
'WBC_K_UL', 14.200, 'K/uL', NOW() - interval '5 days',
'digitization_backfill', '${BATCH1_ID}', NOW() - interval '12 hours')
ON CONFLICT (id) DO NOTHING;
" || {
fail "seed clinical.observations for FHIR verification"
return
}
patient_count="$(psql_query "SELECT count(*) FROM clinical.patients WHERE mrn = 'VCR-000001';" || echo "0")"
if [[ "${patient_count:-0}" -ge 1 ]]; then
pass "seeded clinical patient VCR-000001 for FHIR verification"
else
fail "seeded clinical patient VCR-000001 for FHIR verification"
fi
}
test_fhir_metadata() {
section "2. FHIR metadata — GET /fhir/metadata"
local metadata types
metadata="$(fhir_get '/metadata')"
types="$(jq -r '.rest[0].resource[].type' <<<"$metadata" 2>/dev/null | sort | tr '\n' ' ')"
if jq -e '.resourceType == "CapabilityStatement"' <<<"$metadata" >/dev/null 2>&1; then
pass "metadata returns CapabilityStatement"
else
fail "metadata returns CapabilityStatement"
fi
if grep -q 'Patient' <<<"$types" && grep -q 'Encounter' <<<"$types" && grep -q 'Observation' <<<"$types"; then
pass "metadata lists Patient, Encounter, Observation resources"
else
fail "metadata lists Patient, Encounter, Observation resources (got: $types)"
fi
}
resolve_patient_id() {
section "3. Resolve FHIR Patient ID"
local search_json
search_json="$(fhir_get "/Patient?name=Santos" "$ADMIN_TOKEN")"
RESOLVED_PATIENT_ID="$(jq -er '.entry[0].resource.id // empty' <<<"$search_json" 2>/dev/null || true)"
if [[ -z "$RESOLVED_PATIENT_ID" ]]; then
RESOLVED_PATIENT_ID="$PATIENT1_ID"
log " NOTE: Patient search returned no entries; using seeded ID $PATIENT1_ID"
fi
if [[ -n "$RESOLVED_PATIENT_ID" ]]; then
pass "resolved FHIR patient ID ($RESOLVED_PATIENT_ID)"
else
fail "resolved FHIR patient ID"
fi
}
test_fhir_patient_read() {
section "4. FHIR Patient read & search"
local patient_json mrn gender birth_date identifier_json
patient_json="$(fhir_get "/Patient/${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Patient"' <<<"$patient_json" >/dev/null 2>&1; then
pass "GET /fhir/Patient/{id} returns Patient resource"
else
fail "GET /fhir/Patient/{id} returns Patient resource"
fi
mrn="$(jq -er '.identifier[0].value // empty' <<<"$patient_json" 2>/dev/null || true)"
if [[ "$mrn" == "VCR-000001" ]]; then
pass "Patient read includes MRN identifier VCR-000001"
else
fail "Patient read includes MRN identifier VCR-000001 (got: ${mrn:-<empty>})"
fi
gender="$(jq -er '.gender // empty' <<<"$patient_json" 2>/dev/null || true)"
birth_date="$(jq -er '.birthDate // empty' <<<"$patient_json" 2>/dev/null || true)"
if [[ "$gender" == "female" && "$birth_date" == "1978-03-15" ]]; then
pass "Patient read includes gender and birthDate"
else
fail "Patient read includes gender and birthDate (gender=$gender birthDate=$birth_date)"
fi
identifier_json="$(fhir_get "/Patient?identifier=VCR-000001" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$identifier_json")" -ge 1 ]]; then
pass "GET /fhir/Patient?identifier=VCR-000001 returns matches"
else
fail "GET /fhir/Patient?identifier=VCR-000001 returns matches"
fi
}
test_fhir_encounter() {
section "5. FHIR Encounter read & search"
local encounter_json status subject patient_search
encounter_json="$(fhir_get "/Encounter/${ENCOUNTER1_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Encounter"' <<<"$encounter_json" >/dev/null 2>&1; then
pass "GET /fhir/Encounter/{id} returns Encounter resource"
else
fail "GET /fhir/Encounter/{id} returns Encounter resource"
fi
status="$(jq -er '.status // empty' <<<"$encounter_json" 2>/dev/null || true)"
subject="$(jq -er '.subject.reference // empty' <<<"$encounter_json" 2>/dev/null || true)"
if [[ "$status" == "in-progress" && "$subject" == "Patient/${RESOLVED_PATIENT_ID}" ]]; then
pass "Encounter read has in-progress status and patient reference"
else
fail "Encounter read has in-progress status and patient reference"
fi
patient_search="$(fhir_get "/Encounter?patient=${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$patient_search")" -ge 1 ]]; then
pass "GET /fhir/Encounter?patient={id} returns matches"
else
fail "GET /fhir/Encounter?patient={id} returns matches"
fi
}
test_fhir_observation() {
section "6. FHIR Observation read, LOINC search, category & date"
local obs_json loinc_code loinc_unit loinc_value loinc_search category_search date_search
obs_json="$(fhir_get "/Observation/${HEART_RATE_OBS_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Observation"' <<<"$obs_json" >/dev/null 2>&1; then
pass "GET /fhir/Observation/{id} returns Observation resource"
else
fail "GET /fhir/Observation/{id} returns Observation resource"
fi
loinc_code="$(jq -er '.code.coding[0].code // empty' <<<"$obs_json" 2>/dev/null || true)"
loinc_unit="$(jq -er '.valueQuantity.unit // empty' <<<"$obs_json" 2>/dev/null || true)"
loinc_value="$(jq -er '.valueQuantity.value // empty' <<<"$obs_json" 2>/dev/null || true)"
if [[ "$loinc_code" == "8867-4" && "$loinc_unit" == "bpm" && "$loinc_value" == "88" ]]; then
pass "Observation read maps HEART_RATE to LOINC 8867-4 with value 88 bpm"
else
fail "Observation read maps HEART_RATE to LOINC 8867-4 with value 88 bpm"
fi
loinc_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&code=8867-4" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.entry | length' <<<"$loinc_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?code=8867-4 resolves LOINC to HEART_RATE"
else
fail "GET /fhir/Observation?code=8867-4 resolves LOINC to HEART_RATE"
fi
category_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&category=vital-signs" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.entry | length' <<<"$category_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?category=vital-signs returns vital sign observations"
else
fail "GET /fhir/Observation?category=vital-signs returns vital sign observations"
fi
date_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&date=ge2026-06-20" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$date_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?date=ge2026-06-20 filters by recordedAt"
else
fail "GET /fhir/Observation?date=ge2026-06-20 filters by recordedAt"
fi
}
test_fhir_patient_everything() {
section "7. FHIR Patient \$everything"
local bundle_json total resource_types has_patient has_encounter has_observation
bundle_json="$(fhir_get "/Patient/${RESOLVED_PATIENT_ID}/\$everything" "$ADMIN_TOKEN")"
total="$(jq -er '.total // 0' <<<"$bundle_json" 2>/dev/null || echo 0)"
resource_types="$(jq -r '[.entry[]?.resource.resourceType] | join(",")' <<<"$bundle_json" 2>/dev/null || true)"
if [[ "$total" -gt 0 ]]; then
pass "GET /fhir/Patient/{id}/\$everything returns non-empty Bundle"
else
fail "GET /fhir/Patient/{id}/\$everything returns non-empty Bundle"
fi
has_patient="$(grep -c 'Patient' <<<"$resource_types" || true)"
has_encounter="$(grep -c 'Encounter' <<<"$resource_types" || true)"
has_observation="$(grep -c 'Observation' <<<"$resource_types" || true)"
if [[ "$has_patient" -ge 1 && "$has_encounter" -ge 1 && "$has_observation" -ge 1 ]]; then
pass "\$everything Bundle contains Patient, Encounter, and Observation"
else
fail "\$everything Bundle contains Patient, Encounter, and Observation (types: $resource_types)"
fi
}
test_fhir_content_type() {
section "8. Content-Type negotiation"
local content_type
content_type="$(curl -sS -o /dev/null -w '%{content_type}' \
"${FHIR_URL}/Patient/${RESOLVED_PATIENT_ID}" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Accept: application/fhir+json')"
if [[ "$content_type" == application/fhir+json* ]]; then
pass "Patient read Content-Type is application/fhir+json"
else
fail "Patient read Content-Type is application/fhir+json (got: $content_type)"
fi
}
test_fhir_error_handling() {
section "9. FHIR error handling — 404 OperationOutcome"
local response http_status issue_code issue_severity
response="$(fhir_get_status "/Patient/00000000-0000-0000-0000-000000000000" "$ADMIN_TOKEN")"
http_status="${response##*__HTTP_STATUS__:}"
response="${response%__HTTP_STATUS__:*}"
issue_code="$(jq -er '.issue[0].code // empty' <<<"$response" 2>/dev/null || true)"
issue_severity="$(jq -er '.issue[0].severity // empty' <<<"$response" 2>/dev/null || true)"
if [[ "$http_status" == "404" ]]; then
pass "unknown Patient returns HTTP 404"
else
fail "unknown Patient returns HTTP 404 (got HTTP $http_status)"
fi
if [[ "$issue_code" == "not-found" && "$issue_severity" == "error" ]]; then
pass "404 response is OperationOutcome with not-found issue"
else
fail "404 response is OperationOutcome with not-found issue"
fi
}
test_bundle_pagination() {
section "10. Bundle pagination links"
local bundle_json
bundle_json="$(fhir_get "/Patient?_count=1&_offset=0" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$bundle_json")" -ge 2 ]]; then
pass "Patient search total >= 2 for pagination test"
else
fail "Patient search total >= 2 for pagination test"
return
fi
if jq -e '.link[] | select(.relation == "self")' <<<"$bundle_json" >/dev/null 2>&1; then
pass "search Bundle includes self link"
else
fail "search Bundle includes self link"
fi
if jq -e '.link[] | select(.relation == "next")' <<<"$bundle_json" >/dev/null 2>&1; then
pass "search Bundle includes next link"
else
fail "search Bundle includes next link"
fi
}
test_integration_tests() {
section "11. dotnet integration tests — FhirIntegrationTests"
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
log " SKIP: dotnet integration tests (VIGILCARE_SKIP_TEST_CHECKS=1)"
return
fi
if ! command -v dotnet >/dev/null 2>&1; then
log " SKIP: dotnet not installed"
return
fi
if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \
--filter "FullyQualifiedName~FhirIntegrationTests" \
--no-restore >/tmp/vigilcare-p11-tests.log 2>&1; then
pass "FhirIntegrationTests passed"
else
fail "FhirIntegrationTests passed"
log " see /tmp/vigilcare-p11-tests.log"
fi
}
print_manual_ui_checklist() {
section "12. Manual Vue UI checks (plan §7)"
log " Login as admin1 → http://localhost:3028/fhir-explorer"
log " - Select Patient resource type; search by name Santos"
log " - Results table shows matching patients"
log " - Click a row to see full FHIR JSON"
log " - Patient \$everything: select patient, Load All Data"
log " - Grouped Patient summary, Encounters list, Observations timeline"
log " - Open CapabilityStatement link opens metadata JSON in new tab"
}
main() {
require_cmd curl
require_cmd jq
log "VigilCare Records — Phase 11 verification"
log "API: $API_URL"
log "FHIR: $FHIR_URL"
assert_api_reachable
test_authentication
ensure_fhir_clinical_seed
test_fhir_metadata
resolve_patient_id
test_fhir_patient_read
test_fhir_encounter
test_fhir_observation
test_fhir_patient_everything
test_fhir_content_type
test_fhir_error_handling
test_bundle_pagination
test_integration_tests
print_manual_ui_checklist
log ""
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if (( FAIL_COUNT > 0 )); then
log "Failed checks:"
for item in "${FAILED_TESTS[@]}"; do
log " - $item"
done
exit 1
fi
log "All Phase 11 API verification checks passed."
log "Complete the manual Vue FHIR Explorer checklist above if not already done."
}
main "$@"
@@ -139,6 +139,24 @@ describe('router navigation guard', () => {
expect(next).toHaveBeenCalledWith() expect(next).toHaveBeenCalledWith()
}) })
it('allows ADMINISTRATOR access to fhir-explorer route', () => {
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
const { next } = runGuard(buildRoute('/fhir-explorer', {
requiresAuth: true,
roles: ['ADMINISTRATOR'],
}))
expect(next).toHaveBeenCalledWith()
})
it('redirects DATA_ENTRY_CLERK from fhir-explorer to /entry', () => {
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
const { next } = runGuard(buildRoute('/fhir-explorer', {
requiresAuth: true,
roles: ['ADMINISTRATOR'],
}))
expect(next).toHaveBeenCalledWith('/entry')
})
it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => { it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => {
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
const { next } = runGuard(buildRoute('/cover-sheets', { const { next } = runGuard(buildRoute('/cover-sheets', {
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import FhirExplorerView from '@/views/FhirExplorerView.vue'
vi.mock('@/api/fhirClient', () => ({
fhirGet: vi.fn(),
openFhirJsonInNewTab: vi.fn(),
}))
vi.mock('@/components/AppHeader.vue', () => ({
default: { template: '<div />' },
}))
vi.mock('@/components/PatientSearch.vue', () => ({
default: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: '<input data-testid="patient-search" @input="$emit(\'update:modelValue\', \'patient-1\')" />',
},
}))
import { fhirGet, openFhirJsonInNewTab } from '@/api/fhirClient'
const mockedFhirGet = vi.mocked(fhirGet)
const mockedOpenTab = vi.mocked(openFhirJsonInNewTab)
const patientBundle = {
resourceType: 'Bundle',
type: 'searchset',
total: 1,
entry: [
{
resource: {
resourceType: 'Patient',
id: 'patient-1',
name: [{ text: 'MARIA SANTOS' }],
identifier: [{ value: 'VCR-000001' }],
birthDate: '1978-03-15',
gender: 'female',
},
},
],
}
const everythingBundle = {
resourceType: 'Bundle',
type: 'searchset',
total: 3,
entry: [
{ resource: { resourceType: 'Patient', id: 'patient-1', name: [{ text: 'MARIA SANTOS' }] } },
{ resource: { resourceType: 'Encounter', id: 'enc-1', status: 'in-progress', period: { start: '2026-06-22' } } },
{
resource: {
resourceType: 'Observation',
id: 'obs-1',
code: { coding: [{ code: '8867-4', display: 'Heart rate' }] },
valueQuantity: { value: 88, unit: 'bpm' },
effectiveDateTime: '2026-06-22T10:00:00Z',
},
},
],
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockedFhirGet.mockResolvedValue(patientBundle)
})
describe('FhirExplorerView', () => {
it('renders resource browser and patient search fields by default', () => {
const wrapper = mount(FhirExplorerView)
expect(wrapper.text()).toContain('Resource Browser')
expect(wrapper.text()).toContain('Patient $everything')
expect(wrapper.find('select').element).toBeTruthy()
expect(wrapper.text()).toContain('MRN (identifier)')
})
it('searches FHIR Patient resources and shows results table', async () => {
const wrapper = mount(FhirExplorerView)
await wrapper.find('input[placeholder="e.g. Santos"]').setValue('Santos')
await wrapper.find('button.btn-primary').trigger('click')
await flushPromises()
expect(mockedFhirGet).toHaveBeenCalledWith('/Patient', {
name: 'Santos',
birthdate: undefined,
identifier: undefined,
_count: 20,
})
expect(wrapper.text()).toContain('MARIA SANTOS')
expect(wrapper.text()).toContain('VCR-000001')
})
it('shows formatted JSON when a result row is clicked', async () => {
const wrapper = mount(FhirExplorerView)
await wrapper.find('button.btn-primary').trigger('click')
await flushPromises()
await wrapper.find('tbody tr').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('"resourceType": "Patient"')
expect(wrapper.text()).toContain('"id": "patient-1"')
})
it('loads Patient $everything bundle grouped by resource type', async () => {
mockedFhirGet.mockResolvedValue(everythingBundle)
const wrapper = mount(FhirExplorerView)
await wrapper.find('[data-testid="patient-search"]').trigger('input')
await flushPromises()
const loadButton = wrapper.findAll('button.btn-primary').find((b) =>
b.text().includes('Load All Data')
)
expect(loadButton).toBeDefined()
await loadButton!.trigger('click')
await flushPromises()
expect(mockedFhirGet).toHaveBeenCalledWith('/Patient/patient-1/$everything')
expect(wrapper.text()).toContain('Encounters (1)')
expect(wrapper.text()).toContain('Observations (1)')
expect(wrapper.text()).toContain('Heart rate')
})
it('opens metadata CapabilityStatement in a new tab', async () => {
mockedFhirGet.mockResolvedValueOnce({ resourceType: 'CapabilityStatement' })
const wrapper = mount(FhirExplorerView)
await wrapper.find('button.btn-secondary').trigger('click')
await flushPromises()
expect(mockedFhirGet).toHaveBeenCalledWith('/metadata')
expect(mockedOpenTab).toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
import axios, { type AxiosInstance } from 'axios'
export interface FhirBundleLink {
relation: string
url: string
}
export interface FhirBundleEntry {
fullUrl?: string
resource?: FhirResource
search?: { mode?: string }
}
export interface FhirBundle {
resourceType: 'Bundle'
type?: string
total?: number
entry?: FhirBundleEntry[]
link?: FhirBundleLink[]
}
export type FhirResource = Record<string, unknown> & {
resourceType: string
id?: string
}
const fhirClient: AxiosInstance = axios.create({
baseURL: '/fhir',
timeout: 30000,
headers: {
Accept: 'application/fhir+json',
},
})
fhirClient.interceptors.request.use((config) => {
const token = localStorage.getItem('vigilcare_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export async function fhirGet<T = unknown>(
path: string,
params?: Record<string, string | number | undefined>
): Promise<T> {
const cleaned: Record<string, string | number> = {}
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') {
cleaned[key] = value
}
}
}
const response = await fhirClient.get<T>(path, { params: cleaned })
return response.data
}
export function openFhirJsonInNewTab(data: unknown, filename = 'fhir-resource.json'): void {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/fhir+json',
})
const url = URL.createObjectURL(blob)
const tab = window.open(url, '_blank')
if (!tab) {
URL.revokeObjectURL(url)
return
}
tab.document.title = filename
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
@@ -11,6 +11,7 @@
<router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link> <router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link>
<router-link to="/patients" class="nav-link">History</router-link> <router-link to="/patients" class="nav-link">History</router-link>
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link> <router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link v-if="auth.canSupervise" to="/fhir-explorer" class="nav-link">FHIR Explorer</router-link>
</nav> </nav>
<slot name="subtitle" /> <slot name="subtitle" />
</div> </div>
@@ -96,6 +96,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/QueueDashboardView.vue'), component: () => import('../views/QueueDashboardView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
}, },
{
path: '/fhir-explorer',
name: 'FhirExplorer',
component: () => import('../views/FhirExplorerView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
{ {
path: '/', path: '/',
redirect: '/login', redirect: '/login',
@@ -0,0 +1,419 @@
<template>
<div class="min-h-screen flex flex-col">
<AppHeader title="FHIR Explorer" />
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">FHIR R4 Explorer</h1>
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
Open CapabilityStatement (/fhir/metadata)
</button>
</div>
<!-- Resource browser -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Resource Browser</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Resource Type</label>
<select v-model="resourceType" class="form-input" @change="clearResults">
<option value="Patient">Patient</option>
<option value="Encounter">Encounter</option>
<option value="Observation">Observation</option>
</select>
</div>
<template v-if="resourceType === 'Patient'">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
<input v-model="patientSearch.name" type="text" class="form-input" placeholder="e.g. Santos" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Birth Date</label>
<input v-model="patientSearch.birthdate" type="date" class="form-input" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">MRN (identifier)</label>
<input v-model="patientSearch.identifier" type="text" class="form-input" placeholder="VCR-000001" />
</div>
</template>
<template v-else-if="resourceType === 'Encounter'">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
<input v-model="encounterSearch.patient" type="text" class="form-input" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<select v-model="encounterSearch.status" class="form-input">
<option value="">Any</option>
<option value="in-progress">in-progress</option>
<option value="finished">finished</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
<input v-model="encounterSearch.date" type="date" class="form-input" />
</div>
</template>
<template v-else>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
<input v-model="observationSearch.patient" type="text" class="form-input" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">LOINC Code</label>
<input v-model="observationSearch.code" type="text" class="form-input" placeholder="8867-4" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label>
<select v-model="observationSearch.category" class="form-input">
<option value="">Any</option>
<option value="vital-signs">vital-signs</option>
<option value="laboratory">laboratory</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date (FHIR prefix)</label>
<input
v-model="observationSearch.date"
type="text"
class="form-input"
placeholder="ge2026-06-20"
/>
</div>
</template>
</div>
<button type="button" class="btn-primary" :disabled="searching" @click="runSearch">
{{ searching ? 'Searching...' : 'Search' }}
</button>
<p v-if="searchError" class="text-clinical-danger text-sm mt-3">{{ searchError }}</p>
<div v-if="searchResults.length > 0" class="mt-6 overflow-x-auto">
<p class="text-sm text-gray-600 mb-2">
{{ searchTotal }} result(s)
</p>
<table class="min-w-full text-sm border border-gray-200 rounded-md overflow-hidden">
<thead class="bg-gray-50 text-left">
<tr>
<th class="px-3 py-2">ID</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">Name</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">MRN</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">DOB</th>
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Status</th>
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Patient</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Code</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Value</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Recorded</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in searchResults"
:key="String(entry.resource?.id)"
class="border-t border-gray-100 hover:bg-primary-50 cursor-pointer"
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
@click="selectResource(entry.resource)"
>
<td class="px-3 py-2 font-mono text-xs">{{ entry.resource?.id }}</td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
{{ patientDisplayName(entry.resource) }}
</td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
{{ patientMrn(entry.resource) }}
</td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
{{ entry.resource?.birthDate ?? '' }}
</td>
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
{{ entry.resource?.status ?? '' }}
</td>
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
{{ subjectReference(entry.resource) }}
</td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
{{ observationCodeDisplay(entry.resource) }}
</td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
{{ observationValueDisplay(entry.resource) }}
</td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
{{ observationEffective(entry.resource) }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="selectedResource" class="mt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Resource JSON</h3>
<pre class="bg-gray-900 text-green-100 text-xs p-4 rounded-md overflow-x-auto max-h-96">{{ formattedSelectedResource }}</pre>
</div>
</div>
<!-- Patient $everything -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Patient $everything</h2>
<p class="text-sm text-gray-600 mb-4">
Load all FHIR resources for a patient in one Bundle.
</p>
<div class="max-w-md mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
<PatientSearch v-model="everythingPatientId" />
</div>
<button
type="button"
class="btn-primary"
:disabled="!everythingPatientId || everythingLoading"
@click="loadEverything"
>
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
</button>
<p v-if="everythingError" class="text-clinical-danger text-sm mt-3">{{ everythingError }}</p>
<div v-if="everythingBundle" class="mt-6 space-y-6">
<div v-if="everythingPatient" class="card bg-primary-50 border border-primary-100">
<h3 class="font-semibold mb-2">Patient</h3>
<p class="text-sm"><span class="text-gray-500">Name:</span> {{ patientDisplayName(everythingPatient) }}</p>
<p class="text-sm"><span class="text-gray-500">MRN:</span> {{ patientMrn(everythingPatient) }}</p>
<p class="text-sm"><span class="text-gray-500">DOB:</span> {{ everythingPatient.birthDate ?? '—' }}</p>
<p class="text-sm"><span class="text-gray-500">Gender:</span> {{ everythingPatient.gender ?? '—' }}</p>
</div>
<div v-if="everythingEncounters.length > 0">
<h3 class="font-semibold mb-2">Encounters ({{ everythingEncounters.length }})</h3>
<ul class="space-y-2">
<li
v-for="enc in everythingEncounters"
:key="String(enc.id)"
class="text-sm border border-gray-200 rounded-md px-3 py-2"
>
<span class="font-mono text-xs text-gray-500">{{ enc.id }}</span>
{{ enc.status }}
<span v-if="enc.period?.start"> · {{ enc.period.start }}</span>
</li>
</ul>
</div>
<div v-if="everythingObservations.length > 0">
<h3 class="font-semibold mb-2">Observations ({{ everythingObservations.length }})</h3>
<ul class="space-y-2">
<li
v-for="obs in everythingObservations"
:key="String(obs.id)"
class="text-sm border border-gray-200 rounded-md px-3 py-2 flex flex-wrap gap-x-3"
>
<span class="font-mono text-xs text-gray-500">{{ obs.id }}</span>
<span>{{ observationCodeDisplay(obs) }}</span>
<span>{{ observationValueDisplay(obs) }}</span>
<span class="text-gray-500">{{ observationEffective(obs) }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import {
fhirGet,
openFhirJsonInNewTab,
type FhirBundle,
type FhirBundleEntry,
type FhirResource,
} from '../api/fhirClient'
type ResourceType = 'Patient' | 'Encounter' | 'Observation'
const resourceType = ref<ResourceType>('Patient')
const searching = ref(false)
const searchError = ref('')
const searchResults = ref<FhirBundleEntry[]>([])
const searchTotal = ref(0)
const selectedResource = ref<FhirResource | null>(null)
const patientSearch = reactive({ name: '', birthdate: '', identifier: '' })
const encounterSearch = reactive({ patient: '', status: '', date: '' })
const observationSearch = reactive({ patient: '', code: '', category: '', date: '' })
const everythingPatientId = ref<string | undefined>()
const everythingLoading = ref(false)
const everythingError = ref('')
const everythingBundle = ref<FhirBundle | null>(null)
const formattedSelectedResource = computed(() =>
selectedResource.value ? JSON.stringify(selectedResource.value, null, 2) : ''
)
const everythingPatient = computed(() =>
everythingBundle.value?.entry
?.map((e) => e.resource)
.find((r) => r?.resourceType === 'Patient') as FhirPatientResource | undefined
)
const everythingEncounters = computed(() =>
(everythingBundle.value?.entry ?? [])
.map((e) => e.resource)
.filter((r): r is FhirEncounterResource => r?.resourceType === 'Encounter')
)
const everythingObservations = computed(() =>
(everythingBundle.value?.entry ?? [])
.map((e) => e.resource)
.filter((r): r is FhirObservationResource => r?.resourceType === 'Observation')
)
type FhirPatientResource = FhirResource & {
name?: { text?: string; family?: string }[]
identifier?: { value?: string }[]
birthDate?: string
gender?: string
}
type FhirEncounterResource = FhirResource & {
status?: string
subject?: { reference?: string }
period?: { start?: string }
}
type FhirObservationResource = FhirResource & {
code?: { coding?: { code?: string; display?: string }[]; text?: string }
valueQuantity?: { value?: number; unit?: string }
effectiveDateTime?: string
effective?: string
}
function clearResults(): void {
searchResults.value = []
searchTotal.value = 0
selectedResource.value = null
searchError.value = ''
}
function selectResource(resource: FhirResource | undefined): void {
selectedResource.value = resource ?? null
}
function patientDisplayName(resource: FhirResource | undefined): string {
if (!resource) return '—'
const patient = resource as FhirPatientResource
const name = patient.name?.[0]
return name?.text ?? name?.family ?? '—'
}
function patientMrn(resource: FhirResource | undefined): string {
if (!resource) return '—'
const patient = resource as FhirPatientResource
return patient.identifier?.[0]?.value ?? '—'
}
function subjectReference(resource: FhirResource | undefined): string {
if (!resource) return '—'
return (resource as FhirEncounterResource).subject?.reference ?? '—'
}
function observationCodeDisplay(resource: FhirResource | undefined): string {
if (!resource) return '—'
const obs = resource as FhirObservationResource
const coding = obs.code?.coding?.[0]
return coding?.display ?? coding?.code ?? obs.code?.text ?? '—'
}
function observationValueDisplay(resource: FhirResource | undefined): string {
if (!resource) return '—'
const qty = (resource as FhirObservationResource).valueQuantity
if (!qty) return '—'
return `${qty.value ?? '—'} ${qty.unit ?? ''}`.trim()
}
function observationEffective(resource: FhirResource | undefined): string {
if (!resource) return '—'
const obs = resource as FhirObservationResource
return obs.effectiveDateTime ?? obs.effective ?? '—'
}
function buildSearchParams(): Record<string, string | number | undefined> {
if (resourceType.value === 'Patient') {
return {
name: patientSearch.name || undefined,
birthdate: patientSearch.birthdate || undefined,
identifier: patientSearch.identifier || undefined,
_count: 20,
}
}
if (resourceType.value === 'Encounter') {
return {
patient: encounterSearch.patient || undefined,
status: encounterSearch.status || undefined,
date: encounterSearch.date || undefined,
_count: 20,
}
}
return {
patient: observationSearch.patient || undefined,
code: observationSearch.code || undefined,
category: observationSearch.category || undefined,
date: observationSearch.date || undefined,
_count: 50,
}
}
async function runSearch(): Promise<void> {
searching.value = true
searchError.value = ''
selectedResource.value = null
try {
const bundle = await fhirGet<FhirBundle>(`/${resourceType.value}`, buildSearchParams())
searchResults.value = bundle.entry ?? []
searchTotal.value = bundle.total ?? searchResults.value.length
} catch (err: unknown) {
searchResults.value = []
searchTotal.value = 0
searchError.value = err instanceof Error ? err.message : 'FHIR search failed'
} finally {
searching.value = false
}
}
async function loadEverything(): Promise<void> {
if (!everythingPatientId.value) return
everythingLoading.value = true
everythingError.value = ''
everythingBundle.value = null
try {
everythingBundle.value = await fhirGet<FhirBundle>(
`/Patient/${everythingPatientId.value}/$everything`
)
} catch (err: unknown) {
everythingError.value = err instanceof Error ? err.message : 'Failed to load patient Bundle'
} finally {
everythingLoading.value = false
}
}
async function openMetadata(): Promise<void> {
try {
const metadata = await fhirGet('/metadata')
openFhirJsonInNewTab(metadata, 'capability-statement.json')
} catch (err: unknown) {
searchError.value = err instanceof Error ? err.message : 'Failed to load metadata'
}
}
</script>
+4
View File
@@ -16,6 +16,10 @@ export default defineConfig({
target: 'http://localhost:5217', target: 'http://localhost:5217',
changeOrigin: true, changeOrigin: true,
}, },
'/fhir': {
target: 'http://localhost:5217',
changeOrigin: true,
},
}, },
}, },
}) })