initial commit

This commit is contained in:
voltsrage
2026-06-26 04:20:20 +08:00
commit 869006e5e7
64 changed files with 4632 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
{
public static ApiResponse<T> Ok(T data) =>
new(true, 200, data, null);
public static ApiResponse<T> Created(T data) =>
new(true, 201, data, null);
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
new(false, statusCode, default, new ApiError(message, code));
}
public record ApiError(string Message, string Code);
@@ -0,0 +1,5 @@
public class BadRequestException : DomainException
{
public BadRequestException(string message, string errorCode = "BAD_REQUEST")
: base(message, errorCode) { }
}
@@ -0,0 +1,5 @@
public class ConflictException : DomainException
{
public ConflictException(string message, string errorCode = "CONFLICT_ERROR")
: base(message, errorCode) { }
}
@@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore;
public static class DbExceptions
{
public static bool IsUniqueViolation(DbUpdateException ex) =>
ex.InnerException?.Message.Contains("23505") == true
|| ex.InnerException?.Message.Contains("unique constraint") == true;
}
@@ -0,0 +1,9 @@
public abstract class DomainException : Exception
{
public string ErrorCode { get; }
protected DomainException(string message, string errorCode) : base(message)
{
ErrorCode = errorCode;
}
}
@@ -0,0 +1,5 @@
public class NotFoundException : DomainException
{
public NotFoundException(string message, string errorCode = "NOT_FOUND")
: base(message, errorCode) { }
}
@@ -0,0 +1,5 @@
public class ValidationException : DomainException
{
public ValidationException(string message, string errorCode = "VALIDATION_ERROR")
: base(message, errorCode) { }
}
@@ -0,0 +1,8 @@
public class JwtOptions
{
public const string Section = "Jwt";
public string Secret { get; set; } = null!;
public string Issuer { get; set; } = null!;
public string Audience { get; set; } = null!;
public int ExpiryMinutes { get; set; } = 480;
}
@@ -0,0 +1,10 @@
public class MinioOptions
{
public const string Section = "Minio";
public string Endpoint { get; set; } = null!;
public string AccessKey { get; set; } = null!;
public string SecretKey { get; set; } = null!;
public string BucketName { get; set; } = "scans";
public bool UseSsl { get; set; }
public int PresignedUrlExpiryMinutes { get; set; } = 15;
}
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// JWT authentication and current user info.
/// </summary>
[ApiController]
[Route("api/v1/auth")]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
public AuthController(IAuthService auth) => _auth = auth;
/// <summary>
/// Authenticates a user and returns a JWT with role claims.
/// </summary>
[HttpPost("login")]
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var result = await _auth.LoginAsync(req);
return Ok(ApiResponse<LoginResponse>.Ok(result));
}
/// <summary>
/// Returns the current authenticated user's profile.
/// </summary>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Me()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var user = await _auth.GetCurrentUserAsync(userId);
return Ok(ApiResponse<object>.Ok(new
{
user.Id, user.Username, user.FullName,
role = user.Role.ToDbString()
}));
}
}
@@ -0,0 +1,116 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Batch CRUD, document upload, and assignment for the digitization workflow.
/// </summary>
[ApiController]
[Route("api/v1/digitization-batches")]
[Produces("application/json")]
[Authorize]
public class DigitizationBatchesController : ControllerBase
{
private readonly IBatchService _batches;
private readonly IDocumentStorageService _storage;
private static readonly HashSet<string> _allowedMimeTypes = new()
{
"application/pdf", "image/jpeg", "image/png"
};
public DigitizationBatchesController(IBatchService batches, IDocumentStorageService storage)
{
_batches = batches;
_storage = storage;
}
/// <summary>
/// Uploads a scanned document and creates a new digitization batch.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task<IActionResult> Create(
IFormFile file,
[FromForm] CreateBatchRequest req)
{
if (file is null || file.Length == 0)
return BadRequest(ApiResponse<object>.Fail(400, "File is required.", "EMPTY_FILE"));
if (!_allowedMimeTypes.Contains(file.ContentType))
return BadRequest(ApiResponse<object>.Fail(400,
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
var parsedBatchType = BatchTypeExtensions.FromDbString(req.BatchType.ToUpperInvariant());
var parsedTrack = string.IsNullOrEmpty(req.Track)
? BatchTrack.Backfill
: BatchTrackExtensions.FromDbString(req.Track.ToUpperInvariant());
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
using var stream = file.OpenReadStream();
var batch = await _batches.CreateAsync(
stream, file.ContentType, parsedBatchType, parsedTrack, req.PatientId, actorUserId);
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(BatchDetailResponse.FromEntity(batch)));
}
/// <summary>
/// Gets a batch by ID with a presigned document URL (15-minute expiry).
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id)
{
var batch = await _batches.GetByIdAsync(id);
var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef);
return Ok(ApiResponse<BatchDetailResponse>.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl)));
}
/// <summary>
/// Lists batches with optional filters and pagination.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? batchType,
[FromQuery] Guid? assignedTo,
[FromQuery] string? track,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
BatchStatus? parsedStatus = string.IsNullOrEmpty(status) ? null : BatchStatusExtensions.FromDbString(status.ToUpperInvariant());
BatchType? parsedBatchType = string.IsNullOrEmpty(batchType) ? null : BatchTypeExtensions.FromDbString(batchType.ToUpperInvariant());
BatchTrack? parsedTrack = string.IsNullOrEmpty(track) ? null : BatchTrackExtensions.FromDbString(track.ToUpperInvariant());
var result = await _batches.ListAsync(parsedStatus, parsedBatchType, assignedTo, parsedTrack, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
/// <summary>
/// Assigns a batch to an entry clerk. Uses Redis lock to prevent double-assignment.
/// </summary>
[HttpPatch("{id:guid}/assign")]
[ProducesResponseType(typeof(ApiResponse<BatchDetailResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Assign(Guid id, [FromBody] AssignBatchRequest req)
{
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var batch = await _batches.AssignAsync(id, req.EntryClerkUserId, actorUserId);
return Ok(ApiResponse<BatchDetailResponse>.Ok(BatchDetailResponse.FromEntity(batch)));
}
}
+19
View File
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<DigitizationBatch> DigitizationBatches => Set<DigitizationBatch>();
public DbSet<ScannedDocument> ScannedDocuments => Set<ScannedDocument>();
public DbSet<DraftPatient> DraftPatients => Set<DraftPatient>();
public DbSet<DraftEncounter> DraftEncounters => Set<DraftEncounter>();
public DbSet<DraftObservation> DraftObservations => Set<DraftObservation>();
public DbSet<DigitizationEvent> DigitizationEvents => Set<DigitizationEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DigitizationBatchConfiguration : IEntityTypeConfiguration<DigitizationBatch>
{
public void Configure(EntityTypeBuilder<DigitizationBatch> builder)
{
builder.ToTable("digitization_batches", t =>
{
t.HasCheckConstraint("chk_batches_status",
"status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
t.HasCheckConstraint("chk_batches_batch_type",
"batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
t.HasCheckConstraint("chk_batches_track",
"track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(b => b.Status)
.HasColumnName("status")
.HasMaxLength(30)
.HasConversion(v => v.ToDbString(), v => BatchStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'UPLOADED'")
.HasSentinel((BatchStatus)(-1));
builder.Property(b => b.BatchType)
.HasColumnName("batch_type")
.HasMaxLength(30)
.HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(b => b.Track)
.HasColumnName("track")
.HasMaxLength(20)
.HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v))
.HasDefaultValueSql("'BACKFILL'")
.HasSentinel((BatchTrack)(-1));
builder.Property(b => b.PatientId).HasColumnName("patient_id");
builder.Property(b => b.EncounterDraftId).HasColumnName("encounter_draft_id");
builder.Property(b => b.DocumentRef).HasColumnName("document_ref").HasMaxLength(500).IsRequired();
builder.Property(b => b.DocumentSha256).HasColumnName("document_sha256").HasMaxLength(64).IsRequired();
builder.Property(b => b.EnableRetroactiveAlerts).HasColumnName("enable_retroactive_alerts").HasDefaultValue(false);
builder.Property(b => b.EnteredByUserId).HasColumnName("entered_by_user_id");
builder.Property(b => b.VerifiedByUserId).HasColumnName("verified_by_user_id");
builder.Property(b => b.ApprovedByUserId).HasColumnName("approved_by_user_id");
builder.Property(b => b.RejectionReason).HasColumnName("rejection_reason");
builder.Property(b => b.PromotedAt).HasColumnName("promoted_at");
builder.Property(b => b.PromotionEncounterId).HasColumnName("promotion_encounter_id");
builder.Property(b => b.SupersedesBatchId).HasColumnName("supersedes_batch_id");
builder.Property(b => b.ClinicianAttestation).HasColumnName("clinician_attestation").HasDefaultValue(false);
builder.Property(b => b.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(b => b.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
builder.HasOne(b => b.EnteredByUser).WithMany().HasForeignKey(b => b.EnteredByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(b => b.VerifiedByUser).WithMany().HasForeignKey(b => b.VerifiedByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(b => b.ApprovedByUser).WithMany().HasForeignKey(b => b.ApprovedByUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.DocumentSha256, b.PatientId, b.CreatedAt });
builder.HasIndex(b => b.SupersedesBatchId).HasFilter("supersedes_batch_id IS NOT NULL");
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DigitizationEventConfiguration : IEntityTypeConfiguration<DigitizationEvent>
{
public void Configure(EntityTypeBuilder<DigitizationEvent> builder)
{
builder.ToTable("digitization_events", t =>
{
t.HasCheckConstraint("chk_digitization_events_event_type",
"event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(e => e.EventType)
.HasColumnName("event_type")
.HasMaxLength(50)
.HasConversion(
v => v.ToDbString(),
v => DigitizationEventTypeExtensions.FromDbString(v))
.IsRequired();
builder.Property(e => e.ActorUserId).HasColumnName("actor_user_id").IsRequired();
builder.Property(e => e.OccurredAt).HasColumnName("occurred_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.MetadataJson).HasColumnName("metadata_json").HasColumnType("jsonb");
builder.HasOne(e => e.Batch).WithMany(b => b.Events).HasForeignKey(e => e.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Actor).WithMany().HasForeignKey(e => e.ActorUserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.BatchId, e.OccurredAt });
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DraftEncounterConfiguration : IEntityTypeConfiguration<DraftEncounter>
{
public void Configure(EntityTypeBuilder<DraftEncounter> builder)
{
builder.ToTable("draft_encounters", t =>
{
t.HasCheckConstraint("chk_draft_encounters_department",
"department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(e => e.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(e => e.AdmissionDate).HasColumnName("admission_date");
builder.Property(e => e.Department)
.HasColumnName("department")
.HasMaxLength(100)
.HasConversion(
v => v.HasValue ? v.Value.ToDbString() : null,
v => v == null ? null : DepartmentExtensions.FromDbString(v));
builder.Property(e => e.RoomBed).HasColumnName("room_bed").HasMaxLength(50);
builder.Property(e => e.AdmissionReason).HasColumnName("admission_reason").HasMaxLength(500);
builder.Property(e => e.DischargeDiagnosis).HasColumnName("discharge_diagnosis").HasMaxLength(500);
builder.Property(e => e.Status).HasColumnName("status").HasMaxLength(20);
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
builder.HasOne(e => e.Batch).WithOne(b => b.DraftEncounter).HasForeignKey<DraftEncounter>(e => e.BatchId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DraftObservationConfiguration : IEntityTypeConfiguration<DraftObservation>
{
public void Configure(EntityTypeBuilder<DraftObservation> builder)
{
builder.ToTable("draft_observations");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(o => o.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(o => o.ObservationCode).HasColumnName("observation_code").HasMaxLength(50).IsRequired();
builder.Property(o => o.Value).HasColumnName("value").HasColumnType("decimal(10,3)").IsRequired();
builder.Property(o => o.Unit).HasColumnName("unit").HasMaxLength(20).IsRequired();
builder.Property(o => o.RecordedAt).HasColumnName("recorded_at").IsRequired();
builder.Property(o => o.Note).HasColumnName("note").HasMaxLength(500);
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(o => o.Batch).WithMany(b => b.DraftObservations).HasForeignKey(o => o.BatchId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(o => new { o.BatchId, o.ObservationCode });
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class DraftPatientConfiguration : IEntityTypeConfiguration<DraftPatient>
{
public void Configure(EntityTypeBuilder<DraftPatient> builder)
{
builder.ToTable("draft_patients", t =>
{
t.HasCheckConstraint("chk_draft_patients_blood_type",
"blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
});
builder.HasKey(p => p.Id);
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(p => p.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(p => p.FullName).HasColumnName("full_name").HasMaxLength(200);
builder.Property(p => p.DateOfBirth).HasColumnName("date_of_birth");
builder.Property(p => p.Sex).HasColumnName("sex").HasMaxLength(10);
builder.Property(p => p.BloodType)
.HasColumnName("blood_type")
.HasMaxLength(10)
.HasConversion(
v => v.HasValue ? v.Value.ToDbString() : null,
v => v == null ? null : BloodTypeExtensions.FromDbString(v));
builder.Property(p => p.EmergencyContact).HasColumnName("emergency_contact").HasMaxLength(500);
builder.Property(p => p.AllergiesJson).HasColumnName("allergies_json").HasColumnType("jsonb");
builder.Property(p => p.NoKnownAllergies).HasColumnName("no_known_allergies").HasDefaultValue(false);
builder.Property(p => p.MedicationsJson).HasColumnName("medications_json").HasColumnType("jsonb");
builder.Property(p => p.NoActiveMedications).HasColumnName("no_active_medications").HasDefaultValue(false);
builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(p => p.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
builder.HasOne(p => p.Batch).WithOne(b => b.DraftPatient).HasForeignKey<DraftPatient>(p => p.BatchId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ScannedDocumentConfiguration : IEntityTypeConfiguration<ScannedDocument>
{
public void Configure(EntityTypeBuilder<ScannedDocument> builder)
{
builder.ToTable("scanned_documents");
builder.HasKey(d => d.Id);
builder.Property(d => d.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(d => d.BatchId).HasColumnName("batch_id").IsRequired();
builder.Property(d => d.ObjectKey).HasColumnName("object_key").HasMaxLength(500).IsRequired();
builder.Property(d => d.Sha256).HasColumnName("sha256").HasMaxLength(64).IsRequired();
builder.Property(d => d.ContentType).HasColumnName("content_type").HasMaxLength(100).IsRequired();
builder.Property(d => d.FileSizeBytes).HasColumnName("file_size_bytes").IsRequired();
builder.Property(d => d.UploadedAt).HasColumnName("uploaded_at").HasDefaultValueSql("NOW()");
builder.HasOne(d => d.Batch).WithOne(b => b.Document).HasForeignKey<ScannedDocument>(d => d.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(d => d.Sha256);
}
}
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users", t =>
{
t.HasCheckConstraint("chk_users_role",
"role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
builder.HasKey(u => u.Id);
builder.Property(u => u.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(u => u.Username).HasColumnName("username").HasMaxLength(100).IsRequired();
builder.Property(u => u.PasswordHash).HasColumnName("password_hash").HasMaxLength(200).IsRequired();
builder.Property(u => u.FullName).HasColumnName("full_name").HasMaxLength(200).IsRequired();
builder.Property(u => u.Role)
.HasColumnName("role")
.HasMaxLength(30)
.HasConversion(
v => v.ToDbString(),
v => UserRoleExtensions.FromDbString(v))
.IsRequired();
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
builder.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasIndex(u => u.Username).IsUnique();
}
}
@@ -0,0 +1,598 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260625195830_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("VerifiedByUserId")
.HasColumnType("uuid")
.HasColumnName("verified_by_user_id");
b.HasKey("Id");
b.HasIndex("ApprovedByUserId");
b.HasIndex("EnteredByUserId");
b.HasIndex("Status");
b.HasIndex("SupersedesBatchId")
.HasFilter("supersedes_batch_id IS NOT NULL");
b.HasIndex("VerifiedByUserId");
b.HasIndex("DocumentSha256", "PatientId", "CreatedAt");
b.ToTable("digitization_batches", null, t =>
{
t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("OccurredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("BatchId", "OccurredAt");
b.ToTable("digitization_events", null, t =>
{
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
});
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("draft_encounters", null, t =>
{
t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
});
});
modelBuilder.Entity("DraftObservation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("BatchId", "ObservationCode");
b.ToTable("draft_observations", (string)null);
});
modelBuilder.Entity("DraftPatient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("draft_patients", null, t =>
{
t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
});
});
modelBuilder.Entity("ScannedDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("UploadedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.HasIndex("Sha256");
b.ToTable("scanned_documents", (string)null);
});
modelBuilder.Entity("User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", null, t =>
{
t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.HasOne("User", "ApprovedByUser")
.WithMany()
.HasForeignKey("ApprovedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("User", "EnteredByUser")
.WithMany()
.HasForeignKey("EnteredByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("User", "VerifiedByUser")
.WithMany()
.HasForeignKey("VerifiedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ApprovedByUser");
b.Navigation("EnteredByUser");
b.Navigation("VerifiedByUser");
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.HasOne("User", "Actor")
.WithMany()
.HasForeignKey("ActorUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("DigitizationBatch", "Batch")
.WithMany("Events")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Actor");
b.Navigation("Batch");
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("DraftEncounter")
.HasForeignKey("DraftEncounter", "BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DraftObservation", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithMany("DraftObservations")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DraftPatient", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("DraftPatient")
.HasForeignKey("DraftPatient", "BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ScannedDocument", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("Document")
.HasForeignKey("ScannedDocument", "BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Navigation("Document");
b.Navigation("DraftEncounter");
b.Navigation("DraftObservations");
b.Navigation("DraftPatient");
b.Navigation("Events");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,316 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
password_hash = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
full_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
role = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.id);
table.CheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
migrationBuilder.CreateTable(
name: "digitization_batches",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false, defaultValueSql: "'UPLOADED'"),
batch_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
track = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValueSql: "'BACKFILL'"),
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
encounter_draft_id = table.Column<Guid>(type: "uuid", nullable: true),
document_ref = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
document_sha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
enable_retroactive_alerts = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
entered_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
verified_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
approved_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
rejection_reason = table.Column<string>(type: "text", nullable: true),
promoted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
promotion_encounter_id = table.Column<Guid>(type: "uuid", nullable: true),
supersedes_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
clinician_attestation = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_digitization_batches", x => x.id);
table.CheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
table.CheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
table.CheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
table.ForeignKey(
name: "FK_digitization_batches_users_approved_by_user_id",
column: x => x.approved_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_digitization_batches_users_entered_by_user_id",
column: x => x.entered_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_digitization_batches_users_verified_by_user_id",
column: x => x.verified_by_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "digitization_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
event_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
actor_user_id = table.Column<Guid>(type: "uuid", nullable: false),
occurred_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
metadata_json = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_digitization_events", x => x.id);
table.CheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
table.ForeignKey(
name: "FK_digitization_events_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_digitization_events_users_actor_user_id",
column: x => x.actor_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "draft_encounters",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
admission_date = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
department = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
room_bed = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
admission_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
discharge_diagnosis = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_draft_encounters", x => x.id);
table.CheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
table.ForeignKey(
name: "FK_draft_encounters_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "draft_observations",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
observation_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
value = table.Column<decimal>(type: "numeric(10,3)", nullable: false),
unit = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
recorded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
note = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_draft_observations", x => x.id);
table.ForeignKey(
name: "FK_draft_observations_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "draft_patients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
full_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
date_of_birth = table.Column<DateOnly>(type: "date", nullable: true),
sex = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
blood_type = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
emergency_contact = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
allergies_json = table.Column<string>(type: "jsonb", nullable: true),
no_known_allergies = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
medications_json = table.Column<string>(type: "jsonb", nullable: true),
no_active_medications = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_draft_patients", x => x.id);
table.CheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
table.ForeignKey(
name: "FK_draft_patients_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "scanned_documents",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
object_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
sha256 = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
content_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
file_size_bytes = table.Column<long>(type: "bigint", nullable: false),
uploaded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_scanned_documents", x => x.id);
table.ForeignKey(
name: "FK_scanned_documents_digitization_batches_batch_id",
column: x => x.batch_id,
principalTable: "digitization_batches",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_approved_by_user_id",
table: "digitization_batches",
column: "approved_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_document_sha256_patient_id_created_at",
table: "digitization_batches",
columns: new[] { "document_sha256", "patient_id", "created_at" });
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_entered_by_user_id",
table: "digitization_batches",
column: "entered_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_status",
table: "digitization_batches",
column: "status");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_supersedes_batch_id",
table: "digitization_batches",
column: "supersedes_batch_id",
filter: "supersedes_batch_id IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_digitization_batches_verified_by_user_id",
table: "digitization_batches",
column: "verified_by_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_events_actor_user_id",
table: "digitization_events",
column: "actor_user_id");
migrationBuilder.CreateIndex(
name: "IX_digitization_events_batch_id_occurred_at",
table: "digitization_events",
columns: new[] { "batch_id", "occurred_at" });
migrationBuilder.CreateIndex(
name: "IX_draft_encounters_batch_id",
table: "draft_encounters",
column: "batch_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_draft_observations_batch_id_observation_code",
table: "draft_observations",
columns: new[] { "batch_id", "observation_code" });
migrationBuilder.CreateIndex(
name: "IX_draft_patients_batch_id",
table: "draft_patients",
column: "batch_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_scanned_documents_batch_id",
table: "scanned_documents",
column: "batch_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_scanned_documents_sha256",
table: "scanned_documents",
column: "sha256");
migrationBuilder.CreateIndex(
name: "IX_users_username",
table: "users",
column: "username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "digitization_events");
migrationBuilder.DropTable(
name: "draft_encounters");
migrationBuilder.DropTable(
name: "draft_observations");
migrationBuilder.DropTable(
name: "draft_patients");
migrationBuilder.DropTable(
name: "scanned_documents");
migrationBuilder.DropTable(
name: "digitization_batches");
migrationBuilder.DropTable(
name: "users");
}
}
}
@@ -0,0 +1,598 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260625195908_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("VerifiedByUserId")
.HasColumnType("uuid")
.HasColumnName("verified_by_user_id");
b.HasKey("Id");
b.HasIndex("ApprovedByUserId");
b.HasIndex("EnteredByUserId");
b.HasIndex("Status");
b.HasIndex("SupersedesBatchId")
.HasFilter("supersedes_batch_id IS NOT NULL");
b.HasIndex("VerifiedByUserId");
b.HasIndex("DocumentSha256", "PatientId", "CreatedAt");
b.ToTable("digitization_batches", null, t =>
{
t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("OccurredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("BatchId", "OccurredAt");
b.ToTable("digitization_events", null, t =>
{
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
});
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("draft_encounters", null, t =>
{
t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
});
});
modelBuilder.Entity("DraftObservation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("BatchId", "ObservationCode");
b.ToTable("draft_observations", (string)null);
});
modelBuilder.Entity("DraftPatient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("draft_patients", null, t =>
{
t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
});
});
modelBuilder.Entity("ScannedDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("UploadedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.HasIndex("Sha256");
b.ToTable("scanned_documents", (string)null);
});
modelBuilder.Entity("User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", null, t =>
{
t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.HasOne("User", "ApprovedByUser")
.WithMany()
.HasForeignKey("ApprovedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("User", "EnteredByUser")
.WithMany()
.HasForeignKey("EnteredByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("User", "VerifiedByUser")
.WithMany()
.HasForeignKey("VerifiedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ApprovedByUser");
b.Navigation("EnteredByUser");
b.Navigation("VerifiedByUser");
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.HasOne("User", "Actor")
.WithMany()
.HasForeignKey("ActorUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("DigitizationBatch", "Batch")
.WithMany("Events")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Actor");
b.Navigation("Batch");
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("DraftEncounter")
.HasForeignKey("DraftEncounter", "BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DraftObservation", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithMany("DraftObservations")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DraftPatient", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("DraftPatient")
.HasForeignKey("DraftPatient", "BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ScannedDocument", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("Document")
.HasForeignKey("ScannedDocument", "BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Navigation("Document");
b.Navigation("DraftEncounter");
b.Navigation("DraftObservations");
b.Navigation("DraftPatient");
b.Navigation("Events");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,595 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareRecordsAPI.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("uuid")
.HasColumnName("approved_by_user_id");
b.Property<string>("BatchType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("batch_type");
b.Property<bool>("ClinicianAttestation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("clinician_attestation");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DocumentRef")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("document_ref");
b.Property<string>("DocumentSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("document_sha256");
b.Property<bool>("EnableRetroactiveAlerts")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("enable_retroactive_alerts");
b.Property<Guid?>("EncounterDraftId")
.HasColumnType("uuid")
.HasColumnName("encounter_draft_id");
b.Property<Guid?>("EnteredByUserId")
.HasColumnType("uuid")
.HasColumnName("entered_by_user_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("PromotedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("promoted_at");
b.Property<Guid?>("PromotionEncounterId")
.HasColumnType("uuid")
.HasColumnName("promotion_encounter_id");
b.Property<string>("RejectionReason")
.HasColumnType("text")
.HasColumnName("rejection_reason");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("status")
.HasDefaultValueSql("'UPLOADED'");
b.Property<Guid?>("SupersedesBatchId")
.HasColumnType("uuid")
.HasColumnName("supersedes_batch_id");
b.Property<string>("Track")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("track")
.HasDefaultValueSql("'BACKFILL'");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid?>("VerifiedByUserId")
.HasColumnType("uuid")
.HasColumnName("verified_by_user_id");
b.HasKey("Id");
b.HasIndex("ApprovedByUserId");
b.HasIndex("EnteredByUserId");
b.HasIndex("Status");
b.HasIndex("SupersedesBatchId")
.HasFilter("supersedes_batch_id IS NOT NULL");
b.HasIndex("VerifiedByUserId");
b.HasIndex("DocumentSha256", "PatientId", "CreatedAt");
b.ToTable("digitization_batches", null, t =>
{
t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
});
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("ActorUserId")
.HasColumnType("uuid")
.HasColumnName("actor_user_id");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("event_type");
b.Property<string>("MetadataJson")
.HasColumnType("jsonb")
.HasColumnName("metadata_json");
b.Property<DateTimeOffset>("OccurredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("BatchId", "OccurredAt");
b.ToTable("digitization_events", null, t =>
{
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
});
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AdmissionDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("admission_date");
b.Property<string>("AdmissionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("admission_reason");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<string>("DischargeDiagnosis")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("discharge_diagnosis");
b.Property<string>("RoomBed")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("room_bed");
b.Property<string>("Status")
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("draft_encounters", null, t =>
{
t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
});
});
modelBuilder.Entity("DraftObservation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("note");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("BatchId", "ObservationCode");
b.ToTable("draft_observations", (string)null);
});
modelBuilder.Entity("DraftPatient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("AllergiesJson")
.HasColumnType("jsonb")
.HasColumnName("allergies_json");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("blood_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("EmergencyContact")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("emergency_contact");
b.Property<string>("FullName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<string>("MedicationsJson")
.HasColumnType("jsonb")
.HasColumnName("medications_json");
b.Property<bool>("NoActiveMedications")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_active_medications");
b.Property<bool>("NoKnownAllergies")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("no_known_allergies");
b.Property<string>("Sex")
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("sex");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.ToTable("draft_patients", null, t =>
{
t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
});
});
modelBuilder.Entity("ScannedDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<Guid>("BatchId")
.HasColumnType("uuid")
.HasColumnName("batch_id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content_type");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint")
.HasColumnName("file_size_bytes");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("object_key");
b.Property<string>("Sha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("sha256");
b.Property<DateTimeOffset>("UploadedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("BatchId")
.IsUnique();
b.HasIndex("Sha256");
b.ToTable("scanned_documents", (string)null);
});
modelBuilder.Entity("User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("full_name");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", null, t =>
{
t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
});
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.HasOne("User", "ApprovedByUser")
.WithMany()
.HasForeignKey("ApprovedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("User", "EnteredByUser")
.WithMany()
.HasForeignKey("EnteredByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("User", "VerifiedByUser")
.WithMany()
.HasForeignKey("VerifiedByUserId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ApprovedByUser");
b.Navigation("EnteredByUser");
b.Navigation("VerifiedByUser");
});
modelBuilder.Entity("DigitizationEvent", b =>
{
b.HasOne("User", "Actor")
.WithMany()
.HasForeignKey("ActorUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("DigitizationBatch", "Batch")
.WithMany("Events")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Actor");
b.Navigation("Batch");
});
modelBuilder.Entity("DraftEncounter", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("DraftEncounter")
.HasForeignKey("DraftEncounter", "BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DraftObservation", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithMany("DraftObservations")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DraftPatient", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("DraftPatient")
.HasForeignKey("DraftPatient", "BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ScannedDocument", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne("Document")
.HasForeignKey("ScannedDocument", "BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Navigation("Document");
b.Navigation("DraftEncounter");
b.Navigation("DraftObservations");
b.Navigation("DraftPatient");
b.Navigation("Events");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
if (await db.Users.AnyAsync()) return;
var users = new[]
{
CreateUser("intake1", "Intake Clerk 1", UserRole.IntakeClerk),
CreateUser("intake2", "Intake Clerk 2", UserRole.IntakeClerk),
CreateUser("entry1", "Entry Clerk 1", UserRole.DataEntryClerk),
CreateUser("entry2", "Entry Clerk 2", UserRole.DataEntryClerk),
CreateUser("verifier1", "Verifier 1", UserRole.Verifier),
CreateUser("verifier2", "Verifier 2", UserRole.Verifier),
CreateUser("approver1", "Clinical Approver 1", UserRole.ClinicalApprover),
CreateUser("approver2", "Clinical Approver 2", UserRole.ClinicalApprover),
CreateUser("clinician1", "Dr. Tanaka", UserRole.Clinician),
CreateUser("clinician2", "Dr. Chen", UserRole.Clinician),
CreateUser("admin1", "Administrator 1", UserRole.Administrator),
CreateUser("admin2", "Administrator 2", UserRole.Administrator),
};
db.Users.AddRange(users);
await db.SaveChangesAsync();
}
private static User CreateUser(string username, string fullName, UserRole role) => new()
{
Id = Guid.NewGuid(),
Username = username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
FullName = fullName,
Role = role,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
}
@@ -0,0 +1,31 @@
public class DigitizationBatch
{
public Guid Id { get; set; }
public BatchStatus Status { get; set; }
public BatchType BatchType { get; set; }
public BatchTrack Track { get; set; }
public Guid? PatientId { get; set; }
public Guid? EncounterDraftId { get; set; }
public string DocumentRef { get; set; } = null!;
public string DocumentSha256 { get; set; } = null!;
public bool EnableRetroactiveAlerts { get; set; }
public Guid? EnteredByUserId { get; set; }
public Guid? VerifiedByUserId { get; set; }
public Guid? ApprovedByUserId { get; set; }
public string? RejectionReason { get; set; }
public DateTimeOffset? PromotedAt { get; set; }
public Guid? PromotionEncounterId { get; set; }
public Guid? SupersedesBatchId { get; set; }
public bool ClinicianAttestation { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public User? EnteredByUser { get; set; }
public User? VerifiedByUser { get; set; }
public User? ApprovedByUser { get; set; }
public ScannedDocument? Document { get; set; }
public DraftPatient? DraftPatient { get; set; }
public DraftEncounter? DraftEncounter { get; set; }
public ICollection<DraftObservation> DraftObservations { get; set; } = new List<DraftObservation>();
public ICollection<DigitizationEvent> Events { get; set; } = new List<DigitizationEvent>();
}
@@ -0,0 +1,12 @@
public class DigitizationEvent
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public DigitizationEventType EventType { get; set; }
public Guid ActorUserId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public string? MetadataJson { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
public User Actor { get; set; } = null!;
}
@@ -0,0 +1,15 @@
public class DraftEncounter
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public DateTimeOffset? AdmissionDate { get; set; }
public Department? Department { get; set; }
public string? RoomBed { get; set; }
public string? AdmissionReason { get; set; }
public string? DischargeDiagnosis { get; set; }
public string? Status { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,13 @@
public class DraftObservation
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string ObservationCode { get; set; } = null!;
public decimal Value { get; set; }
public string Unit { get; set; } = null!;
public DateTimeOffset RecordedAt { get; set; }
public string? Note { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,18 @@
public class DraftPatient
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string? FullName { get; set; }
public DateOnly? DateOfBirth { get; set; }
public string? Sex { get; set; }
public BloodType? BloodType { get; set; }
public string? EmergencyContact { get; set; }
public string? AllergiesJson { get; set; }
public bool NoKnownAllergies { get; set; }
public string? MedicationsJson { get; set; }
public bool NoActiveMedications { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,12 @@
public class ScannedDocument
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public string ObjectKey { get; set; } = null!;
public string Sha256 { get; set; } = null!;
public string ContentType { get; set; } = null!;
public long FileSizeBytes { get; set; }
public DateTimeOffset UploadedAt { get; set; }
public DigitizationBatch Batch { get; set; } = null!;
}
@@ -0,0 +1,10 @@
public class User
{
public Guid Id { get; set; }
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public string FullName { get; set; } = null!;
public UserRole Role { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,30 @@
public enum BatchStatus { Uploaded, InEntry, PendingVerification, Rejected, Verified, AwaitingClinicalApproval, Approved, Promoted }
public static class BatchStatusExtensions
{
public static string ToDbString(this BatchStatus s) => s switch
{
BatchStatus.Uploaded => "UPLOADED",
BatchStatus.InEntry => "IN_ENTRY",
BatchStatus.PendingVerification => "PENDING_VERIFICATION",
BatchStatus.Rejected => "REJECTED",
BatchStatus.Verified => "VERIFIED",
BatchStatus.AwaitingClinicalApproval => "AWAITING_CLINICAL_APPROVAL",
BatchStatus.Approved => "APPROVED",
BatchStatus.Promoted => "PROMOTED",
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
public static BatchStatus FromDbString(string v) => v switch
{
"UPLOADED" => BatchStatus.Uploaded,
"IN_ENTRY" => BatchStatus.InEntry,
"PENDING_VERIFICATION" => BatchStatus.PendingVerification,
"REJECTED" => BatchStatus.Rejected,
"VERIFIED" => BatchStatus.Verified,
"AWAITING_CLINICAL_APPROVAL" => BatchStatus.AwaitingClinicalApproval,
"APPROVED" => BatchStatus.Approved,
"PROMOTED" => BatchStatus.Promoted,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch status: '{v}'")
};
}
@@ -0,0 +1,18 @@
public enum BatchTrack { Backfill, LiveCapture }
public static class BatchTrackExtensions
{
public static string ToDbString(this BatchTrack t) => t switch
{
BatchTrack.Backfill => "BACKFILL",
BatchTrack.LiveCapture => "LIVE_CAPTURE",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static BatchTrack FromDbString(string v) => v switch
{
"BACKFILL" => BatchTrack.Backfill,
"LIVE_CAPTURE" => BatchTrack.LiveCapture,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch track: '{v}'")
};
}
@@ -0,0 +1,28 @@
public enum BatchType { PatientRegistration, EncounterSummary, VitalsSheet, LabResults, MedicationList, AllergyUpdate, Mixed }
public static class BatchTypeExtensions
{
public static string ToDbString(this BatchType t) => t switch
{
BatchType.PatientRegistration => "PATIENT_REGISTRATION",
BatchType.EncounterSummary => "ENCOUNTER_SUMMARY",
BatchType.VitalsSheet => "VITALS_SHEET",
BatchType.LabResults => "LAB_RESULTS",
BatchType.MedicationList => "MEDICATION_LIST",
BatchType.AllergyUpdate => "ALLERGY_UPDATE",
BatchType.Mixed => "MIXED",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static BatchType FromDbString(string v) => v switch
{
"PATIENT_REGISTRATION" => BatchType.PatientRegistration,
"ENCOUNTER_SUMMARY" => BatchType.EncounterSummary,
"VITALS_SHEET" => BatchType.VitalsSheet,
"LAB_RESULTS" => BatchType.LabResults,
"MEDICATION_LIST" => BatchType.MedicationList,
"ALLERGY_UPDATE" => BatchType.AllergyUpdate,
"MIXED" => BatchType.Mixed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown batch type: '{v}'")
};
}
@@ -0,0 +1,37 @@
public enum BloodType { APos, ANeg, BPos, BNeg, AbPos, AbNeg, OPos, ONeg }
public static class BloodTypeExtensions
{
public static string ToDbString(this BloodType t) => t switch
{
BloodType.APos => "A+",
BloodType.ANeg => "A-",
BloodType.BPos => "B+",
BloodType.BNeg => "B-",
BloodType.AbPos => "AB+",
BloodType.AbNeg => "AB-",
BloodType.OPos => "O+",
BloodType.ONeg => "O-",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static BloodType FromDbString(string v) => v switch
{
"A+" => BloodType.APos,
"A-" => BloodType.ANeg,
"B+" => BloodType.BPos,
"B-" => BloodType.BNeg,
"AB+" => BloodType.AbPos,
"AB-" => BloodType.AbNeg,
"O+" => BloodType.OPos,
"O-" => BloodType.ONeg,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown blood type: '{v}'")
};
public static bool TryFromDbString(string? v, out BloodType result)
{
if (v is null) { result = default; return false; }
try { result = FromDbString(v); return true; }
catch (ArgumentOutOfRangeException) { result = default; return false; }
}
}
@@ -0,0 +1,83 @@
public enum Department
{
EmergencyDepartment,
InternalMedicine,
GeneralMedicine,
Surgery,
Icu,
Nicu,
MedSurg,
OutpatientClinic,
Pediatrics,
ObstetricsGynecology,
LaborAndDelivery,
Cardiology,
Orthopedics,
Neurology,
Oncology,
Radiology,
Laboratory,
Psychiatry,
PhysicalTherapy,
Anesthesiology
}
public static class DepartmentExtensions
{
public static string ToDbString(this Department d) => d switch
{
Department.EmergencyDepartment => "Emergency Department",
Department.InternalMedicine => "Internal Medicine",
Department.GeneralMedicine => "General Medicine",
Department.Surgery => "Surgery",
Department.Icu => "ICU",
Department.Nicu => "NICU",
Department.MedSurg => "Medical-Surgical",
Department.OutpatientClinic => "Outpatient Clinic",
Department.Pediatrics => "Pediatrics",
Department.ObstetricsGynecology => "Obstetrics & Gynecology",
Department.LaborAndDelivery => "Labor & Delivery",
Department.Cardiology => "Cardiology",
Department.Orthopedics => "Orthopedics",
Department.Neurology => "Neurology",
Department.Oncology => "Oncology",
Department.Radiology => "Radiology",
Department.Laboratory => "Laboratory",
Department.Psychiatry => "Psychiatry",
Department.PhysicalTherapy => "Physical Therapy",
Department.Anesthesiology => "Anesthesiology",
_ => throw new ArgumentOutOfRangeException(nameof(d))
};
public static Department FromDbString(string v) => v switch
{
"Emergency Department" => Department.EmergencyDepartment,
"Internal Medicine" => Department.InternalMedicine,
"General Medicine" => Department.GeneralMedicine,
"Surgery" => Department.Surgery,
"ICU" => Department.Icu,
"NICU" => Department.Nicu,
"Medical-Surgical" => Department.MedSurg,
"Outpatient Clinic" => Department.OutpatientClinic,
"Pediatrics" => Department.Pediatrics,
"Obstetrics & Gynecology" => Department.ObstetricsGynecology,
"Labor & Delivery" => Department.LaborAndDelivery,
"Cardiology" => Department.Cardiology,
"Orthopedics" => Department.Orthopedics,
"Neurology" => Department.Neurology,
"Oncology" => Department.Oncology,
"Radiology" => Department.Radiology,
"Laboratory" => Department.Laboratory,
"Psychiatry" => Department.Psychiatry,
"Physical Therapy" => Department.PhysicalTherapy,
"Anesthesiology" => Department.Anesthesiology,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown department: '{v}'")
};
public static bool TryFromDbString(string? v, out Department result)
{
if (v is null) { result = default; return false; }
try { result = FromDbString(v); return true; }
catch (ArgumentOutOfRangeException) { result = default; return false; }
}
}
@@ -0,0 +1,73 @@
public enum DigitizationEventType
{
Uploaded,
EntryStarted,
SubmittedForVerification,
Rejected,
Verified,
VerifiedPendingClinical,
AwaitingClinicalApproval,
Approved,
Promoted,
LiveCaptureAttested,
CorrectionRequested,
VerificationFailed,
Superseded,
CorrectionPromoted,
CorrectionUploaded,
PromotionRetrySucceeded,
PromotionRetryFailed,
PromotionRetryExhausted,
PromotionFailed
}
public static class DigitizationEventTypeExtensions
{
public static string ToDbString(this DigitizationEventType t) => t switch
{
DigitizationEventType.Uploaded => "uploaded",
DigitizationEventType.EntryStarted => "entry_started",
DigitizationEventType.SubmittedForVerification => "submitted_for_verification",
DigitizationEventType.Rejected => "rejected",
DigitizationEventType.Verified => "verified",
DigitizationEventType.VerifiedPendingClinical => "verified_pending_clinical",
DigitizationEventType.AwaitingClinicalApproval => "awaiting_clinical_approval",
DigitizationEventType.Approved => "approved",
DigitizationEventType.Promoted => "promoted",
DigitizationEventType.LiveCaptureAttested => "live_capture_attested",
DigitizationEventType.CorrectionRequested => "correction_requested",
DigitizationEventType.VerificationFailed => "verification_failed",
DigitizationEventType.Superseded => "superseded",
DigitizationEventType.CorrectionPromoted => "correction_promoted",
DigitizationEventType.CorrectionUploaded => "correction_uploaded",
DigitizationEventType.PromotionRetrySucceeded => "promotion_retry_succeeded",
DigitizationEventType.PromotionRetryFailed => "promotion_retry_failed",
DigitizationEventType.PromotionRetryExhausted => "promotion_retry_exhausted",
DigitizationEventType.PromotionFailed => "promotion_failed",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
public static DigitizationEventType FromDbString(string v) => v switch
{
"uploaded" => DigitizationEventType.Uploaded,
"entry_started" => DigitizationEventType.EntryStarted,
"submitted_for_verification" => DigitizationEventType.SubmittedForVerification,
"rejected" => DigitizationEventType.Rejected,
"verified" => DigitizationEventType.Verified,
"verified_pending_clinical" => DigitizationEventType.VerifiedPendingClinical,
"awaiting_clinical_approval" => DigitizationEventType.AwaitingClinicalApproval,
"approved" => DigitizationEventType.Approved,
"promoted" => DigitizationEventType.Promoted,
"live_capture_attested" => DigitizationEventType.LiveCaptureAttested,
"correction_requested" => DigitizationEventType.CorrectionRequested,
"verification_failed" => DigitizationEventType.VerificationFailed,
"superseded" => DigitizationEventType.Superseded,
"correction_promoted" => DigitizationEventType.CorrectionPromoted,
"correction_uploaded" => DigitizationEventType.CorrectionUploaded,
"promotion_retry_succeeded" => DigitizationEventType.PromotionRetrySucceeded,
"promotion_retry_failed" => DigitizationEventType.PromotionRetryFailed,
"promotion_retry_exhausted" => DigitizationEventType.PromotionRetryExhausted,
"promotion_failed" => DigitizationEventType.PromotionFailed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
};
}
@@ -0,0 +1,26 @@
public enum UserRole { IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator }
public static class UserRoleExtensions
{
public static string ToDbString(this UserRole r) => r switch
{
UserRole.IntakeClerk => "INTAKE_CLERK",
UserRole.DataEntryClerk => "DATA_ENTRY_CLERK",
UserRole.Verifier => "VERIFIER",
UserRole.ClinicalApprover => "CLINICAL_APPROVER",
UserRole.Clinician => "CLINICIAN",
UserRole.Administrator => "ADMINISTRATOR",
_ => throw new ArgumentOutOfRangeException(nameof(r))
};
public static UserRole FromDbString(string v) => v switch
{
"INTAKE_CLERK" => UserRole.IntakeClerk,
"DATA_ENTRY_CLERK" => UserRole.DataEntryClerk,
"VERIFIER" => UserRole.Verifier,
"CLINICAL_APPROVER" => UserRole.ClinicalApprover,
"CLINICIAN" => UserRole.Clinician,
"ADMINISTRATOR" => UserRole.Administrator,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown user role: '{v}'")
};
}
@@ -0,0 +1,23 @@
using Serilog.Context;
public sealed class CorrelationIdMiddleware
{
private const string Header = "X-Correlation-Id";
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
var correlationId = ctx.Request.Headers[Header].FirstOrDefault()
?? Guid.NewGuid().ToString();
ctx.Response.Headers[Header] = correlationId;
ctx.Items["CorrelationId"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(ctx);
}
}
}
@@ -0,0 +1,50 @@
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (NotFoundException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status404NotFound,
ApiResponse<object>.Fail(StatusCodes.Status404NotFound, ex.Message, ex.ErrorCode));
}
catch (ValidationException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status422UnprocessableEntity,
ApiResponse<object>.Fail(StatusCodes.Status422UnprocessableEntity, ex.Message, ex.ErrorCode));
}
catch (ConflictException ex)
{
_logger.LogWarning("{Message}", ex.Message);
await WriteAsync(context, StatusCodes.Status409Conflict,
ApiResponse<object>.Fail(StatusCodes.Status409Conflict, ex.Message, ex.ErrorCode));
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await WriteAsync(context, StatusCodes.Status500InternalServerError,
ApiResponse<object>.Fail(StatusCodes.Status500InternalServerError,
"An unexpected error occurred", "INTERNAL_ERROR"));
}
}
private static async Task WriteAsync<T>(HttpContext context, int status, ApiResponse<T> body)
{
context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(body);
}
}
@@ -0,0 +1 @@
public record LoginRequest(string Username, string Password);
@@ -0,0 +1 @@
public record LoginResponse(string Token, Guid UserId, string Username, string FullName, string Role);
@@ -0,0 +1 @@
public record AssignBatchRequest(Guid EntryClerkUserId);
@@ -0,0 +1,47 @@
/// <summary>
/// API representation of a digitization batch. Hides EF navigation properties
/// and exposes enum fields as DB string literals.
/// </summary>
public record BatchDetailResponse(
Guid Id,
string Status,
string BatchType,
string Track,
Guid? PatientId,
string DocumentRef,
string? DocumentUrl,
bool EnableRetroactiveAlerts,
Guid? EnteredByUserId,
Guid? VerifiedByUserId,
Guid? ApprovedByUserId,
string? RejectionReason,
DateTimeOffset? PromotedAt,
Guid? PromotionEncounterId,
Guid? SupersedesBatchId,
bool ClinicianAttestation,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
)
{
public static BatchDetailResponse FromEntity(DigitizationBatch batch, string? documentUrl = null) =>
new(
batch.Id,
batch.Status.ToDbString(),
batch.BatchType.ToDbString(),
batch.Track.ToDbString(),
batch.PatientId,
batch.DocumentRef,
documentUrl,
batch.EnableRetroactiveAlerts,
batch.EnteredByUserId,
batch.VerifiedByUserId,
batch.ApprovedByUserId,
batch.RejectionReason,
batch.PromotedAt,
batch.PromotionEncounterId,
batch.SupersedesBatchId,
batch.ClinicianAttestation,
batch.CreatedAt,
batch.UpdatedAt
);
}
@@ -0,0 +1,8 @@
/// <summary>
/// Multipart form metadata for batch creation (file is bound separately as <c>IFormFile</c>).
/// </summary>
public record CreateBatchRequest(
string BatchType,
string? Track,
Guid? PatientId
);
@@ -0,0 +1,9 @@
public record PagedResult<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount
)
{
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
}
+94
View File
@@ -0,0 +1,94 @@
using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Minio;
using Serilog;
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
// Configuration
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.Section));
// Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// MinIO
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!;
builder.Services.AddSingleton<IMinioClient>(new MinioClient()
.WithEndpoint(minioOptions.Endpoint)
.WithCredentials(minioOptions.AccessKey, minioOptions.SecretKey)
.WithSSL(minioOptions.UseSsl)
.Build());
// JWT Authentication
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret))
};
});
builder.Services.AddAuthorization();
// Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IBatchService, BatchService>();
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
});
var app = builder.Build();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<ExceptionHandlerMiddleware>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await DataSeeder.SeedAsync(db);
}
}
catch (System.Exception)
{
throw;
}
public partial class Program { }
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:34859",
"sslPort": 44301
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7223;http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,62 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly JwtOptions _jwtOptions;
public AuthService(AppDbContext db, IOptions<JwtOptions> jwtOptions)
{
_db = db;
_jwtOptions = jwtOptions.Value;
}
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == req.Username);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS");
if (!user.IsActive)
throw new ConflictException("Account is disabled.", "ACCOUNT_DISABLED");
var token = GenerateJwt(user);
return new LoginResponse(token, user.Id, user.Username, user.FullName, user.Role.ToDbString());
}
public async Task<User> GetCurrentUserAsync(Guid userId)
{
var user = await _db.Users.FindAsync(userId);
if (user is null)
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
return user;
}
private string GenerateJwt(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role.ToDbString()),
new Claim("fullName", user.FullName)
};
var token = new JwtSecurityToken(
issuer: _jwtOptions.Issuer,
audience: _jwtOptions.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwtOptions.ExpiryMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,182 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class BatchService : IBatchService
{
private static readonly Dictionary<BatchStatus, HashSet<BatchStatus>> _allowedTransitions = new()
{
[BatchStatus.Uploaded] = new() { BatchStatus.InEntry },
[BatchStatus.InEntry] = new() { BatchStatus.PendingVerification },
[BatchStatus.PendingVerification] = new() { BatchStatus.Verified, BatchStatus.Rejected, BatchStatus.AwaitingClinicalApproval },
[BatchStatus.Rejected] = new() { BatchStatus.InEntry },
[BatchStatus.Verified] = new() { BatchStatus.Approved },
[BatchStatus.AwaitingClinicalApproval] = new() { BatchStatus.Approved, BatchStatus.Rejected },
[BatchStatus.Approved] = new() { BatchStatus.Promoted },
[BatchStatus.Promoted] = new(),
};
private readonly AppDbContext _db;
private readonly IDocumentStorageService _storage;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<BatchService> _logger;
public BatchService(AppDbContext db, IDocumentStorageService storage,
IConnectionMultiplexer redis, ILogger<BatchService> logger)
{
_db = db;
_storage = storage;
_redis = redis;
_logger = logger;
}
public async Task<DigitizationBatch> CreateAsync(
Stream fileStream, string contentType, BatchType batchType,
BatchTrack track, Guid? patientId, Guid actorUserId)
{
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid());
// Duplicate detection: same SHA-256 for same patient within 24 hours.
// Cross-patient duplicates (same form scanned for two patients) are allowed.
if (patientId.HasValue)
{
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
var duplicate = await _db.DigitizationBatches.AnyAsync(b =>
b.DocumentSha256 == sha256 &&
b.PatientId == patientId.Value &&
b.CreatedAt >= cutoff);
if (duplicate)
throw new ConflictException(
"A document with the same content was uploaded for this patient within the last 24 hours.",
"DUPLICATE_DOCUMENT");
}
var batchId = Guid.NewGuid();
var batch = new DigitizationBatch
{
Id = batchId,
Status = BatchStatus.Uploaded,
BatchType = batchType,
Track = track,
PatientId = patientId,
DocumentRef = objectKey,
DocumentSha256 = sha256,
EnableRetroactiveAlerts = false,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var document = new ScannedDocument
{
Id = Guid.NewGuid(),
BatchId = batchId,
ObjectKey = objectKey,
Sha256 = sha256,
ContentType = contentType,
FileSizeBytes = fileSize,
UploadedAt = DateTimeOffset.UtcNow
};
var evt = new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Uploaded,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow
};
_db.DigitizationBatches.Add(batch);
_db.ScannedDocuments.Add(document);
_db.DigitizationEvents.Add(evt);
await _db.SaveChangesAsync();
_logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey);
return batch;
}
public async Task<DigitizationBatch> GetByIdAsync(Guid id)
{
var batch = await _db.DigitizationBatches
.Include(b => b.Document)
.Include(b => b.DraftPatient)
.Include(b => b.DraftEncounter)
.Include(b => b.DraftObservations)
.FirstOrDefaultAsync(b => b.Id == id);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
return batch;
}
public async Task<PagedResult<DigitizationBatch>> ListAsync(
BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track,
int page, int pageSize)
{
var query = _db.DigitizationBatches.AsQueryable();
if (status.HasValue)
query = query.Where(b => b.Status == status.Value);
if (batchType.HasValue)
query = query.Where(b => b.BatchType == batchType.Value);
if (assignedTo.HasValue)
query = query.Where(b => b.EnteredByUserId == assignedTo.Value);
if (track.HasValue)
query = query.Where(b => b.Track == track.Value);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<DigitizationBatch>(items, page, pageSize, total);
}
public async Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId)
{
var batch = await _db.DigitizationBatches.FindAsync(batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
if (batch.Status != BatchStatus.Uploaded)
throw new ConflictException(
"Only batches in 'uploaded' status can be assigned.",
"ILLEGAL_STATUS_TRANSITION");
// Redis lock to prevent double-assignment
var cache = _redis.GetDatabase();
var lockKey = $"batch:assign:{batchId}";
var acquired = await cache.StringSetAsync(lockKey, entryClerkUserId.ToString(),
TimeSpan.FromHours(1), When.NotExists);
if (!acquired)
throw new ConflictException(
"This batch is already assigned to another clerk.",
"BATCH_ALREADY_ASSIGNED");
batch.EnteredByUserId = entryClerkUserId;
batch.UpdatedAt = DateTimeOffset.UtcNow;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.EntryStarted,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(new { assignedTo = entryClerkUserId })
});
await _db.SaveChangesAsync();
return batch;
}
public BatchStatus[] GetAllowedTransitions(BatchStatus current) =>
_allowedTransitions.TryGetValue(current, out var targets)
? targets.ToArray()
: Array.Empty<BatchStatus>();
}
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
public class DocumentStorageService : IDocumentStorageService
{
private readonly IMinioClient _minio;
private readonly MinioOptions _options;
public DocumentStorageService(IMinioClient minio, IOptions<MinioOptions> options)
{
_minio = minio;
_options = options.Value;
}
public async Task<(string objectKey, string sha256, long fileSize)> UploadAsync(
Stream fileStream, string contentType, Guid batchId)
{
var now = DateTimeOffset.UtcNow;
using var sha256 = SHA256.Create();
using var memStream = new MemoryStream();
await fileStream.CopyToAsync(memStream);
memStream.Position = 0;
var hashBytes = sha256.ComputeHash(memStream);
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
memStream.Position = 0;
var extension = contentType switch
{
"application/pdf" => "pdf",
"image/jpeg" => "jpg",
"image/png" => "png",
_ => "bin"
};
var objectKey = $"scans/{now.Year}/{now.Month:D2}/{batchId}/{hashHex}.{extension}";
await EnsureBucketAsync();
await _minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithStreamData(memStream)
.WithObjectSize(memStream.Length)
.WithContentType(contentType));
return (objectKey, hashHex, memStream.Length);
}
public async Task<string> GetPresignedUrlAsync(string objectKey)
{
return await _minio.PresignedGetObjectAsync(new PresignedGetObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
}
private async Task EnsureBucketAsync()
{
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
if (!exists)
await _minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(_options.BucketName));
}
}
@@ -0,0 +1,5 @@
public interface IAuthService
{
Task<LoginResponse> LoginAsync(LoginRequest req);
Task<User> GetCurrentUserAsync(Guid userId);
}
@@ -0,0 +1,8 @@
public interface IBatchService
{
Task<DigitizationBatch> CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId);
Task<DigitizationBatch> GetByIdAsync(Guid id);
Task<PagedResult<DigitizationBatch>> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize);
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
BatchStatus[] GetAllowedTransitions(BatchStatus current);
}
@@ -0,0 +1,5 @@
public interface IDocumentStorageService
{
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
Task<string> GetPresignedUrlAsync(string objectKey);
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Minio" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@VigilCareRecordsAPI_HostAddress = http://localhost:5217
GET {{VigilCareRecordsAPI_HostAddress}}/weatherforecast/
Accept: application/json
###
+52
View File
@@ -0,0 +1,52 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5437;Database=vigilcare_records;Username=postgres;Password=password"
},
"Redis": {
"ConnectionString": "localhost:6383"
},
"Seq": {
"ServerUrl": "http://localhost:5346"
},
"Minio": {
"Endpoint": "localhost:9012",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin",
"BucketName": "scans",
"UseSsl": false,
"PresignedUrlExpiryMinutes": 15
},
"Jwt": {
"Secret": "VigilCareRecordsDevSecretKeyAtLeast32Chars!",
"Issuer": "VigilCareRecords",
"Audience": "VigilCareRecords",
"ExpiryMinutes": 480
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5346"
}
}
],
"Enrich": [ "FromLogContext" ]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}