From b50a0ec305931d8455a1ec409c3c27f0cad94c68 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 27 Jun 2026 20:23:27 +0800 Subject: [PATCH] fix: No audit of field-level draft changes --- .../DigitizationEventConfiguration.cs | 2 +- ...085729_AddDraftAuditEventTypes.Designer.cs | 1461 +++++++++++++++++ .../20260627085729_AddDraftAuditEventTypes.cs | 36 + .../Migrations/AppDbContextModelSnapshot.cs | 2 +- .../Domain/Enums/DigitizationEventType.cs | 8 +- VigilCareRecordsAPI/Services/DraftService.cs | 141 +- docs/vigilcare-records-gap-analysis.md | 4 +- 7 files changed, 1637 insertions(+), 17 deletions(-) create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.Designer.cs create mode 100644 VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.cs diff --git a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs index 0955271..8588a9d 100644 --- a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs +++ b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs @@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration { t.HasCheckConstraint("chk_digitization_events_event_type", - "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled')"); + "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); }); builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.Designer.cs new file mode 100644 index 0000000..3851a30 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.Designer.cs @@ -0,0 +1,1461 @@ +// +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("20260627085729_AddDraftAuditEventTypes")] + partial class AddDraftAuditEventTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", "clinical"); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("ClientAlertId") + .HasColumnType("uuid") + .HasColumnName("client_alert_id"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FeedbackReceived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("feedback_received"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("SyncedFromGateway") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("synced_from_gateway"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ClientAlertId") + .IsUnique() + .HasFilter("client_alert_id IS NOT NULL"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.HasIndex("EncounterId", "AlertType", "ObservationCode") + .HasFilter("status IN ('OPEN', 'ESCALATED')"); + + b.ToTable("clinical_alerts", "clinical", t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status"); + + b.ToTable("live_encounters", null, t => + { + t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsSuperseded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_superseded"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SupersededAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_at"); + + b.Property("SupersededByBatchId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_batch_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IsSuperseded") + .HasFilter("is_superseded = true"); + + b.HasIndex("SourceBatchId"); + + b.HasIndex("EncounterId", "ObservationCode"); + + b.ToTable("live_observations", (string)null); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("PromotionAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("AttemptedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("attempted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at"); + + b.Property("Succeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("succeeded"); + + b.HasKey("Id"); + + b.HasIndex("NextRetryAt") + .HasDatabaseName("ix_promotion_attempts_pending_retry") + .HasFilter("succeeded = false AND next_retry_at IS NOT NULL"); + + b.HasIndex("BatchId", "AttemptNumber") + .IsUnique() + .HasDatabaseName("ix_promotion_attempts_batch_attempt"); + + b.ToTable("promotion_attempts", (string)null); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.HasOne("Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.HasOne("LiveEncounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("PromotionAttempt", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Navigation("Observations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.cs b/VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.cs new file mode 100644 index 0000000..4f99a5b --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260627085729_AddDraftAuditEventTypes.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddDraftAuditEventTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events"); + + migrationBuilder.AddCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events", + sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events"); + + migrationBuilder.AddCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events", + sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled')"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs index a8d2526..b9db08b 100644 --- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -397,7 +397,7 @@ namespace VigilCareRecordsAPI.Data.Migrations b.ToTable("digitization_events", null, t => { - t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled')"); + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); }); }); diff --git a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs index 2466109..efb1070 100644 --- a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs +++ b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs @@ -20,7 +20,9 @@ public enum DigitizationEventType PromotionRetryExhausted, PromotionFailed, DocumentAccessed, - Cancelled + Cancelled, + DraftFieldUpdated, + DraftObservationDeleted } public static class DigitizationEventTypeExtensions @@ -48,6 +50,8 @@ public static class DigitizationEventTypeExtensions DigitizationEventType.PromotionFailed => "promotion_failed", DigitizationEventType.DocumentAccessed => "document_accessed", DigitizationEventType.Cancelled => "cancelled", + DigitizationEventType.DraftFieldUpdated => "draft_field_updated", + DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted", _ => throw new ArgumentOutOfRangeException(nameof(t)) }; @@ -74,6 +78,8 @@ public static class DigitizationEventTypeExtensions "promotion_failed" => DigitizationEventType.PromotionFailed, "document_accessed" => DigitizationEventType.DocumentAccessed, "cancelled" => DigitizationEventType.Cancelled, + "draft_field_updated" => DigitizationEventType.DraftFieldUpdated, + "draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted, _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'") }; } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/DraftService.cs b/VigilCareRecordsAPI/Services/DraftService.cs index 365ceee..3c1c130 100644 --- a/VigilCareRecordsAPI/Services/DraftService.cs +++ b/VigilCareRecordsAPI/Services/DraftService.cs @@ -57,6 +57,13 @@ public class DraftService : IDraftService parsedBloodType = parsed; } + var newAllergiesJson = req.Allergies is not null + ? JsonSerializer.Serialize(req.Allergies) + : null; + var newMedicationsJson = req.Medications is not null + ? JsonSerializer.Serialize(req.Medications) + : null; + var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId); if (patient is null) @@ -70,13 +77,9 @@ public class DraftService : IDraftService Sex = req.Sex, BloodType = parsedBloodType, EmergencyContact = req.EmergencyContact, - AllergiesJson = req.Allergies is not null - ? JsonSerializer.Serialize(req.Allergies) - : null, + AllergiesJson = newAllergiesJson, NoKnownAllergies = req.NoKnownAllergies, - MedicationsJson = req.Medications is not null - ? JsonSerializer.Serialize(req.Medications) - : null, + MedicationsJson = newMedicationsJson, NoActiveMedications = req.NoActiveMedications, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow @@ -85,18 +88,28 @@ public class DraftService : IDraftService } else { + var diffs = new List(); + DiffField(diffs, "patient.fullName", patient.FullName, req.FullName); + DiffField(diffs, "patient.dateOfBirth", patient.DateOfBirth?.ToString("yyyy-MM-dd"), req.DateOfBirth?.ToString("yyyy-MM-dd")); + DiffField(diffs, "patient.sex", patient.Sex, req.Sex); + DiffField(diffs, "patient.bloodType", patient.BloodType?.ToDbString(), parsedBloodType?.ToDbString()); + DiffField(diffs, "patient.emergencyContact", patient.EmergencyContact, req.EmergencyContact); + DiffField(diffs, "patient.allergiesJson", patient.AllergiesJson, newAllergiesJson); + DiffField(diffs, "patient.noKnownAllergies", patient.NoKnownAllergies.ToString(), req.NoKnownAllergies.ToString()); + DiffField(diffs, "patient.medicationsJson", patient.MedicationsJson, newMedicationsJson); + DiffField(diffs, "patient.noActiveMedications", patient.NoActiveMedications.ToString(), req.NoActiveMedications.ToString()); + + if (diffs.Count > 0) + await WriteDraftFieldEventAsync(batchId, actorUserId, "patient", diffs); + patient.FullName = req.FullName; patient.DateOfBirth = req.DateOfBirth; patient.Sex = req.Sex; patient.BloodType = parsedBloodType; patient.EmergencyContact = req.EmergencyContact; - patient.AllergiesJson = req.Allergies is not null - ? JsonSerializer.Serialize(req.Allergies) - : null; + patient.AllergiesJson = newAllergiesJson; patient.NoKnownAllergies = req.NoKnownAllergies; - patient.MedicationsJson = req.Medications is not null - ? JsonSerializer.Serialize(req.Medications) - : null; + patient.MedicationsJson = newMedicationsJson; patient.NoActiveMedications = req.NoActiveMedications; patient.UpdatedAt = DateTimeOffset.UtcNow; } @@ -147,6 +160,17 @@ public class DraftService : IDraftService } else { + var diffs = new List(); + DiffField(diffs, "encounter.admissionDate", encounter.AdmissionDate?.ToString("O"), req.AdmissionDate?.ToString("O")); + DiffField(diffs, "encounter.department", encounter.Department?.ToDbString(), parsedDepartment?.ToDbString()); + DiffField(diffs, "encounter.roomBed", encounter.RoomBed, req.RoomBed); + DiffField(diffs, "encounter.admissionReason", encounter.AdmissionReason, req.AdmissionReason); + DiffField(diffs, "encounter.dischargeDiagnosis", encounter.DischargeDiagnosis, req.DischargeDiagnosis); + DiffField(diffs, "encounter.status", encounter.Status, req.Status); + + if (diffs.Count > 0) + await WriteDraftFieldEventAsync(batchId, actorUserId, "encounter", diffs); + encounter.AdmissionDate = req.AdmissionDate; encounter.Department = parsedDepartment; encounter.RoomBed = req.RoomBed; @@ -213,6 +237,16 @@ public class DraftService : IDraftService if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); + var diffs = new List(); + DiffField(diffs, "observationCode", observation.ObservationCode, req.ObservationCode); + DiffField(diffs, "value", observation.Value.ToString(), req.Value.ToString()); + DiffField(diffs, "unit", observation.Unit, req.Unit); + DiffField(diffs, "recordedAt", observation.RecordedAt.ToString("O"), req.RecordedAt.ToString("O")); + DiffField(diffs, "note", observation.Note, req.Note); + + if (diffs.Count > 0) + await WriteDraftFieldEventAsync(batchId, actorUserId, $"observation:{observationId}", diffs); + observation.ObservationCode = req.ObservationCode; observation.Value = req.Value; observation.Unit = req.Unit; @@ -240,6 +274,23 @@ public class DraftService : IDraftService throw new NotFoundException( "Observation not found in this batch.", "OBSERVATION_NOT_FOUND"); + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.DraftObservationDeleted, + ActorUserId = actorUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(new + { + observationId, + observationCode = observation.ObservationCode, + value = observation.Value, + unit = observation.Unit, + recordedAt = observation.RecordedAt + }) + }); + _db.DraftObservations.Remove(observation); await _db.SaveChangesAsync(); @@ -538,6 +589,72 @@ public class DraftService : IDraftService errors.Add("Mixed batch requires at least one observation with recordedAt, or a complete encounter summary (admission date, department, admission reason)"); } + // ─── Field-level audit helpers ──────────────────────────────── + + private static void DiffField(List diffs, string field, string? oldValue, string? newValue) + { + if (string.Equals(oldValue ?? "", newValue ?? "", StringComparison.Ordinal)) + return; + + diffs.Add(new { field, oldValue = oldValue ?? "", newValue = newValue ?? "" }); + } + + private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(30); + + /// + /// Writes a DraftFieldUpdated event. If an event of the same type was written + /// for this batch within the last 30 seconds, merges the new fields into the + /// existing event's metadata to prevent audit noise from auto-save. + /// + private async Task WriteDraftFieldEventAsync( + Guid batchId, Guid actorUserId, string section, List fieldsChanged) + { + var cutoff = DateTimeOffset.UtcNow.Add(-DebounceWindow); + + var recent = await _db.DigitizationEvents + .Where(e => e.BatchId == batchId + && e.EventType == DigitizationEventType.DraftFieldUpdated + && e.ActorUserId == actorUserId + && e.OccurredAt >= cutoff) + .OrderByDescending(e => e.OccurredAt) + .FirstOrDefaultAsync(); + + if (recent is not null) + { + var existing = !string.IsNullOrWhiteSpace(recent.MetadataJson) + ? JsonSerializer.Deserialize>(recent.MetadataJson) + : new Dictionary(); + + var existingChanges = existing!.TryGetValue("fieldsChanged", out var fc) + ? JsonSerializer.Deserialize>(fc.GetRawText()) ?? new List() + : new List(); + + existingChanges.AddRange(fieldsChanged); + + existing["section"] = JsonSerializer.SerializeToElement(section); + existing["fieldsChanged"] = JsonSerializer.SerializeToElement(existingChanges); + + recent.MetadataJson = JsonSerializer.Serialize(existing); + recent.OccurredAt = DateTimeOffset.UtcNow; + } + else + { + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batchId, + EventType = DigitizationEventType.DraftFieldUpdated, + ActorUserId = actorUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(new + { + section, + fieldsChanged + }) + }); + } + } + // ─── Mapping helpers ───────────────────────────────────────── private static DraftPatientDto MapPatient(DraftPatient p) => new( diff --git a/docs/vigilcare-records-gap-analysis.md b/docs/vigilcare-records-gap-analysis.md index d25c116..063e4a4 100644 --- a/docs/vigilcare-records-gap-analysis.md +++ b/docs/vigilcare-records-gap-analysis.md @@ -637,7 +637,7 @@ Corrections are a daily clinical workflow. When a promoted batch has an error (w --- -## P4 — No toast notification system or success feedback +## ~~P4 — No toast notification system or success feedback~~ DONE ### Problem @@ -702,7 +702,7 @@ The frontend handles critical clinical workflows: data entry, verification, appr --- -## P5 — No audit of field-level draft changes +## ~~P5 — No audit of field-level draft changes~~ DONE ### Problem