diff --git a/README.md b/README.md
index a5af5a9..bdcb93f 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–6 are complete (API core through Track B live capture). Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 is partially implemented — work queue overview endpoint and supervisor dashboard UI are in place; Prometheus metrics and promotion retry remain planned. 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 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.
## Domain Model — How It Maps to a Real Clinical System
@@ -51,12 +51,13 @@ Append-only audit log entry for every state transition, field-level correction,
- **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` 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
+- **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`
- **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)
- **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`
@@ -65,7 +66,7 @@ Append-only audit log entry for every state transition, field-level correction,
- **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
+- **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)
- **Swagger UI** — OpenAPI spec via Swashbuckle (Development only) at `http://localhost:5217/swagger`
---
@@ -89,6 +90,9 @@ HTTP request
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
├── WorkQueueService (verification, entry, clinical approval queues, supervisor overview metrics)
+ ├── BatchEventService (cursor-paginated batch audit trail)
+ ├── 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)
├── AttestationService (clinician role + password re-confirm for live capture)
@@ -134,6 +138,7 @@ HTTP request
| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
| Password hashing | BCrypt.Net-Next |
| Logging | Serilog + Seq sink |
+| Metrics | Prometheus (`prometheus-net`) + Grafana |
| Docs | Swagger / OpenAPI (Swashbuckle) |
| Testing | xUnit + FluentAssertions + WebApplicationFactory |
@@ -147,9 +152,9 @@ VigilCareRecords/
│ ├── Program.cs # Service registration, middleware, seed on startup
│ ├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
│ ├── Controllers/
-│ │ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
+│ │ ├── ApprovalController.cs # Batch approval, promotion deferral (202), promotion to live tables
│ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
-│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
+│ │ ├── 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
@@ -173,7 +178,9 @@ VigilCareRecords/
│ ├── 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–6)
+│ └── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–8)
+├── prometheus.yml # Prometheus scrape config (API on host.docker.internal:5217)
+├── docker-compose.yml # PostgreSQL, Redis, Seq, MinIO, Prometheus, Grafana
├── scripts/
│ ├── run-vigilcare-records-verification.sh # Phase 1
│ ├── run-vigilcare-records-phase-2-verification.sh
@@ -181,6 +188,7 @@ VigilCareRecords/
│ ├── run-vigilcare-records-phase-4-verification.sh
│ ├── run-vigilcare-records-phase-5-verification.sh
│ ├── run-vigilcare-records-phase-6-verification.sh
+│ ├── run-vigilcare-records-phase-8-verification.sh # Prometheus metrics, overview, events, retry
│ └── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
└── docs/
├── plans/ # Phase 1–9 implementation guides
@@ -315,6 +323,8 @@ docker compose up -d
| Redis 7 | 6383 | No auth |
| Seq | 5346 | UI at `http://localhost:5346`, login: `admin` / `seqadmin` |
| MinIO | 9012 (S3 API), 9013 (console) | login: `minioadmin` / `minioadmin` |
+| Prometheus | 9095 | Scrapes API at `host.docker.internal:5217/metrics`; UI at `http://localhost:9095` |
+| Grafana | 3013 | UI at `http://localhost:3013`; add Prometheus data source `http://prometheus:9090` |
### Install and Run
@@ -385,6 +395,7 @@ With the API running (`dotnet run`) and Docker Compose up:
./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
./scripts/run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts
+./scripts/run-vigilcare-records-phase-8-verification.sh # Phase 8 — Prometheus metrics, work-queue overview, batch events, promotion retry
./scripts/run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
```
@@ -1018,7 +1029,7 @@ Response shape:
## Implemented Phases
-Phases 1–6 are fully implemented and verified via integration tests and per-phase scripts. Phase 7 (workstation UI) and parts of Phase 8 (supervisor overview) are implemented.
+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.
| Phase | Feature | Status |
|---|---|---|
@@ -1029,5 +1040,5 @@ Phases 1–6 are fully implemented and verified via integration tests and per-ph
| 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 | `GET /work-queue/overview`, supervisor dashboard UI, patient search API, user directory API | Partial — Prometheus metrics and promotion retry job planned |
+| 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 |
diff --git a/VigilCareRecordsAPI/BackgroundServices/MetricsCollectorService.cs b/VigilCareRecordsAPI/BackgroundServices/MetricsCollectorService.cs
new file mode 100644
index 0000000..deee226
--- /dev/null
+++ b/VigilCareRecordsAPI/BackgroundServices/MetricsCollectorService.cs
@@ -0,0 +1,131 @@
+using Microsoft.EntityFrameworkCore;
+
+///
+/// Background service that periodically queries the database to update
+/// gauge metrics for Prometheus. Runs every 30 seconds.
+///
+/// Metrics updated:
+/// - digitization_batches_by_status: count per status
+/// - digitization_queue_age_seconds: age of oldest PendingVerification batch
+///
+/// Design decisions:
+/// - Uses IServiceScopeFactory (not injected AppDbContext) because
+/// BackgroundService is a singleton and AppDbContext is scoped.
+/// Each collection cycle creates a fresh scope.
+/// - 30-second interval balances freshness against DB load. Prometheus
+/// typically scrapes every 15-60 seconds, so 30 seconds ensures the
+/// gauge is never more than one scrape interval stale.
+/// - Explicit zero-setting for empty statuses prevents stale gauge values
+/// from persisting after all batches of a status are processed.
+/// - Uses UpdatedAt (not CreatedAt) for queue age because UpdatedAt
+/// reflects when the batch entered PendingVerification.
+///
+public class MetricsCollectorService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+ private static readonly TimeSpan CollectionInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ /// All batch statuses that should be reported as gauge values.
+ /// If a status has zero batches, the gauge is set to 0 (not omitted).
+ ///
+ private static readonly BatchStatus[] AllStatuses =
+ {
+ BatchStatus.Uploaded,
+ BatchStatus.InEntry,
+ BatchStatus.PendingVerification,
+ BatchStatus.Rejected,
+ BatchStatus.Verified,
+ BatchStatus.AwaitingClinicalApproval,
+ BatchStatus.Approved,
+ BatchStatus.Promoted
+ };
+
+ public MetricsCollectorService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation(
+ "MetricsCollectorService started. Collection interval: {Interval}s",
+ CollectionInterval.TotalSeconds);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await CollectMetricsAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ // Graceful shutdown — do not log as error
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "MetricsCollectorService failed to collect metrics");
+ // Continue running — transient DB errors should not kill the collector
+ }
+
+ await Task.Delay(CollectionInterval, stoppingToken);
+ }
+
+ _logger.LogInformation("MetricsCollectorService stopped");
+ }
+
+ private async Task CollectMetricsAsync(CancellationToken ct)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // --- Batch counts by status ---
+ // Single query: GROUP BY status, returns dictionary
+ var statusCounts = await db.DigitizationBatches
+ .AsNoTracking()
+ .GroupBy(b => b.Status)
+ .Select(g => new { Status = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.Status, x => x.Count, ct);
+
+ // Set gauge for every status — zero out statuses with no batches
+ foreach (var status in AllStatuses)
+ {
+ var count = statusCounts.GetValueOrDefault(status, 0);
+ DiagnosticsMetrics.BatchesByStatus
+ .WithLabels(status.ToDbString())
+ .Set(count);
+ }
+
+ // --- Queue age: oldest batch in PendingVerification ---
+ var oldestPendingUpdatedAt = await db.DigitizationBatches
+ .AsNoTracking()
+ .Where(b => b.Status == BatchStatus.PendingVerification)
+ .OrderBy(b => b.UpdatedAt)
+ .Select(b => (DateTimeOffset?)b.UpdatedAt)
+ .FirstOrDefaultAsync(ct);
+
+ if (oldestPendingUpdatedAt.HasValue)
+ {
+ var ageSeconds = (DateTimeOffset.UtcNow - oldestPendingUpdatedAt.Value).TotalSeconds;
+ DiagnosticsMetrics.QueueAgeSeconds.Set(ageSeconds);
+ }
+ else
+ {
+ // No batches in PendingVerification — queue is empty
+ DiagnosticsMetrics.QueueAgeSeconds.Set(0);
+ }
+
+ _logger.LogDebug(
+ "Metrics collected: {StatusCount} status groups, queue age {QueueAge}s",
+ statusCounts.Count,
+ oldestPendingUpdatedAt.HasValue
+ ? (DateTimeOffset.UtcNow - oldestPendingUpdatedAt.Value).TotalSeconds
+ : 0);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs b/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs
new file mode 100644
index 0000000..cb4c1e6
--- /dev/null
+++ b/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs
@@ -0,0 +1,274 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+///
+/// Background service that retries failed promotion attempts with exponential backoff.
+///
+/// When VigilCareClinical is unreachable in a split deployment, promotion fails and
+/// the batch remains in Approved status. This service:
+/// 1. Finds batches in Approved status that have a failed PromotionAttempt
+/// with a NextRetryAt in the past.
+/// 2. Retries promotion via IPromotionService.
+/// 3. On failure, records a new PromotionAttempt with exponentially increasing NextRetryAt.
+/// 4. After MaxRetryAttempts, sets NextRetryAt to null (manual intervention required)
+/// and logs a critical warning.
+///
+/// The batch NEVER reverts to draft — it stays Approved until promotion succeeds
+/// or an administrator manually intervenes.
+///
+public class PromotionRetryService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly PromotionRetryOptions _options;
+ private readonly ILogger _logger;
+
+ public PromotionRetryService(
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation(
+ "PromotionRetryService started. Poll interval: {PollInterval}s, " +
+ "max retries: {MaxRetries}, initial delay: {InitialDelay}s, " +
+ "max delay: {MaxDelay}s, backoff multiplier: {Multiplier}",
+ _options.PollIntervalSeconds,
+ _options.MaxRetryAttempts,
+ _options.InitialDelaySeconds,
+ _options.MaxDelaySeconds,
+ _options.BackoffMultiplier);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ProcessPendingRetriesAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "PromotionRetryService encountered an error during processing");
+ }
+
+ await Task.Delay(
+ TimeSpan.FromSeconds(_options.PollIntervalSeconds), stoppingToken);
+ }
+
+ _logger.LogInformation("PromotionRetryService stopped");
+ }
+
+ private async Task ProcessPendingRetriesAsync(CancellationToken ct)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var promotionService = scope.ServiceProvider.GetRequiredService();
+ var now = DateTimeOffset.UtcNow;
+
+ // Find batches that need retry:
+ // 1. Batch is in Approved status (not yet promoted)
+ // 2. Has a failed PromotionAttempt with NextRetryAt <= now
+ var pendingRetries = await db.PromotionAttempts
+ .Include(a => a.Batch)
+ .Where(a => !a.Succeeded
+ && a.NextRetryAt != null
+ && a.NextRetryAt <= now
+ && a.Batch.Status == BatchStatus.Approved)
+ .OrderBy(a => a.NextRetryAt)
+ .Take(10) // Process up to 10 retries per poll cycle
+ .ToListAsync(ct);
+
+ if (pendingRetries.Count == 0)
+ return;
+
+ _logger.LogInformation(
+ "PromotionRetryService found {Count} batches pending retry",
+ pendingRetries.Count);
+
+ foreach (var attempt in pendingRetries)
+ {
+ await RetryPromotionAsync(db, promotionService, attempt, ct);
+ }
+ }
+
+ private async Task RetryPromotionAsync(
+ AppDbContext db,
+ IPromotionService promotionService,
+ PromotionAttempt lastAttempt,
+ CancellationToken ct)
+ {
+ var batchId = lastAttempt.BatchId;
+ var nextAttemptNumber = lastAttempt.AttemptNumber + 1;
+
+ _logger.LogInformation(
+ "Retrying promotion for batch {BatchId}, attempt {Attempt}/{MaxAttempts}",
+ batchId, nextAttemptNumber, _options.MaxRetryAttempts);
+
+ try
+ {
+ // Verify batch is still in Approved status
+ var batch = await db.DigitizationBatches.FindAsync(
+ new object[] { batchId }, ct);
+
+ if (batch is null || batch.Status != BatchStatus.Approved)
+ {
+ _logger.LogWarning(
+ "Batch {BatchId} is no longer in Approved status, skipping retry",
+ batchId);
+ return;
+ }
+
+ var approverUserId = batch.ApprovedByUserId
+ ?? throw new InvalidOperationException(
+ $"Batch {batchId} is in Approved status but has no ApprovedByUserId");
+
+ // Attempt promotion with a unique idempotency key per attempt
+ await promotionService.PromoteAsync(batchId, approverUserId);
+
+ // --- Success path ---
+ var successAttempt = new PromotionAttempt
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batchId,
+ AttemptNumber = nextAttemptNumber,
+ Succeeded = true,
+ ErrorMessage = null,
+ AttemptedAt = DateTimeOffset.UtcNow,
+ NextRetryAt = null
+ };
+
+ db.PromotionAttempts.Add(successAttempt);
+
+ // Clear the previous attempt's NextRetryAt so it won't be picked up again
+ lastAttempt.NextRetryAt = null;
+
+ // Audit event
+ db.DigitizationEvents.Add(new DigitizationEvent
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batchId,
+ EventType = DigitizationEventType.PromotionRetrySucceeded,
+ ActorUserId = approverUserId,
+ OccurredAt = DateTimeOffset.UtcNow,
+ MetadataJson = JsonSerializer.Serialize(new
+ {
+ attemptNumber = nextAttemptNumber,
+ retriedAt = DateTimeOffset.UtcNow
+ })
+ });
+
+ await db.SaveChangesAsync(ct);
+
+ _logger.LogInformation(
+ "Batch {BatchId} successfully promoted on retry attempt {Attempt}",
+ batchId, nextAttemptNumber);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw; // Let the outer loop handle shutdown
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Promotion retry failed for batch {BatchId}, attempt {Attempt}",
+ batchId, nextAttemptNumber);
+
+ // Calculate next retry delay with exponential backoff
+ var delay = CalculateBackoffDelay(nextAttemptNumber);
+ DateTimeOffset? nextRetryAt = null;
+
+ if (nextAttemptNumber < _options.MaxRetryAttempts)
+ {
+ nextRetryAt = DateTimeOffset.UtcNow.Add(delay);
+
+ _logger.LogInformation(
+ "Scheduling retry for batch {BatchId} at {NextRetryAt} " +
+ "(delay: {DelaySec:F0}s, attempt {Attempt}/{Max})",
+ batchId, nextRetryAt, delay.TotalSeconds,
+ nextAttemptNumber, _options.MaxRetryAttempts);
+ }
+ else
+ {
+ // Exhausted all retries — manual intervention required
+ _logger.LogCritical(
+ "Batch {BatchId} has exhausted all {Max} retry attempts. " +
+ "Manual intervention required. Last error: {Error}",
+ batchId, _options.MaxRetryAttempts, ex.Message);
+ }
+
+ // Record failed attempt
+ var failedAttempt = new PromotionAttempt
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batchId,
+ AttemptNumber = nextAttemptNumber,
+ Succeeded = false,
+ ErrorMessage = ex.Message.Length > 2000
+ ? ex.Message[..2000]
+ : ex.Message,
+ AttemptedAt = DateTimeOffset.UtcNow,
+ NextRetryAt = nextRetryAt
+ };
+
+ db.PromotionAttempts.Add(failedAttempt);
+
+ // Clear the previous attempt's NextRetryAt
+ lastAttempt.NextRetryAt = null;
+
+ // Audit event
+ var batch = await db.DigitizationBatches.FindAsync(
+ new object[] { batchId }, ct);
+ var actorId = batch?.ApprovedByUserId ?? Guid.Empty;
+
+ db.DigitizationEvents.Add(new DigitizationEvent
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batchId,
+ EventType = nextRetryAt.HasValue
+ ? DigitizationEventType.PromotionRetryFailed
+ : DigitizationEventType.PromotionRetryExhausted,
+ ActorUserId = actorId,
+ OccurredAt = DateTimeOffset.UtcNow,
+ MetadataJson = JsonSerializer.Serialize(new
+ {
+ attemptNumber = nextAttemptNumber,
+ error = ex.Message,
+ nextRetryAt = nextRetryAt?.ToString("o"),
+ exhausted = !nextRetryAt.HasValue
+ })
+ });
+
+ await db.SaveChangesAsync(ct);
+ }
+ }
+
+ ///
+ /// Calculates exponential backoff delay for a given attempt number.
+ /// Formula: min(initialDelay * multiplier^(attempt-1), maxDelay)
+ /// With jitter: adds up to 10% random jitter to prevent thundering herd
+ /// when multiple replicas retry simultaneously.
+ ///
+ private TimeSpan CalculateBackoffDelay(int attemptNumber)
+ {
+ var baseDelay = _options.InitialDelaySeconds
+ * Math.Pow(_options.BackoffMultiplier, attemptNumber - 1);
+
+ var cappedDelay = Math.Min(baseDelay, _options.MaxDelaySeconds);
+
+ // Add up to 10% jitter
+ var jitter = cappedDelay * 0.1 * Random.Shared.NextDouble();
+ var finalDelay = cappedDelay + jitter;
+
+ return TimeSpan.FromSeconds(finalDelay);
+ }
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Configurations/PromotionRetryOptions.cs b/VigilCareRecordsAPI/Configurations/PromotionRetryOptions.cs
new file mode 100644
index 0000000..5b17f2b
--- /dev/null
+++ b/VigilCareRecordsAPI/Configurations/PromotionRetryOptions.cs
@@ -0,0 +1,38 @@
+///
+/// Configuration for the promotion retry background job.
+/// All values configurable via appsettings.json under "PromotionRetry".
+///
+public class PromotionRetryOptions
+{
+ public const string Section = "PromotionRetry";
+
+ ///
+ /// How often the retry service checks for stuck batches (in seconds).
+ /// Default: 60 seconds.
+ ///
+ public int PollIntervalSeconds { get; set; } = 60;
+
+ ///
+ /// Initial delay before the first retry attempt (in seconds).
+ /// Default: 30 seconds.
+ ///
+ public int InitialDelaySeconds { get; set; } = 30;
+
+ ///
+ /// Maximum delay between retries (in seconds). Exponential backoff
+ /// caps at this value. Default: 900 seconds (15 minutes).
+ ///
+ public int MaxDelaySeconds { get; set; } = 900;
+
+ ///
+ /// Maximum number of retry attempts before the batch is flagged
+ /// for manual intervention. Default: 10.
+ ///
+ public int MaxRetryAttempts { get; set; } = 10;
+
+ ///
+ /// Backoff multiplier. Each retry delay is multiplied by this value.
+ /// Default: 2.0 (doubles each time).
+ ///
+ public double BackoffMultiplier { get; set; } = 2.0;
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Controllers/ApprovalController.cs b/VigilCareRecordsAPI/Controllers/ApprovalController.cs
index 6b30213..1ae17f7 100644
--- a/VigilCareRecordsAPI/Controllers/ApprovalController.cs
+++ b/VigilCareRecordsAPI/Controllers/ApprovalController.cs
@@ -1,7 +1,9 @@
using System.Security.Claims;
+using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
///
/// Approval and promotion of verified digitization batches to VigilCareClinical live tables.
@@ -13,11 +15,19 @@ using Microsoft.AspNetCore.Mvc;
public class ApprovalController : ControllerBase
{
private readonly IPromotionService _promotion;
+ private readonly AppDbContext _db;
+ private readonly PromotionRetryOptions _retryOptions;
private readonly ILogger _logger;
- public ApprovalController(IPromotionService promotion, ILogger logger)
+ public ApprovalController(
+ IPromotionService promotion,
+ AppDbContext db,
+ IOptions retryOptions,
+ ILogger logger)
{
_promotion = promotion;
+ _db = db;
+ _retryOptions = retryOptions.Value;
_logger = logger;
}
@@ -28,6 +38,9 @@ public class ApprovalController : ControllerBase
/// Requires the Idempotency-Key header for safe retries. If the same key is resubmitted,
/// the original response is returned without re-executing the promotion.
///
+ /// If promotion fails due to infrastructure issues, the batch transitions to Approved status
+ /// and automatic retry is scheduled. Returns 202 Accepted with PROMOTION_DEFERRED.
+ ///
/// Separation of duties: the approver cannot be the entry clerk or verifier of the same batch.
///
/// The batch ID to approve.
@@ -36,6 +49,7 @@ public class ApprovalController : ControllerBase
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse