From 869006e5e744c65d8af43b36bc70ebd38261dc02 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Fri, 26 Jun 2026 04:20:20 +0800 Subject: [PATCH] initial commit --- .gitignore | 132 ++++ VigilCareRecords.sln | 22 + VigilCareRecordsAPI/Common/ApiResponse.cs | 13 + .../Common/Exceptions/BadRequestException.cs | 5 + .../Common/Exceptions/ConflictException.cs | 5 + .../Common/Exceptions/DbExceptions.cs | 8 + .../Common/Exceptions/DomainException.cs | 9 + .../Common/Exceptions/NotFoundException.cs | 5 + .../Common/Exceptions/ValidationException.cs | 5 + .../Configurations/JwtOptions.cs | 8 + .../Configurations/MinioOptions.cs | 10 + .../Controllers/AuthController.cs | 47 ++ .../DigitizationBatchesController.cs | 116 +++ VigilCareRecordsAPI/Data/AppDbContext.cs | 19 + .../DigitizationBatchConfiguration.cs | 60 ++ .../DigitizationEventConfiguration.cs | 32 + .../DraftEncounterConfiguration.cs | 32 + .../DraftObservationConfiguration.cs | 22 + .../DraftPatientConfiguration.cs | 35 + .../ScannedDocumentConfiguration.cs | 21 + .../Data/Configurations/UserConfiguration.cs | 30 + .../20260625195830_InitialCreate.Designer.cs | 598 +++++++++++++++ .../20260625195830_InitialCreate.cs | 316 ++++++++ .../20260625195908_InitialSchema.Designer.cs | 598 +++++++++++++++ .../20260625195908_InitialSchema.cs | 22 + .../Migrations/AppDbContextModelSnapshot.cs | 595 +++++++++++++++ VigilCareRecordsAPI/Data/Seed/DataSeeder.cs | 39 + .../Domain/Entities/DigitizationBatch.cs | 31 + .../Domain/Entities/DigitizationEvent.cs | 12 + .../Domain/Entities/DraftEncounter.cs | 15 + .../Domain/Entities/DraftObservation.cs | 13 + .../Domain/Entities/DraftPatient.cs | 18 + .../Domain/Entities/ScannedDocument.cs | 12 + VigilCareRecordsAPI/Domain/Entities/User.cs | 10 + .../Domain/Enums/BatchStatus.cs | 30 + .../Domain/Enums/BatchTrack.cs | 18 + VigilCareRecordsAPI/Domain/Enums/BatchType.cs | 28 + VigilCareRecordsAPI/Domain/Enums/BloodType.cs | 37 + .../Domain/Enums/Department.cs | 83 +++ .../Domain/Enums/DigitizationEventType.cs | 73 ++ VigilCareRecordsAPI/Domain/Enums/UserRole.cs | 26 + .../Middleware/CorrelationIdMiddleware.cs | 23 + .../Middleware/ExceptionHandlerMiddleware.cs | 50 ++ .../Middlewares/CorrelationIdMiddleware.cs | 0 .../Middlewares/ExceptionHandlerMiddleware.cs | 0 .../Models/Records/Auth/LoginRequest.cs | 1 + .../Models/Records/Auth/LoginResponse.cs | 1 + .../Records/Batch/AssignBatchRequest.cs | 1 + .../Records/Batch/BatchDetailResponse.cs | 47 ++ .../Records/Batch/CreateBatchRequest.cs | 8 + .../Models/Records/Common/PagedResult.cs | 9 + VigilCareRecordsAPI/Program.cs | 94 +++ .../Properties/launchSettings.json | 41 ++ VigilCareRecordsAPI/Services/AuthService.cs | 62 ++ VigilCareRecordsAPI/Services/BatchService.cs | 182 +++++ .../Services/DocumentStorageService.cs | 67 ++ .../Services/Interfaces/IAuthService.cs | 5 + .../Services/Interfaces/IBatchService.cs | 8 + .../Interfaces/IDocumentStorageService.cs | 5 + .../VigilCareRecordsAPI.csproj | 26 + VigilCareRecordsAPI/VigilCareRecordsAPI.http | 6 + VigilCareRecordsAPI/appsettings.json | 52 ++ docker-compose.yml | 54 ++ docs/vigilcare-records-prd.md | 680 ++++++++++++++++++ 64 files changed, 4632 insertions(+) create mode 100644 .gitignore create mode 100644 VigilCareRecords.sln create mode 100644 VigilCareRecordsAPI/Common/ApiResponse.cs create mode 100644 VigilCareRecordsAPI/Common/Exceptions/BadRequestException.cs create mode 100644 VigilCareRecordsAPI/Common/Exceptions/ConflictException.cs create mode 100644 VigilCareRecordsAPI/Common/Exceptions/DbExceptions.cs create mode 100644 VigilCareRecordsAPI/Common/Exceptions/DomainException.cs create mode 100644 VigilCareRecordsAPI/Common/Exceptions/NotFoundException.cs create mode 100644 VigilCareRecordsAPI/Common/Exceptions/ValidationException.cs create mode 100644 VigilCareRecordsAPI/Configurations/JwtOptions.cs create mode 100644 VigilCareRecordsAPI/Configurations/MinioOptions.cs create mode 100644 VigilCareRecordsAPI/Controllers/AuthController.cs create mode 100644 VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs create mode 100644 VigilCareRecordsAPI/Data/AppDbContext.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/DigitizationBatchConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/DraftEncounterConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/DraftObservationConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/DraftPatientConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/ScannedDocumentConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Configurations/UserConfiguration.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 VigilCareRecordsAPI/Data/Seed/DataSeeder.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/DigitizationBatch.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/DigitizationEvent.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/DraftEncounter.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/DraftObservation.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/DraftPatient.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/ScannedDocument.cs create mode 100644 VigilCareRecordsAPI/Domain/Entities/User.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/BatchStatus.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/BatchTrack.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/BatchType.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/BloodType.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/Department.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs create mode 100644 VigilCareRecordsAPI/Domain/Enums/UserRole.cs create mode 100644 VigilCareRecordsAPI/Middleware/CorrelationIdMiddleware.cs create mode 100644 VigilCareRecordsAPI/Middleware/ExceptionHandlerMiddleware.cs create mode 100644 VigilCareRecordsAPI/Middlewares/CorrelationIdMiddleware.cs create mode 100644 VigilCareRecordsAPI/Middlewares/ExceptionHandlerMiddleware.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Auth/LoginRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Batch/AssignBatchRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Batch/BatchDetailResponse.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Batch/CreateBatchRequest.cs create mode 100644 VigilCareRecordsAPI/Models/Records/Common/PagedResult.cs create mode 100644 VigilCareRecordsAPI/Program.cs create mode 100644 VigilCareRecordsAPI/Properties/launchSettings.json create mode 100644 VigilCareRecordsAPI/Services/AuthService.cs create mode 100644 VigilCareRecordsAPI/Services/BatchService.cs create mode 100644 VigilCareRecordsAPI/Services/DocumentStorageService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IBatchService.cs create mode 100644 VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs create mode 100644 VigilCareRecordsAPI/VigilCareRecordsAPI.csproj create mode 100644 VigilCareRecordsAPI/VigilCareRecordsAPI.http create mode 100644 VigilCareRecordsAPI/appsettings.json create mode 100644 docker-compose.yml create mode 100644 docs/vigilcare-records-prd.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c09827 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/VigilCareRecords.sln b/VigilCareRecords.sln new file mode 100644 index 0000000..f537cdb --- /dev/null +++ b/VigilCareRecords.sln @@ -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 diff --git a/VigilCareRecordsAPI/Common/ApiResponse.cs b/VigilCareRecordsAPI/Common/ApiResponse.cs new file mode 100644 index 0000000..63ca4b6 --- /dev/null +++ b/VigilCareRecordsAPI/Common/ApiResponse.cs @@ -0,0 +1,13 @@ +public record ApiResponse(bool Success, int StatusCode, T? Data, ApiError? Error) +{ + public static ApiResponse Ok(T data) => + new(true, 200, data, null); + + public static ApiResponse Created(T data) => + new(true, 201, data, null); + + public static ApiResponse Fail(int statusCode, string message, string code) => + new(false, statusCode, default, new ApiError(message, code)); +} + +public record ApiError(string Message, string Code); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Common/Exceptions/BadRequestException.cs b/VigilCareRecordsAPI/Common/Exceptions/BadRequestException.cs new file mode 100644 index 0000000..9159243 --- /dev/null +++ b/VigilCareRecordsAPI/Common/Exceptions/BadRequestException.cs @@ -0,0 +1,5 @@ +public class BadRequestException : DomainException +{ + public BadRequestException(string message, string errorCode = "BAD_REQUEST") + : base(message, errorCode) { } +} diff --git a/VigilCareRecordsAPI/Common/Exceptions/ConflictException.cs b/VigilCareRecordsAPI/Common/Exceptions/ConflictException.cs new file mode 100644 index 0000000..761c7ad --- /dev/null +++ b/VigilCareRecordsAPI/Common/Exceptions/ConflictException.cs @@ -0,0 +1,5 @@ +public class ConflictException : DomainException +{ + public ConflictException(string message, string errorCode = "CONFLICT_ERROR") + : base(message, errorCode) { } +} diff --git a/VigilCareRecordsAPI/Common/Exceptions/DbExceptions.cs b/VigilCareRecordsAPI/Common/Exceptions/DbExceptions.cs new file mode 100644 index 0000000..22eb93c --- /dev/null +++ b/VigilCareRecordsAPI/Common/Exceptions/DbExceptions.cs @@ -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; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Common/Exceptions/DomainException.cs b/VigilCareRecordsAPI/Common/Exceptions/DomainException.cs new file mode 100644 index 0000000..ddda850 --- /dev/null +++ b/VigilCareRecordsAPI/Common/Exceptions/DomainException.cs @@ -0,0 +1,9 @@ +public abstract class DomainException : Exception +{ + public string ErrorCode { get; } + + protected DomainException(string message, string errorCode) : base(message) + { + ErrorCode = errorCode; + } +} diff --git a/VigilCareRecordsAPI/Common/Exceptions/NotFoundException.cs b/VigilCareRecordsAPI/Common/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..59657d4 --- /dev/null +++ b/VigilCareRecordsAPI/Common/Exceptions/NotFoundException.cs @@ -0,0 +1,5 @@ +public class NotFoundException : DomainException +{ + public NotFoundException(string message, string errorCode = "NOT_FOUND") + : base(message, errorCode) { } +} diff --git a/VigilCareRecordsAPI/Common/Exceptions/ValidationException.cs b/VigilCareRecordsAPI/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000..e57da5d --- /dev/null +++ b/VigilCareRecordsAPI/Common/Exceptions/ValidationException.cs @@ -0,0 +1,5 @@ +public class ValidationException : DomainException +{ + public ValidationException(string message, string errorCode = "VALIDATION_ERROR") + : base(message, errorCode) { } +} diff --git a/VigilCareRecordsAPI/Configurations/JwtOptions.cs b/VigilCareRecordsAPI/Configurations/JwtOptions.cs new file mode 100644 index 0000000..95f7c06 --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/JwtOptions.cs @@ -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; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Configurations/MinioOptions.cs b/VigilCareRecordsAPI/Configurations/MinioOptions.cs new file mode 100644 index 0000000..fd8ecdf --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/MinioOptions.cs @@ -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; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/AuthController.cs b/VigilCareRecordsAPI/Controllers/AuthController.cs new file mode 100644 index 0000000..7f863ec --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/AuthController.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + + +/// +/// JWT authentication and current user info. +/// +[ApiController] +[Route("api/v1/auth")] +[Produces("application/json")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _auth; + + public AuthController(IAuthService auth) => _auth = auth; + + /// + /// Authenticates a user and returns a JWT with role claims. + /// + [HttpPost("login")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task Login([FromBody] LoginRequest req) + { + var result = await _auth.LoginAsync(req); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// Returns the current authenticated user's profile. + /// + [HttpGet("me")] + [Authorize] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task Me() + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var user = await _auth.GetCurrentUserAsync(userId); + return Ok(ApiResponse.Ok(new + { + user.Id, user.Username, user.FullName, + role = user.Role.ToDbString() + })); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs new file mode 100644 index 0000000..e0cd76e --- /dev/null +++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs @@ -0,0 +1,116 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + + +/// +/// Batch CRUD, document upload, and assignment for the digitization workflow. +/// +[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 _allowedMimeTypes = new() + { + "application/pdf", "image/jpeg", "image/png" + }; + + public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage) + { + _batches = batches; + _storage = storage; + } + + /// + /// Uploads a scanned document and creates a new digitization batch. + /// + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [RequestSizeLimit(25 * 1024 * 1024)] + public async Task Create( + IFormFile file, + [FromForm] CreateBatchRequest req) + { + if (file is null || file.Length == 0) + return BadRequest(ApiResponse.Fail(400, "File is required.", "EMPTY_FILE")); + + if (!_allowedMimeTypes.Contains(file.ContentType)) + return BadRequest(ApiResponse.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.Created(BatchDetailResponse.FromEntity(batch))); + } + + /// + /// Gets a batch by ID with a presigned document URL (15-minute expiry). + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Get(Guid id) + { + var batch = await _batches.GetByIdAsync(id); + var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef); + + return Ok(ApiResponse.Ok( + BatchDetailResponse.FromEntity(batch, presignedUrl))); + } + + /// + /// Lists batches with optional filters and pagination. + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task 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.Ok(new + { + items = result.Items, + page = result.Page, + pageSize = result.PageSize, + totalCount = result.TotalCount, + totalPages = result.TotalPages + })); + } + + /// + /// Assigns a batch to an entry clerk. Uses Redis lock to prevent double-assignment. + /// + [HttpPatch("{id:guid}/assign")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task 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.Ok(BatchDetailResponse.FromEntity(batch))); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs new file mode 100644 index 0000000..1f5c91e --- /dev/null +++ b/VigilCareRecordsAPI/Data/AppDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users => Set(); + public DbSet DigitizationBatches => Set(); + public DbSet ScannedDocuments => Set(); + public DbSet DraftPatients => Set(); + public DbSet DraftEncounters => Set(); + public DbSet DraftObservations => Set(); + public DbSet DigitizationEvents => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/DigitizationBatchConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DigitizationBatchConfiguration.cs new file mode 100644 index 0000000..ef7a646 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/DigitizationBatchConfiguration.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DigitizationBatchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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"); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs new file mode 100644 index 0000000..b8154c3 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DigitizationEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 }); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/DraftEncounterConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DraftEncounterConfiguration.cs new file mode 100644 index 0000000..cf16833 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/DraftEncounterConfiguration.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DraftEncounterConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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(e => e.BatchId).OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/DraftObservationConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DraftObservationConfiguration.cs new file mode 100644 index 0000000..8bd1e8f --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/DraftObservationConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DraftObservationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 }); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/DraftPatientConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DraftPatientConfiguration.cs new file mode 100644 index 0000000..b3f3915 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/DraftPatientConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class DraftPatientConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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(p => p.BatchId).OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/ScannedDocumentConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/ScannedDocumentConfiguration.cs new file mode 100644 index 0000000..7be6f7e --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/ScannedDocumentConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ScannedDocumentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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(d => d.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(d => d.Sha256); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Configurations/UserConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..2ce98b3 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/UserConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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(); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.Designer.cs new file mode 100644 index 0000000..9e10f28 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.Designer.cs @@ -0,0 +1,598 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("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 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.cs b/VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.cs new file mode 100644 index 0000000..ff296ca --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260625195830_InitialCreate.cs @@ -0,0 +1,316 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + password_hash = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + full_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + role = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + is_active = table.Column(type: "boolean", nullable: false, defaultValue: true), + created_at = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + status = table.Column(type: "character varying(30)", maxLength: 30, nullable: false, defaultValueSql: "'UPLOADED'"), + batch_type = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + track = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'BACKFILL'"), + patient_id = table.Column(type: "uuid", nullable: true), + encounter_draft_id = table.Column(type: "uuid", nullable: true), + document_ref = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + document_sha256 = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + enable_retroactive_alerts = table.Column(type: "boolean", nullable: false, defaultValue: false), + entered_by_user_id = table.Column(type: "uuid", nullable: true), + verified_by_user_id = table.Column(type: "uuid", nullable: true), + approved_by_user_id = table.Column(type: "uuid", nullable: true), + rejection_reason = table.Column(type: "text", nullable: true), + promoted_at = table.Column(type: "timestamp with time zone", nullable: true), + promotion_encounter_id = table.Column(type: "uuid", nullable: true), + supersedes_batch_id = table.Column(type: "uuid", nullable: true), + clinician_attestation = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + batch_id = table.Column(type: "uuid", nullable: false), + event_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + actor_user_id = table.Column(type: "uuid", nullable: false), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + metadata_json = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + batch_id = table.Column(type: "uuid", nullable: false), + admission_date = table.Column(type: "timestamp with time zone", nullable: true), + department = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + room_bed = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + admission_reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + discharge_diagnosis = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + batch_id = table.Column(type: "uuid", nullable: false), + observation_code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + value = table.Column(type: "numeric(10,3)", nullable: false), + unit = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + recorded_at = table.Column(type: "timestamp with time zone", nullable: false), + note = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + batch_id = table.Column(type: "uuid", nullable: false), + full_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + date_of_birth = table.Column(type: "date", nullable: true), + sex = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + blood_type = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + emergency_contact = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + allergies_json = table.Column(type: "jsonb", nullable: true), + no_known_allergies = table.Column(type: "boolean", nullable: false, defaultValue: false), + medications_json = table.Column(type: "jsonb", nullable: true), + no_active_medications = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(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(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + batch_id = table.Column(type: "uuid", nullable: false), + object_key = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + sha256 = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + content_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + file_size_bytes = table.Column(type: "bigint", nullable: false), + uploaded_at = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.Designer.cs new file mode 100644 index 0000000..352cb6e --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.Designer.cs @@ -0,0 +1,598 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("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 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.cs b/VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.cs new file mode 100644 index 0000000..980fc3b --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260625195908_InitialSchema.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..df20805 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,595 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("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 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs b/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs new file mode 100644 index 0000000..95976fd --- /dev/null +++ b/VigilCareRecordsAPI/Data/Seed/DataSeeder.cs @@ -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 + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/DigitizationBatch.cs b/VigilCareRecordsAPI/Domain/Entities/DigitizationBatch.cs new file mode 100644 index 0000000..1fcd4e1 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/DigitizationBatch.cs @@ -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 DraftObservations { get; set; } = new List(); + public ICollection Events { get; set; } = new List(); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/DigitizationEvent.cs b/VigilCareRecordsAPI/Domain/Entities/DigitizationEvent.cs new file mode 100644 index 0000000..c5cb108 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/DigitizationEvent.cs @@ -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!; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/DraftEncounter.cs b/VigilCareRecordsAPI/Domain/Entities/DraftEncounter.cs new file mode 100644 index 0000000..0fb8e54 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/DraftEncounter.cs @@ -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!; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/DraftObservation.cs b/VigilCareRecordsAPI/Domain/Entities/DraftObservation.cs new file mode 100644 index 0000000..e310498 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/DraftObservation.cs @@ -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!; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/DraftPatient.cs b/VigilCareRecordsAPI/Domain/Entities/DraftPatient.cs new file mode 100644 index 0000000..38e737e --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/DraftPatient.cs @@ -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!; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/ScannedDocument.cs b/VigilCareRecordsAPI/Domain/Entities/ScannedDocument.cs new file mode 100644 index 0000000..bfebed3 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/ScannedDocument.cs @@ -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!; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Entities/User.cs b/VigilCareRecordsAPI/Domain/Entities/User.cs new file mode 100644 index 0000000..cb56ab0 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/User.cs @@ -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; } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/BatchStatus.cs b/VigilCareRecordsAPI/Domain/Enums/BatchStatus.cs new file mode 100644 index 0000000..29e994f --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/BatchStatus.cs @@ -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}'") + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/BatchTrack.cs b/VigilCareRecordsAPI/Domain/Enums/BatchTrack.cs new file mode 100644 index 0000000..2c73c1f --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/BatchTrack.cs @@ -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}'") + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/BatchType.cs b/VigilCareRecordsAPI/Domain/Enums/BatchType.cs new file mode 100644 index 0000000..77b42fa --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/BatchType.cs @@ -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}'") + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/BloodType.cs b/VigilCareRecordsAPI/Domain/Enums/BloodType.cs new file mode 100644 index 0000000..600deaf --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/BloodType.cs @@ -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; } + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/Department.cs b/VigilCareRecordsAPI/Domain/Enums/Department.cs new file mode 100644 index 0000000..6fb0d77 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/Department.cs @@ -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; } + } +} diff --git a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs new file mode 100644 index 0000000..37c8222 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs @@ -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}'") + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/UserRole.cs b/VigilCareRecordsAPI/Domain/Enums/UserRole.cs new file mode 100644 index 0000000..4619f43 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Enums/UserRole.cs @@ -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}'") + }; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Middleware/CorrelationIdMiddleware.cs b/VigilCareRecordsAPI/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..4b1d8f8 --- /dev/null +++ b/VigilCareRecordsAPI/Middleware/CorrelationIdMiddleware.cs @@ -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); + } + } +} diff --git a/VigilCareRecordsAPI/Middleware/ExceptionHandlerMiddleware.cs b/VigilCareRecordsAPI/Middleware/ExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..d5f1661 --- /dev/null +++ b/VigilCareRecordsAPI/Middleware/ExceptionHandlerMiddleware.cs @@ -0,0 +1,50 @@ +public class ExceptionHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlerMiddleware(RequestDelegate next, ILogger 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.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode)); + } + catch (ValidationException ex) + { + _logger.LogWarning("{Message}", ex.Message); + await WriteAsync(context, StatusCodes.Status422UnprocessableEntity, + ApiResponse.Fail(StatusCodes.Status422UnprocessableEntity, ex.Message, ex.ErrorCode)); + } + catch (ConflictException ex) + { + _logger.LogWarning("{Message}", ex.Message); + await WriteAsync(context, StatusCodes.Status409Conflict, + ApiResponse.Fail(StatusCodes.Status409Conflict, ex.Message, ex.ErrorCode)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception"); + await WriteAsync(context, StatusCodes.Status500InternalServerError, + ApiResponse.Fail(StatusCodes.Status500InternalServerError, + "An unexpected error occurred", "INTERNAL_ERROR")); + } + } + + private static async Task WriteAsync(HttpContext context, int status, ApiResponse body) + { + context.Response.StatusCode = status; + await context.Response.WriteAsJsonAsync(body); + } +} diff --git a/VigilCareRecordsAPI/Middlewares/CorrelationIdMiddleware.cs b/VigilCareRecordsAPI/Middlewares/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..e69de29 diff --git a/VigilCareRecordsAPI/Middlewares/ExceptionHandlerMiddleware.cs b/VigilCareRecordsAPI/Middlewares/ExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..e69de29 diff --git a/VigilCareRecordsAPI/Models/Records/Auth/LoginRequest.cs b/VigilCareRecordsAPI/Models/Records/Auth/LoginRequest.cs new file mode 100644 index 0000000..838d623 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Auth/LoginRequest.cs @@ -0,0 +1 @@ +public record LoginRequest(string Username, string Password); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs b/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs new file mode 100644 index 0000000..0aa7446 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs @@ -0,0 +1 @@ +public record LoginResponse(string Token, Guid UserId, string Username, string FullName, string Role); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/AssignBatchRequest.cs b/VigilCareRecordsAPI/Models/Records/Batch/AssignBatchRequest.cs new file mode 100644 index 0000000..4129285 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/AssignBatchRequest.cs @@ -0,0 +1 @@ +public record AssignBatchRequest(Guid EntryClerkUserId); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/BatchDetailResponse.cs b/VigilCareRecordsAPI/Models/Records/Batch/BatchDetailResponse.cs new file mode 100644 index 0000000..1f06746 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/BatchDetailResponse.cs @@ -0,0 +1,47 @@ +/// +/// API representation of a digitization batch. Hides EF navigation properties +/// and exposes enum fields as DB string literals. +/// +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 + ); +} diff --git a/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchRequest.cs b/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchRequest.cs new file mode 100644 index 0000000..8dad1dd --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/CreateBatchRequest.cs @@ -0,0 +1,8 @@ +/// +/// Multipart form metadata for batch creation (file is bound separately as IFormFile). +/// +public record CreateBatchRequest( + string BatchType, + string? Track, + Guid? PatientId +); diff --git a/VigilCareRecordsAPI/Models/Records/Common/PagedResult.cs b/VigilCareRecordsAPI/Models/Records/Common/PagedResult.cs new file mode 100644 index 0000000..34befb0 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Common/PagedResult.cs @@ -0,0 +1,9 @@ +public record PagedResult( + IReadOnlyList Items, + int Page, + int PageSize, + int TotalCount +) +{ + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs new file mode 100644 index 0000000..9d1eed7 --- /dev/null +++ b/VigilCareRecordsAPI/Program.cs @@ -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(builder.Configuration.GetSection(JwtOptions.Section)); + builder.Services.Configure(builder.Configuration.GetSection(MinioOptions.Section)); + + // Database + builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + + // MinIO + var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get()!; + builder.Services.AddSingleton(new MinioClient() + .WithEndpoint(minioOptions.Endpoint) + .WithCredentials(minioOptions.AccessKey, minioOptions.SecretKey) + .WithSSL(minioOptions.UseSsl) + .Build()); + + // JWT Authentication + var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!; + 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(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + 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(); + app.UseMiddleware(); + + // 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(); + await db.Database.MigrateAsync(); + await DataSeeder.SeedAsync(db); + } +} +catch (System.Exception) +{ + + throw; +} + + +public partial class Program { } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Properties/launchSettings.json b/VigilCareRecordsAPI/Properties/launchSettings.json new file mode 100644 index 0000000..8a1f534 --- /dev/null +++ b/VigilCareRecordsAPI/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/VigilCareRecordsAPI/Services/AuthService.cs b/VigilCareRecordsAPI/Services/AuthService.cs new file mode 100644 index 0000000..eff01b8 --- /dev/null +++ b/VigilCareRecordsAPI/Services/AuthService.cs @@ -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) + { + _db = db; + _jwtOptions = jwtOptions.Value; + } + + public async Task 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 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); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/BatchService.cs b/VigilCareRecordsAPI/Services/BatchService.cs new file mode 100644 index 0000000..b911526 --- /dev/null +++ b/VigilCareRecordsAPI/Services/BatchService.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; + +public class BatchService : IBatchService +{ + private static readonly Dictionary> _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 _logger; + + public BatchService(AppDbContext db, IDocumentStorageService storage, + IConnectionMultiplexer redis, ILogger logger) + { + _db = db; + _storage = storage; + _redis = redis; + _logger = logger; + } + + public async Task 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 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> 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(items, page, pageSize, total); + } + + public async Task 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(); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/DocumentStorageService.cs b/VigilCareRecordsAPI/Services/DocumentStorageService.cs new file mode 100644 index 0000000..48e3727 --- /dev/null +++ b/VigilCareRecordsAPI/Services/DocumentStorageService.cs @@ -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 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 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)); + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs b/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs new file mode 100644 index 0000000..afea19b --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs @@ -0,0 +1,5 @@ +public interface IAuthService +{ + Task LoginAsync(LoginRequest req); + Task GetCurrentUserAsync(Guid userId); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IBatchService.cs b/VigilCareRecordsAPI/Services/Interfaces/IBatchService.cs new file mode 100644 index 0000000..b88b923 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IBatchService.cs @@ -0,0 +1,8 @@ +public interface IBatchService +{ + Task CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId); + Task GetByIdAsync(Guid id); + Task> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize); + Task AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId); + BatchStatus[] GetAllowedTransitions(BatchStatus current); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs b/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs new file mode 100644 index 0000000..fda9525 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs @@ -0,0 +1,5 @@ +public interface IDocumentStorageService +{ + Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId); + Task GetPresignedUrlAsync(string objectKey); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj new file mode 100644 index 0000000..0e39f4e --- /dev/null +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.http b/VigilCareRecordsAPI/VigilCareRecordsAPI.http new file mode 100644 index 0000000..2ac6640 --- /dev/null +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.http @@ -0,0 +1,6 @@ +@VigilCareRecordsAPI_HostAddress = http://localhost:5217 + +GET {{VigilCareRecordsAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json new file mode 100644 index 0000000..95294a6 --- /dev/null +++ b/VigilCareRecordsAPI/appsettings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..030a13c --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docs/vigilcare-records-prd.md b/docs/vigilcare-records-prd.md new file mode 100644 index 0000000..2b9daa3 --- /dev/null +++ b/docs/vigilcare-records-prd.md @@ -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 1–2 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 7–9 — UI, Observability, Documentation + +Build the side-by-side workstation UI. Add Prometheus metrics and a supervisor queue view. Write `docs/digitization-workstation-guide.md` and an E2E script `./scripts/run-vigilcare-records-verification.sh`. + +--- + +## 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 20–24 — 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