diff --git a/README.md b/README.md
index e5b4ba2..6359ec8 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–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.
+**Implementation status:** Phases 1–9 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 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, 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.
## Domain Model — How It Maps to a Real Clinical System
@@ -46,27 +46,29 @@ 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
-- **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; `409 BATCH_ALREADY_ASSIGNED` on conflict
-- **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); automatic `UPLOADED → IN_ENTRY` transition on first save; draft save requires the acting user to match `enteredByUserId` or hold `Administrator` role (`409 BATCH_NOT_ASSIGNED`)
+- **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`)
- **Submit for Verification** — validates completeness per batch type (e.g. vitals sheet requires linked patient, encounter context, and at least one observation with `recordedAt`); transitions `IN_ENTRY → PENDING_VERIFICATION`; returns `422` with missing fields if incomplete
- **Verification and Rejection** — verifier reviews entry against the scan with field-level checks (`fieldName`, `status: ok|warning|error`, optional `note`); verify pass transitions to `VERIFIED` or `AWAITING_CLINICAL_APPROVAL` based on site configuration for the batch type; verify fail transitions to `REJECTED` with mandatory reason; **separation of duties** enforced: entry clerk cannot verify their own batch (`409 SEPARATION_OF_DUTIES_VIOLATION`)
- **Clinical Approval Routing** — site-configurable per batch type (`SiteConfig.ClinicalApprovalRequired`); high-stakes batch types (encounter summary, vitals sheet, lab results, medication list, mixed) route to `AWAITING_CLINICAL_APPROVAL` after verification for physician sign-off; low-stakes types (patient registration, allergy update) go directly to `VERIFIED`
-- **Approval and Promotion** — `POST /digitization-batches/:id/approve` (on `ApprovalController` only) atomically promotes draft data to live VigilCareClinical tables (`patients`, `encounters`, `observations`) in a single PostgreSQL transaction; generates MRN via PostgreSQL sequence (`VCR-000001`); patient deduplication by name + DOB; encounter matching by patient + department + active status; writes `observation.created` outbox events for downstream consumers; separation of duties enforced (approver cannot be entry clerk or verifier); `Idempotency-Key` header required for safe retries with 24-hour TTL; retroactive alert policy (`enableRetroactiveAlerts`) controls whether backfill observations emit outbox events; on transient infrastructure failure returns **202** with `PROMOTION_DEFERRED` — batch stays `APPROVED` and `PromotionRetryService` retries via `POST /digitization-batches/:id/promote`
+- **Approval and Promotion** — `POST /digitization-batches/:id/approve` (on `ApprovalController` only) atomically promotes draft data to live VigilCareClinical tables (`patients`, `encounters`, `observations`) in a single PostgreSQL transaction via shared `ExecutePromotionCoreAsync`; generates MRN via PostgreSQL sequence (`VCR-000001`); patient deduplication by case-insensitive normalized name + DOB with fuzzy-match warnings (Levenshtein distance ≤ 3) logged when a near-duplicate exists; encounter matching by patient + department + active status; writes `observation.created` outbox events for downstream consumers; separation of duties enforced (approver cannot be entry clerk or verifier); `Idempotency-Key` header required for safe retries with 24-hour TTL; retroactive alert policy (`enableRetroactiveAlerts`) controls whether backfill observations emit outbox events; on transient infrastructure failure returns **202** with `PROMOTION_DEFERRED` — batch stays `APPROVED` and `PromotionRetryService` retries via `POST /digitization-batches/:id/promote` using the same core promotion path
- **Promotion Result Query** — `GET /digitization-batches/:id/promotion-result` returns live entity IDs (patient, MRN, encounter, observations) created during promotion
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
- **Patient Registry Search** — `GET /patients/search?q=` searches live patients by MRN or full name (minimum 2 characters); used by the intake workstation to link uploads to existing patients
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); `GET /work-queue/overview` (aggregate status counts, average queue age, reject rate, oldest pending verification — administrator only); role-restricted access
- **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** — `GET /users?role=` lists active users for batch assignment (intake clerks assign entry clerks via the workstation UI)
+- **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 (intake, entry, verification, supervisor dashboard), split-pane scan viewer with zoom/pan/rotate, draft entry with auto-save, field-level verification checkboxes, presigned URL refresh for long sessions, JWT refresh interceptor
- **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` is 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
+- **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`)
- **Role-Based Access** — six roles (`INTAKE_CLERK`, `DATA_ENTRY_CLERK`, `VERIFIER`, `CLINICAL_APPROVER`, `CLINICIAN`, `ADMINISTRATOR`) with role-based endpoint authorization; twelve seeded demo users (two per role)
- **Auth Audit Events** — append-only `auth_audit_events` table records login, logout, token refresh, and failed login attempts with user ID, IP address, and timestamp
- **Standard Envelope** — all responses use `{ success, statusCode, data, error }` wrapper; validation errors use the same shape with stable error codes
-- **Observability** — Serilog structured logging with Seq sink; correlation IDs via `CorrelationIdMiddleware`; `ExceptionHandlerMiddleware` for consistent error responses; Prometheus metrics at `GET /metrics` (HTTP request histograms, .NET runtime stats, custom gauges for batch counts by status and queue age, promotion duration histogram, rejection counter by reason category); `MetricsCollectorService` refreshes DB-backed gauges every 30 seconds; Prometheus scrapes the API via `prometheus.yml`; Grafana available at `http://localhost:3013` (dashboard panels configured manually)
+- **Observability** — Serilog structured logging with Seq sink; correlation IDs via `CorrelationIdMiddleware`; `ExceptionHandlerMiddleware` for consistent error responses; Prometheus metrics at `GET /metrics` (HTTP request histograms, .NET runtime stats, custom gauges for batch counts by status and queue age, promotion duration histogram, rejection counter by reason category); `MetricsCollectorService` refreshes DB-backed gauges every 30 seconds; Prometheus scrapes the API via `prometheus.yml`; Grafana available at `http://localhost:3013` (dashboard panels configured manually); health probes at `GET /health/live`, `GET /health/ready` (PostgreSQL, Redis, MinIO), and `GET /health/startup`
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only) at `http://localhost:5217/swagger`
---
@@ -94,14 +96,15 @@ HTTP request
├── MetricsCollectorService (periodic DB gauge refresh for Prometheus)
├── PromotionRetryService (exponential backoff retry for deferred promotions)
├── PatientRegistryService (live patient search by MRN or name)
- ├── UserDirectoryService (active user listing for batch assignment)
+ ├── 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)
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
├── PlausibilityValidator (per-code numeric range guard)
├── PostgreSQL (EF Core — writes, keyed reads, clinical schema for live tables)
├── Redis (batch assignment locks)
- └── MinIO (scanned document storage)
+ ├── MinIO (scanned document storage)
+ └── HealthChecks (PostgreSQL, Redis, MinioHealthCheck)
```
**Relationship to VigilCareClinical:** VigilCare Records is the precursor that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way. Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that pass approval are promoted to live tables. Promotion writes directly to VigilCareClinical's `patients`, `encounters`, `observations`, and `outbox_events` tables in a single atomic transaction with idempotency protection.
@@ -157,10 +160,11 @@ VigilCareRecords/
│ │ ├── 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
-│ │ ├── UsersController.cs # User directory for batch assignment
+│ │ ├── UsersController.cs # User directory and admin user management
│ │ ├── LiveCaptureController.cs # Track B live capture: record vitals, open encounter + vitals
│ │ ├── VerificationController.cs # Batch verification and rejection with separation of duties
│ │ └── WorkQueueController.cs # Work queues and supervisor overview
+│ ├── HealthChecks/MinioHealthCheck.cs # MinIO bucket-exists readiness probe
│ ├── Domain/ … # Entities, enums (batch, draft, clinical, user)
│ ├── Services/ … # Auth, batch, draft, verification, promotion, work queue, patient registry, user directory, live capture
│ ├── Models/Records/ … # Request/response DTOs
@@ -177,8 +181,7 @@ VigilCareRecords/
│ │ └── 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
-├── tests/
-│ └── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–8)
+├── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–9)
├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217)
├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana
├── scripts/
@@ -193,6 +196,7 @@ VigilCareRecords/
└── docs/
├── plans/ # Phase 1–9 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
```
@@ -202,27 +206,27 @@ VigilCareRecords/
```
┌──────────────┐
- │ UPLOADED │
- └──────┬───────┘
- │ assign / first save
- ▼
- ┌──────────────┐
- ┌──────────│ IN_ENTRY │◄─────────┐
- │ └──────┬───────┘ │
+ │ UPLOADED │──── cancel ────┐
+ └──────┬───────┘ │
+ │ assign │
+ ▼ │
+ ┌──────────────┐ │
+ ┌──────────│ IN_ENTRY │◄─────────┐ │
+ │ cancel └──────┬───────┘ │ │
│ │ submit │ reject
- │ ▼ │
- │ ┌──────────────┐ │
- │ │ PENDING │─────────┘
- │ │ VERIFICATION │
- │ └──────┬───────┘
- │ │
- │ verify fail │ verify pass
- │ ─────────┤
- │ │
- │ site config │ site config
- │ = false │ = true
- │ ┌────────┴────────┐
- │ ▼ ▼
+ │ ▼ │ │
+ │ ┌──────────────┐ │ │
+ │ │ PENDING │─────────┘ │
+ │ │ VERIFICATION │ │
+ │ └──────┬───────┘ │
+ │ │ │
+ │ verify fail │ verify pass │
+ │ ─────────┤ │
+ │ │ │
+ │ site config │ site config │
+ │ = false │ = true │
+ │ ┌────────┴────────┐ │
+ │ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐
│ │ VERIFIED │ │ AWAITING_CLINICAL │
│ └──────┬───────┘ │ _APPROVAL │
@@ -233,15 +237,19 @@ VigilCareRecords/
│ ┌──────────────┐
└────────────►│ APPROVED │
└──────┬───────┘
- │ promotion (Phase 4)
+ │ promotion
▼
┌──────────────┐
│ PROMOTED │ (terminal — live records exist)
└──────┬───────┘
- │ correction batch promoted (Phase 5)
+ │ correction batch promoted
▼
original observations marked superseded;
correction observations become active
+
+ ┌──────────────┐
+ │ CANCELLED │ (terminal — from UPLOADED, IN_ENTRY, or REJECTED)
+ └──────────────┘
```
**Correction flow (Phase 5):** A `PROMOTED` batch cannot be edited in place. To fix an erroneous live value, intake uploads a new batch with `supersedesBatchId` pointing at the promoted batch. The correction goes through entry → verification → approval like any other batch. On promotion, the original batch's `live_observations` rows are soft-flagged (`is_superseded = true`, `superseded_by_batch_id` set) — never deleted.
@@ -250,14 +258,15 @@ VigilCareRecords/
| From | To |
|---|---|
-| `UPLOADED` | `IN_ENTRY` |
-| `IN_ENTRY` | `PENDING_VERIFICATION` |
+| `UPLOADED` | `IN_ENTRY`, `CANCELLED` |
+| `IN_ENTRY` | `PENDING_VERIFICATION`, `CANCELLED` |
| `PENDING_VERIFICATION` | `VERIFIED`, `AWAITING_CLINICAL_APPROVAL`, `REJECTED` |
-| `REJECTED` | `IN_ENTRY` |
+| `REJECTED` | `IN_ENTRY`, `CANCELLED` |
| `VERIFIED` | `APPROVED` |
| `AWAITING_CLINICAL_APPROVAL` | `APPROVED`, `REJECTED` |
| `APPROVED` | `PROMOTED` |
| `PROMOTED` | *(none — terminal)* |
+| `CANCELLED` | *(none — terminal)* |
Illegal transitions return `409` with a stable error code. A `PROMOTED` batch cannot return to any earlier state. Corrections require a **new** batch referencing `supersedesBatchId`.
@@ -336,7 +345,7 @@ dotnet run
On startup the application:
1. Runs EF Core migrations
-2. Seeds twelve demo users (two per role: intake clerk, data entry clerk, verifier, clinical approver, clinician, administrator)
+2. Seeds twelve demo users (two per role) and ten demo batches spanning all batch types, both tracks, and every workflow status (including a correction batch, a deferred-promotion candidate, and live-capture vitals)
Swagger UI is available at `http://localhost:5217/swagger` in Development.
@@ -385,6 +394,7 @@ Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO
| `PromotionTests` | 4 | Approval, atomic promotion to live tables, idempotency replay, separation of duties, retroactive alert policy, patient deduplication, MRN generation |
| `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 |
### Verification Scripts
@@ -429,8 +439,8 @@ Error response:
| Method | Path | Auth | Description |
|---|---|---|---|
-| POST | `/auth/login` | Anonymous | Authenticate with username/password; returns access + refresh tokens |
-| POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for new access + refresh token pair |
+| POST | `/auth/login` | Anonymous | Authenticate with username/password; returns access + refresh tokens (rate-limited: 10/5 min) |
+| POST | `/auth/refresh` | Anonymous | Exchange a valid refresh token for new access + refresh token pair (rate-limited: 10/5 min) |
| POST | `/auth/logout` | Anonymous | Revoke the refresh token server-side |
| GET | `/auth/me` | JWT | Returns the authenticated user's profile |
@@ -475,8 +485,9 @@ Error response:
|---|---|---|
| POST | `/digitization-batches` | Upload a scanned document and create a batch (multipart/form-data) |
| GET | `/digitization-batches` | List batches; optional `status`, `batchType`, `assignedTo`, `track` filters; paginated |
-| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry) |
-| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock) |
+| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry); audits `document_accessed` |
+| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock; transitions to `IN_ENTRY`) |
+| POST | `/digitization-batches/{id}/cancel` | Cancel batch permanently (administrator only; `UPLOADED`, `IN_ENTRY`, or `REJECTED`) |
**POST body (multipart/form-data):**
@@ -504,6 +515,14 @@ Error response:
|---|---|---|---|
| `entryClerkUserId` | Guid | yes | User ID of the entry clerk to assign |
+**POST `/digitization-batches/{id}/cancel` body:**
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `reason` | string | yes | Cancellation reason (minimum 5 characters) |
+
+**Status codes:** `409 ILLEGAL_STATUS_TRANSITION` when the batch is not in a cancellable status.
+
### Draft Data Entry
| Method | Path | Description |
@@ -613,7 +632,7 @@ Error response:
**Separation of duties:** The approver cannot be the entry clerk (`enteredByUserId`) or the verifier (`verifiedByUserId`) of the same batch. Both checks return `409 SEPARATION_OF_DUTIES_VIOLATION`.
-**Patient deduplication:** On promotion, the service matches existing patients by `fullName` + `dateOfBirth`. If a match is found, the existing patient record is updated with any new fields from the draft. Otherwise, a new patient is created with a sequence-generated MRN (`VCR-NNNNNN`).
+**Patient deduplication:** On promotion, the service matches existing patients by case-insensitive normalized `fullName` + `dateOfBirth`. Near-matches (Levenshtein distance ≤ 3 on normalized name with same DOB) are logged as warnings. If an exact normalized match is found, the existing patient record is updated with any new fields from the draft. Otherwise, a new patient is created with a sequence-generated MRN (`VCR-NNNNNN`).
**Encounter matching:** Active encounters for the same patient and department are reused. Otherwise, a new encounter is created. Encounters with a discharge diagnosis are created with `discharged` status.
@@ -642,7 +661,7 @@ Query parameters: `after` (ISO-8601 cursor from previous page's `nextCursor`), `
|---|---|---|
| `id` | Guid | Event ID |
| `batchId` | Guid | Batch ID |
-| `eventType` | string | e.g. `uploaded`, `verified`, `promoted`, `promotion_retry_failed` |
+| `eventType` | string | e.g. `uploaded`, `verified`, `promoted`, `document_accessed`, `cancelled`, `promotion_retry_failed` |
| `actorUserId` | Guid | User who performed the action |
| `actorUsername` | string | Actor username |
| `actorFullName` | string | Actor display name |
@@ -653,7 +672,7 @@ Query parameters: `after` (ISO-8601 cursor from previous page's `nextCursor`), `
| Field | Type | Description |
|---|---|---|
-| `statusCounts` | object | Batch count per status (all 8 statuses present) |
+| `statusCounts` | object | Batch count per status (all 9 statuses present) |
| `averageTimeInQueueMinutes` | number | Average age of batches in `PENDING_VERIFICATION` |
| `rejectRate` | number | Rejections / (rejections + verifications) over the last 24 hours (0.0–1.0) |
| `oldestPendingVerificationMinutes` | number | Age of the oldest batch in `PENDING_VERIFICATION` |
@@ -672,13 +691,30 @@ Query parameters: `after` (ISO-8601 cursor from previous page's `nextCursor`), `
| `fullName` | string | Patient full name |
| `mrn` | string | Medical record number |
-### User Directory
+### User Directory and Management
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/users?role=` | Intake Clerk, Administrator | List active users; optional `role` filter (e.g. `DATA_ENTRY_CLERK`) |
+| POST | `/users` | Administrator | Create a new user |
+| PATCH | `/users/{id}` | Administrator | Update `fullName`, `role`, and/or `isActive` |
+| POST | `/users/{id}/reset-password` | Administrator | Set a new password for any user |
+| POST | `/users/me/change-password` | Any authenticated | Self-service password change (requires current password) |
-Used by the intake workstation assign-clerk dialog.
+Used by the intake workstation assign-clerk dialog. Admin endpoints support operator provisioning without database access.
+
+**POST `/users` body:**
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `username` | string | yes | Unique username |
+| `password` | string | yes | Password (minimum strength enforced) |
+| `fullName` | string | yes | Display name |
+| `role` | string | yes | One of the six user roles |
+
+**PATCH `/users/{id}` body:** any of `fullName`, `role`, `isActive` (set `isActive: false` to deactivate).
+
+**Status codes:** `409 USERNAME_TAKEN` on duplicate username; `422` on weak password.
### Patient Digitization History
@@ -770,6 +806,18 @@ Used by the intake workstation assign-clerk dialog.
Track B still creates a full audit trail: each submission writes a `DigitizationBatch` (status `PROMOTED`, `documentRef = "live-capture"`), draft observation rows, `live_capture_attested` and `promoted` digitization events, live `Observation` rows with `source = live_capture`, and outbox events for downstream alerting.
+### Health Checks
+
+Unauthenticated probe endpoints for orchestrators and load balancers:
+
+| Method | Path | Description |
+|---|---|---|
+| GET | `/health/live` | Process liveness (always 200 if the app is running) |
+| GET | `/health/ready` | Readiness — PostgreSQL, Redis, and MinIO must be reachable |
+| GET | `/health/startup` | Startup — PostgreSQL reachable (post-migration) |
+
+Returns `503` when a required dependency is unhealthy.
+
---
## Data Models
@@ -778,7 +826,7 @@ Track B still creates a full audit trail: each submission writes a `Digitization
```
id Guid PK
-status string UPLOADED | IN_ENTRY | PENDING_VERIFICATION | REJECTED | VERIFIED | AWAITING_CLINICAL_APPROVAL | APPROVED | PROMOTED
+status string UPLOADED | IN_ENTRY | PENDING_VERIFICATION | REJECTED | VERIFIED | AWAITING_CLINICAL_APPROVAL | APPROVED | PROMOTED | CANCELLED
batchType string PATIENT_REGISTRATION | ENCOUNTER_SUMMARY | VITALS_SHEET | LAB_RESULTS | MEDICATION_LIST | ALLERGY_UPDATE | MIXED
track string BACKFILL | LIVE_CAPTURE
patientId Guid? nullable until linked
@@ -861,7 +909,7 @@ uploadedAt DateTimeOffset
```
id Guid PK
batchId Guid FK → DigitizationBatch
-eventType string uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | live_capture_attested | correction_uploaded | correction_promoted | superseded | ...
+eventType string uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | live_capture_attested | correction_uploaded | correction_promoted | superseded | document_accessed | cancelled | ...
actorUserId Guid FK → User
occurredAt DateTimeOffset
metadataJson string? optional JSON (field checks, rejection reason, assigned-to)
@@ -1090,16 +1138,17 @@ Response shape:
## Implemented Phases
-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.
+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.
| Phase | Feature | Status |
|---|---|---|
| 1 | Schema, EF Core migrations, JWT authentication with refresh tokens, six user roles, batch CRUD, MinIO upload with SHA-256 and presigned URLs, batch status machine with transition matrix, duplicate document detection, Redis batch assignment locks, twelve seeded demo users, auth audit events | Done |
-| 2 | Draft data entry API (patient demographics, encounter context, observations), plausibility validation on observation values, batch-type completeness validation on submit-for-verification, automatic `UPLOADED → IN_ENTRY` transition on first save, assignment guard (`BATCH_NOT_ASSIGNED`), `DraftEntryTests` integration tests | Done |
+| 2 | Draft data entry API (patient demographics, encounter context, observations), plausibility validation on observation values, batch-type completeness validation on submit-for-verification, assignment-time and fallback `UPLOADED`/`REJECTED → `IN_ENTRY` transitions, assignment guard (`BATCH_NOT_ASSIGNED`), `DraftEntryTests` integration tests | Done |
| 3 | Verification with field-level checks (`ok`, `warning`, `error` per field), rejection with mandatory reason (min 10 chars), separation of duties enforcement (`SEPARATION_OF_DUTIES_VIOLATION`), site-configurable clinical approval routing per batch type, work queue endpoints (verification, entry, clinical approval) with role-based access, `VerificationTests` integration tests | Done |
-| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
+| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by normalized name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
| 5 | Correction batches via `supersedesBatchId`, supersession validation on create (`422`/`404`/`409`), append-only `live_observations` supersession flags (`is_superseded`, `superseded_by_batch_id`, `superseded_at`), correction promotion reuses original encounter and linked patient, `correction_uploaded`/`correction_promoted`/`superseded` audit events, `GET /patients/:id/digitization-history` with correction chain and per-batch audit trails, `CorrectionSupersessionTests` integration tests, Phase 5 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 |
| 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 docs (`docs/digitization-workstation-guide.md`); extended seed data (demo patients/batches across all statuses) | Partial |
+| 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 |
+| — | Health probes (`/health/live`, `/health/ready`, `/health/startup`), user management CRUD + password endpoints, auth rate limiting, document access audit, batch cancellation, unified `ExecutePromotionCoreAsync` for approve + retry promote, assignment-time `IN_ENTRY` transition | Done |
diff --git a/VigilCareRecordsAPI.Tests/BatchOperationsTests.cs b/VigilCareRecordsAPI.Tests/BatchOperationsTests.cs
new file mode 100644
index 0000000..f33b20f
--- /dev/null
+++ b/VigilCareRecordsAPI.Tests/BatchOperationsTests.cs
@@ -0,0 +1,275 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+///
+/// Tests for batch cancellation and sort parameters.
+///
+[Collection("Database")]
+public class BatchOperationsTests : IAsyncLifetime
+{
+ private readonly ApiFixture _fixture;
+ private HttpClient _adminClient = null!;
+
+ public BatchOperationsTests(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);
+ _adminClient = await AuthHelper.LoginAsync(_fixture, "admin1");
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ // ---------------------------------------------------------------
+ // Cancellation tests
+ // ---------------------------------------------------------------
+
+ [Fact]
+ public async Task Cancel_UploadedBatch_Returns200AndTransitionsToCancelled()
+ {
+ // Arrange: upload a batch
+ var batchId = await UploadBatchAsync();
+
+ // Act
+ var response = await _adminClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/cancel",
+ new { Reason = "Wrong document scanned" });
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ body.GetProperty("data").GetProperty("status").GetString()
+ .Should().Be("CANCELLED");
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var batch = await db.DigitizationBatches.FindAsync(batchId);
+ batch!.Status.Should().Be(BatchStatus.Cancelled);
+
+ var cancelEvent = await db.DigitizationEvents
+ .FirstOrDefaultAsync(e => e.BatchId == batchId
+ && e.EventType == DigitizationEventType.Cancelled);
+ cancelEvent.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task Cancel_InEntryBatch_Returns200()
+ {
+ // Arrange: upload + assign (transitions to IN_ENTRY)
+ var batchId = await UploadBatchAsync();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
+
+ await _adminClient.PatchAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/assign",
+ new { EntryClerkUserId = entryUserId });
+
+ // Act
+ var response = await _adminClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/cancel",
+ new { Reason = "Test upload during training" });
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // Reload to verify
+ db.ChangeTracker.Clear();
+ var batch = await db.DigitizationBatches.FindAsync(batchId);
+ batch!.Status.Should().Be(BatchStatus.Cancelled);
+ }
+
+ [Fact]
+ public async Task Cancel_RejectedBatch_Returns200()
+ {
+ // Arrange: seed a rejected batch
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
+ var rejectedBatch = await BatchSeedHelper.SeedBatchInRejectedAsync(db, entryUserId);
+
+ // Act
+ var response = await _adminClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{rejectedBatch.Id}/cancel",
+ new { Reason = "Patient linked incorrectly" });
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task Cancel_PromotedBatch_Returns409()
+ {
+ // Arrange: drive batch to promoted
+ var batchId = await BatchPipelineHelper.CreateAndVerifyBatchAsync(_fixture);
+ var approverClient = await AuthHelper.LoginAsync(_fixture, "approver1");
+ approverClient.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
+ await approverClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/approve",
+ new ApproveRequest());
+ approverClient.DefaultRequestHeaders.Remove("Idempotency-Key");
+
+ // Act
+ var response = await _adminClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/cancel",
+ new { Reason = "Should not work" });
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ }
+
+ [Fact]
+ public async Task Cancel_NonAdmin_Returns403()
+ {
+ var batchId = await UploadBatchAsync();
+ var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1");
+
+ var response = await entryClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/cancel",
+ new { Reason = "Should not be allowed" });
+
+ response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ }
+
+ [Fact]
+ public async Task Cancel_CancelledBatchExcludedFromEntryQueue()
+ {
+ // Arrange: seed an IN_ENTRY batch and cancel it
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
+
+ var batchId = await UploadBatchAsync();
+ await _adminClient.PatchAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/assign",
+ new { EntryClerkUserId = entryUserId });
+
+ await _adminClient.PostAsJsonAsync(
+ $"/api/v1/digitization-batches/{batchId}/cancel",
+ new { Reason = "Cleanup" });
+
+ // Act
+ var entryClient = await AuthHelper.LoginAsync(_fixture, "entry1");
+ var response = await entryClient.GetAsync("/api/v1/work-queue/entry");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ var items = body.GetProperty("data").GetProperty("items");
+ var batchIds = Enumerable.Range(0, items.GetArrayLength())
+ .Select(i => items[i].GetProperty("batchId").GetString())
+ .ToList();
+
+ batchIds.Should().NotContain(batchId.ToString());
+ }
+
+ // ---------------------------------------------------------------
+ // Sort parameter tests
+ // ---------------------------------------------------------------
+
+ [Fact]
+ public async Task List_SortByCreatedAtAsc_ReturnsOldestFirst()
+ {
+ // Arrange: create two batches
+ var batchId1 = await UploadBatchAsync();
+ await Task.Delay(50);
+ var batchId2 = await UploadBatchAsync();
+
+ // Act
+ var response = await _adminClient.GetAsync(
+ "/api/v1/digitization-batches?sortBy=createdAt&sortDirection=asc");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ var items = body.GetProperty("data").GetProperty("items");
+ var ids = Enumerable.Range(0, items.GetArrayLength())
+ .Select(i => items[i].GetProperty("id").GetGuid())
+ .ToList();
+
+ var idx1 = ids.IndexOf(batchId1);
+ var idx2 = ids.IndexOf(batchId2);
+ idx1.Should().BeLessThan(idx2, "oldest batch should come first with asc sort");
+ }
+
+ [Fact]
+ public async Task List_InvalidSortField_Returns422()
+ {
+ var response = await _adminClient.GetAsync(
+ "/api/v1/digitization-batches?sortBy=invalidField");
+
+ response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
+ }
+
+ [Fact]
+ public async Task WorkQueue_SortByCreatedAtDesc_ReturnsNewestFirst()
+ {
+ // Arrange: seed two pending verification batches with different ages
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
+
+ var olderBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(db, entryUserId);
+ var newerBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(db, entryUserId);
+
+ // Make the "older" batch actually older
+ olderBatch.CreatedAt = DateTimeOffset.UtcNow.AddHours(-2);
+ olderBatch.UpdatedAt = DateTimeOffset.UtcNow.AddHours(-2);
+ newerBatch.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
+ newerBatch.UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
+ await db.SaveChangesAsync();
+
+ // Act
+ var verifierClient = await AuthHelper.LoginAsync(_fixture, "verifier1");
+ var response = await verifierClient.GetAsync(
+ "/api/v1/work-queue/verification?sortBy=createdAt&sortDirection=desc");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var body = await response.Content.ReadFromJsonAsync();
+ var items = body.GetProperty("data").GetProperty("items");
+ var batchIds = Enumerable.Range(0, items.GetArrayLength())
+ .Select(i => items[i].GetProperty("batchId").GetString())
+ .ToList();
+
+ var idxNewer = batchIds.IndexOf(newerBatch.Id.ToString());
+ var idxOlder = batchIds.IndexOf(olderBatch.Id.ToString());
+ idxNewer.Should().BeLessThan(idxOlder, "newest batch should come first with desc sort");
+ }
+
+ // ---------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------
+
+ private async Task UploadBatchAsync()
+ {
+ var intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
+
+ var fileContent = new ByteArrayContent(
+ System.Text.Encoding.ASCII.GetBytes(
+ $"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}"));
+ fileContent.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
+
+ var formData = new MultipartFormDataContent
+ {
+ { fileContent, "file", "test.pdf" },
+ { new StringContent("VITALS_SHEET"), "batchType" }
+ };
+
+ var response = await intakeClient.PostAsync("/api/v1/digitization-batches", formData);
+ response.EnsureSuccessStatusCode();
+
+ var body = await response.Content.ReadFromJsonAsync();
+ return body.GetProperty("data").GetProperty("id").GetGuid();
+ }
+}
diff --git a/VigilCareRecordsAPI.Tests/VerificationTests.cs b/VigilCareRecordsAPI.Tests/VerificationTests.cs
index fd93611..31d9426 100644
--- a/VigilCareRecordsAPI.Tests/VerificationTests.cs
+++ b/VigilCareRecordsAPI.Tests/VerificationTests.cs
@@ -434,10 +434,11 @@ public class VerificationTests : IAsyncLifetime
}
///
- /// Test: Entry queue returns Uploaded, InEntry, and Rejected batches.
+ /// Test: Entry queue returns InEntry and Rejected batches, but NOT Uploaded
+ /// (Uploaded batches must be assigned first, which transitions them to InEntry).
///
[Fact]
- public async Task EntryQueue_ReturnsUploadedInEntryAndRejectedBatches()
+ public async Task EntryQueue_ReturnsInEntryAndRejectedBatches_ExcludesUploaded()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
@@ -451,7 +452,7 @@ public class VerificationTests : IAsyncLifetime
var pendingBatch = await BatchSeedHelper.SeedBatchInPendingVerificationAsync(
db, (await BatchSeedHelper.UserIdAsync(db, "entry2")));
- // Seed an Uploaded batch directly
+ // Seed an Uploaded batch directly (should NOT appear — must be assigned first)
var uploadedBatchId = Guid.NewGuid();
db.DigitizationBatches.Add(new DigitizationBatch
{
@@ -495,12 +496,12 @@ public class VerificationTests : IAsyncLifetime
.ToList();
batchIds.Should().Contain(rejectedBatch.Id.ToString());
- batchIds.Should().Contain(uploadedBatchId.ToString());
+ batchIds.Should().NotContain(uploadedBatchId.ToString(),
+ "Uploaded batches must be assigned before appearing in the entry queue");
batchIds.Should().NotContain(pendingBatch.Id.ToString());
var entryStatuses = new[]
{
- BatchStatus.Uploaded.ToDbString(),
BatchStatus.InEntry.ToDbString(),
BatchStatus.Rejected.ToDbString()
};
diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs
index 7a32549..dca9d34 100644
--- a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs
+++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs
@@ -124,13 +124,15 @@ public class DigitizationBatchesController : ControllerBase
[FromQuery] Guid? assignedTo,
[FromQuery] string? track,
[FromQuery] int page = 1,
- [FromQuery] int pageSize = 20)
+ [FromQuery] int pageSize = 20,
+ [FromQuery] string sortBy = "createdAt",
+ [FromQuery] string sortDirection = "desc")
{
BatchStatus? parsedStatus = string.IsNullOrEmpty(status) ? null : BatchStatusExtensions.FromDbString(status.ToUpperInvariant());
BatchType? parsedBatchType = string.IsNullOrEmpty(batchType) ? null : BatchTypeExtensions.FromDbString(batchType.ToUpperInvariant());
BatchTrack? parsedTrack = string.IsNullOrEmpty(track) ? null : BatchTrackExtensions.FromDbString(track.ToUpperInvariant());
- var result = await _batches.ListAsync(parsedStatus, parsedBatchType, assignedTo, parsedTrack, page, pageSize);
+ var result = await _batches.ListAsync(parsedStatus, parsedBatchType, assignedTo, parsedTrack, page, pageSize, sortBy, sortDirection);
return Ok(ApiResponse.Ok(new BatchListResponse(
result.Items.Select(b => BatchDetailResponse.FromEntity(b)).ToList(),
result.Page,
@@ -154,6 +156,23 @@ public class DigitizationBatchesController : ControllerBase
return Ok(ApiResponse.Ok(BatchDetailResponse.FromEntity(batch)));
}
+ ///
+ /// Cancels a batch permanently. Only batches in UPLOADED, IN_ENTRY, or REJECTED
+ /// status can be cancelled. Admin only.
+ ///
+ [HttpPost("{id:guid}/cancel")]
+ [Authorize(Roles = "ADMINISTRATOR")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse