From 5646dfddb4affc199d5e4287f9b20fb00fd572fa Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 27 Jun 2026 22:23:45 +0800 Subject: [PATCH] feature: HL7 FHIR R4 Integration --- README.md | 84 ++- .../FhirIntegrationTests.cs | 298 +++++++++ .../Fixtures/ApiFixture.cs | 3 +- .../Helpers/FhirClinicalSeedHelper.cs | 98 +++ .../Configurations/FhirOptions.cs | 9 + .../Fhir/FhirEncounterController.cs | 42 ++ .../Fhir/FhirMetadataController.cs | 27 + .../Fhir/FhirObservationController.cs | 45 ++ .../Controllers/Fhir/FhirPatientController.cs | 65 ++ .../Infrastructure/Fhir/FhirErrorHelper.cs | 18 + .../Fhir/FhirJsonOutputFormatter.cs | 31 + .../Fhir/Mappers/EncounterMapper.cs | 90 +++ .../Fhir/Mappers/ObservationMapper.cs | 137 ++++ .../Fhir/Mappers/PatientMapper.cs | 83 +++ VigilCareRecordsAPI/Program.cs | 8 +- VigilCareRecordsAPI/Services/FhirService.cs | 360 +++++++++++ .../Services/Interfaces/IFhirService.cs | 22 + .../VigilCareRecordsAPI.csproj | 1 + VigilCareRecordsAPI/appsettings.json | 6 + ...vigilcare-records-phase-11-verification.sh | 585 ++++++++++++++++++ .../src/__tests__/router/guards.test.ts | 18 + .../__tests__/views/FhirExplorerView.test.ts | 142 +++++ vigilcare-records-web/src/api/fhirClient.ts | 72 +++ .../src/components/AppHeader.vue | 1 + vigilcare-records-web/src/router/index.ts | 6 + .../src/views/FhirExplorerView.vue | 419 +++++++++++++ vigilcare-records-web/vite.config.ts | 4 + 27 files changed, 2658 insertions(+), 16 deletions(-) create mode 100644 VigilCareRecordsAPI.Tests/FhirIntegrationTests.cs create mode 100644 VigilCareRecordsAPI.Tests/Helpers/FhirClinicalSeedHelper.cs create mode 100644 VigilCareRecordsAPI/Configurations/FhirOptions.cs create mode 100644 VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs create mode 100644 VigilCareRecordsAPI/Controllers/Fhir/FhirMetadataController.cs create mode 100644 VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs create mode 100644 VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs create mode 100644 VigilCareRecordsAPI/Infrastructure/Fhir/FhirErrorHelper.cs create mode 100644 VigilCareRecordsAPI/Infrastructure/Fhir/FhirJsonOutputFormatter.cs create mode 100644 VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/EncounterMapper.cs create mode 100644 VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/ObservationMapper.cs create mode 100644 VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/PatientMapper.cs create mode 100644 VigilCareRecordsAPI/Services/FhirService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IFhirService.cs create mode 100755 scripts/run-vigilcare-records-phase-11-verification.sh create mode 100644 vigilcare-records-web/src/__tests__/views/FhirExplorerView.test.ts create mode 100644 vigilcare-records-web/src/api/fhirClient.ts create mode 100644 vigilcare-records-web/src/views/FhirExplorerView.vue diff --git a/README.md b/README.md index 938d941..39dfeea 100644 --- a/README.md +++ b/README.md @@ -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. -**Implementation status:** Phases 1–9 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 1–10 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 @@ -45,7 +45,8 @@ Append-only audit log entry for every state transition, field-level correction, ## 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 1–100 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 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`) @@ -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 - **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 -- **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` - **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`) @@ -99,6 +100,8 @@ HTTP request ├── UserDirectoryService (active user listing, create/update/deactivate, password reset/change) ├── AttestationService (clinician role + password re-confirm for live capture) ├── 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) ├── PlausibilityValidator (per-code numeric range guard) ├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables) @@ -143,6 +146,7 @@ HTTP request | Logging | Serilog + Seq sink | | Metrics | Prometheus (`prometheus-net`) + Grafana | | Docs | Swagger / OpenAPI (Swashbuckle) | +| Barcode / PDF | QRCoder (cover sheet QR codes; raw PDF generation) | | Testing | xUnit + FluentAssertions + WebApplicationFactory | --- @@ -157,6 +161,7 @@ VigilCareRecords/ │ ├── Controllers/ │ │ ├── ApprovalController.cs # Batch approval, promotion deferral (202), promotion to live tables │ │ ├── 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 │ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit │ │ ├── PatientsController.cs # Patient search and digitization history @@ -175,13 +180,13 @@ VigilCareRecords/ │ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh │ │ ├── stores/ # Pinia: auth, batches, liveCapture │ │ ├── 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 │ │ ├── composables/ # usePresignedUrl (URL refresh), useToast (notifications) │ │ └── types/index.ts # TypeScript interfaces matching API response shapes │ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217 │ └── tailwind.config.js # Clinical color palette and layout component classes -├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–9) +├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–10) ├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217) ├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana ├── scripts/ @@ -192,9 +197,11 @@ VigilCareRecords/ │ ├── run-vigilcare-records-phase-5-verification.sh │ ├── run-vigilcare-records-phase-6-verification.sh │ ├── 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/ - ├── plans/ # Phase 1–9 implementation guides + ├── plans/ # Phase 1–11 implementation guides ├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference ├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog └── 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 | |---|---|---| -| `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 | | `verifier1` | `password` | `/verification` — field-level verification | | `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 | | `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 | +| `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 | ### 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-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-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 | |---|---|---|---| | `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` | -| `track` | string | no | `BACKFILL` (default) or `LIVE_CAPTURE` | -| `patientId` | Guid | no | Link to existing patient (enables duplicate detection) | +| `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` — overridden by cover sheet when `coverSheetCode` is set | +| `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 | +| `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:** @@ -512,8 +524,8 @@ Error response: |---|---| | 201 | Batch created | | 400 | Empty file or invalid MIME type | -| 404 | Superseded batch not found (`SUPERSEDED_BATCH_NOT_FOUND`) | -| 409 | Duplicate document (same SHA-256 for same patient within 24 hours); batch already superseded (`BATCH_ALREADY_SUPERSEDED`) | +| 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`); cover sheet already used (`COVER_SHEET_ALREADY_USED`) | | 422 | Superseded batch not in `PROMOTED` status (`SUPERSEDED_BATCH_NOT_PROMOTED`) | **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. +### Cover Sheets + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/cover-sheets/generate` | Intake Clerk, Administrator | Generate 1–100 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 (1–100) | +| `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 | Method | Path | Description | @@ -829,6 +865,24 @@ Returns `503` when a required dependency is unhealthy. ## 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 ``` @@ -1149,7 +1203,7 @@ Response shape: ## Implemented Phases -Phases 1–9 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 1–10 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 | |---|---|---| @@ -1162,4 +1216,6 @@ Phases 1–9 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 | | 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 | +| 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 | +| 11 | HL7 FHIR R4 read-only API for promoted clinical data (Patient, Encounter, Observation) | Planned | diff --git a/VigilCareRecordsAPI.Tests/FhirIntegrationTests.cs b/VigilCareRecordsAPI.Tests/FhirIntegrationTests.cs new file mode 100644 index 0000000..4b3c82f --- /dev/null +++ b/VigilCareRecordsAPI.Tests/FhirIntegrationTests.cs @@ -0,0 +1,298 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Integration tests for FHIR R4 read/search endpoints (Phase 11 Step 7). +/// +[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(); + 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 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 ParseJsonAsync(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + return JsonDocument.Parse(json).RootElement; + } +} diff --git a/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs index 599b47f..8f44c58 100644 --- a/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs +++ b/VigilCareRecordsAPI.Tests/Fixtures/ApiFixture.cs @@ -18,7 +18,8 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime { ["ConnectionStrings:DefaultConnection"] = "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" }); }); } diff --git a/VigilCareRecordsAPI.Tests/Helpers/FhirClinicalSeedHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/FhirClinicalSeedHelper.cs new file mode 100644 index 0000000..31f4ca8 --- /dev/null +++ b/VigilCareRecordsAPI.Tests/Helpers/FhirClinicalSeedHelper.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; + +/// +/// 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. +/// +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(); + } +} diff --git a/VigilCareRecordsAPI/Configurations/FhirOptions.cs b/VigilCareRecordsAPI/Configurations/FhirOptions.cs new file mode 100644 index 0000000..e4d5278 --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/FhirOptions.cs @@ -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"; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs new file mode 100644 index 0000000..4d2503c --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs @@ -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; + + /// + /// FHIR read: GET /fhir/Encounter/{id} + /// + [HttpGet("{id}")] + public async Task Read(string id) + { + var encounter = await _fhir.GetEncounterAsync(Guid.Parse(id)); + if (encounter is null) + return NotFound(FhirErrorHelper.NotFound("Encounter", id)); + + return Ok(encounter); + } + + /// + /// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z + /// Supports search by patient reference, status, and date range. + /// + [HttpGet] + public async Task 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); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirMetadataController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirMetadataController.cs new file mode 100644 index 0000000..f2a9145 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirMetadataController.cs @@ -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; + + /// + /// FHIR metadata: GET /fhir/metadata + /// Returns the server's CapabilityStatement describing supported + /// resources, interactions, and search parameters. + /// No authentication required (FHIR spec requirement). + /// + [HttpGet("metadata")] + [AllowAnonymous] + [ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)] + public IActionResult GetMetadata() + { + return Ok(_fhir.GetCapabilityStatement()); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs new file mode 100644 index 0000000..4f89989 --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs @@ -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; + + /// + /// FHIR read: GET /fhir/Observation/{id} + /// + [HttpGet("{id}")] + public async Task Read(string id) + { + var observation = await _fhir.GetObservationAsync(Guid.Parse(id)); + if (observation is null) + return NotFound(FhirErrorHelper.NotFound("Observation", id)); + + return Ok(observation); + } + + /// + /// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W + /// Supports search by patient reference, LOINC code, date range, and category. + /// + [HttpGet] + public async Task 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); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs new file mode 100644 index 0000000..7e0367f --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs @@ -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; + + /// + /// FHIR read: GET /fhir/Patient/{id} + /// Returns a single Patient resource by logical ID. + /// + [HttpGet("{id}")] + [ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Hl7.Fhir.Model.OperationOutcome), StatusCodes.Status404NotFound)] + public async Task Read(string id) + { + var patient = await _fhir.GetPatientAsync(Guid.Parse(id)); + if (patient is null) + return NotFound(FhirErrorHelper.NotFound("Patient", id)); + + return Ok(patient); + } + + /// + /// 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. + /// + [HttpGet] + [ProducesResponseType(typeof(Hl7.Fhir.Model.Bundle), StatusCodes.Status200OK)] + public async Task 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); + } + + /// + /// FHIR $everything: GET /fhir/Patient/{id}/$everything + /// Returns a Bundle containing the Patient resource, all Encounters, + /// and all Observations for the patient. + /// + [HttpGet("{id}/$everything")] + [ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] + public async Task Everything(string id) + { + var bundle = await _fhir.GetPatientEverythingAsync(Guid.Parse(id)); + if (bundle is null) + return NotFound(FhirErrorHelper.NotFound("Patient", id)); + + return Ok(bundle); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Infrastructure/Fhir/FhirErrorHelper.cs b/VigilCareRecordsAPI/Infrastructure/Fhir/FhirErrorHelper.cs new file mode 100644 index 0000000..ba323e9 --- /dev/null +++ b/VigilCareRecordsAPI/Infrastructure/Fhir/FhirErrorHelper.cs @@ -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", + } + } + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Infrastructure/Fhir/FhirJsonOutputFormatter.cs b/VigilCareRecordsAPI/Infrastructure/Fhir/FhirJsonOutputFormatter.cs new file mode 100644 index 0000000..d65f953 --- /dev/null +++ b/VigilCareRecordsAPI/Infrastructure/Fhir/FhirJsonOutputFormatter.cs @@ -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); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/EncounterMapper.cs b/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/EncounterMapper.cs new file mode 100644 index 0000000..d4755f2 --- /dev/null +++ b/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/EncounterMapper.cs @@ -0,0 +1,90 @@ +using Hl7.Fhir.Model; +using FhirEncounter = Hl7.Fhir.Model.Encounter; + +public static class EncounterMapper +{ + /// + /// 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. + /// + 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; + } +} diff --git a/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/ObservationMapper.cs b/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/ObservationMapper.cs new file mode 100644 index 0000000..4c982eb --- /dev/null +++ b/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/ObservationMapper.cs @@ -0,0 +1,137 @@ +using Hl7.Fhir.Model; +using FhirObservation = Hl7.Fhir.Model.Observation; + +public static class ObservationMapper +{ + private static readonly Dictionary 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 VitalSignCodes = + [ + "HEART_RATE", "TEMP_C", "BP_SYSTOLIC", "BP_DIASTOLIC", "RESP_RATE", "SPO2" + ]; + + /// + /// 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. + /// + 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; + } + + /// + /// Reverse LOINC → VigilCare code mapping for FHIR search by LOINC code. + /// + public static IReadOnlyDictionary GetReverseLoincMapping() => LoincToVigilCare; + + /// + /// VigilCare observation codes that represent vital signs (used for category search). + /// + public static IReadOnlyCollection 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); +} diff --git a/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/PatientMapper.cs b/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/PatientMapper.cs new file mode 100644 index 0000000..cb36997 --- /dev/null +++ b/VigilCareRecordsAPI/Infrastructure/Fhir/Mappers/PatientMapper.cs @@ -0,0 +1,83 @@ +using Hl7.Fhir.Model; +using FhirPatient = Hl7.Fhir.Model.Patient; + +public static class PatientMapper +{ + /// + /// 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. + /// + 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 + { + new("http://terminology.hl7.org/CodeSystem/v2-0131", "C", "Emergency Contact") + }, + Name = new HumanName { Text = entity.EmergencyContact }, + }); + } + + return patient; + } +} diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index a35ff73..688a2f1 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -51,6 +51,8 @@ try builder.Services.Configure( builder.Configuration.GetSection(PromotionRetryOptions.Section)); + builder.Services.Configure(builder.Configuration.GetSection(FhirOptions.Section)); + // JWT Authentication var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!; @@ -122,6 +124,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -143,7 +146,10 @@ try builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddScoped(); builder.Services.AddControllers(options => - options.Filters.AddService()); + { + options.OutputFormatters.Insert(0, new FhirJsonOutputFormatter()); + options.Filters.AddService(); + }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddVigilCareRecordsSwagger(); diff --git a/VigilCareRecordsAPI/Services/FhirService.cs b/VigilCareRecordsAPI/Services/FhirService.cs new file mode 100644 index 0000000..1cd9e93 --- /dev/null +++ b/VigilCareRecordsAPI/Services/FhirService.cs @@ -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 options) + { + _db = db; + _options = options.Value; + } + + // --- Patient --- + + public async Task 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 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().ToList(), + total, count, offset, "Patient"); + } + + // --- Encounter --- + + public async Task 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 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().ToList(), + total, count, offset, "Encounter"); + } + + // --- Observation --- + + public async Task 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 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().ToList(), + total, count, offset, "Observation"); + } + + public async Task 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 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 + { + new() + { + Mode = CapabilityStatement.RestfulCapabilityMode.Server, + Resource = new List + { + 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( + 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 ApplyObservationDateFilter( + IQueryable 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; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IFhirService.cs b/VigilCareRecordsAPI/Services/Interfaces/IFhirService.cs new file mode 100644 index 0000000..c3e5473 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IFhirService.cs @@ -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 GetPatientAsync(Guid id); + Task SearchPatientsAsync(string? name, string? birthdate, string? identifier, int count, int offset); + + Task GetEncounterAsync(Guid id); + Task SearchEncountersAsync(string? patient, string? status, string? date, int count, int offset); + + Task GetObservationAsync(Guid id); + Task SearchObservationsAsync( + string? patient, string? code, string? date, + string? category, string? encounter, int count, int offset); + + Task GetPatientEverythingAsync(Guid patientId); + + CapabilityStatement GetCapabilityStatement(); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj index da32871..9b1fe14 100644 --- a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj @@ -13,6 +13,7 @@ + diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json index 155d47a..61ff8f8 100644 --- a/VigilCareRecordsAPI/appsettings.json +++ b/VigilCareRecordsAPI/appsettings.json @@ -70,5 +70,11 @@ "MaxDelaySeconds": 900, "MaxRetryAttempts": 10, "BackoffMultiplier": 2.0 + }, + "Fhir": { + "BaseUrl": "http://localhost:5271/fhir", + "PublisherName": "VigilCare Records", + "PublisherUrl": "https://vigilcare.local", + "ServerVersion": "1.0.0" } } \ No newline at end of file diff --git a/scripts/run-vigilcare-records-phase-11-verification.sh b/scripts/run-vigilcare-records-phase-11-verification.sh new file mode 100755 index 0000000..5672bb5 --- /dev/null +++ b/scripts/run-vigilcare-records-phase-11-verification.sh @@ -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 1–10 seed data (admin1) +# +# Environment overrides: +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /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:-}" + 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:-})" + 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 "$@" diff --git a/vigilcare-records-web/src/__tests__/router/guards.test.ts b/vigilcare-records-web/src/__tests__/router/guards.test.ts index 9735c98..da7be8c 100644 --- a/vigilcare-records-web/src/__tests__/router/guards.test.ts +++ b/vigilcare-records-web/src/__tests__/router/guards.test.ts @@ -139,6 +139,24 @@ describe('router navigation guard', () => { 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', () => { const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') const { next } = runGuard(buildRoute('/cover-sheets', { diff --git a/vigilcare-records-web/src/__tests__/views/FhirExplorerView.test.ts b/vigilcare-records-web/src/__tests__/views/FhirExplorerView.test.ts new file mode 100644 index 0000000..4c9621b --- /dev/null +++ b/vigilcare-records-web/src/__tests__/views/FhirExplorerView.test.ts @@ -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: '
' }, +})) + +vi.mock('@/components/PatientSearch.vue', () => ({ + default: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: '', + }, +})) + +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() + }) +}) diff --git a/vigilcare-records-web/src/api/fhirClient.ts b/vigilcare-records-web/src/api/fhirClient.ts new file mode 100644 index 0000000..0ad2d3f --- /dev/null +++ b/vigilcare-records-web/src/api/fhirClient.ts @@ -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 & { + 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( + path: string, + params?: Record +): Promise { + const cleaned: Record = {} + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + cleaned[key] = value + } + } + } + + const response = await fhirClient.get(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) +} diff --git a/vigilcare-records-web/src/components/AppHeader.vue b/vigilcare-records-web/src/components/AppHeader.vue index 2cd25c1..392dae2 100644 --- a/vigilcare-records-web/src/components/AppHeader.vue +++ b/vigilcare-records-web/src/components/AppHeader.vue @@ -11,6 +11,7 @@ Live Capture History Dashboard + FHIR Explorer
diff --git a/vigilcare-records-web/src/router/index.ts b/vigilcare-records-web/src/router/index.ts index 748fe54..b3bacff 100644 --- a/vigilcare-records-web/src/router/index.ts +++ b/vigilcare-records-web/src/router/index.ts @@ -96,6 +96,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/QueueDashboardView.vue'), meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, }, + { + path: '/fhir-explorer', + name: 'FhirExplorer', + component: () => import('../views/FhirExplorerView.vue'), + meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, + }, { path: '/', redirect: '/login', diff --git a/vigilcare-records-web/src/views/FhirExplorerView.vue b/vigilcare-records-web/src/views/FhirExplorerView.vue new file mode 100644 index 0000000..6d7d1a5 --- /dev/null +++ b/vigilcare-records-web/src/views/FhirExplorerView.vue @@ -0,0 +1,419 @@ + + + diff --git a/vigilcare-records-web/vite.config.ts b/vigilcare-records-web/vite.config.ts index 13f3459..61446a5 100644 --- a/vigilcare-records-web/vite.config.ts +++ b/vigilcare-records-web/vite.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ target: 'http://localhost:5217', changeOrigin: true, }, + '/fhir': { + target: 'http://localhost:5217', + changeOrigin: true, + }, }, }, }) \ No newline at end of file