diff --git a/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs b/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs
new file mode 100644
index 0000000..0019b56
--- /dev/null
+++ b/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs
@@ -0,0 +1,216 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+///
+/// Polls for uploaded batches without OCR results and pre-fills draft fields.
+/// OCR is opt-in and non-blocking: failures leave the batch in UPLOADED status
+/// for manual entry.
+///
+public class OcrProcessingService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly OcrOptions _options;
+ private readonly ILogger _logger;
+
+ public OcrProcessingService(
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation(
+ "OcrProcessingService started. Provider: {Provider}, poll interval: {PollInterval}s, confidence threshold: {Threshold}",
+ _options.Provider,
+ _options.PollIntervalSeconds,
+ _options.ConfidenceThreshold);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ProcessPendingBatchesAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "OCR processing cycle failed");
+ }
+
+ await Task.Delay(
+ TimeSpan.FromSeconds(_options.PollIntervalSeconds), stoppingToken);
+ }
+
+ _logger.LogInformation("OcrProcessingService stopped");
+ }
+
+ private async Task ProcessPendingBatchesAsync(CancellationToken ct)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var ocr = scope.ServiceProvider.GetRequiredService();
+ var storage = scope.ServiceProvider.GetRequiredService();
+ var preFiller = scope.ServiceProvider.GetRequiredService();
+
+ var pendingBatches = await db.DigitizationBatches
+ .AsNoTracking()
+ .Where(b => b.Status == BatchStatus.Uploaded)
+ .Where(b => !db.OcrResults.Any(o => o.BatchId == b.Id))
+ .Where(b => !db.DigitizationEvents.Any(e =>
+ e.BatchId == b.Id &&
+ (e.EventType == DigitizationEventType.OcrCompleted ||
+ e.EventType == DigitizationEventType.OcrFailed)))
+ .OrderBy(b => b.CreatedAt)
+ .Take(5)
+ .ToListAsync(ct);
+
+ foreach (var batch in pendingBatches)
+ {
+ await ProcessBatchAsync(db, ocr, storage, preFiller, batch, ct);
+ }
+ }
+
+ private async Task ProcessBatchAsync(
+ AppDbContext db,
+ IOcrService ocr,
+ IDocumentStorageService storage,
+ OcrDraftPreFiller preFiller,
+ DigitizationBatch batch,
+ CancellationToken ct)
+ {
+ var currentStatus = await db.DigitizationBatches
+ .AsNoTracking()
+ .Where(b => b.Id == batch.Id)
+ .Select(b => b.Status)
+ .FirstOrDefaultAsync(ct);
+
+ if (currentStatus != BatchStatus.Uploaded)
+ {
+ _logger.LogDebug(
+ "Skipping OCR for batch {BatchId}: status is {Status}",
+ batch.Id, currentStatus.ToDbString());
+ return;
+ }
+
+ var actorUserId = await db.DigitizationEvents
+ .AsNoTracking()
+ .Where(e => e.BatchId == batch.Id &&
+ (e.EventType == DigitizationEventType.Uploaded ||
+ e.EventType == DigitizationEventType.CorrectionUploaded))
+ .OrderBy(e => e.OccurredAt)
+ .Select(e => e.ActorUserId)
+ .FirstOrDefaultAsync(ct);
+
+ if (actorUserId == Guid.Empty)
+ {
+ _logger.LogWarning(
+ "Skipping OCR for batch {BatchId}: no upload event found",
+ batch.Id);
+ return;
+ }
+
+ var document = await db.ScannedDocuments
+ .AsNoTracking()
+ .FirstOrDefaultAsync(d => d.BatchId == batch.Id, ct);
+
+ if (document is null)
+ {
+ _logger.LogWarning(
+ "Skipping OCR for batch {BatchId}: scanned document metadata not found",
+ batch.Id);
+ return;
+ }
+
+ var startedAt = DateTimeOffset.UtcNow;
+ db.DigitizationEvents.Add(new DigitizationEvent
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batch.Id,
+ EventType = DigitizationEventType.OcrStarted,
+ ActorUserId = actorUserId,
+ OccurredAt = startedAt,
+ MetadataJson = JsonSerializer.Serialize(new
+ {
+ provider = _options.Provider
+ })
+ });
+ await db.SaveChangesAsync(ct);
+
+ try
+ {
+ await using var documentStream = await storage.DownloadAsync(document.ObjectKey);
+ var extraction = await ocr.ExtractAsync(documentStream, document.ContentType);
+
+ await preFiller.PreFillAsync(batch.Id, batch.BatchType, extraction);
+
+ var fieldConfidences = extraction.Fields
+ .GroupBy(f => f.FieldName, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().Confidence, StringComparer.Ordinal);
+
+ var processedAt = DateTimeOffset.UtcNow;
+ db.OcrResults.Add(new OcrResult
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batch.Id,
+ Provider = _options.Provider,
+ FieldConfidencesJson = JsonSerializer.Serialize(fieldConfidences),
+ RawText = extraction.RawText,
+ DurationMs = extraction.DurationMs,
+ ProcessedAt = processedAt
+ });
+
+ db.DigitizationEvents.Add(new DigitizationEvent
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batch.Id,
+ EventType = DigitizationEventType.OcrCompleted,
+ ActorUserId = actorUserId,
+ OccurredAt = processedAt,
+ MetadataJson = JsonSerializer.Serialize(new
+ {
+ provider = _options.Provider,
+ durationMs = extraction.DurationMs,
+ fieldCount = extraction.Fields.Count,
+ confidentFieldCount = fieldConfidences.Count(kv => kv.Value >= _options.ConfidenceThreshold)
+ })
+ });
+
+ await db.SaveChangesAsync(ct);
+
+ _logger.LogInformation(
+ "OCR completed for batch {BatchId}: provider={Provider}, fields={FieldCount}, duration={DurationMs}ms",
+ batch.Id, _options.Provider, extraction.Fields.Count, extraction.DurationMs);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ db.DigitizationEvents.Add(new DigitizationEvent
+ {
+ Id = Guid.NewGuid(),
+ BatchId = batch.Id,
+ EventType = DigitizationEventType.OcrFailed,
+ ActorUserId = actorUserId,
+ OccurredAt = DateTimeOffset.UtcNow,
+ MetadataJson = JsonSerializer.Serialize(new
+ {
+ provider = _options.Provider,
+ error = ex.Message
+ })
+ });
+ await db.SaveChangesAsync(ct);
+
+ _logger.LogWarning(
+ ex,
+ "OCR failed for batch {BatchId}: {ErrorMessage}",
+ batch.Id, ex.Message);
+ }
+ }
+}
diff --git a/VigilCareRecordsAPI/Configurations/AzureOcrOptions.cs b/VigilCareRecordsAPI/Configurations/AzureOcrOptions.cs
new file mode 100644
index 0000000..6752f8a
--- /dev/null
+++ b/VigilCareRecordsAPI/Configurations/AzureOcrOptions.cs
@@ -0,0 +1,5 @@
+public class AzureOcrOptions
+{
+ public string Endpoint { get; set; } = "";
+ public string ApiKey { get; set; } = "";
+}
diff --git a/VigilCareRecordsAPI/Configurations/OcrOptions.cs b/VigilCareRecordsAPI/Configurations/OcrOptions.cs
new file mode 100644
index 0000000..716ff8b
--- /dev/null
+++ b/VigilCareRecordsAPI/Configurations/OcrOptions.cs
@@ -0,0 +1,12 @@
+public class OcrOptions
+{
+ public const string Section = "Ocr";
+
+ public bool Enabled { get; set; } = false;
+ public string Provider { get; set; } = "azure"; // "azure" or "tesseract"
+ public double ConfidenceThreshold { get; set; } = 0.7;
+ public int PollIntervalSeconds { get; set; } = 15;
+
+ public AzureOcrOptions Azure { get; set; } = new();
+ public TesseractOcrOptions Tesseract { get; set; } = new();
+}
diff --git a/VigilCareRecordsAPI/Configurations/TesseractOcrOptions.cs b/VigilCareRecordsAPI/Configurations/TesseractOcrOptions.cs
new file mode 100644
index 0000000..cc6affd
--- /dev/null
+++ b/VigilCareRecordsAPI/Configurations/TesseractOcrOptions.cs
@@ -0,0 +1,5 @@
+public class TesseractOcrOptions
+{
+ public string DataPath { get; set; } = "/usr/share/tessdata";
+ public string Language { get; set; } = "eng";
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs
index 4d2503c..b69a45c 100644
--- a/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs
+++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs
@@ -25,7 +25,7 @@ public class FhirEncounterController : ControllerBase
}
///
- /// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z
+ /// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z
/// Supports search by patient reference, status, and date range.
///
[HttpGet]
diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs
index 4f89989..5eaf343 100644
--- a/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs
+++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs
@@ -25,7 +25,7 @@ public class FhirObservationController : ControllerBase
}
///
- /// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W
+ /// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W
/// Supports search by patient reference, LOINC code, date range, and category.
///
[HttpGet]
diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs
index 7e0367f..6636d0e 100644
--- a/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs
+++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs
@@ -29,7 +29,7 @@ public class FhirPatientController : ControllerBase
}
///
- /// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z
+ /// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z
/// Supports search by name (contains), birthdate (exact), and MRN identifier.
/// Returns a FHIR Bundle of type searchset.
///
diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs
index a638ba3..c23db1e 100644
--- a/VigilCareRecordsAPI/Data/AppDbContext.cs
+++ b/VigilCareRecordsAPI/Data/AppDbContext.cs
@@ -24,6 +24,7 @@ public class AppDbContext : DbContext
public DbSet LiveObservations => Set();
public DbSet PromotionAttempts => Set();
public DbSet CoverSheets => Set();
+ public DbSet OcrResults => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs
index 8588a9d..a297c5d 100644
--- a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs
+++ b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs
@@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration
{
t.HasCheckConstraint("chk_digitization_events_event_type",
- "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')");
+ "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
diff --git a/VigilCareRecordsAPI/Data/Configurations/OcrResultConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/OcrResultConfiguration.cs
new file mode 100644
index 0000000..485ca05
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Configurations/OcrResultConfiguration.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class OcrResultConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("ocr_results");
+ builder.HasKey(o => o.Id);
+ builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
+ builder.Property(o => o.BatchId).HasColumnName("batch_id").IsRequired();
+ builder.Property(o => o.Provider).HasColumnName("provider").HasMaxLength(20).IsRequired();
+ builder.Property(o => o.FieldConfidencesJson).HasColumnName("field_confidences_json").HasColumnType("jsonb").IsRequired();
+ builder.Property(o => o.RawText).HasColumnName("raw_text");
+ builder.Property(o => o.DurationMs).HasColumnName("duration_ms").IsRequired();
+ builder.Property(o => o.ProcessedAt).HasColumnName("processed_at").HasDefaultValueSql("NOW()");
+
+ builder.HasIndex(o => o.BatchId).IsUnique();
+ builder.HasOne(o => o.Batch)
+ .WithOne()
+ .HasForeignKey(o => o.BatchId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.Designer.cs
new file mode 100644
index 0000000..36d4fd4
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.Designer.cs
@@ -0,0 +1,1628 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace VigilCareRecordsAPI.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260627170141_AddOcrResult")]
+ partial class AddOcrResult
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AlertThreshold", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("CriticalHigh")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("critical_high");
+
+ b.Property("CriticalLow")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("critical_low");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("display_name");
+
+ b.Property("ObservationCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("observation_code");
+
+ b.Property("SuppressionWindowMinutes")
+ .HasColumnType("integer")
+ .HasColumnName("suppression_window_minutes");
+
+ b.Property("Unit")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("unit");
+
+ b.Property("WarningHigh")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("warning_high");
+
+ b.Property("WarningLow")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("warning_low");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ObservationCode")
+ .IsUnique();
+
+ b.ToTable("alert_thresholds", "clinical");
+ });
+
+ modelBuilder.Entity("AuthAuditEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("event_type");
+
+ b.Property("MetadataJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata_json");
+
+ b.Property("OccurredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "OccurredAt");
+
+ b.ToTable("auth_audit_events", null, t =>
+ {
+ t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')");
+ });
+ });
+
+ modelBuilder.Entity("ClinicalAlert", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AcknowledgedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acknowledged_at");
+
+ b.Property("AcknowledgedBy")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("acknowledged_by");
+
+ b.Property("AlertType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("alert_type");
+
+ b.Property("ClientAlertId")
+ .HasColumnType("uuid")
+ .HasColumnName("client_alert_id");
+
+ b.Property("Details")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("details");
+
+ b.Property("EncounterId")
+ .HasColumnType("uuid")
+ .HasColumnName("encounter_id");
+
+ b.Property("FeedbackReceived")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("feedback_received");
+
+ b.Property("ObservationCode")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("observation_code");
+
+ b.Property("ObservationId")
+ .HasColumnType("uuid")
+ .HasColumnName("observation_id");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("resolved_at");
+
+ b.Property("Severity")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("severity");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status")
+ .HasDefaultValueSql("'OPEN'");
+
+ b.Property("SyncedFromGateway")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("synced_from_gateway");
+
+ b.Property("TriggeredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("triggered_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientAlertId")
+ .IsUnique()
+ .HasFilter("client_alert_id IS NOT NULL");
+
+ b.HasIndex("EncounterId", "TriggeredAt");
+
+ b.HasIndex("PatientId", "TriggeredAt");
+
+ b.HasIndex("Severity", "TriggeredAt")
+ .HasFilter("status = 'OPEN'");
+
+ b.HasIndex("EncounterId", "AlertType", "ObservationCode")
+ .HasFilter("status IN ('OPEN', 'ESCALATED')");
+
+ b.ToTable("clinical_alerts", "clinical", t =>
+ {
+ t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')");
+
+ t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
+
+ t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
+ });
+ });
+
+ modelBuilder.Entity("CoverSheet", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AssignToUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("assign_to_user_id");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("BatchType")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("batch_type");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("GeneratedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("generated_by_user_id");
+
+ b.Property("IsUsed")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_used");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("Track")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("track");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AssignToUserId");
+
+ b.HasIndex("BatchId");
+
+ b.HasIndex("Code")
+ .IsUnique()
+ .HasDatabaseName("ix_cover_sheets_code");
+
+ b.HasIndex("GeneratedByUserId");
+
+ b.HasIndex("PatientId");
+
+ b.HasIndex("IsUsed", "CreatedAt")
+ .HasDatabaseName("ix_cover_sheets_unused")
+ .HasFilter("is_used = false");
+
+ b.ToTable("cover_sheets", (string)null);
+ });
+
+ modelBuilder.Entity("DigitizationBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("approved_by_user_id");
+
+ b.Property("BatchType")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("batch_type");
+
+ b.Property("ClinicianAttestation")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("clinician_attestation");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("DocumentRef")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("document_ref");
+
+ b.Property("DocumentSha256")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("document_sha256");
+
+ b.Property("EnableRetroactiveAlerts")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("enable_retroactive_alerts");
+
+ b.Property("EncounterDraftId")
+ .HasColumnType("uuid")
+ .HasColumnName("encounter_draft_id");
+
+ b.Property("EnteredByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("entered_by_user_id");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("PromotedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("promoted_at");
+
+ b.Property("PromotionEncounterId")
+ .HasColumnType("uuid")
+ .HasColumnName("promotion_encounter_id");
+
+ b.Property("RejectionReason")
+ .HasColumnType("text")
+ .HasColumnName("rejection_reason");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("status")
+ .HasDefaultValueSql("'UPLOADED'");
+
+ b.Property("SupersedesBatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("supersedes_batch_id");
+
+ b.Property("Track")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("track")
+ .HasDefaultValueSql("'BACKFILL'");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("VerifiedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("verified_by_user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovedByUserId");
+
+ b.HasIndex("EnteredByUserId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("SupersedesBatchId")
+ .HasFilter("supersedes_batch_id IS NOT NULL");
+
+ b.HasIndex("VerifiedByUserId");
+
+ b.HasIndex("DocumentSha256", "PatientId", "CreatedAt");
+
+ b.ToTable("digitization_batches", null, t =>
+ {
+ t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
+
+ t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')");
+
+ t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
+ });
+ });
+
+ modelBuilder.Entity("DigitizationEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_user_id");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("event_type");
+
+ b.Property("MetadataJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata_json");
+
+ b.Property("OccurredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserId");
+
+ b.HasIndex("BatchId", "OccurredAt");
+
+ b.ToTable("digitization_events", null, t =>
+ {
+ t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
+ });
+ });
+
+ modelBuilder.Entity("DraftEncounter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AdmissionDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("admission_date");
+
+ b.Property("AdmissionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("admission_reason");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Department")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("department");
+
+ b.Property("DischargeDiagnosis")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("discharge_diagnosis");
+
+ b.Property("RoomBed")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("room_bed");
+
+ b.Property("Status")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.ToTable("draft_encounters", null, t =>
+ {
+ t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
+ });
+ });
+
+ modelBuilder.Entity("DraftObservation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("note");
+
+ b.Property("ObservationCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("observation_code");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("Unit")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("unit");
+
+ b.Property("Value")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("value");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId", "ObservationCode");
+
+ b.ToTable("draft_observations", (string)null);
+ });
+
+ modelBuilder.Entity("DraftPatient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AllergiesJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("allergies_json");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("BloodType")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("blood_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date")
+ .HasColumnName("date_of_birth");
+
+ b.Property("EmergencyContact")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("emergency_contact");
+
+ b.Property("FullName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("full_name");
+
+ b.Property("MedicationsJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("medications_json");
+
+ b.Property("NoActiveMedications")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("no_active_medications");
+
+ b.Property("NoKnownAllergies")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("no_known_allergies");
+
+ b.Property("Sex")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("sex");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.ToTable("draft_patients", null, t =>
+ {
+ t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
+ });
+ });
+
+ modelBuilder.Entity("Encounter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AdmissionDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("admission_date");
+
+ b.Property("AdmissionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("admission_reason");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Department")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("department");
+
+ b.Property("DischargeDiagnosis")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("discharge_diagnosis");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("RoomBed")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("room_bed");
+
+ b.Property("SourceBatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_batch_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PatientId", "Status")
+ .HasDatabaseName("ix_encounters_patient_status");
+
+ b.ToTable("encounters", "clinical", t =>
+ {
+ t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
+ });
+ });
+
+ modelBuilder.Entity("IdempotencyRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("HttpStatusCode")
+ .HasColumnType("integer")
+ .HasColumnName("http_status_code");
+
+ b.Property("IdempotencyKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("idempotency_key");
+
+ b.Property("OperationName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("operation_name");
+
+ b.Property("ResourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("resource_id");
+
+ b.Property("ResponseBodyJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("response_body_json");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_idempotency_records_expires_at");
+
+ b.HasIndex("IdempotencyKey", "OperationName")
+ .IsUnique()
+ .HasDatabaseName("ix_idempotency_records_key_operation");
+
+ b.ToTable("idempotency_records", (string)null);
+ });
+
+ modelBuilder.Entity("LiveEncounter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AdmissionDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("admission_date");
+
+ b.Property("AdmissionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("admission_reason");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Department")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("department");
+
+ b.Property("DischargeDiagnosis")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("discharge_diagnosis");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("RoomBed")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("room_bed");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PatientId", "Status");
+
+ b.ToTable("live_encounters", null, t =>
+ {
+ t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
+ });
+ });
+
+ modelBuilder.Entity("LiveObservation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("EncounterId")
+ .HasColumnType("uuid")
+ .HasColumnName("encounter_id");
+
+ b.Property("IsSuperseded")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("is_superseded");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("note");
+
+ b.Property("ObservationCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("observation_code");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SourceBatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_batch_id");
+
+ b.Property("SupersededAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("superseded_at");
+
+ b.Property("SupersededByBatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("superseded_by_batch_id");
+
+ b.Property("Unit")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("unit");
+
+ b.Property("Value")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("value");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsSuperseded")
+ .HasFilter("is_superseded = true");
+
+ b.HasIndex("SourceBatchId");
+
+ b.HasIndex("EncounterId", "ObservationCode");
+
+ b.ToTable("live_observations", (string)null);
+ });
+
+ modelBuilder.Entity("Observation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("EncounterId")
+ .HasColumnType("uuid")
+ .HasColumnName("encounter_id");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("note");
+
+ b.Property("ObservationCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("observation_code");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("source");
+
+ b.Property("SourceBatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_batch_id");
+
+ b.Property("SourceDraftObservationId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_draft_observation_id");
+
+ b.Property("Unit")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("unit");
+
+ b.Property("Value")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("value");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PatientId");
+
+ b.HasIndex("SourceBatchId")
+ .HasDatabaseName("ix_observations_source_batch")
+ .HasFilter("source_batch_id IS NOT NULL");
+
+ b.HasIndex("EncounterId", "ObservationCode")
+ .HasDatabaseName("ix_observations_encounter_code");
+
+ b.ToTable("observations", "clinical");
+ });
+
+ modelBuilder.Entity("OcrResult", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("DurationMs")
+ .HasColumnType("integer")
+ .HasColumnName("duration_ms");
+
+ b.Property("FieldConfidencesJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("field_confidences_json");
+
+ b.Property("ProcessedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("provider");
+
+ b.Property("RawText")
+ .HasColumnType("text")
+ .HasColumnName("raw_text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.ToTable("ocr_results", (string)null);
+ });
+
+ modelBuilder.Entity("OutboxEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AggregateId")
+ .HasColumnType("uuid")
+ .HasColumnName("aggregate_id");
+
+ b.Property("AggregateType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("aggregate_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("event_type");
+
+ b.Property("PayloadJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload_json");
+
+ b.Property("ProcessedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at");
+
+ b.Property("RetryCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("retry_count");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProcessedAt")
+ .HasDatabaseName("ix_outbox_events_unprocessed")
+ .HasFilter("processed_at IS NULL");
+
+ b.ToTable("outbox_events", "clinical");
+ });
+
+ modelBuilder.Entity("Patient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AllergiesJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("allergies_json");
+
+ b.Property("BloodType")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("blood_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date")
+ .HasColumnName("date_of_birth");
+
+ b.Property("EmergencyContact")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("emergency_contact");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("full_name");
+
+ b.Property("Mrn")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("mrn");
+
+ b.Property("NoKnownAllergies")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("no_known_allergies");
+
+ b.Property("Sex")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("sex");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Mrn")
+ .IsUnique()
+ .HasDatabaseName("ix_patients_mrn");
+
+ b.ToTable("patients", "clinical", t =>
+ {
+ t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
+ });
+ });
+
+ modelBuilder.Entity("PromotionAttempt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AttemptNumber")
+ .HasColumnType("integer")
+ .HasColumnName("attempt_number");
+
+ b.Property("AttemptedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("attempted_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)")
+ .HasColumnName("error_message");
+
+ b.Property("NextRetryAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("next_retry_at");
+
+ b.Property("Succeeded")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("succeeded");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NextRetryAt")
+ .HasDatabaseName("ix_promotion_attempts_pending_retry")
+ .HasFilter("succeeded = false AND next_retry_at IS NOT NULL");
+
+ b.HasIndex("BatchId", "AttemptNumber")
+ .IsUnique()
+ .HasDatabaseName("ix_promotion_attempts_batch_attempt");
+
+ b.ToTable("promotion_attempts", (string)null);
+ });
+
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("ReplacedByTokenId")
+ .HasColumnType("uuid")
+ .HasColumnName("replaced_by_token_id");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReplacedByTokenId");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId", "RevokedAt");
+
+ b.ToTable("refresh_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("ScannedDocument", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("content_type");
+
+ b.Property("FileSizeBytes")
+ .HasColumnType("bigint")
+ .HasColumnName("file_size_bytes");
+
+ b.Property("ObjectKey")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("object_key");
+
+ b.Property("Sha256")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("sha256");
+
+ b.Property("UploadedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("uploaded_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.HasIndex("Sha256");
+
+ b.ToTable("scanned_documents", (string)null);
+ });
+
+ modelBuilder.Entity("User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("is_active");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("password_hash");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("username");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("users", null, t =>
+ {
+ t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
+ });
+ });
+
+ modelBuilder.Entity("AuthAuditEvent", b =>
+ {
+ b.HasOne("User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("CoverSheet", b =>
+ {
+ b.HasOne("User", "AssignToUser")
+ .WithMany()
+ .HasForeignKey("AssignToUserId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithMany()
+ .HasForeignKey("BatchId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("User", "GeneratedByUser")
+ .WithMany()
+ .HasForeignKey("GeneratedByUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Patient", "Patient")
+ .WithMany()
+ .HasForeignKey("PatientId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("AssignToUser");
+
+ b.Navigation("Batch");
+
+ b.Navigation("GeneratedByUser");
+
+ b.Navigation("Patient");
+ });
+
+ modelBuilder.Entity("DigitizationBatch", b =>
+ {
+ b.HasOne("User", "ApprovedByUser")
+ .WithMany()
+ .HasForeignKey("ApprovedByUserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("User", "EnteredByUser")
+ .WithMany()
+ .HasForeignKey("EnteredByUserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("User", "VerifiedByUser")
+ .WithMany()
+ .HasForeignKey("VerifiedByUserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("ApprovedByUser");
+
+ b.Navigation("EnteredByUser");
+
+ b.Navigation("VerifiedByUser");
+ });
+
+ modelBuilder.Entity("DigitizationEvent", b =>
+ {
+ b.HasOne("User", "Actor")
+ .WithMany()
+ .HasForeignKey("ActorUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithMany("Events")
+ .HasForeignKey("BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Actor");
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DraftEncounter", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne("DraftEncounter")
+ .HasForeignKey("DraftEncounter", "BatchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DraftObservation", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithMany("DraftObservations")
+ .HasForeignKey("BatchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DraftPatient", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne("DraftPatient")
+ .HasForeignKey("DraftPatient", "BatchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("Encounter", b =>
+ {
+ b.HasOne("Patient", "Patient")
+ .WithMany()
+ .HasForeignKey("PatientId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Patient");
+ });
+
+ modelBuilder.Entity("LiveEncounter", b =>
+ {
+ b.HasOne("Patient", null)
+ .WithMany()
+ .HasForeignKey("PatientId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("LiveObservation", b =>
+ {
+ b.HasOne("LiveEncounter", "Encounter")
+ .WithMany("Observations")
+ .HasForeignKey("EncounterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Encounter");
+ });
+
+ modelBuilder.Entity("Observation", b =>
+ {
+ b.HasOne("Encounter", "Encounter")
+ .WithMany()
+ .HasForeignKey("EncounterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Patient", "Patient")
+ .WithMany()
+ .HasForeignKey("PatientId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Encounter");
+
+ b.Navigation("Patient");
+ });
+
+ modelBuilder.Entity("OcrResult", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne()
+ .HasForeignKey("OcrResult", "BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("PromotionAttempt", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithMany()
+ .HasForeignKey("BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.HasOne("RefreshToken", "ReplacedByToken")
+ .WithMany()
+ .HasForeignKey("ReplacedByTokenId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ReplacedByToken");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("ScannedDocument", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne("Document")
+ .HasForeignKey("ScannedDocument", "BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DigitizationBatch", b =>
+ {
+ b.Navigation("Document");
+
+ b.Navigation("DraftEncounter");
+
+ b.Navigation("DraftObservations");
+
+ b.Navigation("DraftPatient");
+
+ b.Navigation("Events");
+ });
+
+ modelBuilder.Entity("LiveEncounter", b =>
+ {
+ b.Navigation("Observations");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.cs b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.cs
new file mode 100644
index 0000000..f2adaf2
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.cs
@@ -0,0 +1,69 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace VigilCareRecordsAPI.Data.Migrations
+{
+ ///
+ public partial class AddOcrResult : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropCheckConstraint(
+ name: "chk_digitization_events_event_type",
+ table: "digitization_events");
+
+ migrationBuilder.CreateTable(
+ name: "ocr_results",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
+ batch_id = table.Column(type: "uuid", nullable: false),
+ provider = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ field_confidences_json = table.Column(type: "jsonb", nullable: false),
+ raw_text = table.Column(type: "text", nullable: true),
+ duration_ms = table.Column(type: "integer", nullable: false),
+ processed_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ocr_results", x => x.id);
+ table.ForeignKey(
+ name: "FK_ocr_results_digitization_batches_batch_id",
+ column: x => x.batch_id,
+ principalTable: "digitization_batches",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.AddCheckConstraint(
+ name: "chk_digitization_events_event_type",
+ table: "digitization_events",
+ sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ocr_results_batch_id",
+ table: "ocr_results",
+ column: "batch_id",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ocr_results");
+
+ migrationBuilder.DropCheckConstraint(
+ name: "chk_digitization_events_event_type",
+ table: "digitization_events");
+
+ migrationBuilder.AddCheckConstraint(
+ name: "chk_digitization_events_event_type",
+ table: "digitization_events",
+ sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')");
+ }
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs
index 0292423..99c8b76 100644
--- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -476,7 +476,7 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.ToTable("digitization_events", null, t =>
{
- t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')");
+ t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
});
});
@@ -1007,6 +1007,51 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.ToTable("observations", "clinical");
});
+ modelBuilder.Entity("OcrResult", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("DurationMs")
+ .HasColumnType("integer")
+ .HasColumnName("duration_ms");
+
+ b.Property("FieldConfidencesJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("field_confidences_json");
+
+ b.Property("ProcessedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("provider");
+
+ b.Property("RawText")
+ .HasColumnType("text")
+ .HasColumnName("raw_text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.ToTable("ocr_results", (string)null);
+ });
+
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property("Id")
@@ -1506,6 +1551,17 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("Patient");
});
+ modelBuilder.Entity("OcrResult", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne()
+ .HasForeignKey("OcrResult", "BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
modelBuilder.Entity("PromotionAttempt", b =>
{
b.HasOne("DigitizationBatch", "Batch")
diff --git a/VigilCareRecordsAPI/Domain/Entities/OcrResult.cs b/VigilCareRecordsAPI/Domain/Entities/OcrResult.cs
new file mode 100644
index 0000000..3c28609
--- /dev/null
+++ b/VigilCareRecordsAPI/Domain/Entities/OcrResult.cs
@@ -0,0 +1,12 @@
+public class OcrResult
+{
+ public Guid Id { get; set; }
+ public Guid BatchId { get; set; }
+ public string Provider { get; set; } = null!; // "azure" or "tesseract"
+ public string FieldConfidencesJson { get; set; } = null!; // serialized Dictionary
+ public string? RawText { get; set; }
+ public int DurationMs { get; set; }
+ public DateTimeOffset ProcessedAt { get; set; }
+
+ public DigitizationBatch Batch { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs
index efb1070..3c21f37 100644
--- a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs
+++ b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs
@@ -22,7 +22,10 @@ public enum DigitizationEventType
DocumentAccessed,
Cancelled,
DraftFieldUpdated,
- DraftObservationDeleted
+ DraftObservationDeleted,
+ OcrStarted,
+ OcrCompleted,
+ OcrFailed
}
public static class DigitizationEventTypeExtensions
@@ -52,6 +55,9 @@ public static class DigitizationEventTypeExtensions
DigitizationEventType.Cancelled => "cancelled",
DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
+ DigitizationEventType.OcrStarted => "ocr_started",
+ DigitizationEventType.OcrCompleted => "ocr_completed",
+ DigitizationEventType.OcrFailed => "ocr_failed",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
@@ -80,6 +86,9 @@ public static class DigitizationEventTypeExtensions
"cancelled" => DigitizationEventType.Cancelled,
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
"draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted,
+ "ocr_started" => DigitizationEventType.OcrStarted,
+ "ocr_completed" => DigitizationEventType.OcrCompleted,
+ "ocr_failed" => DigitizationEventType.OcrFailed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
};
}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs b/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs
index 919415d..fa19f74 100644
--- a/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs
+++ b/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs
@@ -2,7 +2,8 @@ public record DraftPayloadResponse(
Guid BatchId,
string Status,
string BatchType,
- BatchTypeFieldRequirements FieldRequirements,
+ BatchTypeFieldRequirements FieldRequirements,
+ OcrConfidenceMap? OcrConfidence,
DraftPatientDto? Patient,
DraftEncounterDto? Encounter,
List Observations
diff --git a/VigilCareRecordsAPI/Models/Records/Batch/OcrConfidenceMap.cs b/VigilCareRecordsAPI/Models/Records/Batch/OcrConfidenceMap.cs
new file mode 100644
index 0000000..f9aeec3
--- /dev/null
+++ b/VigilCareRecordsAPI/Models/Records/Batch/OcrConfidenceMap.cs
@@ -0,0 +1,6 @@
+public record OcrConfidenceMap(
+ string Provider,
+ DateTimeOffset ProcessedAt,
+ int DurationMs,
+ Dictionary FieldConfidences
+);
diff --git a/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractedField.cs b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractedField.cs
new file mode 100644
index 0000000..afaa556
--- /dev/null
+++ b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractedField.cs
@@ -0,0 +1,5 @@
+public record OcrExtractedField(
+ string FieldName, // e.g. "patient.fullName", "observation.HEART_RATE.value"
+ string RawValue, // raw text as extracted
+ double Confidence // 0.0 – 1.0
+);
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractionResult.cs b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractionResult.cs
new file mode 100644
index 0000000..9cbd694
--- /dev/null
+++ b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractionResult.cs
@@ -0,0 +1,5 @@
+public record OcrExtractionResult(
+ List Fields,
+ string RawText,
+ int DurationMs
+);
diff --git a/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs
index b9006ff..146e921 100644
--- a/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs
+++ b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs
@@ -2,29 +2,30 @@
/// Aggregate work queue health metrics for the supervisor dashboard.
/// Returned by GET /api/v1/work-queue/overview.
///
-public record WorkQueueOverviewResponse(
+public record WorkQueueOverviewResponse
+{
///
/// Count of batches per status. Key is the DB status string
/// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION").
/// All 8 statuses are always present, even if count is 0.
///
- Dictionary StatusCounts,
+ public required Dictionary StatusCounts { get; init; }
///
/// Average time in minutes that batches currently in PendingVerification
/// have been waiting. Zero if no batches are pending.
///
- double AverageTimeInQueueMinutes,
+ public required double AverageTimeInQueueMinutes { get; init; }
///
/// Rejection rate as a decimal (0.0 to 1.0). Calculated as
/// rejections / (rejections + verifications) over the last 24 hours.
///
- double RejectRate,
+ public required double RejectRate { get; init; }
///
/// Age in minutes of the oldest batch in PendingVerification status.
/// Zero if no batches are pending.
///
- double OldestPendingVerificationMinutes
-);
\ No newline at end of file
+ public required double OldestPendingVerificationMinutes { get; init; }
+}
diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs
index 688a2f1..be6d931 100644
--- a/VigilCareRecordsAPI/Program.cs
+++ b/VigilCareRecordsAPI/Program.cs
@@ -53,6 +53,23 @@ try
builder.Services.Configure(builder.Configuration.GetSection(FhirOptions.Section));
+ builder.Services.Configure(builder.Configuration.GetSection(OcrOptions.Section));
+
+ var ocrOptions = builder.Configuration.GetSection(OcrOptions.Section).Get();
+
+ if (ocrOptions?.Enabled == true)
+ {
+ builder.Services.AddSingleton();
+
+ if (ocrOptions.Provider == "azure")
+ builder.Services.AddScoped();
+ else
+ builder.Services.AddScoped();
+
+ builder.Services.AddScoped();
+ builder.Services.AddHostedService();
+ }
+
// JWT Authentication
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!;
diff --git a/VigilCareRecordsAPI/Services/DocumentStorageService.cs b/VigilCareRecordsAPI/Services/DocumentStorageService.cs
index 48e3727..53a5023 100644
--- a/VigilCareRecordsAPI/Services/DocumentStorageService.cs
+++ b/VigilCareRecordsAPI/Services/DocumentStorageService.cs
@@ -58,6 +58,18 @@ public class DocumentStorageService : IDocumentStorageService
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
}
+ public async Task