feature: Approval and Promotion to VigilCareClinical
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Approval and promotion of verified digitization batches to VigilCareClinical live tables.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/digitization-batches")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class ApprovalController : ControllerBase
|
||||
{
|
||||
private readonly IPromotionService _promotion;
|
||||
private readonly ILogger<ApprovalController> _logger;
|
||||
|
||||
public ApprovalController(IPromotionService promotion, ILogger<ApprovalController> logger)
|
||||
{
|
||||
_promotion = promotion;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves a verified or awaiting-clinical-approval batch and promotes its draft data
|
||||
/// into live VigilCareClinical tables (Patient, Encounter, Observations).
|
||||
///
|
||||
/// Requires the Idempotency-Key header for safe retries. If the same key is resubmitted,
|
||||
/// the original response is returned without re-executing the promotion.
|
||||
///
|
||||
/// Separation of duties: the approver cannot be the entry clerk or verifier of the same batch.
|
||||
/// </summary>
|
||||
/// <param name="id">The batch ID to approve.</param>
|
||||
/// <param name="request">Optional request body with alert configuration.</param>
|
||||
/// <returns>Promotion result with live IDs for patient, encounter, and observations.</returns>
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = "CLINICAL_APPROVER,ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PromotionResultResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Approve(Guid id, [FromBody] ApproveRequest? request)
|
||||
{
|
||||
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"Idempotency-Key header is required for promotion operations.",
|
||||
"MISSING_IDEMPOTENCY_KEY"));
|
||||
|
||||
if (idempotencyKey.Length > 100)
|
||||
return BadRequest(ApiResponse<object>.Fail(400,
|
||||
"Idempotency-Key must be at most 100 characters.",
|
||||
"INVALID_IDEMPOTENCY_KEY"));
|
||||
|
||||
var approverUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var enableRetroactiveAlerts = request?.EnableRetroactiveAlerts ?? false;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Approve request for batch {BatchId} by user {UserId} with idempotency key {Key}",
|
||||
id, approverUserId, idempotencyKey);
|
||||
|
||||
var result = await _promotion.ApproveAndPromoteAsync(
|
||||
id, approverUserId, enableRetroactiveAlerts, idempotencyKey);
|
||||
|
||||
return Ok(ApiResponse<PromotionResultResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the promotion result for an already-promoted batch, including the live IDs
|
||||
/// for Patient, Encounter, and Observations that were created during promotion.
|
||||
/// </summary>
|
||||
/// <param name="id">The batch ID to query.</param>
|
||||
/// <returns>Promotion result with live clinical entity IDs.</returns>
|
||||
[HttpGet("{id:guid}/promotion-result")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ApiResponse<PromotionResultResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> GetPromotionResult(Guid id)
|
||||
{
|
||||
var result = await _promotion.GetPromotionResultAsync(id);
|
||||
return Ok(ApiResponse<PromotionResultResponse>.Ok(result));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ public class AppDbContext : DbContext
|
||||
public DbSet<DigitizationEvent> DigitizationEvents => Set<DigitizationEvent>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<AuthAuditEvent> AuthAuditEvents => Set<AuthAuditEvent>();
|
||||
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
||||
public DbSet<Patient> Patients => Set<Patient>();
|
||||
public DbSet<Encounter> Encounters => Set<Encounter>();
|
||||
public DbSet<Observation> Observations => Set<Observation>();
|
||||
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class EncounterConfiguration : IEntityTypeConfiguration<Encounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Encounter> builder)
|
||||
{
|
||||
builder.ToTable("encounters", "clinical", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department",
|
||||
"department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||
});
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.PatientId).HasColumnName("patient_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).IsRequired();
|
||||
builder.Property(e => e.SourceBatchId).HasColumnName("source_batch_id");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(e => e.Patient).WithMany().HasForeignKey(e => e.PatientId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasIndex(e => new { e.PatientId, e.Status }).HasDatabaseName("ix_encounters_patient_status");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class IdempotencyRecordConfiguration : IEntityTypeConfiguration<IdempotencyRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<IdempotencyRecord> builder)
|
||||
{
|
||||
builder.ToTable("idempotency_records");
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(r => r.IdempotencyKey).HasColumnName("idempotency_key").HasMaxLength(100).IsRequired();
|
||||
builder.Property(r => r.OperationName).HasColumnName("operation_name").HasMaxLength(100).IsRequired();
|
||||
builder.Property(r => r.ResourceId).HasColumnName("resource_id").IsRequired();
|
||||
builder.Property(r => r.HttpStatusCode).HasColumnName("http_status_code").IsRequired();
|
||||
builder.Property(r => r.ResponseBodyJson).HasColumnName("response_body_json").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(r => r.ExpiresAt).HasColumnName("expires_at").IsRequired();
|
||||
|
||||
builder.HasIndex(r => new { r.IdempotencyKey, r.OperationName })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_idempotency_records_key_operation");
|
||||
|
||||
builder.HasIndex(r => r.ExpiresAt)
|
||||
.HasDatabaseName("ix_idempotency_records_expires_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class ObservationConfiguration : IEntityTypeConfiguration<Observation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Observation> builder)
|
||||
{
|
||||
builder.ToTable("observations", "clinical");
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(o => o.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||
builder.Property(o => o.PatientId).HasColumnName("patient_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.Source).HasColumnName("source").HasMaxLength(30).IsRequired();
|
||||
builder.Property(o => o.SourceDraftObservationId).HasColumnName("source_draft_observation_id");
|
||||
builder.Property(o => o.SourceBatchId).HasColumnName("source_batch_id");
|
||||
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(o => o.Encounter).WithMany().HasForeignKey(o => o.EncounterId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(o => o.Patient).WithMany().HasForeignKey(o => o.PatientId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasIndex(o => new { o.EncounterId, o.ObservationCode }).HasDatabaseName("ix_observations_encounter_code");
|
||||
builder.HasIndex(o => o.SourceBatchId).HasFilter("source_batch_id IS NOT NULL").HasDatabaseName("ix_observations_source_batch");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class OutboxEventConfiguration : IEntityTypeConfiguration<OutboxEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OutboxEvent> builder)
|
||||
{
|
||||
builder.ToTable("outbox_events", "clinical");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(e => e.EventType).HasColumnName("event_type").HasMaxLength(100).IsRequired();
|
||||
builder.Property(e => e.AggregateType).HasColumnName("aggregate_type").HasMaxLength(50).IsRequired();
|
||||
builder.Property(e => e.AggregateId).HasColumnName("aggregate_id").IsRequired();
|
||||
builder.Property(e => e.PayloadJson).HasColumnName("payload_json").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(e => e.ProcessedAt).HasColumnName("processed_at");
|
||||
builder.Property(e => e.RetryCount).HasColumnName("retry_count").HasDefaultValue(0);
|
||||
|
||||
builder.HasIndex(e => e.ProcessedAt)
|
||||
.HasFilter("processed_at IS NULL")
|
||||
.HasDatabaseName("ix_outbox_events_unprocessed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class PatientConfiguration : IEntityTypeConfiguration<Patient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Patient> builder)
|
||||
{
|
||||
builder.ToTable("patients", "clinical", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_patients_blood_type",
|
||||
"blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
|
||||
});
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(p => p.Mrn).HasColumnName("mrn").HasMaxLength(20).IsRequired();
|
||||
builder.Property(p => p.FullName).HasColumnName("full_name").HasMaxLength(200).IsRequired();
|
||||
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.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(p => p.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(p => p.Mrn).IsUnique().HasDatabaseName("ix_patients_mrn");
|
||||
}
|
||||
}
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
// <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("20260626060803_AddIdempotencyRecords")]
|
||||
partial class AddIdempotencyRecords
|
||||
{
|
||||
/// <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("AuthAuditEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.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.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "OccurredAt");
|
||||
|
||||
b.ToTable("auth_audit_events", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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("IdempotencyRecord", 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<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<int>("HttpStatusCode")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("http_status_code");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("OperationName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("operation_name");
|
||||
|
||||
b.Property<Guid>("ResourceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<string>("ResponseBodyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("response_body_json");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("ix_idempotency_records_expires_at");
|
||||
|
||||
b.HasIndex("IdempotencyKey", "OperationName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_idempotency_records_key_operation");
|
||||
|
||||
b.ToTable("idempotency_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", 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<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<Guid?>("ReplacedByTokenId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("replaced_by_token_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReplacedByTokenId");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "RevokedAt");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScannedDocument", b =>
|
||||
{
|
||||
b.Property<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("AuthAuditEvent", b =>
|
||||
{
|
||||
b.HasOne("User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
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("RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("RefreshToken", "ReplacedByToken")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReplacedByTokenId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ReplacedByToken");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScannedDocument", b =>
|
||||
{
|
||||
b.HasOne("DigitizationBatch", "Batch")
|
||||
.WithOne("Document")
|
||||
.HasForeignKey("ScannedDocument", "BatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Batch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DigitizationBatch", b =>
|
||||
{
|
||||
b.Navigation("Document");
|
||||
|
||||
b.Navigation("DraftEncounter");
|
||||
|
||||
b.Navigation("DraftObservations");
|
||||
|
||||
b.Navigation("DraftPatient");
|
||||
|
||||
b.Navigation("Events");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIdempotencyRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "idempotency_records",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
idempotency_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
operation_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
resource_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
http_status_code = table.Column<int>(type: "integer", nullable: false),
|
||||
response_body_json = table.Column<string>(type: "jsonb", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_idempotency_records", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_idempotency_records_expires_at",
|
||||
table: "idempotency_records",
|
||||
column: "expires_at");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_idempotency_records_key_operation",
|
||||
table: "idempotency_records",
|
||||
columns: new[] { "idempotency_key", "operation_name" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "idempotency_records");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddClinicalSchemaAndPromotion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "clinical");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_events",
|
||||
schema: "clinical",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
event_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
aggregate_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
aggregate_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
payload_json = table.Column<string>(type: "jsonb", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
retry_count = table.Column<int>(type: "integer", nullable: false, defaultValue: 0)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_outbox_events", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "patients",
|
||||
schema: "clinical",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
mrn = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
full_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
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),
|
||||
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_patients", x => x.id);
|
||||
table.CheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "encounters",
|
||||
schema: "clinical",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
patient_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: false),
|
||||
source_batch_id = table.Column<Guid>(type: "uuid", 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_encounters", x => x.id);
|
||||
table.CheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||
table.ForeignKey(
|
||||
name: "FK_encounters_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalSchema: "clinical",
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "observations",
|
||||
schema: "clinical",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
patient_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),
|
||||
source = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
source_draft_observation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
source_batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_observations", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_observations_encounters_encounter_id",
|
||||
column: x => x.encounter_id,
|
||||
principalSchema: "clinical",
|
||||
principalTable: "encounters",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_observations_patients_patient_id",
|
||||
column: x => x.patient_id,
|
||||
principalSchema: "clinical",
|
||||
principalTable: "patients",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_encounters_patient_status",
|
||||
schema: "clinical",
|
||||
table: "encounters",
|
||||
columns: new[] { "patient_id", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_observations_encounter_code",
|
||||
schema: "clinical",
|
||||
table: "observations",
|
||||
columns: new[] { "encounter_id", "observation_code" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_observations_patient_id",
|
||||
schema: "clinical",
|
||||
table: "observations",
|
||||
column: "patient_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_observations_source_batch",
|
||||
schema: "clinical",
|
||||
table: "observations",
|
||||
column: "source_batch_id",
|
||||
filter: "source_batch_id IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_events_unprocessed",
|
||||
schema: "clinical",
|
||||
table: "outbox_events",
|
||||
column: "processed_at",
|
||||
filter: "processed_at IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_patients_mrn",
|
||||
schema: "clinical",
|
||||
table: "patients",
|
||||
column: "mrn",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "observations",
|
||||
schema: "clinical");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_events",
|
||||
schema: "clinical");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "encounters",
|
||||
schema: "clinical");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "patients",
|
||||
schema: "clinical");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareRecordsAPI.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMrnSequence : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"CREATE SEQUENCE IF NOT EXISTS clinical.mrn_sequence START WITH 1 INCREMENT BY 1;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP SEQUENCE IF EXISTS clinical.mrn_sequence;");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,332 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", 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<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<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<string>("RoomBed")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("room_bed");
|
||||
|
||||
b.Property<Guid?>("SourceBatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("source_batch_id");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.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("PatientId", "Status")
|
||||
.HasDatabaseName("ix_encounters_patient_status");
|
||||
|
||||
b.ToTable("encounters", "clinical", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("IdempotencyRecord", b =>
|
||||
{
|
||||
b.Property<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<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<int>("HttpStatusCode")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("http_status_code");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("OperationName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("operation_name");
|
||||
|
||||
b.Property<Guid>("ResourceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<string>("ResponseBodyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("response_body_json");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("ix_idempotency_records_expires_at");
|
||||
|
||||
b.HasIndex("IdempotencyKey", "OperationName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_idempotency_records_key_operation");
|
||||
|
||||
b.ToTable("idempotency_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", 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<Guid>("EncounterId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
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<Guid>("PatientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("patient_id");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasColumnName("source");
|
||||
|
||||
b.Property<Guid?>("SourceBatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("source_batch_id");
|
||||
|
||||
b.Property<Guid?>("SourceDraftObservationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("source_draft_observation_id");
|
||||
|
||||
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("PatientId");
|
||||
|
||||
b.HasIndex("SourceBatchId")
|
||||
.HasDatabaseName("ix_observations_source_batch")
|
||||
.HasFilter("source_batch_id IS NOT NULL");
|
||||
|
||||
b.HasIndex("EncounterId", "ObservationCode")
|
||||
.HasDatabaseName("ix_observations_encounter_code");
|
||||
|
||||
b.ToTable("observations", "clinical");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OutboxEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid>("AggregateId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("aggregate_id");
|
||||
|
||||
b.Property<string>("AggregateType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("aggregate_type");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload_json");
|
||||
|
||||
b.Property<DateTimeOffset?>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("retry_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProcessedAt")
|
||||
.HasDatabaseName("ix_outbox_events_unprocessed")
|
||||
.HasFilter("processed_at IS NULL");
|
||||
|
||||
b.ToTable("outbox_events", "clinical");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Patient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AllergiesJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("allergies_json");
|
||||
|
||||
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")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("full_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("mrn");
|
||||
|
||||
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("Mrn")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_patients_mrn");
|
||||
|
||||
b.ToTable("patients", "clinical", t =>
|
||||
{
|
||||
t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -663,6 +989,36 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
||||
b.Navigation("Batch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Encounter", b =>
|
||||
{
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Observation", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
.WithMany()
|
||||
.HasForeignKey("EncounterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Encounter");
|
||||
|
||||
b.Navigation("Patient");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("RefreshToken", "ReplacedByToken")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
public class Encounter
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PatientId { 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; } = null!;
|
||||
public Guid? SourceBatchId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public Patient Patient { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public class IdempotencyRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string IdempotencyKey { get; set; } = null!;
|
||||
public string OperationName { get; set; } = null!;
|
||||
public Guid ResourceId { get; set; }
|
||||
public int HttpStatusCode { get; set; }
|
||||
public string ResponseBodyJson { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
public class Observation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EncounterId { get; set; }
|
||||
public Guid PatientId { 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 string Source { get; set; } = null!;
|
||||
public Guid? SourceDraftObservationId { get; set; }
|
||||
public Guid? SourceBatchId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
public Patient Patient { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public class OutboxEvent
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string EventType { get; set; } = null!;
|
||||
public string AggregateType { get; set; } = null!;
|
||||
public Guid AggregateId { get; set; }
|
||||
public string PayloadJson { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ProcessedAt { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
public class Patient
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Mrn { get; set; } = null!;
|
||||
public string FullName { get; set; } = null!;
|
||||
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 DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
public record ApproveRequest(
|
||||
bool? EnableRetroactiveAlerts = false
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
public record PromotionResultResponse(
|
||||
Guid BatchId,
|
||||
string Status,
|
||||
Guid PatientId,
|
||||
string Mrn,
|
||||
Guid EncounterId,
|
||||
Guid[] ObservationIds,
|
||||
DateTimeOffset PromotedAt,
|
||||
int OutboxEventsWritten
|
||||
);
|
||||
@@ -62,6 +62,9 @@ try
|
||||
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
|
||||
builder.Services.AddScoped<IVerificationService, VerificationService>();
|
||||
builder.Services.AddScoped<IWorkQueueService, WorkQueueService>();
|
||||
builder.Services.AddScoped<IPromotionService, PromotionService>();
|
||||
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
||||
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class IdempotencyService : IIdempotencyService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24);
|
||||
|
||||
public IdempotencyService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IdempotencyRecord?> GetExistingAsync(string idempotencyKey, string operationName)
|
||||
{
|
||||
var record = await _db.IdempotencyRecords
|
||||
.FirstOrDefaultAsync(r =>
|
||||
r.IdempotencyKey == idempotencyKey &&
|
||||
r.OperationName == operationName &&
|
||||
r.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
|
||||
int httpStatusCode, object responseBody, TimeSpan? ttl = null)
|
||||
{
|
||||
var effectiveTtl = ttl ?? DefaultTtl;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record = new IdempotencyRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdempotencyKey = idempotencyKey,
|
||||
OperationName = operationName,
|
||||
ResourceId = resourceId,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
ResponseBodyJson = JsonSerializer.Serialize(responseBody, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
}),
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now.Add(effectiveTtl)
|
||||
};
|
||||
|
||||
_db.IdempotencyRecords.Add(record);
|
||||
// SaveChanges is called by the caller (within the same transaction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
public interface IIdempotencyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the cached response if the key was already used, or null if this is a new key.
|
||||
/// </summary>
|
||||
Task<IdempotencyRecord?> GetExistingAsync(string idempotencyKey, string operationName);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the result of an operation for future deduplication.
|
||||
/// Must be called within the same transaction as the operation.
|
||||
/// </summary>
|
||||
Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
|
||||
int httpStatusCode, object responseBody, TimeSpan? ttl = null);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public interface IMrnGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the next unique MRN in the format "VCR-{6-digit padded number}".
|
||||
/// Uses a PostgreSQL sequence for atomic increment under concurrent access.
|
||||
/// </summary>
|
||||
Task<string> GenerateNextMrnAsync();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
public interface IPromotionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Approves a verified or awaiting-clinical-approval batch and promotes its draft data
|
||||
/// into live VigilCareClinical tables within a single atomic transaction.
|
||||
/// </summary>
|
||||
/// <param name="batchId">The batch to promote.</param>
|
||||
/// <param name="approverUserId">The user performing the approval (separation-of-duties enforced).</param>
|
||||
/// <param name="enableRetroactiveAlerts">
|
||||
/// If true, outbox events are written for backfill observations so the alert engine processes them.
|
||||
/// If false (default), backfill observations are silently inserted with no alert path.
|
||||
/// Live-capture track always writes outbox events regardless of this flag.
|
||||
/// </param>
|
||||
/// <param name="idempotencyKey">Optional key for idempotent promotion. If provided and already used, returns cached result.</param>
|
||||
/// <returns>The promotion result with all created live IDs.</returns>
|
||||
Task<PromotionResultResponse> ApproveAndPromoteAsync(
|
||||
Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the promotion result for an already-promoted batch.
|
||||
/// </summary>
|
||||
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
public class MrnGenerator : IMrnGenerator
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private const string MrnPrefix = "VCR";
|
||||
private const string SequenceName = "clinical.mrn_sequence";
|
||||
|
||||
public MrnGenerator(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateNextMrnAsync()
|
||||
{
|
||||
// Use PostgreSQL sequence for atomic, gap-free numbering
|
||||
var connection = _db.Database.GetDbConnection();
|
||||
var wasOpen = connection.State == System.Data.ConnectionState.Open;
|
||||
|
||||
if (!wasOpen)
|
||||
await connection.OpenAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = $"SELECT nextval('{SequenceName}')";
|
||||
|
||||
// If we're in a transaction, enlist the command
|
||||
if (_db.Database.CurrentTransaction is not null)
|
||||
{
|
||||
command.Transaction = _db.Database.CurrentTransaction.GetDbTransaction();
|
||||
}
|
||||
|
||||
var nextVal = (long)(await command.ExecuteScalarAsync())!;
|
||||
return $"{MrnPrefix}-{nextVal:D6}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!wasOpen)
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class PromotionService : IPromotionService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IIdempotencyService _idempotency;
|
||||
private readonly IMrnGenerator _mrnGenerator;
|
||||
private readonly ILogger<PromotionService> _logger;
|
||||
|
||||
public PromotionService(
|
||||
AppDbContext db,
|
||||
IIdempotencyService idempotency,
|
||||
IMrnGenerator mrnGenerator,
|
||||
ILogger<PromotionService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_idempotency = idempotency;
|
||||
_mrnGenerator = mrnGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PromotionResultResponse> ApproveAndPromoteAsync(
|
||||
Guid batchId, Guid approverUserId, bool enableRetroactiveAlerts, string? idempotencyKey)
|
||||
{
|
||||
// --- Idempotency check (before transaction) ---
|
||||
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
var existing = await _idempotency.GetExistingAsync(idempotencyKey, "batch_promote");
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Idempotent replay for batch {BatchId} with key {Key}",
|
||||
batchId, idempotencyKey);
|
||||
|
||||
return JsonSerializer.Deserialize<PromotionResultResponse>(
|
||||
existing.ResponseBodyJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load batch with all draft data ---
|
||||
var batch = await _db.DigitizationBatches
|
||||
.Include(b => b.DraftPatient)
|
||||
.Include(b => b.DraftEncounter)
|
||||
.Include(b => b.DraftObservations)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
// --- Status validation ---
|
||||
if (batch.Status != BatchStatus.Verified && batch.Status != BatchStatus.AwaitingClinicalApproval)
|
||||
throw new ConflictException(
|
||||
$"Batch must be in 'verified' or 'awaiting_clinical_approval' status to approve. Current status: '{batch.Status.ToDbString()}'.",
|
||||
"ILLEGAL_STATUS_TRANSITION");
|
||||
|
||||
// --- Separation of duties: approver cannot be the entry clerk ---
|
||||
if (batch.EnteredByUserId == approverUserId)
|
||||
throw new ConflictException(
|
||||
"Separation of duties: the entry clerk cannot approve their own batch.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Also cannot be the verifier ---
|
||||
if (batch.VerifiedByUserId == approverUserId)
|
||||
throw new ConflictException(
|
||||
"Separation of duties: the verifier cannot also approve the same batch.",
|
||||
"SEPARATION_OF_DUTIES_VIOLATION");
|
||||
|
||||
// --- Validate draft data completeness ---
|
||||
if (batch.DraftPatient is null)
|
||||
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
||||
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
|
||||
|
||||
// --- Begin atomic transaction ---
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// === Step 1: Create or update Patient ===
|
||||
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
|
||||
|
||||
// === Step 2: Create or match Encounter ===
|
||||
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
|
||||
|
||||
// === Step 3: Insert each DraftObservation as live Observation ===
|
||||
var (observationIds, outboxCount) = await PromoteObservationsAsync(
|
||||
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
|
||||
|
||||
// === Step 4: Update batch status to Promoted ===
|
||||
batch.Status = BatchStatus.Promoted;
|
||||
batch.ApprovedByUserId = approverUserId;
|
||||
batch.PromotedAt = now;
|
||||
batch.PromotionEncounterId = encounter.Id;
|
||||
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
|
||||
batch.UpdatedAt = now;
|
||||
|
||||
// === Step 5: Write DigitizationEvent ===
|
||||
_db.DigitizationEvents.Add(new DigitizationEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
EventType = DigitizationEventType.Promoted,
|
||||
ActorUserId = approverUserId,
|
||||
OccurredAt = now,
|
||||
MetadataJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
patientId = patient.Id,
|
||||
mrn = patient.Mrn,
|
||||
encounterId = encounter.Id,
|
||||
observationCount = observationIds.Length,
|
||||
outboxEventsWritten = outboxCount,
|
||||
enableRetroactiveAlerts,
|
||||
track = batch.Track.ToDbString()
|
||||
})
|
||||
});
|
||||
|
||||
// === Step 6: Store idempotency record (within same transaction) ===
|
||||
var result = new PromotionResultResponse(
|
||||
BatchId: batchId,
|
||||
Status: BatchStatus.Promoted.ToDbString(),
|
||||
PatientId: patient.Id,
|
||||
Mrn: patient.Mrn,
|
||||
EncounterId: encounter.Id,
|
||||
ObservationIds: observationIds,
|
||||
PromotedAt: now,
|
||||
OutboxEventsWritten: outboxCount
|
||||
);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
{
|
||||
await _idempotency.SaveAsync(
|
||||
idempotencyKey, "batch_promote", batchId,
|
||||
200, result, TimeSpan.FromHours(24));
|
||||
}
|
||||
|
||||
// === Step 7: Commit ===
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch {BatchId} promoted: Patient {PatientId} (MRN {Mrn}), " +
|
||||
"Encounter {EncounterId}, {ObsCount} observations, {OutboxCount} outbox events",
|
||||
batchId, patient.Id, patient.Mrn, encounter.Id,
|
||||
observationIds.Length, outboxCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId)
|
||||
{
|
||||
var batch = await _db.DigitizationBatches
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId);
|
||||
|
||||
if (batch is null)
|
||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
||||
|
||||
if (batch.Status != BatchStatus.Promoted)
|
||||
throw new ConflictException(
|
||||
$"Batch is not promoted. Current status: '{batch.Status.ToDbString()}'.",
|
||||
"BATCH_NOT_PROMOTED");
|
||||
|
||||
// Retrieve the live patient by looking up the encounter
|
||||
var encounter = await _db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.FirstOrDefaultAsync(e => e.Id == batch.PromotionEncounterId);
|
||||
|
||||
if (encounter is null)
|
||||
throw new NotFoundException(
|
||||
"Promotion encounter not found. Data may be inconsistent.",
|
||||
"PROMOTION_ENCOUNTER_NOT_FOUND");
|
||||
|
||||
var observationIds = await _db.Observations
|
||||
.Where(o => o.SourceBatchId == batchId)
|
||||
.Select(o => o.Id)
|
||||
.ToArrayAsync();
|
||||
|
||||
return new PromotionResultResponse(
|
||||
BatchId: batchId,
|
||||
Status: batch.Status.ToDbString(),
|
||||
PatientId: encounter.PatientId,
|
||||
Mrn: encounter.Patient.Mrn,
|
||||
EncounterId: encounter.Id,
|
||||
ObservationIds: observationIds,
|
||||
PromotedAt: batch.PromotedAt!.Value,
|
||||
OutboxEventsWritten: 0 // Historical count not stored; use event metadata
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private async Task<Patient> CreateOrUpdatePatientAsync(DraftPatient draft, DateTimeOffset now)
|
||||
{
|
||||
// Attempt to match existing patient by name + DOB (simple dedup)
|
||||
Patient? existing = null;
|
||||
|
||||
if (draft.DateOfBirth.HasValue)
|
||||
{
|
||||
existing = await _db.Patients
|
||||
.FirstOrDefaultAsync(p =>
|
||||
p.FullName == draft.FullName &&
|
||||
p.DateOfBirth == draft.DateOfBirth);
|
||||
}
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
// Update fields that may have new information
|
||||
existing.Sex = draft.Sex ?? existing.Sex;
|
||||
existing.BloodType = draft.BloodType ?? existing.BloodType;
|
||||
existing.EmergencyContact = draft.EmergencyContact ?? existing.EmergencyContact;
|
||||
existing.AllergiesJson = draft.AllergiesJson ?? existing.AllergiesJson;
|
||||
existing.NoKnownAllergies = draft.NoKnownAllergies || existing.NoKnownAllergies;
|
||||
existing.UpdatedAt = now;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Matched existing patient {PatientId} (MRN {Mrn}) by name + DOB",
|
||||
existing.Id, existing.Mrn);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new patient with generated MRN
|
||||
var mrn = await _mrnGenerator.GenerateNextMrnAsync();
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = mrn,
|
||||
FullName = draft.FullName!,
|
||||
DateOfBirth = draft.DateOfBirth,
|
||||
Sex = draft.Sex,
|
||||
BloodType = draft.BloodType,
|
||||
EmergencyContact = draft.EmergencyContact,
|
||||
AllergiesJson = draft.AllergiesJson,
|
||||
NoKnownAllergies = draft.NoKnownAllergies,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.Patients.Add(patient);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new patient {PatientId} with MRN {Mrn}",
|
||||
patient.Id, mrn);
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
private async Task<Encounter> CreateOrMatchEncounterAsync(
|
||||
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
|
||||
{
|
||||
// If the batch has a draft encounter, try to match an active encounter
|
||||
// for the same patient in the same department
|
||||
if (batch.DraftEncounter is not null)
|
||||
{
|
||||
var existingEncounter = await _db.Encounters
|
||||
.FirstOrDefaultAsync(e =>
|
||||
e.PatientId == patientId &&
|
||||
e.Department == batch.DraftEncounter.Department &&
|
||||
e.Status == "active" &&
|
||||
e.SourceBatchId != batch.Id);
|
||||
|
||||
if (existingEncounter is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Matched existing active encounter {EncounterId} for patient {PatientId}",
|
||||
existingEncounter.Id, patientId);
|
||||
|
||||
return existingEncounter;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine encounter status from draft
|
||||
var encounterStatus = batch.DraftEncounter?.Status ?? "active";
|
||||
if (batch.DraftEncounter?.DischargeDiagnosis is not null)
|
||||
{
|
||||
encounterStatus = "discharged";
|
||||
}
|
||||
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patientId,
|
||||
AdmissionDate = batch.DraftEncounter?.AdmissionDate,
|
||||
Department = batch.DraftEncounter?.Department,
|
||||
RoomBed = batch.DraftEncounter?.RoomBed,
|
||||
AdmissionReason = batch.DraftEncounter?.AdmissionReason,
|
||||
DischargeDiagnosis = batch.DraftEncounter?.DischargeDiagnosis,
|
||||
Status = encounterStatus,
|
||||
SourceBatchId = batch.Id,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.Encounters.Add(encounter);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created encounter {EncounterId} for patient {PatientId} with status '{Status}'",
|
||||
encounter.Id, patientId, encounterStatus);
|
||||
|
||||
return encounter;
|
||||
}
|
||||
|
||||
private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
|
||||
DigitizationBatch batch, Guid patientId, Guid encounterId,
|
||||
bool enableRetroactiveAlerts, DateTimeOffset now)
|
||||
{
|
||||
var observationIds = new List<Guid>();
|
||||
var outboxCount = 0;
|
||||
|
||||
// Determine the source label based on batch track
|
||||
var source = batch.Track == BatchTrack.LiveCapture
|
||||
? "live_capture"
|
||||
: "digitization_backfill";
|
||||
|
||||
// Determine whether outbox events should be written for this batch
|
||||
var shouldAlert = batch.Track == BatchTrack.LiveCapture || enableRetroactiveAlerts;
|
||||
|
||||
foreach (var draft in batch.DraftObservations)
|
||||
{
|
||||
var observationId = Guid.NewGuid();
|
||||
|
||||
var observation = new Observation
|
||||
{
|
||||
Id = observationId,
|
||||
EncounterId = encounterId,
|
||||
PatientId = patientId,
|
||||
ObservationCode = draft.ObservationCode,
|
||||
Value = draft.Value,
|
||||
Unit = draft.Unit,
|
||||
RecordedAt = draft.RecordedAt,
|
||||
Note = draft.Note,
|
||||
Source = source,
|
||||
SourceDraftObservationId = draft.Id,
|
||||
SourceBatchId = batch.Id,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.Observations.Add(observation);
|
||||
observationIds.Add(observationId);
|
||||
|
||||
// Write outbox event only if alerting is enabled for this batch
|
||||
if (shouldAlert)
|
||||
{
|
||||
_db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EventType = "observation.created",
|
||||
AggregateType = "Observation",
|
||||
AggregateId = observationId,
|
||||
PayloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
observationId,
|
||||
encounterId,
|
||||
patientId,
|
||||
observationCode = draft.ObservationCode,
|
||||
value = draft.Value,
|
||||
unit = draft.Unit,
|
||||
recordedAt = draft.RecordedAt,
|
||||
source,
|
||||
batchId = batch.Id,
|
||||
track = batch.Track.ToDbString()
|
||||
}),
|
||||
CreatedAt = now,
|
||||
ProcessedAt = null,
|
||||
RetryCount = 0
|
||||
});
|
||||
|
||||
outboxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Promoted {Count} observations for batch {BatchId}, " +
|
||||
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
|
||||
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
|
||||
|
||||
return (observationIds.ToArray(), outboxCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user