initial commit

This commit is contained in:
voltsrage
2026-06-26 04:20:20 +08:00
commit 869006e5e7
64 changed files with 4632 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
# =========================
# Build results
# =========================
bin/
obj/
out/
publish/
# =========================
# User-specific files
# =========================
*.user
*.rsuser
*.suo
*.userosscache
*.sln.docstates
# =========================
# Logs
# =========================
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# =========================
# Visual Studio Code
# =========================
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# =========================
# Rider / JetBrains
# =========================
.idea/
*.sln.iml
# =========================
# Visual Studio
# =========================
.vs/
# =========================
# OS generated files
# =========================
.DS_Store
Thumbs.db
desktop.ini
# =========================
# Environment files
# =========================
.env
.env.*
!.env.example
# =========================
# ASP.NET / secrets
# =========================
appsettings.Development.json
appsettings.*.local.json
secrets.json
# User Secrets (ASP.NET Core)
secrets/
**/secrets.json
# =========================
# Entity Framework
# =========================
# Migrations should usually be committed (DO NOT ignore)
# But temp files:
*.dbmdl
*.edmx.diagram
# =========================
# NuGet
# =========================
*.nupkg
*.snupkg
packages/
# keep lock file (important for reproducibility)
!packages.lock.json
# =========================
# Node (if using frontend)
# =========================
node_modules/
dist/
build/
data-protection-keys/
# =========================
# Docker
# =========================
docker-compose.override.yml
*.local.yml
# =========================
# Test results
# =========================
TestResults/
coverage/
*.coverage
*.coveragexml
# =========================
# Publish profiles
# =========================
Properties/PublishProfiles/*.pubxml
!Properties/PublishProfiles/*.pubxml.user
# =========================
# Azure / cloud artifacts
# =========================
*.azurePubxml
*.publishsettings
# =========================
# Temporary files
# =========================
*.tmp
*.temp
*.swp
*.bak
*.cache
docs/plans
+22
View File
@@ -0,0 +1,22 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCareRecordsAPI", "VigilCareRecordsAPI\VigilCareRecordsAPI.csproj", "{3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3975B2F5-259E-43BF-B1F3-FA08AF8EF90C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+13
View File
@@ -0,0 +1,13 @@
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
{
public static ApiResponse<T> Ok(T data) =>
new(true, 200, data, null);
public static ApiResponse<T> Created(T data) =>
new(true, 201, data, null);
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
new(false, statusCode, default, new ApiError(message, code));
}
public record ApiError(string Message, string Code);
@@ -0,0 +1,5 @@
public class BadRequestException : DomainException
{
public BadRequestException(string message, string errorCode = "BAD_REQUEST")
: base(message, errorCode) { }
}
@@ -0,0 +1,5 @@
public class ConflictException : DomainException
{
public ConflictException(string message, string errorCode = "CONFLICT_ERROR")
: base(message, errorCode) { }
}
@@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore;
public static class DbExceptions
{
public static bool IsUniqueViolation(DbUpdateException ex) =>
ex.InnerException?.Message.Contains("23505") == true
|| ex.InnerException?.Message.Contains("unique constraint") == true;
}
@@ -0,0 +1,9 @@
public abstract class DomainException : Exception
{
public string ErrorCode { get; }
protected DomainException(string message, string errorCode) : base(message)
{
ErrorCode = errorCode;
}
}
@@ -0,0 +1,5 @@
public class NotFoundException : DomainException
{
public NotFoundException(string message, string errorCode = "NOT_FOUND")
: base(message, errorCode) { }
}
@@ -0,0 +1,5 @@
public class ValidationException : DomainException
{
public ValidationException(string message, string errorCode = "VALIDATION_ERROR")
: base(message, errorCode) { }
}
@@ -0,0 +1,8 @@
public class JwtOptions
{
public const string Section = "Jwt";
public string Secret { get; set; } = null!;
public string Issuer { get; set; } = null!;
public string Audience { get; set; } = null!;
public int ExpiryMinutes { get; set; } = 480;
}
@@ -0,0 +1,10 @@
public class MinioOptions
{
public const string Section = "Minio";
public string Endpoint { get; set; } = null!;
public string AccessKey { get; set; } = null!;
public string SecretKey { get; set; } = null!;
public string BucketName { get; set; } = "scans";
public bool UseSsl { get; set; }
public int PresignedUrlExpiryMinutes { get; set; } = 15;
}
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// JWT authentication and current user info.
/// </summary>
[ApiController]
[Route("api/v1/auth")]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
public AuthController(IAuthService auth) => _auth = auth;
/// <summary>
/// Authenticates a user and returns a JWT with role claims.
/// </summary>
[HttpPost("login")]
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var result = await _auth.LoginAsync(req);
return Ok(ApiResponse<LoginResponse>.Ok(result));
}
/// <summary>
/// Returns the current authenticated user's profile.
/// </summary>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Me()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await _auth.GetCurrentUserAsync(userId);
return Ok(ApiResponse<object>.Ok(new
{
user.Id, user.Username, user.FullName,
role = user.Role.ToDbString()
}));
}
}
@@ -0,0 +1,116 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Batch CRUD, document upload, and assignment for the digitization workflow.
/// </summary>
[ApiController]
[Route("api/v1/digitization-batches")]
[Produces("application/json")]
[Authorize]
public class DigitizationBatchesController : ControllerBase
{
private readonly IBatchService _batches;
private readonly IDocumentStorageService _storage;
private static readonly HashSet<string> _allowedMimeTypes = new()
{
"application/pdf", "image/jpeg", "image/png"
};
public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage)
{
_batches = batches;
_storage = storage;
}
/// <summary>
/// Uploads a scanned document and creates a new digitization batch.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task<IActionResult> Create(
IFormFile file,
[FromForm] CreateBatchRequest req)
{
if (file is null || file.Length == 0)
return BadRequest(ApiResponse<object>.Fail(400, "File is required.", "EMPTY_FILE"));
if (!_allowedMimeTypes.Contains(file.ContentType))
return BadRequest(ApiResponse<object>.Fail(400,
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
var parsedBatchType = BatchTypeExtensions.FromDbString(req.BatchType.ToUpperInvariant());
var parsedTrack = string.IsNullOrEmpty(req.Track)
? BatchTrack.Backfill
: BatchTrackExtensions.FromDbString(req.Track.ToUpperInvariant());
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
using var stream = file.OpenReadStream();
var batch = await _batches.CreateAsync(
stream, file.ContentType, parsedBatchType, parsedTrack, req.PatientId, actorUserId);
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(BatchDetailResponse.FromEntity(batch)));
}
/// <summary>
/// Gets a batch by ID with a presigned document URL (15-minute expiry).
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id)
{
var batch = await _batches.GetByIdAsync(id);
var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef);
return Ok(ApiResponse<BatchDetailResponse>.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl)));
}
/// <summary>
/// Lists batches with optional filters and pagination.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? batchType,
[FromQuery] Guid? assignedTo,
[FromQuery] string? track,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
BatchStatus? parsedStatus = string.IsNullOrEmpty(status) ? null : BatchStatusExtensions.FromDbString(status.ToUpperInvariant());
BatchType? parsedBatchType = string.IsNullOrEmpty(batchType) ? null : BatchTypeExtensions.FromDbString(batchType.ToUpperInvariant());
BatchTrack? parsedTrack = string.IsNullOrEmpty(track) ? null : BatchTrackExtensions.FromDbString(track.ToUpperInvariant());
var result = await _batches.ListAsync(parsedStatus, parsedBatchType, assignedTo, parsedTrack, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
/// <summary>
/// Assigns a batch to an entry clerk. Uses Redis lock to prevent double-assignment.
/// </summary>
[HttpPatch("{id:guid}/assign")]
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Assign(Guid id, [FromBody] AssignBatchRequest req)
{
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var batch = await _batches.AssignAsync(id, req.EntryClerkUserId, actorUserId);
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
}
}
+19
View File
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<DigitizationBatch> DigitizationBatches => Set<DigitizationBatch>();
public DbSet<ScannedDocument> ScannedDocuments => Set<ScannedDocument>();
public DbSet<DraftPatient> DraftPatients => Set<DraftPatient>();
public DbSet<DraftEncounter> DraftEncounters => Set<DraftEncounter>();
public DbSet<DraftObservation> DraftObservations => Set<DraftObservation>();
public DbSet<DigitizationEvent> DigitizationEvents => Set<DigitizationEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DigitizationBatchConfiguration : IEntityTypeConfiguration<DigitizationBatch>
{
public void Configure(EntityTypeBuilder<DigitizationBatch> builder)
{
builder.ToTable("digitization_batches", t =>
{
t.HasCheckConstraint("chk_batches_status",
"status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
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_track",
"track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(b => b.Status)
.HasColumnName("status")
.HasMaxLength(30)
.HasConversion(v => v.ToDbString(), v => BatchStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'UPLOADED'")
.HasSentinel((BatchStatus)(-1));
builder.Property(b => b.BatchType)
.HasColumnName("batch_type")
.HasMaxLength(30)
.HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(b => b.Track)
.HasColumnName("track")
.HasMaxLength(20)
.HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v))
.HasDefaultValueSql("'BACKFILL'")
.HasSentinel((BatchTrack)(-1));
builder.Property(b => b.PatientId).HasColumnName("patient_id");
builder.Property(b => b.EncounterDraftId).HasColumnName("encounter_draft_id");
builder.Property(b => b.DocumentRef).HasColumnName("document_ref").HasMaxLength(500).IsRequired();
builder.Property(b => b.DocumentSha256).HasColumnName("document_sha256").HasMaxLength(64).IsRequired();
builder.Property(b => b.EnableRetroactiveAlerts).HasColumnName("enable_retroactive_alerts").HasDefaultValue(false);
builder.Property(b => b.EnteredByUserId).HasColumnName("entered_by_user_id");
builder.Property(b => b.VerifiedByUserId).HasColumnName("verified_by_user_id");
builder.Property(b => b.ApprovedByUserId).HasColumnName("approved_by_user_id");
builder.Property(b => b.RejectionReason).HasColumnName("rejection_reason");
builder.Property(b => b.PromotedAt).HasColumnName("promoted_at");
builder.Property(b => b.PromotionEncounterId).HasColumnName("promotion_encounter_id");
builder.Property(b => b.SupersedesBatchId).HasColumnName("supersedes_batch_id");
builder.Property(b => b.ClinicianAttestation).HasColumnName("clinician_attestation").HasDefaultValue(false);
builder.Property(b => b.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(b => b.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
builder.HasOne(b => b.EnteredByUser).WithMany().HasForeignKey(b => b.EnteredByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(b => b.VerifiedByUser).WithMany().HasForeignKey(b => b.VerifiedByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(b => b.ApprovedByUser).WithMany().HasForeignKey(b => b.ApprovedByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.DocumentSha256, b.PatientId, b.CreatedAt });
builder.HasIndex(b => b.SupersedesBatchId).HasFilter("supersedes_batch_id IS NOT NULL");
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DigitizationEventConfiguration : IEntityTypeConfiguration<DigitizationEvent>
{
public void Configure(EntityTypeBuilder<DigitizationEvent> builder)
{
builder.ToTable("digitization_events", 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')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(e => e.EventType)
.HasColumnName("event_type")
.HasMaxLength(50)
.HasConversion(
v => v.ToDbString(),
v => DigitizationEventTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(e => e.ActorUserId).HasColumnName("actor_user_id").IsRequired();
builder.Property(e => e.OccurredAt).HasColumnName("occurred_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.MetadataJson).HasColumnName("metadata_json").HasColumnType("jsonb");
builder.HasOne(e => e.Batch).WithMany(b => b.Events).HasForeignKey(e => e.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Actor).WithMany().HasForeignKey(e => e.ActorUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.BatchId, e.OccurredAt });
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DraftEncounterConfiguration : IEntityTypeConfiguration<DraftEncounter>
{
public void Configure(EntityTypeBuilder<DraftEncounter> builder)
{
builder.ToTable("draft_encounters", 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')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(e => e.AdmissionDate).HasColumnName("admission_date");
builder.Property(e => e.Department)
.HasColumnName("department")
.HasMaxLength(100)
.HasConversion(
v => v.HasValue ? v.Value.ToDbString() : null,
v => v == null ? null : DepartmentExtensions.FromDbString(v));
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(50);
builder.Property(e => e.AdmissionReason).HasColumnName("admission_reason").HasMaxLength(500);
builder.Property(e => e.DischargeDiagnosis).HasColumnName("discharge_diagnosis").HasMaxLength(500);
builder.Property(e => e.Status).HasColumnName("status").HasMaxLength(20);
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
builder.HasOne(e => e.Batch).WithOne(b => b.DraftEncounter).HasForeignKey<DraftEncounter>(e => e.BatchId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DraftObservationConfiguration : IEntityTypeConfiguration<DraftObservation>
{
public void Configure(EntityTypeBuilder<DraftObservation> builder)
{
builder.ToTable("draft_observations");
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.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at").IsRequired();
builder.Property(o => o.Note).HasColumnName("note").HasMaxLength(500);
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(o => o.Batch).WithMany(b => b.DraftObservations).HasForeignKey(o => o.BatchId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(o => new { o.BatchId, o.ObservationCode });
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DraftPatientConfiguration : IEntityTypeConfiguration<DraftPatient>
{
public void Configure(EntityTypeBuilder<DraftPatient> builder)
{
builder.ToTable("draft_patients", t =>
{
t.HasCheckConstraint("chk_draft_patients_blood_type",
"blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
});
builder.HasKey(p => p.Id);
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(p => p.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(p => p.FullName).HasColumnName("full_name").HasMaxLength(200);
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
builder.Property(p => p.Sex).HasColumnName("sex").HasMaxLength(10);
builder.Property(p => p.BloodType)
.HasColumnName("blood_type")
.HasMaxLength(10)
.HasConversion(
v => v.HasValue ? v.Value.ToDbString() : null,
v => v == null ? null : BloodTypeExtensions.FromDbString(v));
builder.Property(p => p.EmergencyContact).HasColumnName("emergency_contact").HasMaxLength(500);
builder.Property(p => p.AllergiesJson).HasColumnName("allergies_json").HasColumnType("jsonb");
builder.Property(p => p.NoKnownAllergies).HasColumnName("no_known_allergies").HasDefaultValue(false);
builder.Property(p => p.MedicationsJson).HasColumnName("medications_json").HasColumnType("jsonb");
builder.Property(p => p.NoActiveMedications).HasColumnName("no_active_medications").HasDefaultValue(false);
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(p => p.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
builder.HasOne(p => p.Batch).WithOne(b => b.DraftPatient).HasForeignKey<DraftPatient>(p => p.BatchId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ScannedDocumentConfiguration : IEntityTypeConfiguration<ScannedDocument>
{
public void Configure(EntityTypeBuilder<ScannedDocument> builder)
{
builder.ToTable("scanned_documents");
builder.HasKey(d => d.Id);
builder.Property(d => d.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(d => d.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(d => d.ObjectKey).HasColumnName("object_key").HasMaxLength(500).IsRequired();
builder.Property(d => d.Sha256).HasColumnName("sha256").HasMaxLength(64).IsRequired();
builder.Property(d => d.ContentType).HasColumnName("content_type").HasMaxLength(100).IsRequired();
builder.Property(d => d.FileSizeBytes).HasColumnName("file_size_bytes").IsRequired();
builder.Property(d => d.UploadedAt).HasColumnName("uploaded_at").HasDefaultValueSql("NOW()");
builder.HasOne(d => d.Batch).WithOne(b => b.Document).HasForeignKey<ScannedDocument>(d => d.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(d => d.Sha256);
}
}
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users", t =>
{
t.HasCheckConstraint("chk_users_role",
"role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
builder.HasKey(u => u.Id);
builder.Property(u => u.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(u => u.Username).HasColumnName("username").HasMaxLength(100).IsRequired();
builder.Property(u => u.PasswordHash).HasColumnName("password_hash").HasMaxLength(200).IsRequired();
builder.Property(u => u.FullName).HasColumnName("full_name").HasMaxLength(200).IsRequired();
builder.Property(u => u.Role)
.HasColumnName("role")
.HasMaxLength(30)
.HasConversion(
v => v.ToDbString(),
v => UserRoleExtensions.FromDbString(v))
.IsRequired();
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
builder.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(u => u.Username).IsUnique();
}
}
@@ -0,0 +1,598 @@
// <auto-generated />
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("20260625195830_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
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("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("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')");
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("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')");
});
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("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("ScannedDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("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("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("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");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,316 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
password_hash = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
full_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
role = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.id);
table.CheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
migrationBuilder.CreateTable(
name: "digitization_batches",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false, defaultValueSql: "'UPLOADED'"),
batch_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
track = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'BACKFILL'"),
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
encounter_draft_id = table.Column<Guid>(type: "uuid", nullable: true),
document_ref = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
document_sha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
enable_retroactive_alerts = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
entered_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
verified_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
approved_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
rejection_reason = table.Column<string>(type: "text", nullable: true),
promoted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
promotion_encounter_id = table.Column<Guid>(type: "uuid", nullable: true),
supersedes_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
clinician_attestation = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_digitization_batches", x => x.id);
table.CheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
table.CheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
table.CheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
table.ForeignKey(
name: "FK_digitization_batches_users_approved_by_user_id",
column: x => x.approved_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_digitization_batches_users_entered_by_user_id",
column: x => x.entered_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_digitization_batches_users_verified_by_user_id",
column: x => x.verified_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "digitization_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
event_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
actor_user_id = table.Column<Guid>(type: "uuid", nullable: false),
occurred_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
metadata_json = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_digitization_events", x => x.id);
table.CheckConstraint("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')");
table.ForeignKey(
name: "FK_digitization_events_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_digitization_events_users_actor_user_id",
column: x => x.actor_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "draft_encounters",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
admission_date = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
room_bed = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
admission_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
discharge_diagnosis = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_draft_encounters", x => x.id);
table.CheckConstraint("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')");
table.ForeignKey(
name: "FK_draft_encounters_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "draft_observations",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
note = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_draft_observations", x => x.id);
table.ForeignKey(
name: "FK_draft_observations_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "draft_patients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
full_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
date_of_birth = table.Column<DateOnly>(type: "date", nullable: true),
sex = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
blood_type = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
emergency_contact = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
allergies_json = table.Column<string>(type: "jsonb", nullable: true),
no_known_allergies = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
medications_json = table.Column<string>(type: "jsonb", nullable: true),
no_active_medications = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_draft_patients", x => x.id);
table.CheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
table.ForeignKey(
name: "FK_draft_patients_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "scanned_documents",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
object_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
sha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
content_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
file_size_bytes = table.Column<long>(type: "bigint", nullable: false),
uploaded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_scanned_documents", x => x.id);
table.ForeignKey(
name: "FK_scanned_documents_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_approved_by_user_id",
table: "digitization_batches",
column: "approved_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_document_sha256_patient_id_created_at",
table: "digitization_batches",
columns: new[] { "document_sha256", "patient_id", "created_at" });
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_entered_by_user_id",
table: "digitization_batches",
column: "entered_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_status",
table: "digitization_batches",
column: "status");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_supersedes_batch_id",
table: "digitization_batches",
column: "supersedes_batch_id",
filter: "supersedes_batch_id IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_verified_by_user_id",
table: "digitization_batches",
column: "verified_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_events_actor_user_id",
table: "digitization_events",
column: "actor_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_events_batch_id_occurred_at",
table: "digitization_events",
columns: new[] { "batch_id", "occurred_at" });
migrationBuilder.CreateIndex(
name: "IX_draft_encounters_batch_id",
table: "draft_encounters",
column: "batch_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_draft_observations_batch_id_observation_code",
table: "draft_observations",
columns: new[] { "batch_id", "observation_code" });
migrationBuilder.CreateIndex(
name: "IX_draft_patients_batch_id",
table: "draft_patients",
column: "batch_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_scanned_documents_batch_id",
table: "scanned_documents",
column: "batch_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_scanned_documents_sha256",
table: "scanned_documents",
column: "sha256");
migrationBuilder.CreateIndex(
name: "IX_users_username",
table: "users",
column: "username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "digitization_events");
migrationBuilder.DropTable(
name: "draft_encounters");
migrationBuilder.DropTable(
name: "draft_observations");
migrationBuilder.DropTable(
name: "draft_patients");
migrationBuilder.DropTable(
name: "scanned_documents");
migrationBuilder.DropTable(
name: "digitization_batches");
migrationBuilder.DropTable(
name: "users");
}
}
}
@@ -0,0 +1,598 @@
// <auto-generated />
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("20260625195908_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
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("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("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')");
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("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')");
});
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("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("ScannedDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("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("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("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");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,595 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("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')");
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("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')");
});
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("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("ScannedDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("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("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("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");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
if (await db.Users.AnyAsync()) return;
var users = new[]
{
CreateUser("intake1", "Intake Clerk 1", UserRole.IntakeClerk),
CreateUser("intake2", "Intake Clerk 2", UserRole.IntakeClerk),
CreateUser("entry1", "Entry Clerk 1", UserRole.DataEntryClerk),
CreateUser("entry2", "Entry Clerk 2", UserRole.DataEntryClerk),
CreateUser("verifier1", "Verifier 1", UserRole.Verifier),
CreateUser("verifier2", "Verifier 2", UserRole.Verifier),
CreateUser("approver1", "Clinical Approver 1", UserRole.ClinicalApprover),
CreateUser("approver2", "Clinical Approver 2", UserRole.ClinicalApprover),
CreateUser("clinician1", "Dr. Tanaka", UserRole.Clinician),
CreateUser("clinician2", "Dr. Chen", UserRole.Clinician),
CreateUser("admin1", "Administrator 1", UserRole.Administrator),
CreateUser("admin2", "Administrator 2", UserRole.Administrator),
};
db.Users.AddRange(users);
await db.SaveChangesAsync();
}
private static User CreateUser(string username, string fullName, UserRole role) => new()
{
Id = Guid.NewGuid(),
Username = username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
FullName = fullName,
Role = role,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
}
@@ -0,0 +1,31 @@
public class DigitizationBatch
{
public Guid Id { get; set; }
public BatchStatus Status { get; set; }
public BatchType BatchType { get; set; }
public BatchTrack Track { get; set; }
public Guid? PatientId { get; set; }
public Guid? EncounterDraftId { get; set; }
public string DocumentRef { get; set; } = null!;
public string DocumentSha256 { get; set; } = null!;
public bool EnableRetroactiveAlerts { get; set; }
public Guid? EnteredByUserId { get; set; }
public Guid? VerifiedByUserId { get; set; }
public Guid? ApprovedByUserId { get; set; }
public string? RejectionReason { get; set; }
public DateTimeOffset? PromotedAt { get; set; }
public Guid? PromotionEncounterId { get; set; }
public Guid? SupersedesBatchId { get; set; }
public bool ClinicianAttestation { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public User? EnteredByUser { get; set; }
public User? VerifiedByUser { get; set; }
public User? ApprovedByUser { get; set; }
public ScannedDocument? Document { get; set; }
public DraftPatient? DraftPatient { get; set; }
public DraftEncounter? DraftEncounter { get; set; }
public ICollection<DraftObservation> DraftObservations { get; set; } = new List<DraftObservation>();
public ICollection<DigitizationEvent> Events { get; set; } = new List<DigitizationEvent>();
}
@@ -0,0 +1,12 @@
public class DigitizationEvent
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public DigitizationEventType EventType { get; set; }
public Guid ActorUserId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public string? MetadataJson { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
public User Actor { get; set; } = null!;
}
@@ -0,0 +1,15 @@
public class DraftEncounter
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public DateTimeOffset? AdmissionDate { get; set; }
public Department? Department { get; set; }
public string? RoomBed { get; set; }
public string? AdmissionReason { get; set; }
public string? DischargeDiagnosis { get; set; }
public string? Status { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,13 @@
public class DraftObservation
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string ObservationCode { get; set; } = null!;
public decimal Value { get; set; }
public string Unit { get; set; } = null!;
public DateTimeOffset RecordedAt { get; set; }
public string? Note { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,18 @@
public class DraftPatient
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string? FullName { get; set; }
public DateOnly? DateOfBirth { get; set; }
public string? Sex { get; set; }
public BloodType? BloodType { get; set; }
public string? EmergencyContact { get; set; }
public string? AllergiesJson { get; set; }
public bool NoKnownAllergies { get; set; }
public string? MedicationsJson { get; set; }
public bool NoActiveMedications { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,12 @@
public class ScannedDocument
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string ObjectKey { get; set; } = null!;
public string Sha256 { get; set; } = null!;
public string ContentType { get; set; } = null!;
public long FileSizeBytes { get; set; }
public DateTimeOffset UploadedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,10 @@
public class User
{
public Guid Id { get; set; }
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public string FullName { get; set; } = null!;
public UserRole Role { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,30 @@
public enum BatchStatus { Uploaded, InEntry, PendingVerification, Rejected, Verified, AwaitingClinicalApproval, Approved, Promoted }
public static class BatchStatusExtensions
{
public static string ToDbString(this BatchStatus s) => s switch
{
BatchStatus.Uploaded => "UPLOADED",
BatchStatus.InEntry => "IN_ENTRY",
BatchStatus.PendingVerification => "PENDING_VERIFICATION",
BatchStatus.Rejected => "REJECTED",
BatchStatus.Verified => "VERIFIED",
BatchStatus.AwaitingClinicalApproval => "AWAITING_CLINICAL_APPROVAL",
BatchStatus.Approved => "APPROVED",
BatchStatus.Promoted => "PROMOTED",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static BatchStatus FromDbString(string v) => v switch
{
"UPLOADED" => BatchStatus.Uploaded,
"IN_ENTRY" => BatchStatus.InEntry,
"PENDING_VERIFICATION" => BatchStatus.PendingVerification,
"REJECTED" => BatchStatus.Rejected,
"VERIFIED" => BatchStatus.Verified,
"AWAITING_CLINICAL_APPROVAL" => BatchStatus.AwaitingClinicalApproval,
"APPROVED" => BatchStatus.Approved,
"PROMOTED" => BatchStatus.Promoted,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch status: '{v}'")
};
}
@@ -0,0 +1,18 @@
public enum BatchTrack { Backfill, LiveCapture }
public static class BatchTrackExtensions
{
public static string ToDbString(this BatchTrack t) => t switch
{
BatchTrack.Backfill => "BACKFILL",
BatchTrack.LiveCapture => "LIVE_CAPTURE",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static BatchTrack FromDbString(string v) => v switch
{
"BACKFILL" => BatchTrack.Backfill,
"LIVE_CAPTURE" => BatchTrack.LiveCapture,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch track: '{v}'")
};
}
@@ -0,0 +1,28 @@
public enum BatchType { PatientRegistration, EncounterSummary, VitalsSheet, LabResults, MedicationList, AllergyUpdate, Mixed }
public static class BatchTypeExtensions
{
public static string ToDbString(this BatchType t) => t switch
{
BatchType.PatientRegistration => "PATIENT_REGISTRATION",
BatchType.EncounterSummary => "ENCOUNTER_SUMMARY",
BatchType.VitalsSheet => "VITALS_SHEET",
BatchType.LabResults => "LAB_RESULTS",
BatchType.MedicationList => "MEDICATION_LIST",
BatchType.AllergyUpdate => "ALLERGY_UPDATE",
BatchType.Mixed => "MIXED",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static BatchType FromDbString(string v) => v switch
{
"PATIENT_REGISTRATION" => BatchType.PatientRegistration,
"ENCOUNTER_SUMMARY" => BatchType.EncounterSummary,
"VITALS_SHEET" => BatchType.VitalsSheet,
"LAB_RESULTS" => BatchType.LabResults,
"MEDICATION_LIST" => BatchType.MedicationList,
"ALLERGY_UPDATE" => BatchType.AllergyUpdate,
"MIXED" => BatchType.Mixed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch type: '{v}'")
};
}
@@ -0,0 +1,37 @@
public enum BloodType { APos, ANeg, BPos, BNeg, AbPos, AbNeg, OPos, ONeg }
public static class BloodTypeExtensions
{
public static string ToDbString(this BloodType t) => t switch
{
BloodType.APos => "A+",
BloodType.ANeg => "A-",
BloodType.BPos => "B+",
BloodType.BNeg => "B-",
BloodType.AbPos => "AB+",
BloodType.AbNeg => "AB-",
BloodType.OPos => "O+",
BloodType.ONeg => "O-",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static BloodType FromDbString(string v) => v switch
{
"A+" => BloodType.APos,
"A-" => BloodType.ANeg,
"B+" => BloodType.BPos,
"B-" => BloodType.BNeg,
"AB+" => BloodType.AbPos,
"AB-" => BloodType.AbNeg,
"O+" => BloodType.OPos,
"O-" => BloodType.ONeg,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown blood type: '{v}'")
};
public static bool TryFromDbString(string? v, out BloodType result)
{
if (v is null) { result = default; return false; }
try { result = FromDbString(v); return true; }
catch (ArgumentOutOfRangeException) { result = default; return false; }
}
}
@@ -0,0 +1,83 @@
public enum Department
{
EmergencyDepartment,
InternalMedicine,
GeneralMedicine,
Surgery,
Icu,
Nicu,
MedSurg,
OutpatientClinic,
Pediatrics,
ObstetricsGynecology,
LaborAndDelivery,
Cardiology,
Orthopedics,
Neurology,
Oncology,
Radiology,
Laboratory,
Psychiatry,
PhysicalTherapy,
Anesthesiology
}
public static class DepartmentExtensions
{
public static string ToDbString(this Department d) => d switch
{
Department.EmergencyDepartment => "Emergency Department",
Department.InternalMedicine => "Internal Medicine",
Department.GeneralMedicine => "General Medicine",
Department.Surgery => "Surgery",
Department.Icu => "ICU",
Department.Nicu => "NICU",
Department.MedSurg => "Medical-Surgical",
Department.OutpatientClinic => "Outpatient Clinic",
Department.Pediatrics => "Pediatrics",
Department.ObstetricsGynecology => "Obstetrics & Gynecology",
Department.LaborAndDelivery => "Labor & Delivery",
Department.Cardiology => "Cardiology",
Department.Orthopedics => "Orthopedics",
Department.Neurology => "Neurology",
Department.Oncology => "Oncology",
Department.Radiology => "Radiology",
Department.Laboratory => "Laboratory",
Department.Psychiatry => "Psychiatry",
Department.PhysicalTherapy => "Physical Therapy",
Department.Anesthesiology => "Anesthesiology",
_ => throw new ArgumentOutOfRangeException(nameof(d))
};
public static Department FromDbString(string v) => v switch
{
"Emergency Department" => Department.EmergencyDepartment,
"Internal Medicine" => Department.InternalMedicine,
"General Medicine" => Department.GeneralMedicine,
"Surgery" => Department.Surgery,
"ICU" => Department.Icu,
"NICU" => Department.Nicu,
"Medical-Surgical" => Department.MedSurg,
"Outpatient Clinic" => Department.OutpatientClinic,
"Pediatrics" => Department.Pediatrics,
"Obstetrics & Gynecology" => Department.ObstetricsGynecology,
"Labor & Delivery" => Department.LaborAndDelivery,
"Cardiology" => Department.Cardiology,
"Orthopedics" => Department.Orthopedics,
"Neurology" => Department.Neurology,
"Oncology" => Department.Oncology,
"Radiology" => Department.Radiology,
"Laboratory" => Department.Laboratory,
"Psychiatry" => Department.Psychiatry,
"Physical Therapy" => Department.PhysicalTherapy,
"Anesthesiology" => Department.Anesthesiology,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown department: '{v}'")
};
public static bool TryFromDbString(string? v, out Department result)
{
if (v is null) { result = default; return false; }
try { result = FromDbString(v); return true; }
catch (ArgumentOutOfRangeException) { result = default; return false; }
}
}
@@ -0,0 +1,73 @@
public enum DigitizationEventType
{
Uploaded,
EntryStarted,
SubmittedForVerification,
Rejected,
Verified,
VerifiedPendingClinical,
AwaitingClinicalApproval,
Approved,
Promoted,
LiveCaptureAttested,
CorrectionRequested,
VerificationFailed,
Superseded,
CorrectionPromoted,
CorrectionUploaded,
PromotionRetrySucceeded,
PromotionRetryFailed,
PromotionRetryExhausted,
PromotionFailed
}
public static class DigitizationEventTypeExtensions
{
public static string ToDbString(this DigitizationEventType t) => t switch
{
DigitizationEventType.Uploaded => "uploaded",
DigitizationEventType.EntryStarted => "entry_started",
DigitizationEventType.SubmittedForVerification => "submitted_for_verification",
DigitizationEventType.Rejected => "rejected",
DigitizationEventType.Verified => "verified",
DigitizationEventType.VerifiedPendingClinical => "verified_pending_clinical",
DigitizationEventType.AwaitingClinicalApproval => "awaiting_clinical_approval",
DigitizationEventType.Approved => "approved",
DigitizationEventType.Promoted => "promoted",
DigitizationEventType.LiveCaptureAttested => "live_capture_attested",
DigitizationEventType.CorrectionRequested => "correction_requested",
DigitizationEventType.VerificationFailed => "verification_failed",
DigitizationEventType.Superseded => "superseded",
DigitizationEventType.CorrectionPromoted => "correction_promoted",
DigitizationEventType.CorrectionUploaded => "correction_uploaded",
DigitizationEventType.PromotionRetrySucceeded => "promotion_retry_succeeded",
DigitizationEventType.PromotionRetryFailed => "promotion_retry_failed",
DigitizationEventType.PromotionRetryExhausted => "promotion_retry_exhausted",
DigitizationEventType.PromotionFailed => "promotion_failed",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static DigitizationEventType FromDbString(string v) => v switch
{
"uploaded" => DigitizationEventType.Uploaded,
"entry_started" => DigitizationEventType.EntryStarted,
"submitted_for_verification" => DigitizationEventType.SubmittedForVerification,
"rejected" => DigitizationEventType.Rejected,
"verified" => DigitizationEventType.Verified,
"verified_pending_clinical" => DigitizationEventType.VerifiedPendingClinical,
"awaiting_clinical_approval" => DigitizationEventType.AwaitingClinicalApproval,
"approved" => DigitizationEventType.Approved,
"promoted" => DigitizationEventType.Promoted,
"live_capture_attested" => DigitizationEventType.LiveCaptureAttested,
"correction_requested" => DigitizationEventType.CorrectionRequested,
"verification_failed" => DigitizationEventType.VerificationFailed,
"superseded" => DigitizationEventType.Superseded,
"correction_promoted" => DigitizationEventType.CorrectionPromoted,
"correction_uploaded" => DigitizationEventType.CorrectionUploaded,
"promotion_retry_succeeded" => DigitizationEventType.PromotionRetrySucceeded,
"promotion_retry_failed" => DigitizationEventType.PromotionRetryFailed,
"promotion_retry_exhausted" => DigitizationEventType.PromotionRetryExhausted,
"promotion_failed" => DigitizationEventType.PromotionFailed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
};
}
@@ -0,0 +1,26 @@
public enum UserRole { IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator }
public static class UserRoleExtensions
{
public static string ToDbString(this UserRole r) => r switch
{
UserRole.IntakeClerk => "INTAKE_CLERK",
UserRole.DataEntryClerk => "DATA_ENTRY_CLERK",
UserRole.Verifier => "VERIFIER",
UserRole.ClinicalApprover => "CLINICAL_APPROVER",
UserRole.Clinician => "CLINICIAN",
UserRole.Administrator => "ADMINISTRATOR",
_ => throw new ArgumentOutOfRangeException(nameof(r))
};
public static UserRole FromDbString(string v) => v switch
{
"INTAKE_CLERK" => UserRole.IntakeClerk,
"DATA_ENTRY_CLERK" => UserRole.DataEntryClerk,
"VERIFIER" => UserRole.Verifier,
"CLINICAL_APPROVER" => UserRole.ClinicalApprover,
"CLINICIAN" => UserRole.Clinician,
"ADMINISTRATOR" => UserRole.Administrator,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown user role: '{v}'")
};
}
@@ -0,0 +1,23 @@
using Serilog.Context;
public sealed class CorrelationIdMiddleware
{
private const string Header = "X-Correlation-Id";
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
var correlationId = ctx.Request.Headers[Header].FirstOrDefault()
?? Guid.NewGuid().ToString();
ctx.Response.Headers[Header] = correlationId;
ctx.Items["CorrelationId"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(ctx);
}
}
}
@@ -0,0 +1,50 @@
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (NotFoundException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status404NotFound,
ApiResponse<object>.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode));
}
catch (ValidationException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status422UnprocessableEntity,
ApiResponse<object>.Fail(StatusCodes.Status422UnprocessableEntity, ex.Message, ex.ErrorCode));
}
catch (ConflictException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status409Conflict,
ApiResponse<object>.Fail(StatusCodes.Status409Conflict, ex.Message, ex.ErrorCode));
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await WriteAsync(context, StatusCodes.Status500InternalServerError,
ApiResponse<object>.Fail(StatusCodes.Status500InternalServerError,
"An unexpected error occurred", "INTERNAL_ERROR"));
}
}
private static async Task WriteAsync<T>(HttpContext context, int status, ApiResponse<T> body)
{
context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(body);
}
}
@@ -0,0 +1 @@
public record LoginRequest(string Username, string Password);
@@ -0,0 +1 @@
public record LoginResponse(string Token, Guid UserId, string Username, string FullName, string Role);
@@ -0,0 +1 @@
public record AssignBatchRequest(Guid EntryClerkUserId);
@@ -0,0 +1,47 @@
/// <summary>
/// API representation of a digitization batch. Hides EF navigation properties
/// and exposes enum fields as DB string literals.
/// </summary>
public record BatchDetailResponse(
Guid Id,
string Status,
string BatchType,
string Track,
Guid? PatientId,
string DocumentRef,
string? DocumentUrl,
bool EnableRetroactiveAlerts,
Guid? EnteredByUserId,
Guid? VerifiedByUserId,
Guid? ApprovedByUserId,
string? RejectionReason,
DateTimeOffset? PromotedAt,
Guid? PromotionEncounterId,
Guid? SupersedesBatchId,
bool ClinicianAttestation,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
)
{
public static BatchDetailResponse FromEntity(DigitizationBatch batch, string? documentUrl = null) =>
new(
batch.Id,
batch.Status.ToDbString(),
batch.BatchType.ToDbString(),
batch.Track.ToDbString(),
batch.PatientId,
batch.DocumentRef,
documentUrl,
batch.EnableRetroactiveAlerts,
batch.EnteredByUserId,
batch.VerifiedByUserId,
batch.ApprovedByUserId,
batch.RejectionReason,
batch.PromotedAt,
batch.PromotionEncounterId,
batch.SupersedesBatchId,
batch.ClinicianAttestation,
batch.CreatedAt,
batch.UpdatedAt
);
}
@@ -0,0 +1,8 @@
/// <summary>
/// Multipart form metadata for batch creation (file is bound separately as <c>IFormFile</c>).
/// </summary>
public record CreateBatchRequest(
string BatchType,
string? Track,
Guid? PatientId
);
@@ -0,0 +1,9 @@
public record PagedResult<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount
)
{
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
}
+94
View File
@@ -0,0 +1,94 @@
using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Minio;
using Serilog;
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
// Configuration
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.Section));
// Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// MinIO
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!;
builder.Services.AddSingleton<IMinioClient>(new MinioClient()
.WithEndpoint(minioOptions.Endpoint)
.WithCredentials(minioOptions.AccessKey, minioOptions.SecretKey)
.WithSSL(minioOptions.UseSsl)
.Build());
// JWT Authentication
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret))
};
});
builder.Services.AddAuthorization();
// Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IBatchService, BatchService>();
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
});
var app = builder.Build();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<ExceptionHandlerMiddleware>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await DataSeeder.SeedAsync(db);
}
}
catch (System.Exception)
{
throw;
}
public partial class Program { }
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:34859",
"sslPort": 44301
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7223;http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,62 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly JwtOptions _jwtOptions;
public AuthService(AppDbContext db, IOptions<JwtOptions> jwtOptions)
{
_db = db;
_jwtOptions = jwtOptions.Value;
}
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == req.Username);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS");
if (!user.IsActive)
throw new ConflictException("Account is disabled.", "ACCOUNT_DISABLED");
var token = GenerateJwt(user);
return new LoginResponse(token, user.Id, user.Username, user.FullName, user.Role.ToDbString());
}
public async Task<User> GetCurrentUserAsync(Guid userId)
{
var user = await _db.Users.FindAsync(userId);
if (user is null)
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
return user;
}
private string GenerateJwt(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role.ToDbString()),
new Claim("fullName", user.FullName)
};
var token = new JwtSecurityToken(
issuer: _jwtOptions.Issuer,
audience: _jwtOptions.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwtOptions.ExpiryMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,182 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class BatchService : IBatchService
{
private static readonly Dictionary<BatchStatus, HashSet<BatchStatus>> _allowedTransitions = new()
{
[BatchStatus.Uploaded] = new() { BatchStatus.InEntry },
[BatchStatus.InEntry] = new() { BatchStatus.PendingVerification },
[BatchStatus.PendingVerification] = new() { BatchStatus.Verified, BatchStatus.Rejected, BatchStatus.AwaitingClinicalApproval },
[BatchStatus.Rejected] = new() { BatchStatus.InEntry },
[BatchStatus.Verified] = new() { BatchStatus.Approved },
[BatchStatus.AwaitingClinicalApproval] = new() { BatchStatus.Approved, BatchStatus.Rejected },
[BatchStatus.Approved] = new() { BatchStatus.Promoted },
[BatchStatus.Promoted] = new(),
};
private readonly AppDbContext _db;
private readonly IDocumentStorageService _storage;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<BatchService> _logger;
public BatchService(AppDbContext db, IDocumentStorageService storage,
IConnectionMultiplexer redis, ILogger<BatchService> logger)
{
_db = db;
_storage = storage;
_redis = redis;
_logger = logger;
}
public async Task<DigitizationBatch> CreateAsync(
Stream fileStream, string contentType, BatchType batchType,
BatchTrack track, Guid? patientId, Guid actorUserId)
{
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid());
// Duplicate detection: same SHA-256 for same patient within 24 hours.
// Cross-patient duplicates (same form scanned for two patients) are allowed.
if (patientId.HasValue)
{
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
var duplicate = await _db.DigitizationBatches.AnyAsync(b =>
b.DocumentSha256 == sha256 &&
b.PatientId == patientId.Value &&
b.CreatedAt >= cutoff);
if (duplicate)
throw new ConflictException(
"A document with the same content was uploaded for this patient within the last 24 hours.",
"DUPLICATE_DOCUMENT");
}
var batchId = Guid.NewGuid();
var batch = new DigitizationBatch
{
Id = batchId,
Status = BatchStatus.Uploaded,
BatchType = batchType,
Track = track,
PatientId = patientId,
DocumentRef = objectKey,
DocumentSha256 = sha256,
EnableRetroactiveAlerts = false,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var document = new ScannedDocument
{
Id = Guid.NewGuid(),
BatchId = batchId,
ObjectKey = objectKey,
Sha256 = sha256,
ContentType = contentType,
FileSizeBytes = fileSize,
UploadedAt = DateTimeOffset.UtcNow
};
var evt = new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Uploaded,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow
};
_db.DigitizationBatches.Add(batch);
_db.ScannedDocuments.Add(document);
_db.DigitizationEvents.Add(evt);
await _db.SaveChangesAsync();
_logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey);
return batch;
}
public async Task<DigitizationBatch> GetByIdAsync(Guid id)
{
var batch = await _db.DigitizationBatches
.Include(b => b.Document)
.Include(b => b.DraftPatient)
.Include(b => b.DraftEncounter)
.Include(b => b.DraftObservations)
.FirstOrDefaultAsync(b => b.Id == id);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
return batch;
}
public async Task<PagedResult<DigitizationBatch>> ListAsync(
BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track,
int page, int pageSize)
{
var query = _db.DigitizationBatches.AsQueryable();
if (status.HasValue)
query = query.Where(b => b.Status == status.Value);
if (batchType.HasValue)
query = query.Where(b => b.BatchType == batchType.Value);
if (assignedTo.HasValue)
query = query.Where(b => b.EnteredByUserId == assignedTo.Value);
if (track.HasValue)
query = query.Where(b => b.Track == track.Value);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<DigitizationBatch>(items, page, pageSize, total);
}
public async Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId)
{
var batch = await _db.DigitizationBatches.FindAsync(batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
if (batch.Status != BatchStatus.Uploaded)
throw new ConflictException(
"Only batches in 'uploaded' status can be assigned.",
"ILLEGAL_STATUS_TRANSITION");
// Redis lock to prevent double-assignment
var cache = _redis.GetDatabase();
var lockKey = $"batch:assign:{batchId}";
var acquired = await cache.StringSetAsync(lockKey, entryClerkUserId.ToString(),
TimeSpan.FromHours(1), When.NotExists);
if (!acquired)
throw new ConflictException(
"This batch is already assigned to another clerk.",
"BATCH_ALREADY_ASSIGNED");
batch.EnteredByUserId = entryClerkUserId;
batch.UpdatedAt = DateTimeOffset.UtcNow;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.EntryStarted,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(new { assignedTo = entryClerkUserId })
});
await _db.SaveChangesAsync();
return batch;
}
public BatchStatus[] GetAllowedTransitions(BatchStatus current) =>
_allowedTransitions.TryGetValue(current, out var targets)
? targets.ToArray()
: Array.Empty<BatchStatus>();
}
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
public class DocumentStorageService : IDocumentStorageService
{
private readonly IMinioClient _minio;
private readonly MinioOptions _options;
public DocumentStorageService(IMinioClient minio, IOptions<MinioOptions> options)
{
_minio = minio;
_options = options.Value;
}
public async Task<(string objectKey, string sha256, long fileSize)> UploadAsync(
Stream fileStream, string contentType, Guid batchId)
{
var now = DateTimeOffset.UtcNow;
using var sha256 = SHA256.Create();
using var memStream = new MemoryStream();
await fileStream.CopyToAsync(memStream);
memStream.Position = 0;
var hashBytes = sha256.ComputeHash(memStream);
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
memStream.Position = 0;
var extension = contentType switch
{
"application/pdf" => "pdf",
"image/jpeg" => "jpg",
"image/png" => "png",
_ => "bin"
};
var objectKey = $"scans/{now.Year}/{now.Month:D2}/{batchId}/{hashHex}.{extension}";
await EnsureBucketAsync();
await _minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithStreamData(memStream)
.WithObjectSize(memStream.Length)
.WithContentType(contentType));
return (objectKey, hashHex, memStream.Length);
}
public async Task<string> GetPresignedUrlAsync(string objectKey)
{
return await _minio.PresignedGetObjectAsync(new PresignedGetObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
}
private async Task EnsureBucketAsync()
{
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
if (!exists)
await _minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(_options.BucketName));
}
}
@@ -0,0 +1,5 @@
public interface IAuthService
{
Task<LoginResponse> LoginAsync(LoginRequest req);
Task<User> GetCurrentUserAsync(Guid userId);
}
@@ -0,0 +1,8 @@
public interface IBatchService
{
Task<DigitizationBatch> CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId);
Task<DigitizationBatch> GetByIdAsync(Guid id);
Task<PagedResult<DigitizationBatch>> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize);
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
BatchStatus[] GetAllowedTransitions(BatchStatus current);
}
@@ -0,0 +1,5 @@
public interface IDocumentStorageService
{
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
Task<string> GetPresignedUrlAsync(string objectKey);
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Minio" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@VigilCareRecordsAPI_HostAddress = http://localhost:5217
GET {{VigilCareRecordsAPI_HostAddress}}/weatherforecast/
Accept: application/json
###
+52
View File
@@ -0,0 +1,52 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5437;Database=vigilcare_records;Username=postgres;Password=password"
},
"Redis": {
"ConnectionString": "localhost:6383"
},
"Seq": {
"ServerUrl": "http://localhost:5346"
},
"Minio": {
"Endpoint": "localhost:9012",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin",
"BucketName": "scans",
"UseSsl": false,
"PresignedUrlExpiryMinutes": 15
},
"Jwt": {
"Secret": "VigilCareRecordsDevSecretKeyAtLeast32Chars!",
"Issuer": "VigilCareRecords",
"Audience": "VigilCareRecords",
"ExpiryMinutes": 480
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5346"
}
}
],
"Enrich": [ "FromLogContext" ]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
+54
View File
@@ -0,0 +1,54 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: vigilcare_records
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5437:5432"
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- vigilcare
redis:
image: redis:7-alpine
ports:
- "6383:6379"
networks:
- vigilcare
seq:
image: datalust/seq:latest
environment:
ACCEPT_EULA: "Y"
ports:
- "5346:80"
volumes:
- seq_data:/data
networks:
- vigilcare
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9012:9000"
- "9013:9001"
volumes:
- minio_data:/data
networks:
- vigilcare
volumes:
pg_data:
seq_data:
minio_data:
networks:
vigilcare:
driver: bridge
+680
View File
@@ -0,0 +1,680 @@
# PRD: VigilCare Records — Paper Chart Digitization & Approval Platform
## Overview
A clinical records intake system that converts paper-based patient charts into structured, human-verified digital records before they enter the VigilCareClinical alerting pipeline. Designed for small hospitals, district clinics, and island health systems where the majority of historical and day-to-day records still exist on paper.
The workflow is deliberately manual at every extraction step:
**Scan / upload → Human data entry → Human verification → Approved patient record**
There is **no OCR** in scope. Every structured field is typed by a human who reads the scan. A second human compares the entry against the original image before the record becomes clinically authoritative. Unapproved drafts never trigger alerts, scoring, or surveillance.
VigilCareClinical ([vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md)) remains the downstream intelligence layer: threshold alerting, NEWS2, sepsis detection, ward dashboard, and long-term archival. VigilCare Records is the **precursor** that creates the fuel — patient identity, encounters, and observations — in a governed, auditable way.
This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events), `sd-mid-013` (CQRS — draft vs live read models), `sd-junior-004` (RBAC), and senior trade-off conversations around **data quality gates vs time-to-value** in resource-constrained health systems.
**Stack:** .NET 8 Web API, PostgreSQL, MinIO (scanned document storage), Redis (work-queue assignment locks), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI (separate repo or `VigilCare.Records.Web` project).
**Prerequisite / companion:** VigilCareClinicalAPI Phases 12 minimum (patient, encounter, observation ingest). Full VigilCare value unlocks as approved observations flow into the existing Kafka alert pipeline.
---
## Goals
- Provide a complete scan-to-approved workflow for paper chart conversion without OCR or machine extraction
- Enforce **separation of duties**: the person who enters data cannot verify their own entry
- Promote approved structured data into VigilCareClinical's live domain model (Patient, Encounter, Observation) atomically
- Maintain a full audit trail linking every approved field to its source scan, entry clerk, verifier, and approver
- Support two operational modes: **Track A** (historical backfill with full dual-human gate) and **Track B** (live bedside capture with clinician attestation, lighter gate)
- Produce a deployable precursor that makes VigilCareClinical credible in paper-only facilities — not as a standalone EMR replacement
## Non-Goals
- **OCR or automated field extraction** — explicitly out of scope for v1; may be evaluated in a future phase after human-verified baseline quality is established
- HL7/FHIR compliance or LIS instrument integration
- Full EMR functionality (billing, pharmacy inventory, scheduling beyond basic encounter open)
- Replacing VigilCareClinical's alert engine, scoring, or ward dashboard
- HIPAA-certified or jurisdiction-specific medical device registration (model the patterns; certification scoped per deployment)
- Multi-facility federated identity across islands (single-tenant deployment per site in v1)
---
## Problem Statement
VigilCareClinical assumes structured, timestamped clinical data already exists. In paper-based facilities:
1. Patient identity lives in folders, ward books, or duplicate index cards — no stable MRN workflow
2. Vitals and lab results are handwritten — illegible, untimestamped at minute precision, or lost between visits
3. There is no encounter boundary — "the patient" is not the same as "this admission" or "this clinic visit"
4. Clinicians cannot trust machine-generated alerts if the underlying values were guessed from poor handwriting
Without a digitization and approval layer, VigilCare has nothing to observe. With it, even a 20-bed district hospital can convert charts incrementally and activate real-time alerting as live capture replaces paper forms.
---
## Relationship to VigilCareClinical
```
┌─────────────────────────────────────────────────────────────────────┐
│ VigilCare Records (this PRD) │
│ │
│ Scan → Entry → Verify → Approve │
│ ↓ │
│ Draft tables (never alert) │
│ ↓ on approval │
│ Promotion service ──────────────────────────────────────────────┐ │
└──────────────────────────────────────────────────────────────────│──┘
┌──────────────────────────────────────────────────────────────────▼──┐
│ VigilCareClinicalAPI │
│ │
│ Patient → Encounter → Observation → Outbox → Kafka → Alerts │
└─────────────────────────────────────────────────────────────────────┘
```
**Invariant:** Draft observations are invisible to VigilCareClinical's alert engine, Elasticsearch ward projection, and scoring consumers. Only batches that have passed approval are promoted to live tables. Promotion is idempotent — re-running an approval job for the same batch produces no duplicate live rows.
**Track A (backfill):** Full pipeline — scan, entry, verification, approval. Historical vitals and labs enter as observations with `recordedAt` taken from the chart (not scan time). Alerts on backfilled critical values are **suppressed by default** unless the facility explicitly opts in per batch (`enableRetroactiveAlerts: false` default).
**Track B (live capture):** Credentialed clinician enters vitals at bedside. Skips verification queue; requires `clinicianAttestation: true` on submit. Observations promote immediately to VigilCareClinical with `source: live_capture`. Every live-capture submission still creates a `DigitizationBatch` (audit unit) with draft observations, `DigitizationEvent` entries, and live rows in a single transaction — see [Track B audit model](#track-b-audit-model).
**Deployment model (v1):** Integrated PostgreSQL — Records draft tables and VigilCareClinical live tables (`patients`, `encounters`, `observations`, `outbox_events`) share one database instance (separate schemas). Promotion runs in a single local transaction. Split deployment with HTTP + saga retry is documented as a future deployment option, not the portfolio default.
---
## API Conventions
Same response envelope as VigilCareClinical and other portfolio projects. Prefix: `/api/v1`.
**Success:**
```json
{
"success": true,
"statusCode": 200,
"data": {},
"error": null
}
```
**Error:**
```json
{
"success": false,
"statusCode": 422,
"data": null,
"error": {
"message": "Verifier cannot approve a batch they entered.",
"code": "SEPARATION_OF_DUTIES_VIOLATION"
}
}
```
**Pagination:** Work queues and batch lists use offset pagination (`?page=1&pageSize=20`). Audit event history uses cursor pagination on `(occurred_at DESC, id DESC)`.
**Idempotency:** `POST /api/v1/digitization-batches/:id/approve` accepts an `Idempotency-Key` header. Duplicate approval requests return the same promotion result without creating duplicate live observations in VigilCareClinical.
---
## Roles and Permissions
| Role | Permissions |
|---|---|
| **Intake clerk** | Upload scans, create batches, assign patient (existing MRN or new registration draft) |
| **Data entry clerk** | Edit draft fields on batches in `uploaded` or `rejected` state; submit for verification |
| **Verifier** | Review batches in `pending_verification`; field-level pass/fail; reject with reason; cannot verify batches they entered |
| **Clinical approver** | Final promotion trigger via `POST .../approve` for batches in `verified` or `awaiting_clinical_approval`; cannot approve batches they entered |
| **Clinician** | Track B live capture; attestation on own entries (attestation satisfies verify + approve for that batch only) |
| **Administrator** | User management, batch type config, retroactive alert policy, work-queue reassignment |
### Verification vs approval (Track A)
Track A uses **two distinct human gates** before promotion:
1. **Verifier** — compares structured draft fields against the scan (`POST .../verify` or `POST .../reject`). This is the dual-human data-quality check.
2. **Clinical approver** — authorizes promotion (`POST .../approve`), which creates live clinical records. Every Track A batch requires this step regardless of batch type.
**Optional third gate:** For high-stakes `batchType` values, site configuration routes verify-pass batches to `awaiting_clinical_approval` instead of `verified`. The clinical approver then reviews after the verifier before promotion. See [Site configuration](#site-configuration) below.
| After verifier pass | Next status | Who calls `approve` |
|---|---|---|
| Site config: clinical sign-off **not** required for this `batchType` | `verified` | Clinical approver (or administrator) |
| Site config: clinical sign-off **required** for this `batchType` | `awaiting_clinical_approval` | Clinical approver (or administrator) |
Separation of duties is enforced at the service layer, not only in the UI. `enteredByUserId === currentUserId` blocks verify and approve actions with `409 SEPARATION_OF_DUTIES_VIOLATION`. Verifiers never call `approve`; clinical approvers never call `verify`.
### Site configuration
Per-site `ClinicalApprovalRequired` maps `batchType` to whether verify-pass routes to `awaiting_clinical_approval` (physician queue) vs `verified` (ready for immediate approver sign-off). Default portfolio configuration:
| batchType | Clinical sign-off after verify? |
|---|---|
| `patient_registration` | No |
| `allergy_update` | No |
| `encounter_summary` | Yes |
| `vitals_sheet` | Yes |
| `lab_results` | Yes |
| `medication_list` | Yes |
| `mixed` | Yes |
Administrators may override this map per deployment in `appsettings.json` (`SiteConfig.ClinicalApprovalRequired`).
---
## Domain Model
### DigitizationBatch
The unit of work for one digitization effort — typically one scanned document or one logical chart section (vitals sheet, lab report, admission face sheet).
| Field | Description |
|---|---|
| `id` | UUID |
| `status` | See state machine below |
| `batchType` | `patient_registration`, `encounter_summary`, `vitals_sheet`, `lab_results`, `medication_list`, `allergy_update`, `mixed` |
| `patientId` | Nullable until linked; may reference draft or live patient |
| `encounterDraftId` | Nullable; encounter context for vitals/labs |
| `documentRef` | MinIO object key for the scanned PDF/image |
| `documentSha256` | Content hash for integrity verification |
| `track` | `backfill` (Track A) or `live_capture` (Track B) |
| `enableRetroactiveAlerts` | Default `false`; if `true` on approval, promoted observations participate in alerting |
| `enteredByUserId` | Set on first draft save |
| `verifiedByUserId` | Set on verification pass |
| `approvedByUserId` | Set on final approval |
| `rejectionReason` | Nullable; required when status → `rejected` |
| `promotedAt` | Nullable; timestamp when live records created |
| `promotionEncounterId` | VigilCareClinical encounter ID after promotion |
| `supersedesBatchId` | Nullable; links a correction batch to the batch it replaces |
| `clinicianAttestation` | `true` for Track B batches attested at bedside |
### DraftPatient / DraftPatientUpdate
Structured patient fields extracted from paper — demographics, allergies, blood type, emergency contact, medications (for `medication_list` batches). On approval of a `patient_registration` or `allergy_update` batch, merges into VigilCareClinical `Patient` (create or patch). `medicationsJson` is stored on the digitization audit record in v1; medication rows are not promoted to a pharmacy module (out of scope).
### DraftEncounter
A clinical episode extracted from the chart: admission date, department, room/bed, admission reason, discharge diagnosis (if applicable). Promotes to VigilCareClinical `Encounter`.
### DraftObservation
A single measurable value: observation code, numeric value, unit, `recordedAt` (from chart, required), optional note. Subject to the same plausibility ranges as VigilCareClinical ingest. Never written to live `observations` until batch approval.
### DigitizationEvent
Append-only audit log entry for every state transition and field-level correction.
```json
{
"id": "uuid",
"batchId": "uuid",
"eventType": "uploaded | entry_started | submitted_for_verification | rejected | verified | awaiting_clinical_approval | approved | promoted | live_capture_attested | correction_requested",
"actorUserId": "uuid",
"occurredAt": "2026-06-22T14:30:00Z",
"metadata": {
"rejectionReason": "SpO2 value unclear on scan — decimal ambiguous",
"fieldsChanged": ["observations[2].value"]
}
}
```
### ScannedDocument
Stored in MinIO. Original paper is the legal source; the scan is the working reference for entry and verification. Retention: minimum 7 years (configurable per jurisdiction). Scanned documents are **never deleted** when a batch is rejected — only the draft is returned for correction.
---
## Batch Status State Machine
```
┌──────────────┐
│ uploaded │
└──────┬───────┘
│ assign / first save
┌──────────────┐
┌──────────│ in_entry │◄─────────┐
│ └──────┬───────┘ │
│ │ submit │ reject
│ ▼ │
│ ┌──────────────┐ │
│ │ pending │─────────┘
│ │ verification │
│ └──────┬───────┘
│ │
│ verify fail │ verify pass
│ ─────────┤
│ │
│ site config │ site config
│ = false │ = true
│ ┌────────┴────────┐
│ ▼ ▼
│ ┌──────────────┐ ┌──────────────────────┐
│ │ verified │ │ awaiting_clinical │
│ └──────┬───────┘ │ _approval │
│ │ └──────────┬─────────────┘
│ └──────────┬──────────┘
│ │ clinical approver: approve
│ ▼
│ ┌──────────────┐
└────────────►│ approved │
└──────┬───────┘
│ promotion (sync or retry job)
┌──────────────┐
│ promoted │ (terminal — live records exist)
└──────────────┘
Track B shortcut: live_capture creates a batch already in promoted — see Track B audit model.
```
**Allowed transitions (Track A):**
| From | To |
|---|---|
| `uploaded` | `in_entry` |
| `in_entry` | `pending_verification` |
| `pending_verification` | `verified`, `awaiting_clinical_approval`, `rejected` |
| `rejected` | `in_entry` |
| `verified` | `approved` |
| `awaiting_clinical_approval` | `approved`, `rejected` |
| `approved` | `promoted` |
| `promoted` | *(none — terminal)* |
Illegal transitions return `409` with a stable error code. A `promoted` batch cannot return to any earlier state. Corrections require a **new** batch referencing `supersedesBatchId`.
### Track B audit model
Track B is **not** a bypass of the batch model — it is a shortcut through the **workflow states**, not the audit unit. Every live-capture submission:
1. Creates a `DigitizationBatch` with `track: live_capture`, `batchType: vitals_sheet` (or appropriate type), `documentRef: "live-capture"`, and a synthetic `documentSha256` derived from clinician + encounter + timestamp (no scan file).
2. Writes `DraftObservation` rows for every entered value (audit trail).
3. Sets `enteredByUserId`, `verifiedByUserId`, and `approvedByUserId` to the attesting clinician (attestation replaces the dual-human gate for that batch only).
4. Inserts live `Observation` rows and outbox events in the **same transaction**, leaving the batch in `promoted` immediately.
5. Emits `DigitizationEvent` entries: `live_capture_attested`, then `promoted`.
This keeps digitization history, patient coverage stats, and Prometheus batch metrics consistent across both tracks.
---
## Features
---
### 1. Document Upload and Batch Creation
**Description:** Intake clerk scans or uploads a paper record. System stores the file in MinIO, computes SHA-256, creates a batch in `uploaded` state, and writes a `DigitizationEvent`.
**Endpoints:**
- `POST /api/v1/digitization-batches` — multipart upload: `file` (PDF, JPEG, PNG; max 25 MB), `batchType`, optional `patientId`, optional `track` (default `backfill`)
- `GET /api/v1/digitization-batches/:id` — batch detail with document presigned URL (15-minute expiry)
- `GET /api/v1/digitization-batches` — filter by `status`, `batchType`, `assignedTo`, `track`; paginated
- `PATCH /api/v1/digitization-batches/:id/assign` — assign to entry clerk (Redis lock prevents double-assignment)
**Validation:**
- Accepted MIME types: `application/pdf`, `image/jpeg`, `image/png`
- Reject empty files
- **Duplicate detection (when `patientId` is set):** reject if `documentSha256` matches an existing batch for the **same patient** within 24 hours (`409 DUPLICATE_DOCUMENT`) — prevents accidental double-scan of the same chart page
- **Cross-patient duplicates:** the same physical form scanned for two different patients is allowed (distinct patients, distinct batches). Operators may still see a warning in the UI if the SHA matches any batch site-wide (informational only in v1)
**Concepts practiced:** Object storage for immutable document artifacts, content-addressed deduplication, presigned URLs for secure document viewing without proxying binary through the API.
---
### 2. Draft Data Entry
**Description:** Entry clerk views the scan alongside structured form fields. Saves draft patient updates, encounter context, and observations. Batch moves to `in_entry` on first save.
**Endpoints:**
- `GET /api/v1/digitization-batches/:id/draft` — full draft payload: patient fields, encounter, observations[]
- `PUT /api/v1/digitization-batches/:id/draft/patient` — upsert draft patient demographics or updates
- `PUT /api/v1/digitization-batches/:id/draft/encounter` — upsert draft encounter fields
- `POST /api/v1/digitization-batches/:id/draft/observations` — add observation row
- `PUT /api/v1/digitization-batches/:id/draft/observations/:obsId` — edit observation
- `DELETE /api/v1/digitization-batches/:id/draft/observations/:obsId` — remove observation from draft
- `POST /api/v1/digitization-batches/:id/submit-for-verification` — validates completeness, transitions to `pending_verification`
**Required fields before submit (by batch type):**
| batchType | Required draft content |
|---|---|
| `patient_registration` | Full name, date of birth, sex; MRN generated on approval if new |
| `encounter_summary` | Linked patient; encounter with admission date, department, admission reason |
| `vitals_sheet` | Linked patient, encounter context, ≥1 observation with `recordedAt` |
| `lab_results` | Linked patient, encounter, ≥1 lab observation code, `recordedAt` |
| `medication_list` | Linked patient; `medicationsJson` with ≥1 entry **or** explicit `noActiveMedications: true` |
| `allergy_update` | Linked patient, allergies list (may be empty with explicit `noKnownAllergies: true`) |
| `mixed` | Linked patient, encounter context, and **at least one** of: ≥1 observation with `recordedAt`, or complete encounter summary (admission date + department + admission reason) |
**Concurrent draft editing:** Redis assignment lock prevents double-assignment at intake (`PATCH .../assign`). Draft saves require the acting user to match `enteredByUserId` (or hold the `Administrator` role); otherwise `409 BATCH_NOT_ASSIGNED`. v1 does not use field-level optimistic locking — within an assigned session, last write wins. Administrators may reassign via work-queue tools, which clears the Redis lock and updates `enteredByUserId`.
**Plausibility validation:** Reuse VigilCareClinical observation plausibility ranges at draft save time. Out-of-range values return `422 OBSERVATION_OUT_OF_PLAUSIBLE_RANGE` with the same error shape — catch decimal errors (5.2 vs 52) before verification.
**UI requirement (digitization workstation):** Side-by-side layout — scan viewer (zoom, pan, rotate) on the left; structured entry form on the right. Field-level "verified" checkbox for verifier pass (stored in draft metadata, not live record).
**Concepts practiced:** Draft vs live data separation, optimistic UI with server validation, batch-type-driven validation rules.
---
### 3. Verification and Rejection
**Description:** Verifier reviews entry against the scan. Can approve field-by-field or reject the entire batch with a mandatory reason. Verifier cannot be the entry clerk.
On verify pass: status → `verified` or `awaiting_clinical_approval` per [site configuration](#site-configuration). On verify fail: status → `rejected` (returns to entry queue).
**Endpoints:**
- `GET /api/v1/work-queue/verification` — batches in `pending_verification`, sorted by `submittedAt ASC`
- `GET /api/v1/work-queue/clinical-approval` — batches in `awaiting_clinical_approval`, sorted by `submittedAt ASC`
- `POST /api/v1/digitization-batches/:id/verify` — body: `{ "fieldChecks": [{ "fieldPath": "observations[0].value", "passed": true }], "passed": true }`
- `POST /api/v1/digitization-batches/:id/reject` — body: `{ "reason": "..." }` → status `rejected`, notifies entry clerk
Reject is allowed from `pending_verification` (verifier; separation of duties applies) or `awaiting_clinical_approval` (clinical approver; separation of duties does **not** apply — approver may not have been the entry clerk by role design).
**Concepts practiced:** Separation of duties enforcement, work-queue patterns, structured rejection loops.
---
### 4. Approval and Promotion to VigilCareClinical
**Description:** Final approval triggers an atomic promotion: draft records become live Patient / Encounter / Observation rows in VigilCareClinical (same database in integrated deployment, or HTTP calls to VigilCareClinical API in split deployment). Batch status → `promoted`.
**Endpoints:**
- `POST /api/v1/digitization-batches/:id/approve` — requires `verified` or `awaiting_clinical_approval`; restricted to `ClinicalApprover` or `Administrator`; Idempotency-Key supported
- `GET /api/v1/digitization-batches/:id/promotion-result` — live IDs created: `patientId`, `encounterId`, `observationIds[]`
**Promotion transaction sequence:**
1. Begin database transaction (or saga with compensating actions in split deployment)
2. Create or update `Patient` in VigilCareClinical (assign MRN if new)
3. Create or match `Encounter` (open as `active` or `discharged` based on draft)
4. Insert each `DraftObservation` as live `Observation` with `source: digitization_backfill` or `source: live_capture`
5. Write outbox events for each observation (Kafka pipeline activates **only if** `enableRetroactiveAlerts` or `track: live_capture`)
6. Update batch status → `promoted`, set `promotedAt`, write `DigitizationEvent`
7. Commit
**Default alert behavior:**
| track | enableRetroactiveAlerts | Alert pipeline |
|---|---|---|
| `backfill` | `false` (default) | Observations stored; no alert evaluation |
| `backfill` | `true` | Full VigilCareClinical alert path |
| `live_capture` | n/a | Full alert path immediately |
**Concepts practiced:** Outbox pattern for promotion side effects, idempotent promotion, configurable clinical safety policy for historical data.
---
### 5. Corrections and Supersession
**Description:** Approved records are not silently edited. A correction creates a new batch with `supersedesBatchId` pointing to the original. Correction goes through the full entry → verify → approve cycle. On promotion, erroneous live observations are marked `superseded` (append-only — not deleted).
**Endpoints:**
- `POST /api/v1/digitization-batches` — body includes optional `supersedesBatchId`
- `GET /api/v1/patients/:id/digitization-history` — all batches for a patient with promotion status
**Concepts practiced:** Immutable clinical audit trail, correction-as-new-batch pattern (same as national digital services land registry approach).
---
### 6. Live Capture (Track B)
**Description:** Credentialed clinician enters vitals or labs at point of care on a tablet. No verification queue. Each submission creates an audit `DigitizationBatch` (see [Track B audit model](#track-b-audit-model)), writes draft observations for traceability, and promotes live observations synchronously with full alert evaluation.
**Endpoints:**
- `POST /api/v1/live-capture/encounters/:encounterId/observations` — body: observation fields + `clinicianAttestation: true` + password re-confirm or PIN
- `POST /api/v1/live-capture/encounters` — open encounter + initial vitals in one request (outpatient workflow)
**Validation:** Requires `Role: Clinician`. Creates batch + draft observations + live observations in one transaction; batch lands in `promoted` immediately. Returns VigilCareClinical observation IDs and any synchronous critical alerts generated.
**Concepts practiced:** Lighter gate for real-time care vs heavy gate for backfill; same underlying observation schema.
---
### 7. Patient Registry (Draft and Live)
**Description:** Search and link batches to patients. Support new patient registration through the draft pipeline.
**Endpoints:**
- `GET /api/v1/patients/search?q=` — search live VigilCareClinical patients by MRN or name
- `POST /api/v1/patients/draft` — create draft-only patient (no MRN until approval)
- `GET /api/v1/patients/:id/summary` — live patient + pending draft batches + digitization coverage stats
**Digitization coverage stat:** `approvedBatchCount / estimatedTotalBatches` — optional manual `estimatedChartSections` per patient for progress tracking.
---
### 8. Work Queues and Operational Dashboard
**Description:** Supervisors monitor backlog, assignment, and throughput.
**Endpoints:**
- `GET /api/v1/work-queue/entry` — batches awaiting or in entry
- `GET /api/v1/work-queue/clinical-approval` — batches awaiting physician sign-off after verification
- `GET /api/v1/work-queue/overview` — counts by status, average time-in-queue, reject rate
- `GET /api/v1/digitization-batches/:id/events` — cursor-paginated audit trail
**Metrics (Prometheus):**
- `digitization_batches_by_status` (gauge)
- `digitization_promotion_duration_seconds` (histogram)
- `digitization_rejection_total` (counter)
- `digitization_queue_age_seconds` (gauge — oldest pending verification)
---
### 9. Authentication and Audit
**Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged.
**Endpoints:**
- `POST /api/v1/auth/login`
- `GET /api/v1/auth/me`
**Audit requirements:**
- Who viewed a scan and when
- Who changed which draft field (field-level diff in event metadata on save)
- Who approved promotion and which live record IDs were created
---
## Digitization Workstation UI
Separate Vue 3 SPA or Razor-hosted frontend. Four primary views:
| View | User | Purpose |
|---|---|---|
| **Intake** | Intake clerk | Upload, assign patient, print MRN label |
| **Entry** | Data entry clerk | Side-by-side scan + form |
| **Verification** | Verifier | Side-by-side with field checkboxes, approve/reject |
| **Queue dashboard** | Supervisor | Backlog, reject rate, clerk throughput |
Not a full EMR UI. No clinical alerting views — those remain in VigilCareClinical's ward dashboard.
---
## Data Storage
| Store | Purpose |
|---|---|
| **PostgreSQL** | Draft tables, batch metadata, digitization events, user/role data. Shares database with VigilCareClinical in integrated deployment. |
| **MinIO** | Scanned PDFs and images; content-addressed keys `scans/{year}/{month}/{batchId}/{sha256}.pdf` |
| **Redis** | Batch assignment locks (`SET batch:assign:{id} NX EX 3600`). Work-queue counters are derived from PostgreSQL queries in v1 (no Redis counter cache required) |
---
## Integration Contract with VigilCareClinical
**v1 deployment:** integrated PostgreSQL — promotion writes directly to VigilCareClinical tables in a single transaction. Split deployment (Records service calling VigilCareClinical REST + `PromotionRetryService`) remains supported as an alternate topology; see Phase 8.
On promotion, VigilCare Records writes to the same tables VigilCareClinical owns:
| Draft entity | Live entity | Notes |
|---|---|---|
| `DraftPatient` | `patients` | MRN generated via existing `GenerateMrnAsync` logic |
| `DraftEncounter` | `encounters` | Status from draft; `roomBed`, `admissionReason` mapped |
| `DraftObservation` | `observations` | Same `observationCode`, `value`, `unit`, `recordedAt`; adds `metadata.source` |
Observation codes must match VigilCareClinical's catalog: `HEART_RATE`, `TEMP_C`, `BP_SYSTOLIC`, `BP_DIASTOLIC`, `RESP_RATE`, `SPO2`, `POTASSIUM_MEQ_L`, `GLUCOSE_MG_DL`, `WBC_K_UL`, `LACTATE_MMOL_L`, etc.
If VigilCareClinical is unreachable in split deployment, batch remains `approved` and a background promotion retry job runs with exponential backoff. Batch does not revert to draft.
---
## Acceptance Criteria
| Criterion | Verification |
|---|---|
| Separation of duties | Entry clerk cannot verify or approve own batch — `409` |
| Draft isolation | Draft observations never appear in VigilCareClinical alert queries or ward dashboard |
| Promotion atomicity | Partial promotion (patient created, observations failed) never committed |
| Idempotent approval | Duplicate `Idempotency-Key` on approve returns same result, no duplicate observations |
| Rejection loop | Rejected batch returns to entry; resubmit reaches verification again |
| Backfill alert suppression | Default backfill promotion creates observations with zero alerts |
| Live capture alert path | Track B critical potassium triggers synchronous alert in VigilCareClinical |
| Audit completeness | Every status transition has a `DigitizationEvent` with actor and timestamp |
| Document immutability | Scan object in MinIO not modified or deleted on reject/correct |
| Plausibility at draft | Value 520 for potassium rejected at draft save, not at promotion |
| Clinical approval routing | `vitals_sheet` verify-pass → `awaiting_clinical_approval` when site config requires it |
| Track B audit batch | Live capture creates `DigitizationBatch` in `promoted` with draft observations and events |
| Draft assignment guard | Unassigned clerk receives `409 BATCH_NOT_ASSIGNED` on draft save |
| Cross-patient duplicate scan | Same SHA for two different patients allowed; same patient within 24h rejected |
---
## Build Order
| Phase | Focus |
|---|---|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine |
| 2 | Draft entry API (patient, encounter, observations), submit-for-verification |
| 3 | Verification, rejection, separation of duties, work queues |
| 4 | Promotion service → VigilCareClinical live tables, outbox integration, idempotency |
| 5 | Corrections / supersession, patient digitization history |
| 6 | Track B live capture with clinician attestation |
| 7 | Digitization workstation UI (entry + verification side-by-side) |
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job |
| 9 | Seed data, E2E verification script, clinical scenario documentation |
---
## Step-by-Step Guide
Complete phases in order. Promotion (Phase 4) must not be built until the draft state machine and separation of duties are correct — debugging promotion bugs alongside workflow bugs is painful.
---
### Phase 1 — Schema, Upload, and Status Machine
**What to do:**
1. Create `digitization_batches`, `draft_patients`, `draft_encounters`, `draft_observations`, `digitization_events` tables.
2. Implement batch status machine with explicit transition matrix; illegal transitions → `409`.
3. Wire MinIO upload with SHA-256 computation and presigned GET URLs.
4. Seed two users per role (entry, verifier, clinician) for separation-of-duties testing.
5. Implement JWT auth with role claims.
**Why:**
The status machine is the backbone. Getting transitions wrong means drafts leak into live data or approved batches get re-edited. Test every illegal transition before building entry forms.
---
### Phase 2 — Draft Entry
**What to do:**
1. Implement draft CRUD endpoints and batch-type validation on submit.
2. Port plausibility validator from VigilCareClinical (shared library or duplicated with comment linking source).
3. Write integration tests: incomplete vitals batch cannot submit; plausible observations save; implausible rejected.
**Why:**
Plausibility at draft save prevents the most common digitization error — decimal misplacement — from ever reaching verification.
---
### Phase 3 — Verification and Rejection
**What to do:**
1. Implement verification and rejection endpoints with separation-of-duties checks.
2. Build work-queue endpoints sorted by `submittedAt`.
3. Test: entry clerk A submits → verifier A attempts verify → `409`; verifier B succeeds.
**Why:**
Separation of duties is a clinical trust requirement, not a nice-to-have. Enforce in the service layer from day one.
---
### Phase 4 — Promotion
**What to do:**
1. Implement approval endpoint and promotion transaction against VigilCareClinical tables.
2. Wire outbox events for observations where alerting is enabled.
3. Implement `enableRetroactiveAlerts` flag — default `false`.
4. Test full path: upload → entry → verify → approve → observations in live table → zero alerts for backfill default.
5. Test idempotency: approve twice with same key → one set of live rows.
**Why:**
This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2 environment where synchronous critical alerting exists, and verify Track B vs Track A behavior explicitly.
---
### Phase 5 — Corrections
**What to do:**
1. Add `supersedesBatchId` and supersession logic on promotion.
2. Mark superseded live observations inactive (soft flag, not delete).
3. Test: wrong potassium promoted → correction batch → new value live, old value superseded in audit.
---
### Phase 6 — Live Capture (Track B)
**What to do:**
1. Implement clinician attestation endpoint bypassing verification.
2. Test critical value entered via live capture → alert fires before response returns.
---
### Phases 79 — UI, Observability, Documentation
Build the side-by-side workstation UI. Add Prometheus metrics and a supervisor queue view. Write `docs/digitization-workstation-guide.md` and an E2E script `./scripts/run-vigilcare-records-verification.sh`.
---
## Deployment Notes (Small Island Context)
- **Single-site tenant:** One hospital or health district per deployment. No cross-island federation in v1.
- **Offline intake (optional extension):** Scan and draft entry on a local server; promotion queued until uplink to central VigilCareClinical returns. Aligns with VigilCareClinical climate-resilience Phases 2024 — Records gateway can share the same ward-first sync pattern.
- **Staffing reality:** Same person may hold entry and intake roles, but **never** entry and verifier on the same batch. System enforces this even when staff roster is small.
- **Paper originals:** Scanned document is the working copy; physical chart remains legal original until jurisdiction defines otherwise. README must state this explicitly.
---
## Success Metrics (Operational)
| Metric | Target (6 months post go-live) |
|---|---|
| Charts with ≥1 approved batch | 80% of active patients |
| Average verification turnaround | < 24 hours |
| Rejection rate | < 15% (indicates entry quality or scan quality issues if higher) |
| Live capture share of new observations | Trending up month-over-month |
| VigilCareClinical alerts from live capture | ≥1 demonstrated critical-value workflow per site |
---
## Architecture Decisions
| Decision | Choice (v1) | Rationale |
|---|---|---|
| **Integrated DB vs split API** | Integrated PostgreSQL, shared instance, separate schemas | Atomic promotion in one transaction; simpler to build and demo; split path documented for production hardening |
| **Clinical approver routing** | Site config map (see [Site configuration](#site-configuration)); default requires physician queue for encounter/vitals/labs/medications/mixed | High-stakes chart sections get an extra gate; registration and allergy-only updates stay verifier → approver |
| **Retroactive alerts** | `enableRetroactiveAlerts: false` default on backfill | Prevents alert storms from historical critical values; opt-in per batch for facilities that accept the risk |
| **MRN issuance** | Locally generated MRN via VigilCareClinical `GenerateMrnAsync` | National health ID integration deferred per deployment |
## Open Questions (per deployment)
1. **Retroactive alerts policy:** Will the Ministry of Health allow backfilled critical values to trigger pages, or is storage-only the mandated default?
2. **MRN issuance:** National health ID integration vs locally generated MRN when a national registry becomes available?
3. **Split deployment:** When does the site require Records and Clinical in separate services (saga + retry) vs integrated database?
---
## References
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
- [Completed/national-digital-services-architecture.md](Completed/national-digital-services-architecture.md) — scan-and-verify pattern for paper-to-digital government services