chore: update readme and prd
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
||||||
|
|
||||||
**Implementation status:** Phases 1–8 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion retry with exponential backoff, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 (E2E verification and clinical scenario docs) is partially implemented. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
**Implementation status:** Phases 1–8 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). 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 is partially complete: the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`) and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)) are in place; extended demo seed data (patients and batches across all statuses) is not yet implemented. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||||
|
|
||||||
## Domain Model — How It Maps to a Real Clinical System
|
## Domain Model — How It Maps to a Real Clinical System
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ VigilCareRecords/
|
|||||||
│ └── 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
|
||||||
└── docs/
|
└── docs/
|
||||||
├── plans/ # Phase 1–9 implementation guides
|
├── plans/ # Phase 1–9 implementation guides
|
||||||
├── digitization-workstation-guide.md # Clerk workflow and UI reference
|
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
|
||||||
└── vigilcare-records-prd.md # Product requirements and phase roadmap
|
└── vigilcare-records-prd.md # Product requirements and phase roadmap
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -359,7 +359,9 @@ Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the
|
|||||||
| `verifier1` | `password` | `/verification` — field-level verification |
|
| `verifier1` | `password` | `/verification` — field-level verification |
|
||||||
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
|
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
|
||||||
|
|
||||||
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for the full clerk workflow.
|
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for clinical scenarios (backfill, live capture, corrections) and the full clerk workflow.
|
||||||
|
|
||||||
|
**Paper originals:** The scanned document is the working reference for entry and verification. The physical chart remains the legal original until jurisdiction-specific retention rules apply. Scans are never deleted on batch rejection.
|
||||||
|
|
||||||
Production build:
|
Production build:
|
||||||
|
|
||||||
@@ -443,9 +445,8 @@ Error response:
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `accessToken` | string | JWT bearer token (15 min) |
|
| `token` | string | JWT bearer token (15 min) |
|
||||||
| `refreshToken` | string | Opaque refresh token (7 days) |
|
| `refreshToken` | string | Opaque refresh token (7 days) |
|
||||||
| `expiresAt` | DateTimeOffset | Access token expiration |
|
|
||||||
| `userId` | Guid | User ID |
|
| `userId` | Guid | User ID |
|
||||||
| `username` | string | Username |
|
| `username` | string | Username |
|
||||||
| `displayName` | string | Display name |
|
| `displayName` | string | Display name |
|
||||||
@@ -570,7 +571,9 @@ Error response:
|
|||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/digitization-batches/{id}/approve` | Clinical Approver, Administrator | Approve and atomically promote draft data to live clinical tables |
|
| POST | `/digitization-batches/{id}/approve` | Clinical Approver, Administrator | Approve and atomically promote draft data to live clinical tables |
|
||||||
|
| POST | `/digitization-batches/{id}/promote` | Clinical Approver, Administrator | Manually promote an `APPROVED` batch (used after deferred promotion or by operators) |
|
||||||
| GET | `/digitization-batches/{id}/promotion-result` | Any authenticated | Retrieve live entity IDs created during promotion |
|
| GET | `/digitization-batches/{id}/promotion-result` | Any authenticated | Retrieve live entity IDs created during promotion |
|
||||||
|
| GET | `/digitization-batches/{id}/events` | Administrator, Verifier, Clinical Approver | Cursor-paginated batch audit trail with actor username and full name |
|
||||||
|
|
||||||
**POST `/approve` headers:**
|
**POST `/approve` headers:**
|
||||||
|
|
||||||
@@ -602,6 +605,7 @@ Error response:
|
|||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 200 | Batch approved and promoted (or idempotent replay) |
|
| 200 | Batch approved and promoted (or idempotent replay) |
|
||||||
|
| 202 | Promotion deferred — batch stays `APPROVED`; `PromotionRetryService` retries automatically (`PROMOTION_DEFERRED`) |
|
||||||
| 400 | Missing or invalid `Idempotency-Key` header |
|
| 400 | Missing or invalid `Idempotency-Key` header |
|
||||||
| 404 | Batch not found |
|
| 404 | Batch not found |
|
||||||
| 409 | Illegal status transition or separation of duties violation |
|
| 409 | Illegal status transition or separation of duties violation |
|
||||||
@@ -624,6 +628,27 @@ Error response:
|
|||||||
|
|
||||||
All work queue endpoints support pagination via `?page=1&pageSize=20` (except `overview`).
|
All work queue endpoints support pagination via `?page=1&pageSize=20` (except `overview`).
|
||||||
|
|
||||||
|
### Batch Audit Trail
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/digitization-batches/{id}/events` | Administrator, Verifier, Clinical Approver | Cursor-paginated digitization events for a batch |
|
||||||
|
|
||||||
|
Query parameters: `after` (ISO-8601 cursor from previous page's `nextCursor`), `pageSize` (default 50, max 200). Events are ordered chronologically (oldest first).
|
||||||
|
|
||||||
|
**Event object (`BatchEventResponse`):**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Guid | Event ID |
|
||||||
|
| `batchId` | Guid | Batch ID |
|
||||||
|
| `eventType` | string | e.g. `uploaded`, `verified`, `promoted`, `promotion_retry_failed` |
|
||||||
|
| `actorUserId` | Guid | User who performed the action |
|
||||||
|
| `actorUsername` | string | Actor username |
|
||||||
|
| `actorFullName` | string | Actor display name |
|
||||||
|
| `occurredAt` | DateTimeOffset | Event timestamp |
|
||||||
|
| `metadataJson` | string? | Optional JSON (field checks, rejection reason, etc.) |
|
||||||
|
|
||||||
**GET `/work-queue/overview` response:**
|
**GET `/work-queue/overview` response:**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -967,6 +992,20 @@ createdAt DateTimeOffset
|
|||||||
expiresAt DateTimeOffset 24-hour TTL
|
expiresAt DateTimeOffset 24-hour TTL
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### PromotionAttempt
|
||||||
|
|
||||||
|
Tracks each promotion attempt for deferred-retry batches. `PromotionRetryService` polls rows where `nextRetryAt <= now` and the batch is still `APPROVED`.
|
||||||
|
|
||||||
|
```
|
||||||
|
id Guid PK
|
||||||
|
batchId Guid FK → DigitizationBatch
|
||||||
|
attemptNumber int 1-based attempt counter
|
||||||
|
succeeded bool whether this attempt completed promotion
|
||||||
|
errorMessage string? failure reason when succeeded = false
|
||||||
|
attemptedAt DateTimeOffset
|
||||||
|
nextRetryAt DateTimeOffset? scheduled retry time (null on success)
|
||||||
|
```
|
||||||
|
|
||||||
### User
|
### User
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1006,7 +1045,9 @@ occurredAt DateTimeOffset
|
|||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
List endpoints use offset pagination:
|
### Offset pagination
|
||||||
|
|
||||||
|
List endpoints (batch list, work queues) use offset pagination:
|
||||||
|
|
||||||
| Param | Default | Description |
|
| Param | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1025,11 +1066,31 @@ Response shape:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Cursor pagination
|
||||||
|
|
||||||
|
`GET /digitization-batches/{id}/events` uses cursor pagination on `occurredAt`:
|
||||||
|
|
||||||
|
| Param | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `after` | — | ISO-8601 timestamp cursor from the previous page's `nextCursor` |
|
||||||
|
| `pageSize` | 50 | Events per page (max 200) |
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [],
|
||||||
|
"pageSize": 50,
|
||||||
|
"nextCursor": "2026-06-27T12:00:00Z",
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implemented Phases
|
## Implemented Phases
|
||||||
|
|
||||||
Phases 1–8 are fully implemented and verified via integration tests and per-phase scripts. Phase 9 (E2E verification and clinical scenario documentation) is partially implemented.
|
Phases 1–8 are fully implemented and verified via integration tests and per-phase scripts. Phase 9 (E2E verification, clinical scenario docs, extended seed data) is partially complete.
|
||||||
|
|
||||||
| Phase | Feature | Status |
|
| Phase | Feature | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1041,4 +1102,4 @@ Phases 1–8 are fully implemented and verified via integration tests and per-ph
|
|||||||
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
|
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
|
||||||
| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing, split-pane scan viewer, draft entry with auto-save, verification checkboxes, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard shell | Done |
|
| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing, split-pane scan viewer, draft entry with auto-save, verification checkboxes, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard shell | Done |
|
||||||
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
|
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
|
||||||
| 9 | E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario documentation | Partial |
|
| 9 | E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`); extended seed data (demo patients/batches across all statuses) | Partial |
|
||||||
|
|||||||
@@ -1,39 +1,327 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
public static class DataSeeder
|
public static class DataSeeder
|
||||||
{
|
{
|
||||||
|
// Deterministic IDs for referencing seeded entities in tests and scripts
|
||||||
|
private static readonly Guid IntakeClerk1Id = Guid.Parse("a1000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid IntakeClerk2Id = Guid.Parse("a1000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid EntryClerk1Id = Guid.Parse("a2000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid EntryClerk2Id = Guid.Parse("a2000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid Verifier1Id = Guid.Parse("a3000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Verifier2Id = Guid.Parse("a3000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid Approver1Id = Guid.Parse("a4000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Approver2Id = Guid.Parse("a4000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid Clinician1Id = Guid.Parse("a5000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Clinician2Id = Guid.Parse("a5000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid Admin1Id = Guid.Parse("a6000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Admin2Id = Guid.Parse("a6000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
// Patient IDs
|
||||||
|
private static readonly Guid Patient1Id = Guid.Parse("b1000000-0000-0000-0000-000000000001");
|
||||||
|
private static readonly Guid Patient2Id = Guid.Parse("b1000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid Patient3Id = Guid.Parse("b1000000-0000-0000-0000-000000000003");
|
||||||
|
private static readonly Guid Patient4Id = Guid.Parse("b1000000-0000-0000-0000-000000000004");
|
||||||
|
private static readonly Guid Patient5Id = Guid.Parse("b1000000-0000-0000-0000-000000000005");
|
||||||
|
|
||||||
public static async Task SeedAsync(AppDbContext db)
|
public static async Task SeedAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
var existing = await db.Users.Select(u => u.Username).ToListAsync();
|
if (await db.Users.AnyAsync()) return;
|
||||||
if (existing.Count >= 12) return;
|
|
||||||
|
|
||||||
var all = new[]
|
// ── Users (2 per role) ────────────────────────────────────────────────
|
||||||
|
var users = new[]
|
||||||
{
|
{
|
||||||
CreateUser("intake1", "Intake Clerk 1", UserRole.IntakeClerk),
|
CreateUser(IntakeClerk1Id, "intake1", "Intake Clerk 1", UserRole.IntakeClerk),
|
||||||
CreateUser("intake2", "Intake Clerk 2", UserRole.IntakeClerk),
|
CreateUser(IntakeClerk2Id, "intake2", "Intake Clerk 2", UserRole.IntakeClerk),
|
||||||
CreateUser("entry1", "Entry Clerk 1", UserRole.DataEntryClerk),
|
CreateUser(EntryClerk1Id, "entry1", "Entry Clerk 1", UserRole.DataEntryClerk),
|
||||||
CreateUser("entry2", "Entry Clerk 2", UserRole.DataEntryClerk),
|
CreateUser(EntryClerk2Id, "entry2", "Entry Clerk 2", UserRole.DataEntryClerk),
|
||||||
CreateUser("verifier1", "Verifier 1", UserRole.Verifier),
|
CreateUser(Verifier1Id, "verifier1", "Verifier 1", UserRole.Verifier),
|
||||||
CreateUser("verifier2", "Verifier 2", UserRole.Verifier),
|
CreateUser(Verifier2Id, "verifier2", "Verifier 2", UserRole.Verifier),
|
||||||
CreateUser("approver1", "Clinical Approver 1", UserRole.ClinicalApprover),
|
CreateUser(Approver1Id, "approver1", "Clinical Approver 1", UserRole.ClinicalApprover),
|
||||||
CreateUser("approver2", "Clinical Approver 2", UserRole.ClinicalApprover),
|
CreateUser(Approver2Id, "approver2", "Clinical Approver 2", UserRole.ClinicalApprover),
|
||||||
CreateUser("clinician1", "Dr. Tanaka", UserRole.Clinician),
|
CreateUser(Clinician1Id, "clinician1", "Dr. Tanaka", UserRole.Clinician),
|
||||||
CreateUser("clinician2", "Dr. Chen", UserRole.Clinician),
|
CreateUser(Clinician2Id, "clinician2", "Dr. Chen", UserRole.Clinician),
|
||||||
CreateUser("admin1", "Administrator 1", UserRole.Administrator),
|
CreateUser(Admin1Id, "admin1", "Administrator 1", UserRole.Administrator),
|
||||||
CreateUser("admin2", "Administrator 2", UserRole.Administrator),
|
CreateUser(Admin2Id, "admin2", "Administrator 2", UserRole.Administrator),
|
||||||
};
|
};
|
||||||
|
db.Users.AddRange(users);
|
||||||
|
|
||||||
var existingSet = existing.ToHashSet();
|
// ── Batches across all types, tracks, and statuses ────────────────────
|
||||||
var missing = all.Where(u => !existingSet.Contains(u.Username)).ToArray();
|
var now = DateTimeOffset.UtcNow;
|
||||||
if (missing.Length == 0) return;
|
|
||||||
|
// Batch 1: VITALS_SHEET / BACKFILL / PROMOTED (full lifecycle complete)
|
||||||
|
var batch1Id = Guid.Parse("c1000000-0000-0000-0000-000000000001");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch1Id,
|
||||||
|
Status = BatchStatus.Promoted,
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient1Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000001/abc123.pdf",
|
||||||
|
DocumentSha256 = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||||
|
EnableRetroactiveAlerts = false,
|
||||||
|
EnteredByUserId = EntryClerk1Id,
|
||||||
|
VerifiedByUserId = Verifier1Id,
|
||||||
|
ApprovedByUserId = Approver1Id,
|
||||||
|
PromotedAt = now.AddHours(-12),
|
||||||
|
CreatedAt = now.AddDays(-3),
|
||||||
|
UpdatedAt = now.AddHours(-12),
|
||||||
|
});
|
||||||
|
db.DraftPatients.Add(new DraftPatient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), BatchId = batch1Id,
|
||||||
|
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.AddDays(-2),
|
||||||
|
});
|
||||||
|
db.DraftEncounters.Add(new DraftEncounter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), BatchId = batch1Id,
|
||||||
|
AdmissionDate = now.AddDays(-5),
|
||||||
|
Department = Department.InternalMedicine, RoomBed = "2A-04",
|
||||||
|
AdmissionReason = "Pneumonia with elevated WBC",
|
||||||
|
Status = "active",
|
||||||
|
CreatedAt = now.AddDays(-3), UpdatedAt = now.AddDays(-2),
|
||||||
|
});
|
||||||
|
db.DraftObservations.AddRange(
|
||||||
|
CreateObservation(batch1Id, "HEART_RATE", 88m, "bpm", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch1Id, "TEMP_C", 38.7m, "C", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch1Id, "BP_SYSTOLIC", 128m, "mmHg", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch1Id, "BP_DIASTOLIC", 82m, "mmHg", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch1Id, "RESP_RATE", 22m, "breaths/min", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch1Id, "SPO2", 94m, "%", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch1Id, "WBC_K_UL", 14.2m, "K/uL", now.AddDays(-5))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch 2: PATIENT_REGISTRATION / BACKFILL / PENDING_VERIFICATION
|
||||||
|
var batch2Id = Guid.Parse("c1000000-0000-0000-0000-000000000002");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch2Id,
|
||||||
|
Status = BatchStatus.PendingVerification,
|
||||||
|
BatchType = BatchType.PatientRegistration,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient2Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000002/def456.pdf",
|
||||||
|
DocumentSha256 = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
|
||||||
|
EnteredByUserId = EntryClerk2Id,
|
||||||
|
CreatedAt = now.AddDays(-1),
|
||||||
|
UpdatedAt = now.AddHours(-6),
|
||||||
|
});
|
||||||
|
db.DraftPatients.Add(new DraftPatient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), BatchId = batch2Id,
|
||||||
|
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.AddHours(-6),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch 3: LAB_RESULTS / BACKFILL / REJECTED (awaiting re-entry)
|
||||||
|
var batch3Id = Guid.Parse("c1000000-0000-0000-0000-000000000003");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch3Id,
|
||||||
|
Status = BatchStatus.Rejected,
|
||||||
|
BatchType = BatchType.LabResults,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient3Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000003/ghi789.pdf",
|
||||||
|
DocumentSha256 = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
|
||||||
|
EnteredByUserId = EntryClerk1Id,
|
||||||
|
VerifiedByUserId = Verifier2Id,
|
||||||
|
RejectionReason = "Potassium value unclear on scan - decimal position ambiguous (5.2 vs 52). Please re-examine source document.",
|
||||||
|
CreatedAt = now.AddDays(-2),
|
||||||
|
UpdatedAt = now.AddHours(-18),
|
||||||
|
});
|
||||||
|
db.DraftPatients.Add(new DraftPatient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), BatchId = batch3Id,
|
||||||
|
FullName = "Leilani Tupou", DateOfBirth = new DateOnly(1990, 7, 22),
|
||||||
|
Sex = "female", BloodType = BloodType.BPos,
|
||||||
|
CreatedAt = now.AddDays(-2), UpdatedAt = now.AddHours(-20),
|
||||||
|
});
|
||||||
|
db.DraftObservations.AddRange(
|
||||||
|
CreateObservation(batch3Id, "POTASSIUM_MEQ_L", 52m, "mEq/L", now.AddDays(-4)),
|
||||||
|
CreateObservation(batch3Id, "GLUCOSE_MG_DL", 110m, "mg/dL", now.AddDays(-4)),
|
||||||
|
CreateObservation(batch3Id, "LACTATE_MMOL_L", 1.8m, "mmol/L", now.AddDays(-4))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch 4: ENCOUNTER_SUMMARY / BACKFILL / IN_ENTRY
|
||||||
|
var batch4Id = Guid.Parse("c1000000-0000-0000-0000-000000000004");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch4Id,
|
||||||
|
Status = BatchStatus.InEntry,
|
||||||
|
BatchType = BatchType.EncounterSummary,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient1Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000004/jkl012.pdf",
|
||||||
|
DocumentSha256 = "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
|
||||||
|
EnteredByUserId = EntryClerk2Id,
|
||||||
|
CreatedAt = now.AddHours(-4),
|
||||||
|
UpdatedAt = now.AddHours(-2),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch 5: ALLERGY_UPDATE / BACKFILL / AWAITING_CLINICAL_APPROVAL
|
||||||
|
var batch5Id = Guid.Parse("c1000000-0000-0000-0000-000000000005");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch5Id,
|
||||||
|
Status = BatchStatus.AwaitingClinicalApproval,
|
||||||
|
BatchType = BatchType.AllergyUpdate,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient4Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000005/mno345.pdf",
|
||||||
|
DocumentSha256 = "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
|
||||||
|
EnteredByUserId = EntryClerk1Id,
|
||||||
|
VerifiedByUserId = Verifier1Id,
|
||||||
|
CreatedAt = now.AddDays(-1),
|
||||||
|
UpdatedAt = now.AddHours(-3),
|
||||||
|
});
|
||||||
|
db.DraftPatients.Add(new DraftPatient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), BatchId = batch5Id,
|
||||||
|
FullName = "Anh Tran", DateOfBirth = new DateOnly(1985, 1, 30),
|
||||||
|
Sex = "female",
|
||||||
|
AllergiesJson = "[\"Codeine\", \"Iodine contrast\", \"Latex\"]",
|
||||||
|
NoKnownAllergies = false,
|
||||||
|
CreatedAt = now.AddDays(-1), UpdatedAt = now.AddHours(-5),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch 6: VITALS_SHEET / LIVE_CAPTURE / PROMOTED (Track B)
|
||||||
|
var batch6Id = Guid.Parse("c1000000-0000-0000-0000-000000000006");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch6Id,
|
||||||
|
Status = BatchStatus.Promoted,
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.LiveCapture,
|
||||||
|
PatientId = Patient5Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000006/pqr678.pdf",
|
||||||
|
DocumentSha256 = "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1",
|
||||||
|
EnteredByUserId = Clinician1Id,
|
||||||
|
ClinicianAttestation = true,
|
||||||
|
PromotedAt = now.AddHours(-1),
|
||||||
|
CreatedAt = now.AddHours(-2),
|
||||||
|
UpdatedAt = now.AddHours(-1),
|
||||||
|
});
|
||||||
|
db.DraftObservations.AddRange(
|
||||||
|
CreateObservation(batch6Id, "HEART_RATE", 72m, "bpm", now.AddHours(-2)),
|
||||||
|
CreateObservation(batch6Id, "TEMP_C", 36.8m, "C", now.AddHours(-2)),
|
||||||
|
CreateObservation(batch6Id, "BP_SYSTOLIC", 120m, "mmHg", now.AddHours(-2)),
|
||||||
|
CreateObservation(batch6Id, "BP_DIASTOLIC", 78m, "mmHg", now.AddHours(-2)),
|
||||||
|
CreateObservation(batch6Id, "SPO2", 98m, "%", now.AddHours(-2))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch 7: MEDICATION_LIST / BACKFILL / UPLOADED (fresh, unassigned)
|
||||||
|
var batch7Id = Guid.Parse("c1000000-0000-0000-0000-000000000007");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch7Id,
|
||||||
|
Status = BatchStatus.Uploaded,
|
||||||
|
BatchType = BatchType.MedicationList,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient2Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000007/stu901.pdf",
|
||||||
|
DocumentSha256 = "a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8",
|
||||||
|
CreatedAt = now.AddMinutes(-30),
|
||||||
|
UpdatedAt = now.AddMinutes(-30),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch 8: MIXED / BACKFILL / VERIFIED
|
||||||
|
var batch8Id = Guid.Parse("c1000000-0000-0000-0000-000000000008");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch8Id,
|
||||||
|
Status = BatchStatus.Verified,
|
||||||
|
BatchType = BatchType.Mixed,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient3Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000008/vwx234.pdf",
|
||||||
|
DocumentSha256 = "b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9",
|
||||||
|
EnteredByUserId = EntryClerk2Id,
|
||||||
|
VerifiedByUserId = Verifier1Id,
|
||||||
|
CreatedAt = now.AddDays(-1),
|
||||||
|
UpdatedAt = now.AddHours(-8),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch 9: VITALS_SHEET / BACKFILL / APPROVED (waiting for promotion — tests retry worker)
|
||||||
|
var batch9Id = Guid.Parse("c1000000-0000-0000-0000-000000000009");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch9Id,
|
||||||
|
Status = BatchStatus.Approved,
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient4Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000009/yza567.pdf",
|
||||||
|
DocumentSha256 = "c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0",
|
||||||
|
EnteredByUserId = EntryClerk1Id,
|
||||||
|
VerifiedByUserId = Verifier2Id,
|
||||||
|
ApprovedByUserId = Approver1Id,
|
||||||
|
CreatedAt = now.AddDays(-1),
|
||||||
|
UpdatedAt = now.AddMinutes(-45),
|
||||||
|
});
|
||||||
|
db.DraftObservations.AddRange(
|
||||||
|
CreateObservation(batch9Id, "HEART_RATE", 92m, "bpm", now.AddDays(-3)),
|
||||||
|
CreateObservation(batch9Id, "TEMP_C", 37.2m, "C", now.AddDays(-3)),
|
||||||
|
CreateObservation(batch9Id, "RESP_RATE", 18m, "breaths/min", now.AddDays(-3)),
|
||||||
|
CreateObservation(batch9Id, "SPO2", 96m, "%", now.AddDays(-3))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch 10: Correction batch (supersedes batch 1)
|
||||||
|
var batch10Id = Guid.Parse("c1000000-0000-0000-0000-000000000010");
|
||||||
|
db.DigitizationBatches.Add(new DigitizationBatch
|
||||||
|
{
|
||||||
|
Id = batch10Id,
|
||||||
|
Status = BatchStatus.PendingVerification,
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
PatientId = Patient1Id,
|
||||||
|
SupersedesBatchId = batch1Id,
|
||||||
|
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000010/correction.pdf",
|
||||||
|
DocumentSha256 = "d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1",
|
||||||
|
EnteredByUserId = EntryClerk2Id,
|
||||||
|
CreatedAt = now.AddHours(-2),
|
||||||
|
UpdatedAt = now.AddHours(-1),
|
||||||
|
});
|
||||||
|
db.DraftObservations.AddRange(
|
||||||
|
CreateObservation(batch10Id, "HEART_RATE", 88m, "bpm", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch10Id, "TEMP_C", 38.7m, "C", now.AddDays(-5)),
|
||||||
|
CreateObservation(batch10Id, "SPO2", 95m, "%", now.AddDays(-5)) // corrected from 94
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Audit events for completed batches ─────────────────────────────────
|
||||||
|
db.DigitizationEvents.AddRange(
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Uploaded, ActorUserId = IntakeClerk1Id, OccurredAt = now.AddDays(-3) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.EntryStarted, ActorUserId = EntryClerk1Id, OccurredAt = now.AddDays(-2).AddHours(-6) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.SubmittedForVerification, ActorUserId = EntryClerk1Id, OccurredAt = now.AddDays(-2) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Verified, ActorUserId = Verifier1Id, OccurredAt = now.AddDays(-1) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Approved, ActorUserId = Approver1Id, OccurredAt = now.AddHours(-14) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Promoted, ActorUserId = Approver1Id, OccurredAt = now.AddHours(-12) },
|
||||||
|
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.Uploaded, ActorUserId = IntakeClerk2Id, OccurredAt = now.AddDays(-2) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.EntryStarted, ActorUserId = EntryClerk1Id, OccurredAt = now.AddDays(-2).AddHours(-3) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.SubmittedForVerification, ActorUserId = EntryClerk1Id, OccurredAt = now.AddHours(-20) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.Rejected, ActorUserId = Verifier2Id, OccurredAt = now.AddHours(-18),
|
||||||
|
MetadataJson = JsonSerializer.Serialize(new { rejectionReason = "Potassium value unclear on scan - decimal position ambiguous" }) },
|
||||||
|
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch6Id, EventType = DigitizationEventType.Uploaded, ActorUserId = Clinician1Id, OccurredAt = now.AddHours(-2) },
|
||||||
|
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch6Id, EventType = DigitizationEventType.Promoted, ActorUserId = Clinician1Id, OccurredAt = now.AddHours(-1) }
|
||||||
|
);
|
||||||
|
|
||||||
db.Users.AddRange(missing);
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static User CreateUser(string username, string fullName, UserRole role) => new()
|
private static User CreateUser(Guid id, string username, string fullName, UserRole role) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = id,
|
||||||
Username = username,
|
Username = username,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
|
||||||
FullName = fullName,
|
FullName = fullName,
|
||||||
@@ -41,4 +329,16 @@ public static class DataSeeder
|
|||||||
IsActive = true,
|
IsActive = true,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static DraftObservation CreateObservation(
|
||||||
|
Guid batchId, string code, decimal value, string unit, DateTimeOffset recordedAt) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
BatchId = batchId,
|
||||||
|
ObservationCode = code,
|
||||||
|
Value = value,
|
||||||
|
Unit = unit,
|
||||||
|
RecordedAt = recordedAt,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+104
-56
@@ -1,5 +1,29 @@
|
|||||||
# PRD: VigilCare Records — Paper Chart Digitization & Approval Platform
|
# PRD: VigilCare Records — Paper Chart Digitization & Approval Platform
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
**Phases 1–8 are complete.** Phase 9 is partially complete.
|
||||||
|
|
||||||
|
| Phase | Scope | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done |
|
||||||
|
| 2 | Draft entry API, plausibility validation, submit-for-verification | Done |
|
||||||
|
| 3 | Verification, rejection, separation of duties, work queues | Done |
|
||||||
|
| 4 | Promotion to VigilCareClinical live tables, outbox, idempotency | Done |
|
||||||
|
| 5 | Corrections / supersession, patient digitization history | Done |
|
||||||
|
| 6 | Track B live capture with clinician attestation | Done |
|
||||||
|
| 7 | Digitization workstation UI (`vigilcare-records-web`) | Done |
|
||||||
|
| 8 | Prometheus metrics, supervisor overview, batch events API, promotion retry | Done |
|
||||||
|
| 9 | E2E verification script, clinical scenario docs, extended seed data | Partial |
|
||||||
|
|
||||||
|
**Delivered in Phase 9 so far:** `scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics smoke test), [digitization-workstation-guide.md](digitization-workstation-guide.md) (backfill, live capture, corrections).
|
||||||
|
|
||||||
|
**Remaining in Phase 9:** Extended `DataSeeder` with demo patients and batches across all statuses/types/tracks for dashboard demos without manual data entry.
|
||||||
|
|
||||||
|
See [README.md](../README.md) for API reference, quick start, and verification scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
A clinical records intake system that converts paper-based patient charts into structured, human-verified digital records before they enter the VigilCareClinical alerting pipeline. Designed for small hospitals, district clinics, and island health systems where the majority of historical and day-to-day records still exist on paper.
|
A clinical records intake system that converts paper-based patient charts into structured, human-verified digital records before they enter the VigilCareClinical alerting pipeline. Designed for small hospitals, district clinics, and island health systems where the majority of historical and day-to-day records still exist on paper.
|
||||||
@@ -14,7 +38,7 @@ VigilCareClinical ([vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md
|
|||||||
|
|
||||||
This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events), `sd-mid-013` (CQRS — draft vs live read models), `sd-junior-004` (RBAC), and senior trade-off conversations around **data quality gates vs time-to-value** in resource-constrained health systems.
|
This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events), `sd-mid-013` (CQRS — draft vs live read models), `sd-junior-004` (RBAC), and senior trade-off conversations around **data quality gates vs time-to-value** in resource-constrained health systems.
|
||||||
|
|
||||||
**Stack:** .NET 8 Web API, PostgreSQL, MinIO (scanned document storage), Redis (work-queue assignment locks), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI (separate repo or `VigilCare.Records.Web` project).
|
**Stack:** .NET 8 Web API, PostgreSQL 16, MinIO (scanned document storage), Redis 7 (batch assignment locks and live-capture threshold cache), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI in `vigilcare-records-web/` (Vite, Pinia, Tailwind CSS).
|
||||||
|
|
||||||
**Prerequisite / companion:** VigilCareClinicalAPI Phases 1–2 minimum (patient, encounter, observation ingest). Full VigilCare value unlocks as approved observations flow into the existing Kafka alert pipeline.
|
**Prerequisite / companion:** VigilCareClinicalAPI Phases 1–2 minimum (patient, encounter, observation ingest). Full VigilCare value unlocks as approved observations flow into the existing Kafka alert pipeline.
|
||||||
|
|
||||||
@@ -300,7 +324,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1. Document Upload and Batch Creation
|
### 1. Document Upload and Batch Creation — *implemented (Phase 1)*
|
||||||
|
|
||||||
**Description:** Intake clerk scans or uploads a paper record. System stores the file in MinIO, computes SHA-256, creates a batch in `uploaded` state, and writes a `DigitizationEvent`.
|
**Description:** Intake clerk scans or uploads a paper record. System stores the file in MinIO, computes SHA-256, creates a batch in `uploaded` state, and writes a `DigitizationEvent`.
|
||||||
|
|
||||||
@@ -320,7 +344,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Draft Data Entry
|
### 2. Draft Data Entry — *implemented (Phase 2)*
|
||||||
|
|
||||||
**Description:** Entry clerk views the scan alongside structured form fields. Saves draft patient updates, encounter context, and observations. Batch moves to `in_entry` on first save.
|
**Description:** Entry clerk views the scan alongside structured form fields. Saves draft patient updates, encounter context, and observations. Batch moves to `in_entry` on first save.
|
||||||
|
|
||||||
@@ -355,7 +379,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Verification and Rejection
|
### 3. Verification and Rejection — *implemented (Phase 3)*
|
||||||
|
|
||||||
**Description:** Verifier reviews entry against the scan. Can approve field-by-field or reject the entire batch with a mandatory reason. Verifier cannot be the entry clerk.
|
**Description:** Verifier reviews entry against the scan. Can approve field-by-field or reject the entire batch with a mandatory reason. Verifier cannot be the entry clerk.
|
||||||
|
|
||||||
@@ -364,7 +388,7 @@ On verify pass: status → `verified` or `awaiting_clinical_approval` per [site
|
|||||||
**Endpoints:**
|
**Endpoints:**
|
||||||
- `GET /api/v1/work-queue/verification` — batches in `pending_verification`, sorted by `submittedAt ASC`
|
- `GET /api/v1/work-queue/verification` — batches in `pending_verification`, sorted by `submittedAt ASC`
|
||||||
- `GET /api/v1/work-queue/clinical-approval` — batches in `awaiting_clinical_approval`, sorted by `submittedAt ASC`
|
- `GET /api/v1/work-queue/clinical-approval` — batches in `awaiting_clinical_approval`, sorted by `submittedAt ASC`
|
||||||
- `POST /api/v1/digitization-batches/:id/verify` — body: `{ "fieldChecks": [{ "fieldPath": "observations[0].value", "passed": true }], "passed": true }`
|
- `POST /api/v1/digitization-batches/:id/verify` — body: `{ "fieldChecks": [{ "fieldName": "observations[0].value", "status": "ok", "note": null }], "passed": true }`
|
||||||
- `POST /api/v1/digitization-batches/:id/reject` — body: `{ "reason": "..." }` → status `rejected`, notifies entry clerk
|
- `POST /api/v1/digitization-batches/:id/reject` — body: `{ "reason": "..." }` → status `rejected`, notifies entry clerk
|
||||||
|
|
||||||
Reject is allowed from `pending_verification` (verifier; separation of duties applies) or `awaiting_clinical_approval` (clinical approver; separation of duties does **not** apply — approver may not have been the entry clerk by role design).
|
Reject is allowed from `pending_verification` (verifier; separation of duties applies) or `awaiting_clinical_approval` (clinical approver; separation of duties does **not** apply — approver may not have been the entry clerk by role design).
|
||||||
@@ -373,12 +397,13 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Approval and Promotion to VigilCareClinical
|
### 4. Approval and Promotion to VigilCareClinical — *implemented (Phase 4)*
|
||||||
|
|
||||||
**Description:** Final approval triggers an atomic promotion: draft records become live Patient / Encounter / Observation rows in VigilCareClinical (same database in integrated deployment, or HTTP calls to VigilCareClinical API in split deployment). Batch status → `promoted`.
|
**Description:** Final approval triggers an atomic promotion: draft records become live Patient / Encounter / Observation rows in VigilCareClinical (same database in integrated deployment, or HTTP calls to VigilCareClinical API in split deployment). Batch status → `promoted`.
|
||||||
|
|
||||||
**Endpoints:**
|
**Endpoints:**
|
||||||
- `POST /api/v1/digitization-batches/:id/approve` — requires `verified` or `awaiting_clinical_approval`; restricted to `ClinicalApprover` or `Administrator`; Idempotency-Key supported
|
- `POST /api/v1/digitization-batches/:id/approve` — requires `verified` or `awaiting_clinical_approval`; restricted to `ClinicalApprover` or `Administrator`; `Idempotency-Key` required; returns **202** with `PROMOTION_DEFERRED` on transient failure (batch stays `approved`)
|
||||||
|
- `POST /api/v1/digitization-batches/:id/promote` — manual promotion of an `approved` batch (also used after deferral)
|
||||||
- `GET /api/v1/digitization-batches/:id/promotion-result` — live IDs created: `patientId`, `encounterId`, `observationIds[]`
|
- `GET /api/v1/digitization-batches/:id/promotion-result` — live IDs created: `patientId`, `encounterId`, `observationIds[]`
|
||||||
|
|
||||||
**Promotion transaction sequence:**
|
**Promotion transaction sequence:**
|
||||||
@@ -402,7 +427,7 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Corrections and Supersession
|
### 5. Corrections and Supersession — *implemented (Phase 5)*
|
||||||
|
|
||||||
**Description:** Approved records are not silently edited. A correction creates a new batch with `supersedesBatchId` pointing to the original. Correction goes through the full entry → verify → approve cycle. On promotion, erroneous live observations are marked `superseded` (append-only — not deleted).
|
**Description:** Approved records are not silently edited. A correction creates a new batch with `supersedesBatchId` pointing to the original. Correction goes through the full entry → verify → approve cycle. On promotion, erroneous live observations are marked `superseded` (append-only — not deleted).
|
||||||
|
|
||||||
@@ -414,7 +439,7 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. Live Capture (Track B)
|
### 6. Live Capture (Track B) — *implemented (Phase 6)*
|
||||||
|
|
||||||
**Description:** Credentialed clinician enters vitals or labs at point of care on a tablet. No verification queue. Each submission creates an audit `DigitizationBatch` (see [Track B audit model](#track-b-audit-model)), writes draft observations for traceability, and promotes live observations synchronously with full alert evaluation.
|
**Description:** Credentialed clinician enters vitals or labs at point of care on a tablet. No verification queue. Each submission creates an audit `DigitizationBatch` (see [Track B audit model](#track-b-audit-model)), writes draft observations for traceability, and promotes live observations synchronously with full alert evaluation.
|
||||||
|
|
||||||
@@ -428,38 +453,46 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7. Patient Registry (Draft and Live)
|
### 7. Patient Registry (Draft and Live) — *partially implemented*
|
||||||
|
|
||||||
**Description:** Search and link batches to patients. Support new patient registration through the draft pipeline.
|
**Description:** Search and link batches to patients. New patient registration flows through the draft pipeline on a `patient_registration` batch; there is no standalone draft-patient API in v1.
|
||||||
|
|
||||||
**Endpoints:**
|
**Implemented endpoints:**
|
||||||
- `GET /api/v1/patients/search?q=` — search live VigilCareClinical patients by MRN or name
|
- `GET /api/v1/patients/search?q=` — search live patients by MRN or name (min 2 characters)
|
||||||
- `POST /api/v1/patients/draft` — create draft-only patient (no MRN until approval)
|
- `GET /api/v1/patients/:id/digitization-history` — all batches for a patient with correction chain and audit trails
|
||||||
|
|
||||||
|
**Deferred (not in v1):**
|
||||||
|
- `POST /api/v1/patients/draft` — create draft-only patient without a batch
|
||||||
- `GET /api/v1/patients/:id/summary` — live patient + pending draft batches + digitization coverage stats
|
- `GET /api/v1/patients/:id/summary` — live patient + pending draft batches + digitization coverage stats
|
||||||
|
|
||||||
**Digitization coverage stat:** `approvedBatchCount / estimatedTotalBatches` — optional manual `estimatedChartSections` per patient for progress tracking.
|
**Digitization coverage stat (future):** `approvedBatchCount / estimatedTotalBatches` — optional manual `estimatedChartSections` per patient for progress tracking.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8. Work Queues and Operational Dashboard
|
### 8. Work Queues and Operational Dashboard — *implemented (Phase 8)*
|
||||||
|
|
||||||
**Description:** Supervisors monitor backlog, assignment, and throughput.
|
**Description:** Supervisors monitor backlog, assignment, and throughput.
|
||||||
|
|
||||||
**Endpoints:**
|
**Endpoints:**
|
||||||
- `GET /api/v1/work-queue/entry` — batches awaiting or in entry
|
- `GET /api/v1/work-queue/entry` — batches awaiting or in entry (`UPLOADED`, `IN_ENTRY`, `REJECTED`)
|
||||||
- `GET /api/v1/work-queue/clinical-approval` — batches awaiting physician sign-off after verification
|
- `GET /api/v1/work-queue/verification` — batches in `PENDING_VERIFICATION`, sorted by submission time
|
||||||
- `GET /api/v1/work-queue/overview` — counts by status, average time-in-queue, reject rate
|
- `GET /api/v1/work-queue/clinical-approval` — batches in `AWAITING_CLINICAL_APPROVAL`
|
||||||
- `GET /api/v1/digitization-batches/:id/events` — cursor-paginated audit trail
|
- `GET /api/v1/work-queue/overview` — counts by status, average time-in-queue, 24h reject rate, oldest pending verification (administrator only)
|
||||||
|
- `GET /api/v1/digitization-batches/:id/events` — cursor-paginated audit trail with actor username and full name
|
||||||
|
|
||||||
**Metrics (Prometheus):**
|
**Promotion retry (Phase 8):** When `POST .../approve` fails due to transient infrastructure errors, the batch stays `APPROVED` and returns **202** with `PROMOTION_DEFERRED`. `PromotionRetryService` retries with exponential backoff; operators may also call `POST .../promote` manually.
|
||||||
|
|
||||||
|
**Metrics (Prometheus) — implemented at `GET /metrics`:**
|
||||||
- `digitization_batches_by_status` (gauge)
|
- `digitization_batches_by_status` (gauge)
|
||||||
- `digitization_promotion_duration_seconds` (histogram)
|
- `digitization_promotion_duration_seconds` (histogram)
|
||||||
- `digitization_rejection_total` (counter)
|
- `digitization_rejection_total` (counter)
|
||||||
- `digitization_queue_age_seconds` (gauge — oldest pending verification)
|
- `digitization_queue_age_seconds` (gauge — oldest pending verification)
|
||||||
|
|
||||||
|
Docker Compose exposes Prometheus on port **9095** and Grafana on **3013**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 9. Authentication and Audit
|
### 9. Authentication and Audit — *implemented (Phases 1, 8)*
|
||||||
|
|
||||||
**Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged.
|
**Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged.
|
||||||
|
|
||||||
@@ -477,18 +510,20 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Digitization Workstation UI
|
## Digitization Workstation UI — *implemented (Phase 7)*
|
||||||
|
|
||||||
Separate Vue 3 SPA or Razor-hosted frontend. Four primary views:
|
Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` → API on **5217**). Four primary views:
|
||||||
|
|
||||||
| View | User | Purpose |
|
| View | User | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Intake** | Intake clerk | Upload, assign patient, print MRN label |
|
| **Intake** | Intake clerk | Upload, assign patient, assign entry clerk |
|
||||||
| **Entry** | Data entry clerk | Side-by-side scan + form |
|
| **Entry** | Data entry clerk | Side-by-side scan + form with auto-save |
|
||||||
| **Verification** | Verifier | Side-by-side with field checkboxes, approve/reject |
|
| **Verification** | Verifier | Side-by-side with field checkboxes, approve/reject |
|
||||||
| **Queue dashboard** | Supervisor | Backlog, reject rate, clerk throughput |
|
| **Queue dashboard** | Administrator | Backlog metrics from work-queue overview |
|
||||||
|
|
||||||
Not a full EMR UI. No clinical alerting views — those remain in VigilCareClinical's ward dashboard.
|
Role-based routing and JWT refresh are implemented. Not a full EMR UI — clinical alerting views remain in VigilCareClinical's ward dashboard.
|
||||||
|
|
||||||
|
See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -522,38 +557,42 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
| Criterion | Verification |
|
| Criterion | Verification | Status |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| Separation of duties | Entry clerk cannot verify or approve own batch — `409` |
|
| Separation of duties | Entry clerk cannot verify or approve own batch — `409` | Done |
|
||||||
| Draft isolation | Draft observations never appear in VigilCareClinical alert queries or ward dashboard |
|
| Draft isolation | Draft observations never appear in VigilCareClinical alert queries or ward dashboard | Done |
|
||||||
| Promotion atomicity | Partial promotion (patient created, observations failed) never committed |
|
| Promotion atomicity | Partial promotion (patient created, observations failed) never committed | Done |
|
||||||
| Idempotent approval | Duplicate `Idempotency-Key` on approve returns same result, no duplicate observations |
|
| Idempotent approval | Duplicate `Idempotency-Key` on approve returns same result, no duplicate observations | Done |
|
||||||
| Rejection loop | Rejected batch returns to entry; resubmit reaches verification again |
|
| Rejection loop | Rejected batch returns to entry; resubmit reaches verification again | Done |
|
||||||
| Backfill alert suppression | Default backfill promotion creates observations with zero alerts |
|
| Backfill alert suppression | Default backfill promotion creates observations with zero alerts | Done |
|
||||||
| Live capture alert path | Track B critical potassium triggers synchronous alert in VigilCareClinical |
|
| Live capture alert path | Track B critical potassium triggers synchronous alert in VigilCareClinical | Done |
|
||||||
| Audit completeness | Every status transition has a `DigitizationEvent` with actor and timestamp |
|
| Audit completeness | Every status transition has a `DigitizationEvent` with actor and timestamp | Done |
|
||||||
| Document immutability | Scan object in MinIO not modified or deleted on reject/correct |
|
| Document immutability | Scan object in MinIO not modified or deleted on reject/correct | Done |
|
||||||
| Plausibility at draft | Value 520 for potassium rejected at draft save, not at promotion |
|
| Plausibility at draft | Value 520 for potassium rejected at draft save, not at promotion | Done |
|
||||||
| Clinical approval routing | `vitals_sheet` verify-pass → `awaiting_clinical_approval` when site config requires it |
|
| Clinical approval routing | `vitals_sheet` verify-pass → `awaiting_clinical_approval` when site config requires it | Done |
|
||||||
| Track B audit batch | Live capture creates `DigitizationBatch` in `promoted` with draft observations and events |
|
| Track B audit batch | Live capture creates `DigitizationBatch` in `promoted` with draft observations and events | Done |
|
||||||
| Draft assignment guard | Unassigned clerk receives `409 BATCH_NOT_ASSIGNED` on draft save |
|
| Draft assignment guard | Unassigned clerk receives `409 BATCH_NOT_ASSIGNED` on draft save | Done |
|
||||||
| Cross-patient duplicate scan | Same SHA for two different patients allowed; same patient within 24h rejected |
|
| Cross-patient duplicate scan | Same SHA for two different patients allowed; same patient within 24h rejected | Done |
|
||||||
|
| Supervisor metrics | Work-queue overview and Prometheus gauges reflect live batch counts | Done |
|
||||||
|
| Promotion retry | Transient promotion failure defers to `APPROVED` with automatic retry | Done |
|
||||||
|
| E2E verification script | `./scripts/run-vigilcare-records-verification-p9.sh` passes against running API | Done |
|
||||||
|
| Extended demo seed data | Startup seed includes patients/batches across all statuses for dashboard demos | Pending |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build Order
|
## Build Order
|
||||||
|
|
||||||
| Phase | Focus |
|
| Phase | Focus | Status |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine |
|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done |
|
||||||
| 2 | Draft entry API (patient, encounter, observations), submit-for-verification |
|
| 2 | Draft entry API (patient, encounter, observations), submit-for-verification | Done |
|
||||||
| 3 | Verification, rejection, separation of duties, work queues |
|
| 3 | Verification, rejection, separation of duties, work queues | Done |
|
||||||
| 4 | Promotion service → VigilCareClinical live tables, outbox integration, idempotency |
|
| 4 | Promotion service → VigilCareClinical live tables, outbox integration, idempotency | Done |
|
||||||
| 5 | Corrections / supersession, patient digitization history |
|
| 5 | Corrections / supersession, patient digitization history | Done |
|
||||||
| 6 | Track B live capture with clinician attestation |
|
| 6 | Track B live capture with clinician attestation | Done |
|
||||||
| 7 | Digitization workstation UI (entry + verification side-by-side) |
|
| 7 | Digitization workstation UI (entry + verification side-by-side) | Done |
|
||||||
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job |
|
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Done |
|
||||||
| 9 | Seed data, E2E verification script, clinical scenario documentation |
|
| 9 | Extended seed data, E2E verification script, clinical scenario documentation | Partial |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -634,7 +673,13 @@ This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2
|
|||||||
|
|
||||||
### Phases 7–9 — UI, Observability, Documentation
|
### Phases 7–9 — UI, Observability, Documentation
|
||||||
|
|
||||||
Build the side-by-side workstation UI. Add Prometheus metrics and a supervisor queue view. Write `docs/digitization-workstation-guide.md` and an E2E script `./scripts/run-vigilcare-records-verification.sh`.
|
**Phase 7 (done):** Vue 3 workstation UI with intake, entry, verification, and supervisor dashboard views. Role-based routing, split-pane scan viewer, presigned URL refresh.
|
||||||
|
|
||||||
|
**Phase 8 (done):** Prometheus metrics, `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202) and `PromotionRetryService`, Docker Prometheus/Grafana stack. Verification: `./scripts/run-vigilcare-records-phase-8-verification.sh`.
|
||||||
|
|
||||||
|
**Phase 9 (partial):**
|
||||||
|
- Done: `docs/digitization-workstation-guide.md`, `./scripts/run-vigilcare-records-verification-p9.sh`, project README
|
||||||
|
- Remaining: extend `DataSeeder.cs` with demo patients and batches across all statuses, types, and tracks
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -678,6 +723,9 @@ Build the side-by-side workstation UI. Add Prometheus metrics and a supervisor q
|
|||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
- [README.md](../README.md) — API reference, quick start, verification scripts, data models
|
||||||
|
- [digitization-workstation-guide.md](digitization-workstation-guide.md) — clinical scenarios (backfill, live capture, corrections)
|
||||||
|
- [plans/](plans/) — phase-by-phase implementation guides (Phases 1–9)
|
||||||
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
|
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
|
||||||
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
|
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
|
||||||
- [Completed/national-digital-services-architecture.md](Completed/national-digital-services-architecture.md) — scan-and-verify pattern for paper-to-digital government services
|
- [Completed/national-digital-services-architecture.md](Completed/national-digital-services-architecture.md) — scan-and-verify pattern for paper-to-digital government services
|
||||||
|
|||||||
Reference in New Issue
Block a user