diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs index 174b60c..e340c17 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs @@ -11,6 +11,7 @@ public static class DbResetHelper try { await db.Database.ExecuteSqlRawAsync(@" + DELETE FROM phi_access_logs; DELETE FROM medication_administrations; DELETE FROM sepsis_bundle_elements; DELETE FROM sepsis_bundles; diff --git a/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs b/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs new file mode 100644 index 0000000..2a4d731 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Phi/PhiEncryptionTests.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +[Collection("Integration")] +public class PhiEncryptionTests +{ + private readonly ApiFixture _fixture; + + public PhiEncryptionTests(ApiFixture fixture) => _fixture = fixture; + + [Fact] + public async Task PatientPhi_StoredEncrypted_ReturnsDecryptedViaApi() + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + client.AsAdmin(); + + var registerResp = await client.PostAsJsonAsync("/api/v1/patients", new + { + firstName = "Encrypted", + lastName = "Patient", + dateOfBirth = "1990-05-20", + gender = "female" + }); + registerResp.EnsureSuccessStatusCode(); + var body = await registerResp.Content.ReadFromJsonAsync(); + var patientId = body.GetProperty("data").GetProperty("id").GetGuid(); + + // Raw DB check — first_name should NOT equal plaintext + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var raw = await db.Database + .SqlQueryRaw($"SELECT first_name AS \"Value\" FROM patients WHERE id = '{patientId}'") + .FirstAsync(); + raw.Should().NotBe("Encrypted"); + + // API returns decrypted + var getResp = await client.GetAsync($"/api/v1/patients/{patientId}"); + getResp.EnsureSuccessStatusCode(); + var patient = await getResp.Content.ReadFromJsonAsync(); + patient.GetProperty("data").GetProperty("firstName").GetString() + .Should().Be("Encrypted"); + } + + [Fact] + public async Task PatientView_WritesPhiAccessLog() + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + var nurseId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + client.AsNurse(nurseId); + + await client.PostAsJsonAsync("/api/v1/patients", new + { + firstName = "PhiLog", + lastName = "TestPatient", + dateOfBirth = "1975-03-15", + gender = "male" + }); + + var listResp = await client.GetAsync("/api/v1/patients?pageSize=1"); + listResp.EnsureSuccessStatusCode(); + var list = await listResp.Content.ReadFromJsonAsync(); + var patientId = list.GetProperty("data").GetProperty("items")[0].GetProperty("id").GetGuid(); + + await client.GetAsync($"/api/v1/patients/{patientId}"); + + client.ClearAuth(); + client.AsAdmin(); + var logsResp = await client.GetAsync($"/api/v1/phi-access-logs?patientId={patientId}"); + logsResp.EnsureSuccessStatusCode(); + var logs = await logsResp.Content.ReadFromJsonAsync(); + logs.GetProperty("data").GetProperty("totalCount").GetInt32() + .Should().BeGreaterThan(0); + } + + [Fact] + public async Task NameSearch_FindsEncryptedPatient() + { + var client = _fixture.CreateClient(); + client.ClearAuth(); + client.AsAdmin(); + + await client.PostAsJsonAsync("/api/v1/patients", new + { + firstName = "Searchable", + lastName = "UniqueName", + dateOfBirth = "1985-01-01", + gender = "male" + }); + + var resp = await client.GetAsync("/api/v1/patients?q=Searchable+UniqueName"); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("data").GetProperty("totalCount").GetInt32() + .Should().BeGreaterThan(0); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/PatientPhiMigrationService.cs b/VigilCareClinicalAPI/BackgroundServices/PatientPhiMigrationService.cs new file mode 100644 index 0000000..674f211 --- /dev/null +++ b/VigilCareClinicalAPI/BackgroundServices/PatientPhiMigrationService.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; + +public class PatientPhiMigrationService : IHostedService +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + public PatientPhiMigrationService( + IServiceProvider services, + ILogger logger) + { + _services = services; + _logger = logger; + } + + public async Task StartAsync(CancellationToken ct) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var crypto = scope.ServiceProvider.GetRequiredService(); + + var patients = await db.Patients.ToListAsync(ct); + var migrated = 0; + + foreach (var patient in patients) + { + if (patient.NameSearchToken is not null && crypto.IsEncrypted(patient.FirstName)) + continue; + + patient.NameSearchToken = crypto.ComputeNameSearchToken( + patient.FirstName, patient.LastName); + migrated++; + } + + if (migrated > 0) + { + await db.SaveChangesAsync(ct); + _logger.LogInformation( + "PHI migration: updated {Count} patient search tokens", migrated); + } + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Commands/EncryptPhiCommand.cs b/VigilCareClinicalAPI/Commands/EncryptPhiCommand.cs new file mode 100644 index 0000000..fcebfe1 --- /dev/null +++ b/VigilCareClinicalAPI/Commands/EncryptPhiCommand.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +public static class EncryptPhiCommand +{ + public static async Task RunAsync(IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var crypto = scope.ServiceProvider.GetRequiredService(); + + var patients = await db.Patients.ToListAsync(); + foreach (var p in patients) + { + p.NameSearchToken = crypto.ComputeNameSearchToken(p.FirstName, p.LastName); + } + + await db.SaveChangesAsync(); + Console.WriteLine($"Encrypted {patients.Count} patient records."); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/PhiAccessLogsController.cs b/VigilCareClinicalAPI/Controllers/PhiAccessLogsController.cs new file mode 100644 index 0000000..4740644 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/PhiAccessLogsController.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +/// +/// PHI access log query for compliance (Admin / Compliance). +/// +[ApiController] +[Route("api/v1/phi-access-logs")] +[Produces("application/json")] +[AuthorizePermission(ClinicalPermissions.AuditRead)] +public class PhiAccessLogsController : ControllerBase +{ + private readonly AppDbContext _db; + + public PhiAccessLogsController(AppDbContext db) => _db = db; + + /// Query PHI access logs — who viewed which patient records. + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task List( + [FromQuery] Guid? patientId, + [FromQuery] Guid? userId, + [FromQuery] string? accessType, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + pageSize = Math.Clamp(pageSize, 1, 100); + var query = _db.PhiAccessLogs.AsNoTracking().AsQueryable(); + + if (patientId.HasValue) + query = query.Where(l => l.PatientId == patientId); + if (userId.HasValue) + query = query.Where(l => l.UserId == userId); + if (!string.IsNullOrEmpty(accessType)) + query = query.Where(l => l.AccessType.ToDbString() == accessType); + if (from.HasValue) + query = query.Where(l => l.AccessedAt >= from); + if (to.HasValue) + query = query.Where(l => l.AccessedAt <= to); + + var total = await query.CountAsync(); + var items = await query + .OrderByDescending(l => l.AccessedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return Ok(ApiResponse.Ok(new + { + items, + page, + pageSize, + totalCount = total, + totalPages = (int)Math.Ceiling(total / (double)pageSize) + })); + } + + /// Access history for a specific patient — common compliance query. + /// Patient id. + /// Page number (1-based). + /// Results per page. + /// A paginated list of PHI access events for the patient. + [HttpGet("patients/{patientId:guid}")] + [AuthorizePermission(ClinicalPermissions.PatientsRead)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task ForPatient( + Guid patientId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + pageSize = Math.Clamp(pageSize, 1, 100); + var query = _db.PhiAccessLogs.AsNoTracking() + .Where(l => l.PatientId == patientId); + + var total = await query.CountAsync(); + var items = await query + .OrderByDescending(l => l.AccessedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return Ok(ApiResponse.Ok(new + { + items, + page, + pageSize, + totalCount = total, + totalPages = (int)Math.Ceiling(total / (double)pageSize) + })); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Data/PatientPhiConverterConfigurator.cs b/VigilCareClinicalAPI/Data/PatientPhiConverterConfigurator.cs index f7ed868..6939e48 100644 --- a/VigilCareClinicalAPI/Data/PatientPhiConverterConfigurator.cs +++ b/VigilCareClinicalAPI/Data/PatientPhiConverterConfigurator.cs @@ -9,11 +9,13 @@ public static class PatientPhiConverterConfigurator var entity = modelBuilder.Entity(); entity.Property(p => p.FirstName) + .HasColumnType("text") .HasConversion( v => crypto.Encrypt(v), v => crypto.Decrypt(v)); entity.Property(p => p.LastName) + .HasColumnType("text") .HasConversion( v => crypto.Encrypt(v), v => crypto.Decrypt(v)); @@ -24,11 +26,13 @@ public static class PatientPhiConverterConfigurator v => v == null ? null : crypto.Decrypt(v)); entity.Property(p => p.EmergencyContactName) + .HasColumnType("text") .HasConversion( v => v == null ? null! : crypto.Encrypt(v), v => v == null ? null : crypto.Decrypt(v)); entity.Property(p => p.EmergencyContactPhone) + .HasColumnType("text") .HasConversion( v => v == null ? null! : crypto.Encrypt(v), v => v == null ? null : crypto.Decrypt(v)); diff --git a/VigilCareClinicalAPI/Migrations/20260622143154_WidenPhiEncryptedColumns.Designer.cs b/VigilCareClinicalAPI/Migrations/20260622143154_WidenPhiEncryptedColumns.Designer.cs new file mode 100644 index 0000000..27a8aa3 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260622143154_WidenPhiEncryptedColumns.Designer.cs @@ -0,0 +1,1361 @@ +// +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 VigilCareClinicalAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260622143154_WidenPhiEncryptedColumns")] + partial class WidenPhiEncryptedColumns + { + /// + 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", (string)null); + }); + + 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("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + 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("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + 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", null, 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("ClinicalAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("action"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entity_type"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("NewValueJson") + .HasColumnType("jsonb") + .HasColumnName("new_value_json"); + + b.Property("PreviousValueJson") + .HasColumnType("jsonb") + .HasColumnName("previous_value_json"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UserDisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("UserId"); + + b.ToTable("clinical_audit_logs", (string)null); + }); + + modelBuilder.Entity("ClinicalUser", 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("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("clinical_users", (string)null); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("ExternalResourceIdentifier", 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("InternalId") + .HasColumnType("uuid") + .HasColumnName("internal_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("resource_type"); + + b.Property("System") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("system"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("ResourceType", "InternalId"); + + b.HasIndex("ResourceType", "System", "Value") + .IsUnique(); + + b.ToTable("external_resource_identifiers", (string)null); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + 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("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + 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("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + 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("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", 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("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL AND failed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .IsRequired() + .HasColumnType("text") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("text") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("text") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NameSearchToken") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name_search_token"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.HasIndex("NameSearchToken"); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("PhiAccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("access_type"); + + b.Property("AccessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("accessed_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResourcePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("resource_path"); + + b.Property("ResultCount") + .HasColumnType("integer") + .HasColumnName("result_count"); + + b.Property("SearchQueryHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("search_query_hash"); + + b.Property("UserDisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AccessedAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("UserId"); + + b.ToTable("phi_access_logs", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260622143154_WidenPhiEncryptedColumns.cs b/VigilCareClinicalAPI/Migrations/20260622143154_WidenPhiEncryptedColumns.cs new file mode 100644 index 0000000..cb67853 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260622143154_WidenPhiEncryptedColumns.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class WidenPhiEncryptedColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "last_name", + table: "patients", + type: "text", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "first_name", + table: "patients", + type: "text", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "emergency_contact_phone", + table: "patients", + type: "text", + maxLength: 20, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(20)", + oldMaxLength: 20, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "emergency_contact_name", + table: "patients", + type: "text", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "last_name", + table: "patients", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "text", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "first_name", + table: "patients", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "text", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "emergency_contact_phone", + table: "patients", + type: "character varying(20)", + maxLength: 20, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldMaxLength: 20, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "emergency_contact_name", + table: "patients", + type: "character varying(200)", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldMaxLength: 200, + oldNullable: true); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 098079e..dd48937 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -835,18 +835,18 @@ namespace VigilCareClinicalAPI.Migrations b.Property("EmergencyContactName") .HasMaxLength(200) - .HasColumnType("character varying(200)") + .HasColumnType("text") .HasColumnName("emergency_contact_name"); b.Property("EmergencyContactPhone") .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("emergency_contact_phone"); b.Property("FirstName") .IsRequired() .HasMaxLength(100) - .HasColumnType("character varying(100)") + .HasColumnType("text") .HasColumnName("first_name"); b.Property("Gender") @@ -858,7 +858,7 @@ namespace VigilCareClinicalAPI.Migrations b.Property("LastName") .IsRequired() .HasMaxLength(100) - .HasColumnType("character varying(100)") + .HasColumnType("text") .HasColumnName("last_name"); b.Property("Mrn") diff --git a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs index 9ec145f..3b91e3a 100644 --- a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs +++ b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs @@ -78,6 +78,11 @@ public sealed class ClinicalMetrics "FHIR mapping failures.", labelNames: new[] { "reason" }); + public readonly Counter PhiAccessLogsTotal = Metrics.CreateCounter( + "phi_access_logs_total", + "PHI access log entries written.", + labelNames: new[] { "access_type" }); + // --- Histograms --- // Measures the full ingest transaction: Redis cache lookup + alert evaluation + diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index a67b1aa..7d5d294 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -216,6 +216,7 @@ try builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHealthChecks() .AddDbContextCheck("postgresql", tags: new[] { "ready" }) @@ -343,6 +344,13 @@ try app.MapMetrics("/metrics"); app.MapControllers(); + + if (args.Contains("encrypt-phi")) + { + await EncryptPhiCommand.RunAsync(app.Services); + return; + } + app.Run(); } diff --git a/VigilCareClinicalAPI/Services/PatientService.cs b/VigilCareClinicalAPI/Services/PatientService.cs index 4ad89f0..71fb046 100644 --- a/VigilCareClinicalAPI/Services/PatientService.cs +++ b/VigilCareClinicalAPI/Services/PatientService.cs @@ -65,7 +65,10 @@ public class PatientService : IPatientService AuditAction.PatientRegistered, "Patient", patient.Id, - newValue: new { patient.Mrn, patient.FirstName, patient.LastName }); + newValue: new { patient.Mrn }); + + var path = _http.HttpContext?.Request.Path.Value ?? "/api/v1/patients"; + await _phiAccess.LogCreateAsync(patient.Id, path); return patient; } @@ -84,10 +87,20 @@ public class PatientService : IPatientService if (!string.IsNullOrWhiteSpace(q)) { - query = query.Where(p => - p.Mrn == q || - EF.Functions.ILike(p.FirstName, $"%{q}%") || - EF.Functions.ILike(p.LastName, $"%{q}%")); + var parts = q.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2) + { + var token = _crypto.ComputeNameSearchToken(parts[0], parts[1]); + query = query.Where(p => p.Mrn == q || p.NameSearchToken == token); + } + else + { + query = query.Where(p => + p.Mrn == q || + (p.NameSearchToken != null && + (p.NameSearchToken == _crypto.ComputeNameSearchToken(q, "") || + p.NameSearchToken == _crypto.ComputeNameSearchToken("", q)))); + } } var total = await query.CountAsync(); @@ -97,6 +110,12 @@ public class PatientService : IPatientService .Take(pageSize) .ToListAsync(); + var path = _http.HttpContext?.Request.Path.Value ?? "/api/v1/patients"; + await _phiAccess.LogListAsync(path, patients.Count, q); + + foreach (var p in patients) + await _phiAccess.LogViewAsync(p.Id, $"{path}?page={page}"); + return new PagedResult(patients, page, pageSize, total); } @@ -121,6 +140,9 @@ public class PatientService : IPatientService if (req.EmergencyContactName is not null) patient.EmergencyContactName = req.EmergencyContactName; if (req.EmergencyContactPhone is not null) patient.EmergencyContactPhone = req.EmergencyContactPhone; + if (req.FirstName is not null || req.LastName is not null) + SetNameSearchToken(patient); + await _db.SaveChangesAsync(); await _audit.WriteAsync( @@ -135,6 +157,9 @@ public class PatientService : IPatientService patient.EmergencyContactName, patient.EmergencyContactPhone }); + var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}"; + await _phiAccess.LogUpdateAsync(patient.Id, path); + return patient; } @@ -147,6 +172,9 @@ public class PatientService : IPatientService if (patient is null) throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND"); + var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}"; + await _phiAccess.LogViewAsync(patient.Id, path); + return patient; } @@ -213,6 +241,8 @@ public class PatientService : IPatientService var existingId = await _identifiers.ResolveInternalIdAsync( ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue); + var fhirPath = _http.HttpContext?.Request.Path.Value ?? "/fhir/R4/Patient"; + if (existingId.HasValue) { var patient = await _db.Patients.FindAsync(existingId.Value) @@ -226,12 +256,13 @@ public class PatientService : IPatientService patient.Allergies = req.Allergies; patient.EmergencyContactName = req.EmergencyContactName; patient.EmergencyContactPhone = req.EmergencyContactPhone; + SetNameSearchToken(patient); await _db.SaveChangesAsync(); + await _phiAccess.LogUpdateAsync(patient.Id, fhirPath); return patient; } - // Use hospital identifier value as MRN when it fits the column constraint (max 20 chars). var mrn = req.IdentifierValue.Length <= 20 ? req.IdentifierValue : await GenerateMrnAsync(); @@ -250,6 +281,7 @@ public class PatientService : IPatientService EmergencyContactPhone = req.EmergencyContactPhone, CreatedAt = DateTimeOffset.UtcNow }; + SetNameSearchToken(newPatient); _db.Patients.Add(newPatient); await _db.SaveChangesAsync(); @@ -260,6 +292,7 @@ public class PatientService : IPatientService req.IdentifierSystem, req.IdentifierValue); + await _phiAccess.LogCreateAsync(newPatient.Id, fhirPath); return newPatient; } diff --git a/VigilCareClinicalAPI/Services/PhiAccessLogService.cs b/VigilCareClinicalAPI/Services/PhiAccessLogService.cs index b2179ea..4782b4e 100644 --- a/VigilCareClinicalAPI/Services/PhiAccessLogService.cs +++ b/VigilCareClinicalAPI/Services/PhiAccessLogService.cs @@ -8,17 +8,20 @@ public class PhiAccessLogService : IPhiAccessLogService private readonly ICurrentUserService _currentUser; private readonly IHttpContextAccessor _http; private readonly PhiEncryptionOptions _options; + private readonly ClinicalMetrics _metrics; public PhiAccessLogService( AppDbContext db, ICurrentUserService currentUser, IHttpContextAccessor http, - IOptions options) + IOptions options, + ClinicalMetrics metrics) { _db = db; _currentUser = currentUser; _http = http; _options = options.Value; + _metrics = metrics; } public async Task LogViewAsync(Guid patientId, string resourcePath) => @@ -68,6 +71,8 @@ public class PhiAccessLogService : IPhiAccessLogService }); await _db.SaveChangesAsync(); + + _metrics.PhiAccessLogsTotal.WithLabels(accessType.ToDbString()).Inc(); } private static string HashQuery(string query) diff --git a/scripts/encrypt-existing-patient-phi.sh b/scripts/encrypt-existing-patient-phi.sh new file mode 100644 index 0000000..f74894d --- /dev/null +++ b/scripts/encrypt-existing-patient-phi.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "Re-save all patients through EF to apply encryption converters..." +dotnet run --project "${ROOT_DIR}/VigilCareClinicalAPI" --no-build -- encrypt-phi +``` + +```bash +chmod +x scripts/encrypt-existing-patient-phi.sh \ No newline at end of file diff --git a/scripts/run-phase32-verification.sh b/scripts/run-phase32-verification.sh new file mode 100644 index 0000000..600488c --- /dev/null +++ b/scripts/run-phase32-verification.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_URL="${BASE_URL:-http://localhost:5270}" + +echo "=== Phase 32 verification ===" + +dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \ + --filter "FullyQualifiedName~PhiEncryption" --no-restore + +TOKEN=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin.demo","password":"DemoAdmin1!"}' \ + | jq -r '.data.accessToken') + +PATIENT_ID=$(curl -sf "${BASE_URL}/api/v1/patients?pageSize=1" \ + -H "Authorization: Bearer ${TOKEN}" \ + | jq -r '.data.items[0].id') + +echo "View patient ${PATIENT_ID}" +curl -sf "${BASE_URL}/api/v1/patients/${PATIENT_ID}" \ + -H "Authorization: Bearer ${TOKEN}" | jq -e '.data.firstName != null' + +echo "Verify PHI access log" +curl -sf "${BASE_URL}/api/v1/phi-access-logs?patientId=${PATIENT_ID}" \ + -H "Authorization: Bearer ${TOKEN}" | jq -e '.data.totalCount >= 1' + +echo "Verify raw DB encryption (requires psql)" +docker compose exec -T postgres psql -U vigilcare -d vigilcare -c \ + "SELECT id, left(first_name, 20) AS encrypted_prefix FROM patients WHERE id = '${PATIENT_ID}';" + +echo "Phase 32 verification complete." \ No newline at end of file