From 24f45851e9d231996f4d8e0a36fa3a4d12b46f31 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Thu, 6 Aug 2026 01:52:53 +0800 Subject: [PATCH] feature: In-App Simulation Runner (Backend) --- .../Client/Models/AlertExplanation.cs | 2 + .../Client/Models/AlertResponse.cs | 2 + .../Client/Models/ApiResponse.cs | 4 +- .../Client/Models/BatchIngestRequest.cs | 4 +- .../CreateMedicationAdministrationRequest.cs | 2 + .../Client/Models/CreateOrderRequest.cs | 2 + .../Client/Models/EncounterResponse.cs | 4 +- .../Client/Models/GcsResponse.cs | 4 +- .../Client/Models/IngestObservationRequest.cs | 3 +- .../Client/Models/LoginModels.cs | 2 + .../Client/Models/News2Response.cs | 4 +- .../Client/Models/OpenEncounterRequest.cs | 4 +- .../Client/Models/OrderResponse.cs | 2 + .../Client/Models/PatientResponse.cs | 2 + .../Client/Models/QsofaResponse.cs | 3 + .../Client/Models/RecordOrderResultRequest.cs | 3 + .../Client/Models/RegisterPatientRequest.cs | 2 + .../Client/Models/SepsisBundleResponse.cs | 2 + .../Client/Models/SofaResponse.cs | 2 + .../Client/Models/SofaStalenessResponse.cs | 4 +- .../Client/VigilCareApiClient.cs | 4 +- .../Engine/IApiPoller.cs | 10 + .../Engine/IReplayObserver.cs | 31 + .../Engine/ReplayEngine.cs | 73 +- .../Engine/ReplayOptions.cs | 4 +- .../Engine/ReplayResult.cs | 4 +- .../Scenarios/DepartmentMapper.cs | 2 + .../Scenarios/ExpectedOutcomeValidator.cs | 2 + .../Scenarios/ScenarioFile.cs | 4 +- .../Scenarios/ScenarioLoader.cs | 59 + .../Scenarios/ScenarioValidator.cs | 2 + .../VigilCare.Simulation.Core.csproj | 13 + .../Client/Models/QsofaResponse.cs | 1 - .../Client/Models/RecordOrderResultRequest.cs | 1 - VigilCare.Simulator/Commands/DryRunCommand.cs | 3 +- .../Commands/ReplayAllCommand.cs | 3 +- VigilCare.Simulator/Commands/ReplayCommand.cs | 3 +- .../Commands/ValidateCommand.cs | 1 + .../Mimic/MimicGenerateCommand.cs | 1 + .../Mimic/MimicScenarioBuilder.cs | 1 + .../Output/ConsoleReplayObserver.cs | 33 + .../Output/SimulatorConsole.cs | 1 + VigilCare.Simulator/Polling/ApiPoller.cs | 6 +- VigilCare.Simulator/Polling/PollResult.cs | 4 +- .../Scenarios/ScenarioLoader.cs | 26 - .../VigilCare.Simulator.csproj | 4 + VigilCareClinical.sln | 6 + .../ClinicalRefactorEndToEndTests.cs | 1 + .../Fixtures/ApiFixture.cs | 27 + .../Fixtures/Scenarios/minimal-sim-01.json | 44 + .../Helpers/DbResetHelper.cs | 1 + .../Helpers/ScenarioReplayHelper.cs | 1 + .../Simulation/SimulationEndpointTests.cs | 152 ++ .../Simulation/SimulationRunnerTests.cs | 197 ++ .../Simulation/TestSimulationClientFactory.cs | 37 + .../VigilCareClinicalAPI.Tests.csproj | 4 +- .../Authorization/ClinicalPermissions.cs | 1 + .../ClinicalRolePermissionMap.cs | 7 + .../Configuration/SimulationOptions.cs | 27 + .../Controllers/SimulationController.cs | 194 ++ VigilCareClinicalAPI/Data/AppDbContext.cs | 1 + .../Configurations/PatientConfiguration.cs | 5 + .../SimulationRunConfiguration.cs | 34 + VigilCareClinicalAPI/Data/Seed/UserSeeder.cs | 116 +- .../Domains/Entities/Patient.cs | 3 + .../Domains/Entities/SimulationRun.cs | 19 + .../Domains/Enums/AuditAction.cs | 6 + .../Domains/Enums/SimulationRunStatus.cs | 24 + ...805171624_AddSimulationSupport.Designer.cs | 2019 +++++++++++++++++ .../20260805171624_AddSimulationSupport.cs | 75 + .../Migrations/AppDbContextModelSnapshot.cs | 96 + .../Records/Simulation/SimulationDtos.cs | 35 + VigilCareClinicalAPI/Program.cs | 35 +- .../Simulation/ISimulationClientFactory.cs | 6 + .../Simulation/RunStateReplayObserver.cs | 39 + .../Services/Simulation/ScenarioCatalog.cs | 79 + .../Simulation/SimulationClientFactory.cs | 26 + .../Services/Simulation/SimulationRunState.cs | 126 + .../Services/Simulation/SimulationRunner.cs | 269 +++ .../VigilCareClinicalAPI.csproj | 1 + .../appsettings.Production.json | 9 +- VigilCareClinicalAPI/appsettings.Testing.json | 10 + VigilCareClinicalAPI/appsettings.json | 9 + 83 files changed, 3974 insertions(+), 120 deletions(-) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/AlertExplanation.cs (96%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/AlertResponse.cs (94%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/ApiResponse.cs (78%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/BatchIngestRequest.cs (57%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/CreateMedicationAdministrationRequest.cs (84%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/CreateOrderRequest.cs (73%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/EncounterResponse.cs (64%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/GcsResponse.cs (77%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/IngestObservationRequest.cs (84%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/LoginModels.cs (87%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/News2Response.cs (71%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/OpenEncounterRequest.cs (53%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/OrderResponse.cs (68%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/PatientResponse.cs (84%) create mode 100644 VigilCare.Simulation.Core/Client/Models/QsofaResponse.cs create mode 100644 VigilCare.Simulation.Core/Client/Models/RecordOrderResultRequest.cs rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/RegisterPatientRequest.cs (77%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/SepsisBundleResponse.cs (92%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/SofaResponse.cs (89%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/Models/SofaStalenessResponse.cs (67%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Client/VigilCareApiClient.cs (99%) create mode 100644 VigilCare.Simulation.Core/Engine/IApiPoller.cs create mode 100644 VigilCare.Simulation.Core/Engine/IReplayObserver.cs rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Engine/ReplayEngine.cs (77%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Engine/ReplayOptions.cs (76%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Engine/ReplayResult.cs (94%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Scenarios/DepartmentMapper.cs (95%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Scenarios/ExpectedOutcomeValidator.cs (99%) rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Scenarios/ScenarioFile.cs (92%) create mode 100644 VigilCare.Simulation.Core/Scenarios/ScenarioLoader.cs rename {VigilCare.Simulator => VigilCare.Simulation.Core}/Scenarios/ScenarioValidator.cs (99%) create mode 100644 VigilCare.Simulation.Core/VigilCare.Simulation.Core.csproj delete mode 100644 VigilCare.Simulator/Client/Models/QsofaResponse.cs delete mode 100644 VigilCare.Simulator/Client/Models/RecordOrderResultRequest.cs create mode 100644 VigilCare.Simulator/Output/ConsoleReplayObserver.cs delete mode 100644 VigilCare.Simulator/Scenarios/ScenarioLoader.cs create mode 100644 VigilCareClinicalAPI.Tests/Fixtures/Scenarios/minimal-sim-01.json create mode 100644 VigilCareClinicalAPI.Tests/Simulation/SimulationEndpointTests.cs create mode 100644 VigilCareClinicalAPI.Tests/Simulation/SimulationRunnerTests.cs create mode 100644 VigilCareClinicalAPI.Tests/Simulation/TestSimulationClientFactory.cs create mode 100644 VigilCareClinicalAPI/Configuration/SimulationOptions.cs create mode 100644 VigilCareClinicalAPI/Controllers/SimulationController.cs create mode 100644 VigilCareClinicalAPI/Data/Configurations/SimulationRunConfiguration.cs create mode 100644 VigilCareClinicalAPI/Domains/Entities/SimulationRun.cs create mode 100644 VigilCareClinicalAPI/Domains/Enums/SimulationRunStatus.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.cs create mode 100644 VigilCareClinicalAPI/Models/Records/Simulation/SimulationDtos.cs create mode 100644 VigilCareClinicalAPI/Services/Simulation/ISimulationClientFactory.cs create mode 100644 VigilCareClinicalAPI/Services/Simulation/RunStateReplayObserver.cs create mode 100644 VigilCareClinicalAPI/Services/Simulation/ScenarioCatalog.cs create mode 100644 VigilCareClinicalAPI/Services/Simulation/SimulationClientFactory.cs create mode 100644 VigilCareClinicalAPI/Services/Simulation/SimulationRunState.cs create mode 100644 VigilCareClinicalAPI/Services/Simulation/SimulationRunner.cs diff --git a/VigilCare.Simulator/Client/Models/AlertExplanation.cs b/VigilCare.Simulation.Core/Client/Models/AlertExplanation.cs similarity index 96% rename from VigilCare.Simulator/Client/Models/AlertExplanation.cs rename to VigilCare.Simulation.Core/Client/Models/AlertExplanation.cs index 23f0b24..a783ead 100644 --- a/VigilCare.Simulator/Client/Models/AlertExplanation.cs +++ b/VigilCare.Simulation.Core/Client/Models/AlertExplanation.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public class AlertExplanation { public List ScoreContributors { get; set; } = new(); diff --git a/VigilCare.Simulator/Client/Models/AlertResponse.cs b/VigilCare.Simulation.Core/Client/Models/AlertResponse.cs similarity index 94% rename from VigilCare.Simulator/Client/Models/AlertResponse.cs rename to VigilCare.Simulation.Core/Client/Models/AlertResponse.cs index b84d600..fd7ce80 100644 --- a/VigilCare.Simulator/Client/Models/AlertResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/AlertResponse.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record AlertResponse( Guid Id, Guid EncounterId, diff --git a/VigilCare.Simulator/Client/Models/ApiResponse.cs b/VigilCare.Simulation.Core/Client/Models/ApiResponse.cs similarity index 78% rename from VigilCare.Simulator/Client/Models/ApiResponse.cs rename to VigilCare.Simulation.Core/Client/Models/ApiResponse.cs index 3a164ee..c9caa59 100644 --- a/VigilCare.Simulator/Client/Models/ApiResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/ApiResponse.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record ApiResponse(bool Success, int StatusCode, T? Data, ApiError? Error); public record ApiError(string Message, string Code); -public record PagedResponse(List Items, int TotalCount, int Page, int PageSize); \ No newline at end of file +public record PagedResponse(List Items, int TotalCount, int Page, int PageSize); diff --git a/VigilCare.Simulator/Client/Models/BatchIngestRequest.cs b/VigilCare.Simulation.Core/Client/Models/BatchIngestRequest.cs similarity index 57% rename from VigilCare.Simulator/Client/Models/BatchIngestRequest.cs rename to VigilCare.Simulation.Core/Client/Models/BatchIngestRequest.cs index 820f3cd..fc426da 100644 --- a/VigilCare.Simulator/Client/Models/BatchIngestRequest.cs +++ b/VigilCare.Simulation.Core/Client/Models/BatchIngestRequest.cs @@ -1 +1,3 @@ -public record BatchIngestRequest(List Observations); \ No newline at end of file +namespace VigilCare.Simulation; + +public record BatchIngestRequest(List Observations); diff --git a/VigilCare.Simulator/Client/Models/CreateMedicationAdministrationRequest.cs b/VigilCare.Simulation.Core/Client/Models/CreateMedicationAdministrationRequest.cs similarity index 84% rename from VigilCare.Simulator/Client/Models/CreateMedicationAdministrationRequest.cs rename to VigilCare.Simulation.Core/Client/Models/CreateMedicationAdministrationRequest.cs index 61cf9c8..160af44 100644 --- a/VigilCare.Simulator/Client/Models/CreateMedicationAdministrationRequest.cs +++ b/VigilCare.Simulation.Core/Client/Models/CreateMedicationAdministrationRequest.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record CreateMedicationAdministrationRequest( string DrugName, decimal Dose, string DoseUnit, string Route, DateTimeOffset? AdministeredAt, string AdministeredBy); diff --git a/VigilCare.Simulator/Client/Models/CreateOrderRequest.cs b/VigilCare.Simulation.Core/Client/Models/CreateOrderRequest.cs similarity index 73% rename from VigilCare.Simulator/Client/Models/CreateOrderRequest.cs rename to VigilCare.Simulation.Core/Client/Models/CreateOrderRequest.cs index d924347..9b7f8e0 100644 --- a/VigilCare.Simulator/Client/Models/CreateOrderRequest.cs +++ b/VigilCare.Simulation.Core/Client/Models/CreateOrderRequest.cs @@ -1 +1,3 @@ +namespace VigilCare.Simulation; + public record CreateOrderRequest(string OrderType, string Description, string OrderedBy); diff --git a/VigilCare.Simulator/Client/Models/EncounterResponse.cs b/VigilCare.Simulation.Core/Client/Models/EncounterResponse.cs similarity index 64% rename from VigilCare.Simulator/Client/Models/EncounterResponse.cs rename to VigilCare.Simulation.Core/Client/Models/EncounterResponse.cs index e82d521..841e4d6 100644 --- a/VigilCare.Simulator/Client/Models/EncounterResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/EncounterResponse.cs @@ -1,4 +1,6 @@ +namespace VigilCare.Simulation; + public record EncounterResponse( Guid Id, Guid PatientId, string EncounterType, string Status, string Department, string AttendingPhysician, string? RoomBed, - string? AdmissionReason, DateTimeOffset AdmittedAt); \ No newline at end of file + string? AdmissionReason, DateTimeOffset AdmittedAt); diff --git a/VigilCare.Simulator/Client/Models/GcsResponse.cs b/VigilCare.Simulation.Core/Client/Models/GcsResponse.cs similarity index 77% rename from VigilCare.Simulator/Client/Models/GcsResponse.cs rename to VigilCare.Simulation.Core/Client/Models/GcsResponse.cs index 2624704..5a3aa02 100644 --- a/VigilCare.Simulator/Client/Models/GcsResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/GcsResponse.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record GcsResponse( int EyeScore, int VerbalScore, int MotorScore, - int TotalScore, string Classification, DateTimeOffset CalculatedAt); \ No newline at end of file + int TotalScore, string Classification, DateTimeOffset CalculatedAt); diff --git a/VigilCare.Simulator/Client/Models/IngestObservationRequest.cs b/VigilCare.Simulation.Core/Client/Models/IngestObservationRequest.cs similarity index 84% rename from VigilCare.Simulator/Client/Models/IngestObservationRequest.cs rename to VigilCare.Simulation.Core/Client/Models/IngestObservationRequest.cs index a0c27e0..48272c6 100644 --- a/VigilCare.Simulator/Client/Models/IngestObservationRequest.cs +++ b/VigilCare.Simulation.Core/Client/Models/IngestObservationRequest.cs @@ -1,5 +1,6 @@ +namespace VigilCare.Simulation; + public record IngestObservationRequest( string ObservationCode, decimal Value, string Unit, string Source, DateTimeOffset RecordedAt, string? IdempotencyKey); - diff --git a/VigilCare.Simulator/Client/Models/LoginModels.cs b/VigilCare.Simulation.Core/Client/Models/LoginModels.cs similarity index 87% rename from VigilCare.Simulator/Client/Models/LoginModels.cs rename to VigilCare.Simulation.Core/Client/Models/LoginModels.cs index 5aa940d..280a535 100644 --- a/VigilCare.Simulator/Client/Models/LoginModels.cs +++ b/VigilCare.Simulation.Core/Client/Models/LoginModels.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record SimLoginRequest(string Username, string Password); public record SimLoginResponse( diff --git a/VigilCare.Simulator/Client/Models/News2Response.cs b/VigilCare.Simulation.Core/Client/Models/News2Response.cs similarity index 71% rename from VigilCare.Simulator/Client/Models/News2Response.cs rename to VigilCare.Simulation.Core/Client/Models/News2Response.cs index d61542f..d36e89e 100644 --- a/VigilCare.Simulator/Client/Models/News2Response.cs +++ b/VigilCare.Simulation.Core/Client/Models/News2Response.cs @@ -1,5 +1,7 @@ +namespace VigilCare.Simulation; + public record News2Response( Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree, int RespRateScore, int Spo2Score, int SystolicBpScore, int HeartRateScore, int ConsciousnessScore, int TemperatureScore, - int SupplementalO2Score, DateTimeOffset CalculatedAt); \ No newline at end of file + int SupplementalO2Score, DateTimeOffset CalculatedAt); diff --git a/VigilCare.Simulator/Client/Models/OpenEncounterRequest.cs b/VigilCare.Simulation.Core/Client/Models/OpenEncounterRequest.cs similarity index 53% rename from VigilCare.Simulator/Client/Models/OpenEncounterRequest.cs rename to VigilCare.Simulation.Core/Client/Models/OpenEncounterRequest.cs index 812dbe6..5e8a378 100644 --- a/VigilCare.Simulator/Client/Models/OpenEncounterRequest.cs +++ b/VigilCare.Simulation.Core/Client/Models/OpenEncounterRequest.cs @@ -1,4 +1,6 @@ +namespace VigilCare.Simulation; + public record OpenEncounterRequest( string EncounterType, string Department, string AttendingPhysician, - string? RoomBed = null, string? AdmissionReason = null); \ No newline at end of file + string? RoomBed = null, string? AdmissionReason = null); diff --git a/VigilCare.Simulator/Client/Models/OrderResponse.cs b/VigilCare.Simulation.Core/Client/Models/OrderResponse.cs similarity index 68% rename from VigilCare.Simulator/Client/Models/OrderResponse.cs rename to VigilCare.Simulation.Core/Client/Models/OrderResponse.cs index 91a8fdf..96c1a23 100644 --- a/VigilCare.Simulator/Client/Models/OrderResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/OrderResponse.cs @@ -1 +1,3 @@ +namespace VigilCare.Simulation; + public record OrderResponse(Guid Id, string Description, string Status); diff --git a/VigilCare.Simulator/Client/Models/PatientResponse.cs b/VigilCare.Simulation.Core/Client/Models/PatientResponse.cs similarity index 84% rename from VigilCare.Simulator/Client/Models/PatientResponse.cs rename to VigilCare.Simulation.Core/Client/Models/PatientResponse.cs index 4876a24..f4c437f 100644 --- a/VigilCare.Simulator/Client/Models/PatientResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/PatientResponse.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record PatientResponse( Guid Id, string Mrn, string FirstName, string LastName, DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt); diff --git a/VigilCare.Simulation.Core/Client/Models/QsofaResponse.cs b/VigilCare.Simulation.Core/Client/Models/QsofaResponse.cs new file mode 100644 index 0000000..e279238 --- /dev/null +++ b/VigilCare.Simulation.Core/Client/Models/QsofaResponse.cs @@ -0,0 +1,3 @@ +namespace VigilCare.Simulation; + +public record QsofaResponse(int ActiveCriteria); diff --git a/VigilCare.Simulation.Core/Client/Models/RecordOrderResultRequest.cs b/VigilCare.Simulation.Core/Client/Models/RecordOrderResultRequest.cs new file mode 100644 index 0000000..eb29157 --- /dev/null +++ b/VigilCare.Simulation.Core/Client/Models/RecordOrderResultRequest.cs @@ -0,0 +1,3 @@ +namespace VigilCare.Simulation; + +public record RecordOrderResultRequest(string? ResultSummary); diff --git a/VigilCare.Simulator/Client/Models/RegisterPatientRequest.cs b/VigilCare.Simulation.Core/Client/Models/RegisterPatientRequest.cs similarity index 77% rename from VigilCare.Simulator/Client/Models/RegisterPatientRequest.cs rename to VigilCare.Simulation.Core/Client/Models/RegisterPatientRequest.cs index e9fe4f2..d51a21a 100644 --- a/VigilCare.Simulator/Client/Models/RegisterPatientRequest.cs +++ b/VigilCare.Simulation.Core/Client/Models/RegisterPatientRequest.cs @@ -1,2 +1,4 @@ +namespace VigilCare.Simulation; + public record RegisterPatientRequest( string FirstName, string LastName, DateOnly DateOfBirth, string Gender); diff --git a/VigilCare.Simulator/Client/Models/SepsisBundleResponse.cs b/VigilCare.Simulation.Core/Client/Models/SepsisBundleResponse.cs similarity index 92% rename from VigilCare.Simulator/Client/Models/SepsisBundleResponse.cs rename to VigilCare.Simulation.Core/Client/Models/SepsisBundleResponse.cs index dc7d5e8..70c405f 100644 --- a/VigilCare.Simulator/Client/Models/SepsisBundleResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/SepsisBundleResponse.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record SepsisBundleElementResponse(string Status); public record SepsisBundleResponse( diff --git a/VigilCare.Simulator/Client/Models/SofaResponse.cs b/VigilCare.Simulation.Core/Client/Models/SofaResponse.cs similarity index 89% rename from VigilCare.Simulator/Client/Models/SofaResponse.cs rename to VigilCare.Simulation.Core/Client/Models/SofaResponse.cs index 9ce7ecd..64c5a93 100644 --- a/VigilCare.Simulator/Client/Models/SofaResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/SofaResponse.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public record SofaResponse( int TotalScore, int RespiratoryScore, int CoagulationScore, int LiverScore, diff --git a/VigilCare.Simulator/Client/Models/SofaStalenessResponse.cs b/VigilCare.Simulation.Core/Client/Models/SofaStalenessResponse.cs similarity index 67% rename from VigilCare.Simulator/Client/Models/SofaStalenessResponse.cs rename to VigilCare.Simulation.Core/Client/Models/SofaStalenessResponse.cs index 069adbb..38ab330 100644 --- a/VigilCare.Simulator/Client/Models/SofaStalenessResponse.cs +++ b/VigilCare.Simulation.Core/Client/Models/SofaStalenessResponse.cs @@ -1,4 +1,6 @@ +namespace VigilCare.Simulation; + public record SofaStalenessResponse( IReadOnlyList StaleComponents, IReadOnlyList MissingComponents, - bool UsedSpO2Fallback); \ No newline at end of file + bool UsedSpO2Fallback); diff --git a/VigilCare.Simulator/Client/VigilCareApiClient.cs b/VigilCare.Simulation.Core/Client/VigilCareApiClient.cs similarity index 99% rename from VigilCare.Simulator/Client/VigilCareApiClient.cs rename to VigilCare.Simulation.Core/Client/VigilCareApiClient.cs index db6841f..4b08403 100644 --- a/VigilCare.Simulator/Client/VigilCareApiClient.cs +++ b/VigilCare.Simulation.Core/Client/VigilCareApiClient.cs @@ -2,6 +2,8 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +namespace VigilCare.Simulation; + public class VigilCareApiClient { private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web); @@ -246,4 +248,4 @@ public class VigilCareApiClient return Normalize(actual) == Normalize(expected); } -} \ No newline at end of file +} diff --git a/VigilCare.Simulation.Core/Engine/IApiPoller.cs b/VigilCare.Simulation.Core/Engine/IApiPoller.cs new file mode 100644 index 0000000..d3d9319 --- /dev/null +++ b/VigilCare.Simulation.Core/Engine/IApiPoller.cs @@ -0,0 +1,10 @@ +namespace VigilCare.Simulation; + +/// +/// Optional post-cluster polling hook. Console hosts display scores/alerts; +/// the API host passes null — polling is a display concern. +/// +public interface IApiPoller +{ + Task PollAndDisplayAsync(Guid encounterId, string simTime); +} diff --git a/VigilCare.Simulation.Core/Engine/IReplayObserver.cs b/VigilCare.Simulation.Core/Engine/IReplayObserver.cs new file mode 100644 index 0000000..5d8561d --- /dev/null +++ b/VigilCare.Simulation.Core/Engine/IReplayObserver.cs @@ -0,0 +1,31 @@ +namespace VigilCare.Simulation; + +public interface IReplayObserver +{ + void Header(string name, string? description); + void Info(string message); + void Event(string simTime, string description); + void Waiting(double deltaMinutes, int delayMs); + void Warn(string message); + void Error(string message); + void DryRun(string message); + void Completed(ReplayResult result); + + /// Fired after each cluster so hosts can report progress. + void Progress(double offsetMinutes, int clusterIndex, int clusterCount); +} + +public sealed class NullReplayObserver : IReplayObserver +{ + public static readonly NullReplayObserver Instance = new(); + + public void Header(string name, string? description) { } + public void Info(string message) { } + public void Event(string simTime, string description) { } + public void Waiting(double deltaMinutes, int delayMs) { } + public void Warn(string message) { } + public void Error(string message) { } + public void DryRun(string message) { } + public void Completed(ReplayResult result) { } + public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) { } +} diff --git a/VigilCare.Simulator/Engine/ReplayEngine.cs b/VigilCare.Simulation.Core/Engine/ReplayEngine.cs similarity index 77% rename from VigilCare.Simulator/Engine/ReplayEngine.cs rename to VigilCare.Simulation.Core/Engine/ReplayEngine.cs index 86b0f47..9505957 100644 --- a/VigilCare.Simulator/Engine/ReplayEngine.cs +++ b/VigilCare.Simulation.Core/Engine/ReplayEngine.cs @@ -1,13 +1,23 @@ +namespace VigilCare.Simulation; + public class ReplayEngine { private readonly VigilCareApiClient _client; - private readonly ApiPoller? _poller; + private readonly IApiPoller? _poller; + private readonly IReplayObserver _observer; + private readonly Func? _onPatientRegistered; private DateTimeOffset _scenarioStartTime; - public ReplayEngine(VigilCareApiClient client, ApiPoller? poller) + public ReplayEngine( + VigilCareApiClient client, + IApiPoller? poller, + IReplayObserver? observer = null, + Func? onPatientRegistered = null) { _client = client; _poller = poller; + _observer = observer ?? NullReplayObserver.Instance; + _onPatientRegistered = onPatientRegistered; } public async Task RunAsync( @@ -18,7 +28,7 @@ public class ReplayEngine _scenarioStartTime = startTime; // --- Phase 1: Setup --- - SimulatorConsole.Header(scenario.Scenario.Name, scenario.Scenario.Description); + _observer.Header(scenario.Scenario.Name, scenario.Scenario.Description); if (options.DryRun) { @@ -26,27 +36,27 @@ public class ReplayEngine { result.EncounterId = options.ExistingEncounterId.Value; if (options.Target == ReplayTarget.Gateway) - SimulatorConsole.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}"); + _observer.DryRun($"Gateway mode — would use existing encounter {result.EncounterId}"); else - SimulatorConsole.DryRun($"Would use existing encounter {result.EncounterId}"); + _observer.DryRun($"Would use existing encounter {result.EncounterId}"); } else { - SimulatorConsole.DryRun("Would register patient: " + + _observer.DryRun("Would register patient: " + $"{scenario.Patient.FirstName} {scenario.Patient.LastName}"); - SimulatorConsole.DryRun("Would open encounter: " + + _observer.DryRun("Would open encounter: " + $"{scenario.Encounter.Department} / {scenario.Encounter.EncounterType}"); } } else if (options.Target == ReplayTarget.Gateway && options.ExistingEncounterId.HasValue) { result.EncounterId = options.ExistingEncounterId.Value; - SimulatorConsole.Info($"Gateway mode — using existing encounter {result.EncounterId}"); + _observer.Info($"Gateway mode — using existing encounter {result.EncounterId}"); } else if (options.ExistingEncounterId.HasValue) { result.EncounterId = options.ExistingEncounterId.Value; - SimulatorConsole.Info($"Using existing encounter {result.EncounterId}"); + _observer.Info($"Using existing encounter {result.EncounterId}"); } else { @@ -56,6 +66,9 @@ public class ReplayEngine DateOnly.Parse(scenario.Patient.DateOfBirth), scenario.Patient.Gender)); + if (_onPatientRegistered is not null) + await _onPatientRegistered(patient.Id, ct); + var encounter = await _client.OpenEncounterAsync(patient.Id, new OpenEncounterRequest( scenario.Encounter.EncounterType, DepartmentMapper.ToApiDepartment(scenario.Encounter.Department), @@ -63,8 +76,8 @@ public class ReplayEngine scenario.Encounter.RoomBed, scenario.Encounter.AdmissionReason)); - SimulatorConsole.Info($"Patient registered: {patient.Id} ({patient.Mrn})"); - SimulatorConsole.Info($"Encounter opened: {encounter.Id} ({encounter.Status})"); + _observer.Info($"Patient registered: {patient.Id} ({patient.Mrn})"); + _observer.Info($"Encounter opened: {encounter.Id} ({encounter.Status})"); result.PatientId = patient.Id; result.EncounterId = encounter.Id; } @@ -76,15 +89,16 @@ public class ReplayEngine .OrderBy(g => g.Key) .ToList(); - foreach (var cluster in clusters) + for (var clusterIndex = 0; clusterIndex < clusters.Count; clusterIndex++) { + var cluster = clusters[clusterIndex]; ct.ThrowIfCancellationRequested(); var deltaMinutes = cluster.Key - lastOffset; if (deltaMinutes > 0 && options.Speed > 0 && !options.DryRun) { var delayMs = (int)(deltaMinutes * 60_000 / options.Speed); - SimulatorConsole.Wait(deltaMinutes, delayMs); + _observer.Waiting(deltaMinutes, delayMs); await Task.Delay(delayMs, ct); } @@ -116,6 +130,7 @@ public class ReplayEngine } lastOffset = cluster.Key; + _observer.Progress(cluster.Key, clusterIndex, clusters.Count); if (options.Poll && !options.DryRun && _poller is not null) { @@ -136,10 +151,10 @@ public class ReplayEngine result.HadExpectedOutcomes = true; result.OutcomeFailures.AddRange(failures); foreach (var failure in failures) - SimulatorConsole.Error(failure); + _observer.Error(failure); } - SimulatorConsole.Summary(result); + _observer.Completed(result); return result; } @@ -160,7 +175,7 @@ public class ReplayEngine var unit = evt.Data.GetProperty("unit").GetString()!; var source = evt.Data.TryGetProperty("source", out var s) ? s.GetString()! : "Manual"; - SimulatorConsole.Event(simTime, $"{code} {value} {unit}"); + _observer.Event(simTime, $"{code} {value} {unit}"); result.ObservationsSent++; if (!options.DryRun) @@ -185,7 +200,7 @@ public class ReplayEngine if (options.DryRun) { - SimulatorConsole.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}"); + _observer.DryRun($"[{simTime}] MEDICATION {drugName} {dose}{doseUnit} {route}"); return; } @@ -194,12 +209,12 @@ public class ReplayEngine if (sent) { - SimulatorConsole.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent"); + _observer.Event(simTime, $"MEDICATION {drugName} {dose}{doseUnit} ({route}) sent"); result.MedicationsSent++; } else { - SimulatorConsole.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)"); + _observer.Warn($"[{simTime}] MEDICATION skipped — endpoint not available (Phase 15 required)"); result.MedicationsSkipped++; } } @@ -215,7 +230,7 @@ public class ReplayEngine if (options.DryRun) { - SimulatorConsole.DryRun($"[{simTime}] ORDER {orderType}: {description}"); + _observer.DryRun($"[{simTime}] ORDER {orderType}: {description}"); return; } @@ -224,12 +239,12 @@ public class ReplayEngine if (placed) { - SimulatorConsole.Event(simTime, $"ORDER {description} placed"); + _observer.Event(simTime, $"ORDER {description} placed"); result.OrdersPlaced++; } else { - SimulatorConsole.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'"); + _observer.Warn($"[{simTime}] ORDER skipped — failed to place '{description}'"); } } @@ -242,7 +257,7 @@ public class ReplayEngine if (options.DryRun) { - SimulatorConsole.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}"); + _observer.DryRun($"[{simTime}] ORDER_RESULT {orderDesc}"); return; } @@ -251,12 +266,12 @@ public class ReplayEngine if (ok) { - SimulatorConsole.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted"); + _observer.Event(simTime, $"ORDER_RESULT {orderDesc} → resulted"); result.OrdersResulted++; } else { - SimulatorConsole.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted"); + _observer.Warn($"[{simTime}] ORDER_RESULT skipped — order '{orderDesc}' not found or already resulted"); } } @@ -285,7 +300,7 @@ public class ReplayEngine if (options.DryRun) { - SimulatorConsole.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}"); + _observer.DryRun($"[{simTime}] ACK {alertType} by {clinicianId}"); return; } @@ -296,8 +311,8 @@ public class ReplayEngine var ok = await _client.TryAcknowledgeAlertAsync( result.EncounterId, alertType, clinicianId, note, waitForAlert, ct); if (ok) - SimulatorConsole.Event(simTime, $"ACK {alertType} by {clinicianId}"); + _observer.Event(simTime, $"ACK {alertType} by {clinicianId}"); else - SimulatorConsole.Warn($"[{simTime}] ACK failed — no open {alertType} alert found"); + _observer.Warn($"[{simTime}] ACK failed — no open {alertType} alert found"); } -} \ No newline at end of file +} diff --git a/VigilCare.Simulator/Engine/ReplayOptions.cs b/VigilCare.Simulation.Core/Engine/ReplayOptions.cs similarity index 76% rename from VigilCare.Simulator/Engine/ReplayOptions.cs rename to VigilCare.Simulation.Core/Engine/ReplayOptions.cs index 2b082f9..fa5f596 100644 --- a/VigilCare.Simulator/Engine/ReplayOptions.cs +++ b/VigilCare.Simulation.Core/Engine/ReplayOptions.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public enum ReplayTarget { Central, Gateway } public record ReplayOptions( @@ -6,4 +8,4 @@ public record ReplayOptions( int PollIntervalSeconds = 5, bool DryRun = false, ReplayTarget Target = ReplayTarget.Central, - Guid? ExistingEncounterId = null); \ No newline at end of file + Guid? ExistingEncounterId = null); diff --git a/VigilCare.Simulator/Engine/ReplayResult.cs b/VigilCare.Simulation.Core/Engine/ReplayResult.cs similarity index 94% rename from VigilCare.Simulator/Engine/ReplayResult.cs rename to VigilCare.Simulation.Core/Engine/ReplayResult.cs index a5d1b5b..70de1fc 100644 --- a/VigilCare.Simulator/Engine/ReplayResult.cs +++ b/VigilCare.Simulation.Core/Engine/ReplayResult.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public class ReplayResult { public string ScenarioId { get; } @@ -15,4 +17,4 @@ public class ReplayResult public bool OutcomesPassed => OutcomeFailures.Count == 0; public ReplayResult(string scenarioId) => ScenarioId = scenarioId; -} \ No newline at end of file +} diff --git a/VigilCare.Simulator/Scenarios/DepartmentMapper.cs b/VigilCare.Simulation.Core/Scenarios/DepartmentMapper.cs similarity index 95% rename from VigilCare.Simulator/Scenarios/DepartmentMapper.cs rename to VigilCare.Simulation.Core/Scenarios/DepartmentMapper.cs index 0080d64..9f28990 100644 --- a/VigilCare.Simulator/Scenarios/DepartmentMapper.cs +++ b/VigilCare.Simulation.Core/Scenarios/DepartmentMapper.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public static class DepartmentMapper { private static readonly Dictionary ScenarioToApi = new(StringComparer.OrdinalIgnoreCase) diff --git a/VigilCare.Simulator/Scenarios/ExpectedOutcomeValidator.cs b/VigilCare.Simulation.Core/Scenarios/ExpectedOutcomeValidator.cs similarity index 99% rename from VigilCare.Simulator/Scenarios/ExpectedOutcomeValidator.cs rename to VigilCare.Simulation.Core/Scenarios/ExpectedOutcomeValidator.cs index 385f9cb..2f39aa5 100644 --- a/VigilCare.Simulator/Scenarios/ExpectedOutcomeValidator.cs +++ b/VigilCare.Simulation.Core/Scenarios/ExpectedOutcomeValidator.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public static class ExpectedOutcomeValidator { private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8); diff --git a/VigilCare.Simulator/Scenarios/ScenarioFile.cs b/VigilCare.Simulation.Core/Scenarios/ScenarioFile.cs similarity index 92% rename from VigilCare.Simulator/Scenarios/ScenarioFile.cs rename to VigilCare.Simulation.Core/Scenarios/ScenarioFile.cs index 7fecb9c..1f9d7cf 100644 --- a/VigilCare.Simulator/Scenarios/ScenarioFile.cs +++ b/VigilCare.Simulation.Core/Scenarios/ScenarioFile.cs @@ -1,5 +1,7 @@ using System.Text.Json; +namespace VigilCare.Simulation; + public record ScenarioFile( ScenarioMeta Scenario, ScenarioPatient Patient, @@ -26,4 +28,4 @@ public record ExpectedOutcome( double AfterOffsetMinutes, string Type, string? AlertType, string? ScoreType, double? ExpectedMinimum, string? Description, - string? NarrativeContains = null); \ No newline at end of file + string? NarrativeContains = null); diff --git a/VigilCare.Simulation.Core/Scenarios/ScenarioLoader.cs b/VigilCare.Simulation.Core/Scenarios/ScenarioLoader.cs new file mode 100644 index 0000000..8b5af4b --- /dev/null +++ b/VigilCare.Simulation.Core/Scenarios/ScenarioLoader.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace VigilCare.Simulation; + +public static class ScenarioLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public static ScenarioFile Load(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"Scenario file not found: {path}"); + + var json = File.ReadAllText(path); + var scenario = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException($"Failed to deserialize: {path}"); + + return scenario with + { + Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList() + }; + } + + /// + /// Enumerates *.json in , skips files that + /// fail to deserialize, and returns pairs sorted by . + /// + public static IReadOnlyList<(ScenarioFile Scenario, string Path)> LoadAll(string directory) + { + if (!Directory.Exists(directory)) + return Array.Empty<(ScenarioFile, string)>(); + + var results = new List<(ScenarioFile Scenario, string Path)>(); + + foreach (var path in Directory.EnumerateFiles(directory, "*.json") + .Where(p => !string.Equals( + Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) + { + try + { + results.Add((Load(path), path)); + } + catch + { + // Skip files that fail to deserialize — catalogue must stay resilient. + } + } + + return results + .OrderBy(r => r.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/VigilCare.Simulator/Scenarios/ScenarioValidator.cs b/VigilCare.Simulation.Core/Scenarios/ScenarioValidator.cs similarity index 99% rename from VigilCare.Simulator/Scenarios/ScenarioValidator.cs rename to VigilCare.Simulation.Core/Scenarios/ScenarioValidator.cs index 7da6d82..dfacfa9 100644 --- a/VigilCare.Simulator/Scenarios/ScenarioValidator.cs +++ b/VigilCare.Simulation.Core/Scenarios/ScenarioValidator.cs @@ -1,3 +1,5 @@ +namespace VigilCare.Simulation; + public static class ScenarioValidator { private static readonly HashSet ValidCodes = new() diff --git a/VigilCare.Simulation.Core/VigilCare.Simulation.Core.csproj b/VigilCare.Simulation.Core/VigilCare.Simulation.Core.csproj new file mode 100644 index 0000000..b8b2c54 --- /dev/null +++ b/VigilCare.Simulation.Core/VigilCare.Simulation.Core.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + VigilCare.Simulation + Copyright (c) 2024-2026 voltsrage. All Rights Reserved. + voltsrage + LICENSE + + + diff --git a/VigilCare.Simulator/Client/Models/QsofaResponse.cs b/VigilCare.Simulator/Client/Models/QsofaResponse.cs deleted file mode 100644 index aae190c..0000000 --- a/VigilCare.Simulator/Client/Models/QsofaResponse.cs +++ /dev/null @@ -1 +0,0 @@ -public record QsofaResponse(int ActiveCriteria); \ No newline at end of file diff --git a/VigilCare.Simulator/Client/Models/RecordOrderResultRequest.cs b/VigilCare.Simulator/Client/Models/RecordOrderResultRequest.cs deleted file mode 100644 index 9a4452d..0000000 --- a/VigilCare.Simulator/Client/Models/RecordOrderResultRequest.cs +++ /dev/null @@ -1 +0,0 @@ -public record RecordOrderResultRequest(string? ResultSummary); \ No newline at end of file diff --git a/VigilCare.Simulator/Commands/DryRunCommand.cs b/VigilCare.Simulator/Commands/DryRunCommand.cs index a78ca4e..4525ef3 100644 --- a/VigilCare.Simulator/Commands/DryRunCommand.cs +++ b/VigilCare.Simulator/Commands/DryRunCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using VigilCare.Simulation; public static class DryRunCommand { @@ -21,7 +22,7 @@ public static class DryRunCommand return; } - var engine = new ReplayEngine(client: null!, poller: null); + var engine = new ReplayEngine(client: null!, poller: null, new ConsoleReplayObserver()); await engine.RunAsync(scenario, new ReplayOptions(DryRun: true)); }, fileArg); diff --git a/VigilCare.Simulator/Commands/ReplayAllCommand.cs b/VigilCare.Simulator/Commands/ReplayAllCommand.cs index 3ce1a16..74a4249 100644 --- a/VigilCare.Simulator/Commands/ReplayAllCommand.cs +++ b/VigilCare.Simulator/Commands/ReplayAllCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using VigilCare.Simulation; public static class ReplayAllCommand { @@ -38,7 +39,7 @@ public static class ReplayAllCommand await client.LoginAsync(username, password); SimulatorConsole.Info("Authenticated."); - var engine = new ReplayEngine(client, poller: null); + var engine = new ReplayEngine(client, poller: null, new ConsoleReplayObserver()); var results = new List(); foreach (var file in files) diff --git a/VigilCare.Simulator/Commands/ReplayCommand.cs b/VigilCare.Simulator/Commands/ReplayCommand.cs index 2219718..b03d1dc 100644 --- a/VigilCare.Simulator/Commands/ReplayCommand.cs +++ b/VigilCare.Simulator/Commands/ReplayCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using VigilCare.Simulation; public static class ReplayCommand { @@ -86,7 +87,7 @@ public static class ReplayCommand } var poller = poll ? new ApiPoller(client) : null; - var engine = new ReplayEngine(client, poller); + var engine = new ReplayEngine(client, poller, new ConsoleReplayObserver()); var options = new ReplayOptions( speed, poll, pollInterval, DryRun: false, Target: gateway ? ReplayTarget.Gateway : ReplayTarget.Central, diff --git a/VigilCare.Simulator/Commands/ValidateCommand.cs b/VigilCare.Simulator/Commands/ValidateCommand.cs index d2df90d..e275cc3 100644 --- a/VigilCare.Simulator/Commands/ValidateCommand.cs +++ b/VigilCare.Simulator/Commands/ValidateCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using VigilCare.Simulation; public static class ValidateCommand { diff --git a/VigilCare.Simulator/Mimic/MimicGenerateCommand.cs b/VigilCare.Simulator/Mimic/MimicGenerateCommand.cs index 57f61d6..51531e8 100644 --- a/VigilCare.Simulator/Mimic/MimicGenerateCommand.cs +++ b/VigilCare.Simulator/Mimic/MimicGenerateCommand.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Text.Json; using Spectre.Console; +using VigilCare.Simulation; public static class MimicGenerateCommand { diff --git a/VigilCare.Simulator/Mimic/MimicScenarioBuilder.cs b/VigilCare.Simulator/Mimic/MimicScenarioBuilder.cs index 769b446..c61f331 100644 --- a/VigilCare.Simulator/Mimic/MimicScenarioBuilder.cs +++ b/VigilCare.Simulator/Mimic/MimicScenarioBuilder.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using VigilCare.Simulation; public record MimicGenerateOptions( int? MaxHours = null, diff --git a/VigilCare.Simulator/Output/ConsoleReplayObserver.cs b/VigilCare.Simulator/Output/ConsoleReplayObserver.cs new file mode 100644 index 0000000..ca79541 --- /dev/null +++ b/VigilCare.Simulator/Output/ConsoleReplayObserver.cs @@ -0,0 +1,33 @@ +using VigilCare.Simulation; + +public sealed class ConsoleReplayObserver : IReplayObserver +{ + public void Header(string name, string? description) => + SimulatorConsole.Header(name, description); + + public void Info(string message) => + SimulatorConsole.Info(message); + + public void Event(string simTime, string description) => + SimulatorConsole.Event(simTime, description); + + public void Waiting(double deltaMinutes, int delayMs) => + SimulatorConsole.Wait(deltaMinutes, delayMs); + + public void Warn(string message) => + SimulatorConsole.Warn(message); + + public void Error(string message) => + SimulatorConsole.Error(message); + + public void DryRun(string message) => + SimulatorConsole.DryRun(message); + + public void Completed(ReplayResult result) => + SimulatorConsole.Summary(result); + + public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) + { + // Console progress is already visible via Event/Wait; no extra output. + } +} diff --git a/VigilCare.Simulator/Output/SimulatorConsole.cs b/VigilCare.Simulator/Output/SimulatorConsole.cs index 392ebe3..6439a8c 100644 --- a/VigilCare.Simulator/Output/SimulatorConsole.cs +++ b/VigilCare.Simulator/Output/SimulatorConsole.cs @@ -1,4 +1,5 @@ using Spectre.Console; +using VigilCare.Simulation; public static class SimulatorConsole { diff --git a/VigilCare.Simulator/Polling/ApiPoller.cs b/VigilCare.Simulator/Polling/ApiPoller.cs index 115a488..1d5a22c 100644 --- a/VigilCare.Simulator/Polling/ApiPoller.cs +++ b/VigilCare.Simulator/Polling/ApiPoller.cs @@ -1,4 +1,6 @@ -public class ApiPoller +using VigilCare.Simulation; + +public class ApiPoller : IApiPoller { private readonly VigilCareApiClient _client; private readonly HashSet _seenAlertIds = new(); @@ -20,4 +22,4 @@ public class ApiPoller var result = new PollResult(news2, gcs, sofa, qsofa, newAlerts, bundle); SimulatorConsole.PollResults(simTime, result); } -} \ No newline at end of file +} diff --git a/VigilCare.Simulator/Polling/PollResult.cs b/VigilCare.Simulator/Polling/PollResult.cs index 9c5be30..6a533e6 100644 --- a/VigilCare.Simulator/Polling/PollResult.cs +++ b/VigilCare.Simulator/Polling/PollResult.cs @@ -1,7 +1,9 @@ +using VigilCare.Simulation; + public record PollResult( News2Response? News2, GcsResponse? Gcs, SofaResponse? Sofa, QsofaResponse? Qsofa, IReadOnlyList NewAlerts, - SepsisBundleResponse? SepsisBundle); \ No newline at end of file + SepsisBundleResponse? SepsisBundle); diff --git a/VigilCare.Simulator/Scenarios/ScenarioLoader.cs b/VigilCare.Simulator/Scenarios/ScenarioLoader.cs deleted file mode 100644 index e422020..0000000 --- a/VigilCare.Simulator/Scenarios/ScenarioLoader.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json; - -public static class ScenarioLoader -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true - }; - - public static ScenarioFile Load(string path) - { - if (!File.Exists(path)) - throw new FileNotFoundException($"Scenario file not found: {path}"); - - var json = File.ReadAllText(path); - var scenario = JsonSerializer.Deserialize(json, JsonOptions) - ?? throw new InvalidOperationException($"Failed to deserialize: {path}"); - - return scenario with - { - Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList() - }; - } -} \ No newline at end of file diff --git a/VigilCare.Simulator/VigilCare.Simulator.csproj b/VigilCare.Simulator/VigilCare.Simulator.csproj index e0bc22a..264a17c 100644 --- a/VigilCare.Simulator/VigilCare.Simulator.csproj +++ b/VigilCare.Simulator/VigilCare.Simulator.csproj @@ -19,4 +19,8 @@ + + + + diff --git a/VigilCareClinical.sln b/VigilCareClinical.sln index aa89938..a33bb8f 100644 --- a/VigilCareClinical.sln +++ b/VigilCareClinical.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway", "Vi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.WardGateway.Tests", "VigilCare.WardGateway.Tests\VigilCare.WardGateway.Tests.csproj", "{1C6D261E-488B-4541-8995-12C5029441A6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VigilCare.Simulation.Core", "VigilCare.Simulation.Core\VigilCare.Simulation.Core.csproj", "{CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,5 +56,9 @@ Global {1C6D261E-488B-4541-8995-12C5029441A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C6D261E-488B-4541-8995-12C5029441A6}.Release|Any CPU.Build.0 = Release|Any CPU + {CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBFE2058-7FD5-4FBF-9275-E0C4E507BC79}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs index d8baa19..40fb77f 100644 --- a/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs +++ b/VigilCareClinicalAPI.Tests/ClinicalRefactorEndToEndTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; +using VigilCare.Simulation; [Collection("Integration")] public class ClinicalRefactorEndToEndTests : IAsyncLifetime diff --git a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs index 5a981ab..b3449d0 100644 --- a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs +++ b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using StackExchange.Redis; @@ -24,6 +26,12 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime public static int RabbitPort { get; } = int.TryParse(Environment.GetEnvironmentVariable("RabbitMq__Port"), out var p) ? p : 5674; + public static string SimulationScenarioDirectory { get; } = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "Fixtures", "Scenarios")); + + public TestSimulationClientFactory SimulationClientFactory => + Services.GetRequiredService(); + // Override configuration to point at a test database — never run tests against // the development database; a botched rollback could corrupt seed data. protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -62,6 +70,16 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime }); config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false); + + // Win over appsettings.Testing.json catalogue path / concurrency defaults. + config.AddInMemoryCollection(new Dictionary + { + ["Simulation:Enabled"] = "true", + ["Simulation:ScenarioDirectory"] = SimulationScenarioDirectory, + ["Simulation:MaxConcurrentRuns"] = "2", + ["Simulation:MaxSpeed"] = "600", + ["Simulation:RunHistoryLimit"] = "50", + }); }); builder.ConfigureServices(services => @@ -77,6 +95,15 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime .AddScheme( TestingAuthHandler.SchemeName, _ => { }); }); + + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddSingleton(_ => + new TestSimulationClientFactory(this)); + services.AddSingleton(sp => + sp.GetRequiredService()); + }); } public async Task InitializeAsync() diff --git a/VigilCareClinicalAPI.Tests/Fixtures/Scenarios/minimal-sim-01.json b/VigilCareClinicalAPI.Tests/Fixtures/Scenarios/minimal-sim-01.json new file mode 100644 index 0000000..6388801 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Fixtures/Scenarios/minimal-sim-01.json @@ -0,0 +1,44 @@ +{ + "scenario": { + "id": "minimal-sim-01", + "name": "Minimal Simulation Fixture", + "description": "Three observation clusters for Phase 36 CI tests.", + "durationMinutes": 2, + "tags": ["test", "minimal"] + }, + "patient": { + "firstName": "Sim", + "lastName": "Fixture", + "dateOfBirth": "1980-01-15", + "gender": "Female" + }, + "encounter": { + "department": "GeneralMedicine", + "encounterType": "Inpatient", + "attendingPhysician": "Dr. Test", + "roomBed": "T-1", + "admissionReason": "Simulation fixture" + }, + "events": [ + { + "offsetMinutes": 0, + "type": "observation", + "data": { "code": "HEART_RATE", "value": 72, "unit": "bpm", "source": "Manual" } + }, + { + "offsetMinutes": 0, + "type": "observation", + "data": { "code": "RESP_RATE", "value": 14, "unit": "/min", "source": "Manual" } + }, + { + "offsetMinutes": 1, + "type": "observation", + "data": { "code": "HEART_RATE", "value": 74, "unit": "bpm", "source": "Manual" } + }, + { + "offsetMinutes": 2, + "type": "observation", + "data": { "code": "HEART_RATE", "value": 70, "unit": "bpm", "source": "Manual" } + } + ] +} diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs index ee50f8d..638ffb6 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs @@ -35,6 +35,7 @@ public static class DbResetHelper DELETE FROM alert_thresholds; DELETE FROM clinical_audit_logs; DELETE FROM clinical_users; + DELETE FROM simulation_runs; DELETE FROM patients; "); return; diff --git a/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs index 2c7debf..df5a39d 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/ScenarioReplayHelper.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using VigilCare.Simulation; public static class ScenarioReplayHelper { diff --git a/VigilCareClinicalAPI.Tests/Simulation/SimulationEndpointTests.cs b/VigilCareClinicalAPI.Tests/Simulation/SimulationEndpointTests.cs new file mode 100644 index 0000000..5aa9632 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Simulation/SimulationEndpointTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +[Collection("Integration")] +public class SimulationEndpointTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + + public SimulationEndpointTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var redis = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedThresholdsOnlyAsync(db, redis); + + _fixture.SimulationClientFactory.HangOnCreate = false; + _fixture.SimulationClientFactory.FailOnCreate = false; + + _client.DefaultRequestHeaders.Remove("X-Test-Role"); + _client.DefaultRequestHeaders.Remove("X-Test-User-Id"); + _client.AsAdmin(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task Config_ReturnsEnabledFalse_WhenDisabled() + { + using var factory = _fixture.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Simulation:Enabled"] = "false", + }); + }); + }); + + var client = factory.CreateClient(); + client.AsAdmin(); + + var resp = await client.GetAsync("/api/v1/simulation/config"); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("data").GetProperty("enabled").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task Scenarios_WhenDisabled_Returns404() + { + using var factory = _fixture.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Simulation:Enabled"] = "false", + }); + }); + }); + + var client = factory.CreateClient(); + client.AsAdmin(); + + var resp = await client.GetAsync("/api/v1/simulation/scenarios"); + resp.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Scenarios_AsNurse_Returns200() + { + _client.AsNurse(); + + var resp = await _client.GetAsync("/api/v1/simulation/scenarios"); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await resp.Content.ReadFromJsonAsync(); + var items = body.GetProperty("data"); + items.GetArrayLength().Should().BeGreaterThan(0); + items[0].GetProperty("id").GetString().Should().Be("minimal-sim-01"); + } + + [Fact] + public async Task StartRun_AsIntegrationRole_Returns403() + { + _client.AsIntegration(); + + var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new + { + scenarioId = "minimal-sim-01", + speed = 600 + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task StartRun_WritesAuditLog() + { + _client.AsPhysician(); + + var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new + { + scenarioId = "minimal-sim-01", + speed = 600 + }); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await resp.Content.ReadFromJsonAsync(); + var runId = body.GetProperty("data").GetProperty("runId").GetGuid(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var audit = await db.ClinicalAuditLogs + .Where(a => a.Action == AuditAction.SimulationRunStarted && a.EntityId == runId) + .SingleOrDefaultAsync(); + + audit.Should().NotBeNull(); + audit!.EntityType.Should().Be("SimulationRun"); + } + + [Fact] + public async Task Config_WhenEnabled_ReturnsLimits() + { + _client.AsNurse(); + + var resp = await _client.GetAsync("/api/v1/simulation/config"); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await resp.Content.ReadFromJsonAsync(); + var data = body.GetProperty("data"); + data.GetProperty("enabled").GetBoolean().Should().BeTrue(); + data.GetProperty("maxSpeed").GetDouble().Should().Be(600); + data.GetProperty("maxConcurrentRuns").GetInt32().Should().Be(2); + } +} diff --git a/VigilCareClinicalAPI.Tests/Simulation/SimulationRunnerTests.cs b/VigilCareClinicalAPI.Tests/Simulation/SimulationRunnerTests.cs new file mode 100644 index 0000000..693e338 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Simulation/SimulationRunnerTests.cs @@ -0,0 +1,197 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StackExchange.Redis; + +[Collection("Integration")] +public class SimulationRunnerTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private ISimulationRunner _runner = null!; + private TestSimulationClientFactory _clientFactory = null!; + + public SimulationRunnerTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var redis = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + await DataSeeder.SeedThresholdsOnlyAsync(db, redis); + + _runner = _fixture.Services.GetRequiredService(); + _clientFactory = _fixture.SimulationClientFactory; + _clientFactory.HangOnCreate = false; + _clientFactory.FailOnCreate = false; + + foreach (var run in _runner.ListRuns()) + _runner.Cancel(run.RunId); + } + + public Task DisposeAsync() + { + _clientFactory.HangOnCreate = false; + _clientFactory.FailOnCreate = false; + return Task.CompletedTask; + } + + [Fact] + public async Task Start_UnknownScenario_Returns422() + { + var act = () => _runner.StartAsync("does-not-exist", 60, "tester", CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.ErrorCode.Should().Be("SIMULATION_SCENARIO_UNKNOWN"); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.SimulationRuns.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Start_SpeedAboveMax_Returns422() + { + var act = () => _runner.StartAsync("minimal-sim-01", 601, "tester", CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.ErrorCode.Should().Be("SIMULATION_SPEED_INVALID"); + } + + [Fact] + public async Task Start_AtConcurrencyLimit_Returns409() + { + _clientFactory.HangOnCreate = true; + + await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); + await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); + + var act = () => _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); + var ex = await act.Should().ThrowAsync(); + ex.Which.ErrorCode.Should().Be("SIMULATION_CONCURRENCY_LIMIT"); + + foreach (var run in _runner.ListRuns()) + _runner.Cancel(run.RunId); + + await WaitForAsync(() => _runner.ListRuns().All(r => + r.Status is SimulationRunStatus.Cancelled or SimulationRunStatus.Failed + or SimulationRunStatus.Completed)); + } + + [Fact] + public async Task Start_CreatesPatientMarkedSimulated() + { + var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); + var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed); + + completed.PatientId.Should().NotBeNull(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var patient = await db.Patients.SingleAsync(p => p.Id == completed.PatientId); + patient.IsSimulated.Should().BeTrue(); + } + + [Fact] + public async Task Run_ProgressAdvances() + { + var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); + + await WaitForAsync(() => + { + var current = _runner.GetRun(state.RunId); + return current is not null && current.LastOffsetMinutes > 0; + }); + + var mid = _runner.GetRun(state.RunId)!; + mid.LastOffsetMinutes.Should().BeGreaterThan(0); + + var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed); + completed.ProgressPercent.Should().Be(100); + completed.ObservationsSent.Should().Be(4); + } + + [Fact] + public async Task Stop_CancelsRun_StatusCancelled() + { + _clientFactory.HangOnCreate = true; + var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); + + _runner.Cancel(state.RunId).Should().BeTrue(); + var cancelled = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled); + cancelled.Status.Should().Be(SimulationRunStatus.Cancelled); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Observations.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Stop_AlreadyCompleted_IsNoOpSuccess() + { + var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); + await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed); + + _runner.Cancel(state.RunId).Should().BeTrue(); + var after = _runner.GetRun(state.RunId)!; + after.Status.Should().Be(SimulationRunStatus.Completed); + } + + [Fact] + public async Task Run_Failure_RecordsFailureReason() + { + _clientFactory.FailOnCreate = true; + var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); + + var failed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Failed); + failed.FailureReason.Should().NotBeNullOrWhiteSpace(); + failed.FailureReason.Should().Contain("Login failed"); + } + + [Fact] + public async Task Shutdown_CancelsActiveRuns() + { + _clientFactory.HangOnCreate = true; + var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); + + var hosted = (IHostedService)_fixture.Services.GetRequiredService(); + await hosted.StopAsync(CancellationToken.None); + + var after = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled); + after.Status.Should().Be(SimulationRunStatus.Cancelled); + } + + private async Task WaitForRunAsync( + Guid runId, SimulationRunStatus expected, TimeSpan? timeout = null) + { + try + { + await WaitForAsync(() => _runner.GetRun(runId)?.Status == expected, timeout); + } + catch (TimeoutException) + { + var actual = _runner.GetRun(runId); + throw new TimeoutException( + $"Expected run {runId} status {expected}, but was {actual?.Status}. " + + $"FailureReason={actual?.FailureReason}"); + } + + return _runner.GetRun(runId)!; + } + + private static async Task WaitForAsync(Func condition, TimeSpan? timeout = null) + { + var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(15)); + while (DateTimeOffset.UtcNow < deadline) + { + if (condition()) + return; + await Task.Delay(25); + } + + throw new TimeoutException("Condition was not met within the timeout."); + } +} diff --git a/VigilCareClinicalAPI.Tests/Simulation/TestSimulationClientFactory.cs b/VigilCareClinicalAPI.Tests/Simulation/TestSimulationClientFactory.cs new file mode 100644 index 0000000..872cbe6 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Simulation/TestSimulationClientFactory.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using VigilCare.Simulation; + +/// +/// Loopback client for WebApplicationFactory tests. Uses X-Test-Role INTEGRATION +/// instead of JWT login (Testing auth scheme ignores Bearer tokens). +/// Uses Server.CreateHandler() to avoid TestServer re-entrancy deadlocks. +/// +public sealed class TestSimulationClientFactory : ISimulationClientFactory +{ + private readonly WebApplicationFactory _factory; + + public TestSimulationClientFactory(WebApplicationFactory factory) => + _factory = factory; + + /// When true, CreateAsync blocks until cancelled — for concurrency/shutdown tests. + public bool HangOnCreate { get; set; } + + /// When true, CreateAsync throws — for failure-path tests. + public bool FailOnCreate { get; set; } + + public async Task CreateAsync(CancellationToken ct = default) + { + if (FailOnCreate) + throw new HttpRequestException("Login failed (401 Unauthorized): invalid credentials"); + + if (HangOnCreate) + await Task.Delay(Timeout.Infinite, ct); + + var http = new HttpClient(_factory.Server.CreateHandler(), disposeHandler: true) + { + BaseAddress = _factory.Server.BaseAddress ?? new Uri("http://localhost"), + }; + http.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION"); + return new VigilCareApiClient(http); + } +} diff --git a/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj b/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj index 166fbb2..dc7222f 100644 --- a/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj +++ b/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj @@ -26,11 +26,11 @@ + - - + diff --git a/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs b/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs index 50c3ffe..5dadade 100644 --- a/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs +++ b/VigilCareClinicalAPI/Authorization/ClinicalPermissions.cs @@ -18,4 +18,5 @@ public static class ClinicalPermissions public const string AuditRead = "audit:read"; public const string UsersAdmin = "users:admin"; public const string AlertsFeedback = "alerts:feedback"; + public const string SimulationRun = "simulation:run"; } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs b/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs index b013e70..f57840c 100644 --- a/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs +++ b/VigilCareClinicalAPI/Authorization/ClinicalRolePermissionMap.cs @@ -17,6 +17,7 @@ public static class ClinicalRolePermissionMap ClinicalPermissions.OrdersWrite, ClinicalPermissions.MedicationsWrite, ClinicalPermissions.AlertsFeedback, + ClinicalPermissions.SimulationRun, }, [ClinicalRole.Physician] = new(StringComparer.Ordinal) { @@ -33,6 +34,7 @@ public static class ClinicalRolePermissionMap ClinicalPermissions.OrdersWrite, ClinicalPermissions.MedicationsWrite, ClinicalPermissions.AlertsFeedback, + ClinicalPermissions.SimulationRun, }, [ClinicalRole.Admin] = new(StringComparer.Ordinal) { @@ -54,6 +56,7 @@ public static class ClinicalRolePermissionMap ClinicalPermissions.AuditRead, ClinicalPermissions.UsersAdmin, ClinicalPermissions.AlertsFeedback, + ClinicalPermissions.SimulationRun, }, [ClinicalRole.Integration] = new(StringComparer.Ordinal) { @@ -61,6 +64,10 @@ public static class ClinicalRolePermissionMap ClinicalPermissions.EncountersWrite, ClinicalPermissions.ObservationsIngest, ClinicalPermissions.MedicationsWrite, + // Phase 36 — simulation runner (Integration) must place orders and ack + // alerts so sepsis-bundle / alert_ack scenario timelines are complete. + ClinicalPermissions.OrdersWrite, + ClinicalPermissions.AlertsAcknowledge, ClinicalPermissions.FhirIngest, ClinicalPermissions.FhirRead, }, diff --git a/VigilCareClinicalAPI/Configuration/SimulationOptions.cs b/VigilCareClinicalAPI/Configuration/SimulationOptions.cs new file mode 100644 index 0000000..3caf757 --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/SimulationOptions.cs @@ -0,0 +1,27 @@ +public class SimulationOptions +{ + public const string Section = "Simulation"; + + /// Master switch. When false, no simulation endpoints or services are registered. + public bool Enabled { get; set; } = false; + + /// Directory containing scenario JSON files. + public string ScenarioDirectory { get; set; } = "Scenarios"; + + /// Base address the runner posts to (the API's own address). + public string LoopbackBaseUrl { get; set; } = "http://localhost:5270"; + + /// Service account the runner authenticates as. + public string RunnerUsername { get; set; } = "simulation.runner"; + + public string RunnerPassword { get; set; } = null!; + + /// Concurrent scenario runs allowed (Phase 38 ward population needs > 1). + public int MaxConcurrentRuns { get; set; } = 8; + + /// Upper bound on replay speed multiplier requested by a client. + public double MaxSpeed { get; set; } = 600; + + /// Completed runs retained in the in-memory registry. + public int RunHistoryLimit { get; set; } = 50; +} diff --git a/VigilCareClinicalAPI/Controllers/SimulationController.cs b/VigilCareClinicalAPI/Controllers/SimulationController.cs new file mode 100644 index 0000000..6be70d0 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/SimulationController.cs @@ -0,0 +1,194 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +/// +/// In-app scenario catalogue and run control for clinical testing sessions. +/// +[ApiController] +[Route("api/v1/simulation")] +[Produces("application/json")] +[Authorize] +public class SimulationController : ControllerBase +{ + private readonly SimulationOptions _options; + private readonly ISimulationRunner? _runner; + private readonly IScenarioCatalog? _catalog; + private readonly ICurrentUserService _currentUser; + private readonly IAuditService _audit; + + public SimulationController( + IOptions options, + IServiceProvider services, + ICurrentUserService currentUser, + IAuditService audit) + { + _options = options.Value; + _runner = services.GetService(); + _catalog = services.GetService(); + _currentUser = currentUser; + _audit = audit; + } + + /// + /// Feature-detect simulation availability without requiring simulation:run. + /// Returns enabled=false when the feature is off (never 404). + /// + [HttpGet("config")] + [AuthorizePermission(ClinicalPermissions.AlertsRead)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public IActionResult GetConfig() + { + if (!_options.Enabled) + return Ok(ApiResponse.Ok(new SimulationConfigResponse(Enabled: false))); + + return Ok(ApiResponse.Ok(new SimulationConfigResponse( + Enabled: true, + MaxSpeed: _options.MaxSpeed, + MaxConcurrentRuns: _options.MaxConcurrentRuns))); + } + + /// + /// Lists available scenario files from the configured scenario directory. + /// + [HttpGet("scenarios")] + [AuthorizePermission(ClinicalPermissions.SimulationRun)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public IActionResult ListScenarios() + { + EnsureEnabled(); + var items = _catalog!.ListScenarios() + .Select(s => new ScenarioSummaryResponse( + s.Scenario.Id, + s.Scenario.Name, + s.Scenario.Description, + s.Scenario.DurationMinutes, + s.Scenario.Tags, + s.Encounter.Department, + s.Events.Count, + s.ExpectedOutcomes?.Count ?? 0)) + .ToList(); + return Ok(ApiResponse>.Ok(items)); + } + + /// + /// Starts a background scenario replay. + /// + [HttpPost("runs")] + [AuthorizePermission(ClinicalPermissions.SimulationRun)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task StartRun( + [FromBody] StartSimulationRunRequest req, CancellationToken ct) + { + EnsureEnabled(); + + var userId = _currentUser.UserId?.ToString() + ?? throw new ValidationException("Authenticated user id is required.", "SIMULATION_USER_REQUIRED"); + + var state = await _runner!.StartAsync(req.ScenarioId, req.Speed, userId, ct); + + await _audit.WriteAsync( + AuditAction.SimulationRunStarted, + "SimulationRun", + state.RunId, + newValue: new + { + state.ScenarioId, + state.ScenarioName, + state.Speed, + StartedBy = userId, + }); + + return StatusCode(201, ApiResponse.Created(ToResponse(state))); + } + + /// + /// Lists active and recent simulation runs from the in-memory registry. + /// + [HttpGet("runs")] + [AuthorizePermission(ClinicalPermissions.SimulationRun)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public IActionResult ListRuns() + { + EnsureEnabled(); + var items = _runner!.ListRuns().Select(ToResponse).ToList(); + return Ok(ApiResponse>.Ok(items)); + } + + /// + /// Gets one simulation run by id. + /// + [HttpGet("runs/{runId:guid}")] + [AuthorizePermission(ClinicalPermissions.SimulationRun)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public IActionResult GetRun(Guid runId) + { + EnsureEnabled(); + var state = _runner!.GetRun(runId) + ?? throw new NotFoundException($"Simulation run '{runId}' was not found."); + return Ok(ApiResponse.Ok(ToResponse(state))); + } + + /// + /// Stops an in-flight run. Idempotent — stopping a finished run succeeds as a no-op. + /// + [HttpPost("runs/{runId:guid}/stop")] + [AuthorizePermission(ClinicalPermissions.SimulationRun)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task StopRun(Guid runId) + { + EnsureEnabled(); + + if (!_runner!.Cancel(runId)) + throw new NotFoundException($"Simulation run '{runId}' was not found."); + + var state = _runner.GetRun(runId) + ?? throw new NotFoundException($"Simulation run '{runId}' was not found."); + + await _audit.WriteAsync( + AuditAction.SimulationRunStopped, + "SimulationRun", + runId, + newValue: new + { + state.ScenarioId, + state.Status, + StoppedBy = _currentUser.UserId?.ToString(), + }); + + return Ok(ApiResponse.Ok(ToResponse(state))); + } + + private void EnsureEnabled() + { + if (!_options.Enabled || _runner is null || _catalog is null) + throw new NotFoundException("Simulation endpoints are not available."); + } + + private static SimulationRunResponse ToResponse(SimulationRunState state) => + new( + state.RunId, + state.ScenarioId, + state.ScenarioName, + state.Status.ToDbString(), + state.Speed, + state.PatientId, + state.EncounterId, + state.PatientDisplayName, + state.StartedAt, + state.ElapsedRealSeconds, + state.LastOffsetMinutes, + state.TotalOffsetMinutes, + state.ProgressPercent, + state.ObservationsSent, + state.MedicationsSent, + state.OrdersPlaced, + state.FailureReason); +} diff --git a/VigilCareClinicalAPI/Data/AppDbContext.cs b/VigilCareClinicalAPI/Data/AppDbContext.cs index 78f5a39..9e8fad5 100644 --- a/VigilCareClinicalAPI/Data/AppDbContext.cs +++ b/VigilCareClinicalAPI/Data/AppDbContext.cs @@ -37,6 +37,7 @@ public class AppDbContext : DbContext public DbSet AlertFeedbacks => Set(); public DbSet AlertQualityMetrics => Set(); public DbSet RefreshTokens => Set(); + public DbSet SimulationRuns => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs index bbe05fa..6c08119 100644 --- a/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/PatientConfiguration.cs @@ -25,6 +25,7 @@ public class PatientConfiguration : IEntityTypeConfiguration .HasColumnName("name_search_token") .HasMaxLength(64); builder.Property(p => p.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + builder.Property(p => p.IsSimulated).HasColumnName("is_simulated").HasDefaultValue(false); // MRN uses exact-match unique index — MRN lookups are always equality checks, // never LIKE/ILIKE. A B-tree unique index satisfies O(log n) point lookup. @@ -33,5 +34,9 @@ public class PatientConfiguration : IEntityTypeConfiguration // at this scale (pg_trgm GIN would be warranted at >500k patients). builder.HasIndex(p => p.Mrn).IsUnique(); builder.HasIndex(p => p.NameSearchToken); + // Filtered index keeps Phase 38 simulated-patient purge cheap. + builder.HasIndex(p => p.IsSimulated) + .HasDatabaseName("IX_Patients_IsSimulated") + .HasFilter("is_simulated = true"); } } diff --git a/VigilCareClinicalAPI/Data/Configurations/SimulationRunConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/SimulationRunConfiguration.cs new file mode 100644 index 0000000..3021ba9 --- /dev/null +++ b/VigilCareClinicalAPI/Data/Configurations/SimulationRunConfiguration.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class SimulationRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("simulation_runs"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(r => r.ScenarioId).HasColumnName("scenario_id").HasMaxLength(100).IsRequired(); + builder.Property(r => r.ScenarioName).HasColumnName("scenario_name").HasMaxLength(200).IsRequired(); + builder.Property(r => r.Speed).HasColumnName("speed"); + builder.Property(r => r.Status).HasColumnName("status").HasMaxLength(20).IsRequired() + .HasConversion( + v => v.ToDbString(), + v => SimulationRunStatusExtensions.FromDbString(v)); + builder.Property(r => r.PatientId).HasColumnName("patient_id"); + builder.Property(r => r.EncounterId).HasColumnName("encounter_id"); + builder.Property(r => r.StartedByUserId).HasColumnName("started_by_user_id").HasMaxLength(100).IsRequired(); + builder.Property(r => r.StartedAt).HasColumnName("started_at"); + builder.Property(r => r.CompletedAt).HasColumnName("completed_at"); + builder.Property(r => r.ObservationsSent).HasColumnName("observations_sent"); + builder.Property(r => r.MedicationsSent).HasColumnName("medications_sent"); + builder.Property(r => r.OrdersPlaced).HasColumnName("orders_placed"); + builder.Property(r => r.LastOffsetMinutes).HasColumnName("last_offset_minutes"); + builder.Property(r => r.TotalOffsetMinutes).HasColumnName("total_offset_minutes"); + builder.Property(r => r.FailureReason).HasColumnName("failure_reason").HasMaxLength(2000); + + builder.HasIndex(r => new { r.Status, r.StartedAt }) + .IsDescending(false, true) + .HasDatabaseName("IX_simulation_runs_status_started_at"); + } +} diff --git a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs index 1169a6b..a551b71 100644 --- a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs @@ -2,49 +2,85 @@ using Microsoft.EntityFrameworkCore; public static class UserSeeder { - public static async Task SeedAsync(AppDbContext db) + public static readonly Guid SimulationRunnerUserId = + Guid.Parse("55555555-5555-5555-5555-555555555555"); + + public static async Task SeedAsync( + AppDbContext db, + bool simulationEnabled = false, + string? simulationRunnerPassword = null) { - if (await db.ClinicalUsers.AnyAsync()) + if (!await db.ClinicalUsers.AnyAsync()) + { + db.ClinicalUsers.AddRange( + new ClinicalUser + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Username = "nurse.demo", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"), + DisplayName = "Demo Nurse", + Role = ClinicalRole.Nurse, + CreatedAt = DateTimeOffset.UtcNow + }, + new ClinicalUser + { + Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), + Username = "physician.demo", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"), + DisplayName = "Dr. Demo Physician", + Role = ClinicalRole.Physician, + CreatedAt = DateTimeOffset.UtcNow + }, + new ClinicalUser + { + Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), + Username = "admin.demo", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"), + DisplayName = "Demo Admin", + Role = ClinicalRole.Admin, + CreatedAt = DateTimeOffset.UtcNow + }, + new ClinicalUser + { + Id = Guid.Parse("44444444-4444-4444-4444-444444444444"), + Username = "integration.mirth", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"), + DisplayName = "Mirth Connect", + Role = ClinicalRole.Integration, + CreatedAt = DateTimeOffset.UtcNow + }); + + await db.SaveChangesAsync(); + } + + if (simulationEnabled) + await EnsureSimulationRunnerAsync(db, simulationRunnerPassword); + } + + /// + /// Seeds the loopback simulation runner account when Simulation:Enabled. + /// Idempotent — safe to call on an existing database that already has demo users. + /// + public static async Task EnsureSimulationRunnerAsync( + AppDbContext db, string? password) + { + if (await db.ClinicalUsers.AnyAsync(u => u.Username == "simulation.runner")) return; - db.ClinicalUsers.AddRange( - new ClinicalUser - { - Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), - Username = "nurse.demo", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"), - DisplayName = "Demo Nurse", - Role = ClinicalRole.Nurse, - CreatedAt = DateTimeOffset.UtcNow - }, - new ClinicalUser - { - Id = Guid.Parse("22222222-2222-2222-2222-222222222222"), - Username = "physician.demo", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"), - DisplayName = "Dr. Demo Physician", - Role = ClinicalRole.Physician, - CreatedAt = DateTimeOffset.UtcNow - }, - new ClinicalUser - { - Id = Guid.Parse("33333333-3333-3333-3333-333333333333"), - Username = "admin.demo", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"), - DisplayName = "Demo Admin", - Role = ClinicalRole.Admin, - CreatedAt = DateTimeOffset.UtcNow - }, - new ClinicalUser - { - Id = Guid.Parse("44444444-4444-4444-4444-444444444444"), - Username = "integration.mirth", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"), - DisplayName = "Mirth Connect", - Role = ClinicalRole.Integration, - CreatedAt = DateTimeOffset.UtcNow - }); + if (string.IsNullOrWhiteSpace(password)) + throw new InvalidOperationException( + "Simulation:Enabled requires Simulation:RunnerPassword to seed simulation.runner."); + + db.ClinicalUsers.Add(new ClinicalUser + { + Id = SimulationRunnerUserId, + Username = "simulation.runner", + PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), + DisplayName = "Simulation Runner", + Role = ClinicalRole.Integration, + CreatedAt = DateTimeOffset.UtcNow + }); await db.SaveChangesAsync(); } -} \ No newline at end of file +} diff --git a/VigilCareClinicalAPI/Domains/Entities/Patient.cs b/VigilCareClinicalAPI/Domains/Entities/Patient.cs index 39f8a42..dc5556f 100644 --- a/VigilCareClinicalAPI/Domains/Entities/Patient.cs +++ b/VigilCareClinicalAPI/Domains/Entities/Patient.cs @@ -16,5 +16,8 @@ public class Patient public string Status { get; set; } = "active"; public DateTimeOffset CreatedAt { get; set; } + /// True when this patient was created by the simulation runner. Never set for ingested clinical data. + public bool IsSimulated { get; set; } + public ICollection Encounters { get; set; } = new List(); } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Entities/SimulationRun.cs b/VigilCareClinicalAPI/Domains/Entities/SimulationRun.cs new file mode 100644 index 0000000..35f6e57 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Entities/SimulationRun.cs @@ -0,0 +1,19 @@ +public class SimulationRun +{ + public Guid Id { get; set; } + public string ScenarioId { get; set; } = null!; + public string ScenarioName { get; set; } = null!; + public double Speed { get; set; } + public SimulationRunStatus Status { get; set; } + public Guid? PatientId { get; set; } + public Guid? EncounterId { get; set; } + public string StartedByUserId { get; set; } = null!; + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public int ObservationsSent { get; set; } + public int MedicationsSent { get; set; } + public int OrdersPlaced { get; set; } + public double LastOffsetMinutes { get; set; } + public double TotalOffsetMinutes { get; set; } + public string? FailureReason { get; set; } +} diff --git a/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs b/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs index 1189019..09eedfc 100644 --- a/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs +++ b/VigilCareClinicalAPI/Domains/Enums/AuditAction.cs @@ -14,6 +14,8 @@ public enum AuditAction AlertFeedbackSubmitted, UserLogout, TokenRefreshed, + SimulationRunStarted, + SimulationRunStopped, } public static class AuditActionExtensions @@ -34,6 +36,8 @@ public static class AuditActionExtensions AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED", AuditAction.UserLogout => "USER_LOGOUT", AuditAction.TokenRefreshed => "TOKEN_REFRESHED", + AuditAction.SimulationRunStarted => "SIMULATION_RUN_STARTED", + AuditAction.SimulationRunStopped => "SIMULATION_RUN_STOPPED", _ => throw new ArgumentOutOfRangeException(nameof(a)) }; @@ -53,6 +57,8 @@ public static class AuditActionExtensions "ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted, "USER_LOGOUT" => AuditAction.UserLogout, "TOKEN_REFRESHED" => AuditAction.TokenRefreshed, + "SIMULATION_RUN_STARTED" => AuditAction.SimulationRunStarted, + "SIMULATION_RUN_STOPPED" => AuditAction.SimulationRunStopped, _ => throw new ArgumentOutOfRangeException(nameof(v)) }; } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Enums/SimulationRunStatus.cs b/VigilCareClinicalAPI/Domains/Enums/SimulationRunStatus.cs new file mode 100644 index 0000000..d4c1c9c --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/SimulationRunStatus.cs @@ -0,0 +1,24 @@ +public enum SimulationRunStatus { Pending, Running, Completed, Cancelled, Failed } + +public static class SimulationRunStatusExtensions +{ + public static string ToDbString(this SimulationRunStatus s) => s switch + { + SimulationRunStatus.Pending => "PENDING", + SimulationRunStatus.Running => "RUNNING", + SimulationRunStatus.Completed => "COMPLETED", + SimulationRunStatus.Cancelled => "CANCELLED", + SimulationRunStatus.Failed => "FAILED", + _ => throw new ArgumentOutOfRangeException(nameof(s)) + }; + + public static SimulationRunStatus FromDbString(string v) => v switch + { + "PENDING" => SimulationRunStatus.Pending, + "RUNNING" => SimulationRunStatus.Running, + "COMPLETED" => SimulationRunStatus.Completed, + "CANCELLED" => SimulationRunStatus.Cancelled, + "FAILED" => SimulationRunStatus.Failed, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown simulation run status: '{v}'") + }; +} diff --git a/VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.Designer.cs b/VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.Designer.cs new file mode 100644 index 0000000..c8e94a9 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.Designer.cs @@ -0,0 +1,2019 @@ +// +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("20260805171624_AddSimulationSupport")] + partial class AddSimulationSupport + { + /// + 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("AlertFeedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AlertId") + .HasColumnType("uuid") + .HasColumnName("alert_id"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("comment"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FeedbackType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("feedback_type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AlertId"); + + b.HasIndex("FeedbackType"); + + b.HasIndex("AlertId", "UserId") + .IsUnique(); + + b.ToTable("alert_feedbacks", (string)null); + }); + + modelBuilder.Entity("AlertQualityMetric", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedCount") + .HasColumnType("integer") + .HasColumnName("acknowledged_count"); + + b.Property("AcknowledgementRate") + .HasColumnType("double precision") + .HasColumnName("acknowledgement_rate"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("AvgSecondsToAcknowledge") + .HasColumnType("double precision") + .HasColumnName("avg_seconds_to_acknowledge"); + + b.Property("AvgSecondsToResolution") + .HasColumnType("double precision") + .HasColumnName("avg_seconds_to_resolution"); + + b.Property("ComputedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("computed_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EscalatedCount") + .HasColumnType("integer") + .HasColumnName("escalated_count"); + + b.Property("FalsePositiveRate") + .HasColumnType("double precision") + .HasColumnName("false_positive_rate"); + + b.Property("FeedbackCount") + .HasColumnType("integer") + .HasColumnName("feedback_count"); + + b.Property("FeedbackFalsePositiveCount") + .HasColumnType("integer") + .HasColumnName("feedback_false_positive_count"); + + b.Property("FeedbackUsefulCount") + .HasColumnType("integer") + .HasColumnName("feedback_useful_count"); + + b.Property("FeedbackWouldActCount") + .HasColumnType("integer") + .HasColumnName("feedback_would_act_count"); + + b.Property("ResolvedCount") + .HasColumnType("integer") + .HasColumnName("resolved_count"); + + b.Property("TotalAlerts") + .HasColumnType("integer") + .HasColumnName("total_alerts"); + + b.Property("UsefulRate") + .HasColumnType("double precision") + .HasColumnName("useful_rate"); + + b.Property("WindowEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("window_start"); + + b.Property("WouldActRate") + .HasColumnType("double precision") + .HasColumnName("would_act_rate"); + + b.HasKey("Id"); + + b.HasIndex("WindowStart"); + + b.HasIndex("AlertType", "WindowStart", "WindowEnd") + .IsUnique(); + + b.ToTable("alert_quality_metrics", (string)null); + }); + + 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("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("Explanation") + .HasColumnType("jsonb") + .HasColumnName("explanation"); + + 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", 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("ClinicalSite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Active") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("active"); + + b.Property("Address") + .HasColumnType("text") + .HasColumnName("address"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SiteCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("site_code"); + + b.HasKey("Id"); + + b.HasIndex("SiteCode") + .IsUnique(); + + b.ToTable("clinical_sites", (string)null); + }); + + modelBuilder.Entity("ClinicalSyncBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchReference") + .HasColumnType("uuid") + .HasColumnName("batch_reference"); + + b.Property("GatewayId") + .HasColumnType("uuid") + .HasColumnName("gateway_id"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status") + .HasDefaultValueSql("'RECEIVED'"); + + b.Property("SubmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchReference") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.HasIndex("GatewayId", "SubmittedAt"); + + b.ToTable("clinical_sync_batches", null, t => + { + t.HasCheckConstraint("chk_clinical_sync_batches_status", "status IN ('RECEIVED','PROCESSING','APPLIED','CONFLICT','REJECTED')"); + }); + }); + + modelBuilder.Entity("ClinicalSyncConflict", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ClientRef") + .HasColumnType("uuid") + .HasColumnName("client_ref"); + + b.Property("ConflictReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("conflict_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("item_type"); + + b.HasKey("Id"); + + b.HasIndex("BatchId"); + + b.ToTable("clinical_sync_conflicts", (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("PatientId", "EncounterType") + .IsUnique() + .HasDatabaseName("ix_encounters_patient_active_type") + .HasFilter("status = 'ACTIVE'"); + + 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("IsSimulated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_simulated"); + + 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("IsSimulated") + .HasDatabaseName("IX_Patients_IsSimulated") + .HasFilter("is_simulated = true"); + + 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("QsofaEvaluation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActiveCriteria") + .HasColumnType("integer") + .HasColumnName("active_criteria"); + + b.Property("Avpu") + .HasColumnType("numeric") + .HasColumnName("avpu"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EvaluatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("evaluated_at"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRate") + .HasColumnType("numeric") + .HasColumnName("resp_rate"); + + b.Property("ScreenAlertFired") + .HasColumnType("boolean") + .HasColumnName("screen_alert_fired"); + + b.Property("SystolicBp") + .HasColumnType("numeric") + .HasColumnName("systolic_bp"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "EvaluatedAt"); + + b.ToTable("qsofa_evaluations", null, t => + { + t.HasCheckConstraint("chk_qsofa_evaluations_active_criteria", "active_criteria >= 0 AND active_criteria <= 3"); + }); + }); + + 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("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("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + 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("SimulationRun", 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("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("failure_reason"); + + b.Property("LastOffsetMinutes") + .HasColumnType("double precision") + .HasColumnName("last_offset_minutes"); + + b.Property("MedicationsSent") + .HasColumnType("integer") + .HasColumnName("medications_sent"); + + b.Property("ObservationsSent") + .HasColumnType("integer") + .HasColumnName("observations_sent"); + + b.Property("OrdersPlaced") + .HasColumnType("integer") + .HasColumnName("orders_placed"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ScenarioId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("scenario_id"); + + b.Property("ScenarioName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scenario_name"); + + b.Property("Speed") + .HasColumnType("double precision") + .HasColumnName("speed"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("StartedByUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("started_by_user_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TotalOffsetMinutes") + .HasColumnType("double precision") + .HasColumnName("total_offset_minutes"); + + b.HasKey("Id"); + + b.HasIndex("Status", "StartedAt") + .IsDescending(false, true) + .HasDatabaseName("IX_simulation_runs_status_started_at"); + + b.ToTable("simulation_runs", (string)null); + }); + + 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("WardGateway", 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("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("GatewayCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("gateway_code"); + + b.Property("LastHeartbeatAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_heartbeat_at"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sync_at"); + + b.Property("ReportedBufferDepth") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("reported_buffer_depth"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status") + .HasDefaultValueSql("'OFFLINE'"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasFilter("status != 'ONLINE'"); + + b.HasIndex("SiteId", "Department"); + + b.HasIndex("SiteId", "GatewayCode") + .IsUnique(); + + b.ToTable("ward_gateways", null, t => + { + t.HasCheckConstraint("chk_ward_gateways_status", "status IN ('ONLINE','DEGRADED','OFFLINE')"); + }); + }); + + modelBuilder.Entity("AlertFeedback", b => + { + b.HasOne("ClinicalAlert", "Alert") + .WithMany("Feedbacks") + .HasForeignKey("AlertId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Alert"); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ClinicalSyncBatch", b => + { + b.HasOne("WardGateway", "Gateway") + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalSite", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Gateway"); + }); + + modelBuilder.Entity("ClinicalSyncConflict", b => + { + b.HasOne("ClinicalSyncBatch", "Batch") + .WithMany("Conflicts") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + 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("QsofaEvaluation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .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("RefreshToken", b => + { + b.HasOne("ClinicalUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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("WardGateway", b => + { + b.HasOne("ClinicalSite", "Site") + .WithMany("Gateways") + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Navigation("Feedbacks"); + }); + + modelBuilder.Entity("ClinicalSite", b => + { + b.Navigation("Gateways"); + }); + + modelBuilder.Entity("ClinicalSyncBatch", b => + { + b.Navigation("Conflicts"); + }); + + 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/20260805171624_AddSimulationSupport.cs b/VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.cs new file mode 100644 index 0000000..788dede --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260805171624_AddSimulationSupport.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddSimulationSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_simulated", + table: "patients", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "simulation_runs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + scenario_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + scenario_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + speed = table.Column(type: "double precision", nullable: false), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + patient_id = table.Column(type: "uuid", nullable: true), + encounter_id = table.Column(type: "uuid", nullable: true), + started_by_user_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + started_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + observations_sent = table.Column(type: "integer", nullable: false), + medications_sent = table.Column(type: "integer", nullable: false), + orders_placed = table.Column(type: "integer", nullable: false), + last_offset_minutes = table.Column(type: "double precision", nullable: false), + total_offset_minutes = table.Column(type: "double precision", nullable: false), + failure_reason = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_simulation_runs", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Patients_IsSimulated", + table: "patients", + column: "is_simulated", + filter: "is_simulated = true"); + + migrationBuilder.CreateIndex( + name: "IX_simulation_runs_status_started_at", + table: "simulation_runs", + columns: new[] { "status", "started_at" }, + descending: new[] { false, true }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "simulation_runs"); + + migrationBuilder.DropIndex( + name: "IX_Patients_IsSimulated", + table: "patients"); + + migrationBuilder.DropColumn( + name: "is_simulated", + table: "patients"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 6c15963..19b13f7 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -1166,6 +1166,12 @@ namespace VigilCareClinicalAPI.Migrations .HasColumnType("character varying(10)") .HasColumnName("gender"); + b.Property("IsSimulated") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_simulated"); + b.Property("LastName") .IsRequired() .HasMaxLength(100) @@ -1193,6 +1199,10 @@ namespace VigilCareClinicalAPI.Migrations b.HasKey("Id"); + b.HasIndex("IsSimulated") + .HasDatabaseName("IX_Patients_IsSimulated") + .HasFilter("is_simulated = true"); + b.HasIndex("Mrn") .IsUnique(); @@ -1524,6 +1534,92 @@ namespace VigilCareClinicalAPI.Migrations }); }); + modelBuilder.Entity("SimulationRun", 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("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("failure_reason"); + + b.Property("LastOffsetMinutes") + .HasColumnType("double precision") + .HasColumnName("last_offset_minutes"); + + b.Property("MedicationsSent") + .HasColumnType("integer") + .HasColumnName("medications_sent"); + + b.Property("ObservationsSent") + .HasColumnType("integer") + .HasColumnName("observations_sent"); + + b.Property("OrdersPlaced") + .HasColumnType("integer") + .HasColumnName("orders_placed"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ScenarioId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("scenario_id"); + + b.Property("ScenarioName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("scenario_name"); + + b.Property("Speed") + .HasColumnType("double precision") + .HasColumnName("speed"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("StartedByUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("started_by_user_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TotalOffsetMinutes") + .HasColumnType("double precision") + .HasColumnName("total_offset_minutes"); + + b.HasKey("Id"); + + b.HasIndex("Status", "StartedAt") + .IsDescending(false, true) + .HasDatabaseName("IX_simulation_runs_status_started_at"); + + b.ToTable("simulation_runs", (string)null); + }); + modelBuilder.Entity("SofaScore", b => { b.Property("Id") diff --git a/VigilCareClinicalAPI/Models/Records/Simulation/SimulationDtos.cs b/VigilCareClinicalAPI/Models/Records/Simulation/SimulationDtos.cs new file mode 100644 index 0000000..6e0e4ed --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Simulation/SimulationDtos.cs @@ -0,0 +1,35 @@ +public record SimulationConfigResponse( + bool Enabled, + double? MaxSpeed = null, + int? MaxConcurrentRuns = null); + +public record ScenarioSummaryResponse( + string Id, + string Name, + string? Description, + int? DurationMinutes, + IReadOnlyList? Tags, + string Department, + int EventCount, + int ExpectedOutcomeCount); + +public record StartSimulationRunRequest(string ScenarioId, double Speed = 60); + +public record SimulationRunResponse( + Guid RunId, + string ScenarioId, + string ScenarioName, + string Status, + double Speed, + Guid? PatientId, + Guid? EncounterId, + string PatientDisplayName, + DateTimeOffset StartedAt, + double ElapsedRealSeconds, + double LastOffsetMinutes, + double TotalOffsetMinutes, + double ProgressPercent, + int ObservationsSent, + int MedicationsSent, + int OrdersPlaced, + string? FailureReason); diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index d68d6b2..58643b6 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -167,6 +167,23 @@ try builder.Services.Configure( builder.Configuration.GetSection(AlertQualityOptions.Section)); + builder.Services.Configure( + builder.Configuration.GetSection(SimulationOptions.Section)); + + var simulationOptions = builder.Configuration + .GetSection(SimulationOptions.Section).Get() ?? new(); + + if (simulationOptions.Enabled) + { + builder.Services.AddHttpClient("simulation-loopback", c => + c.BaseAddress = new Uri(simulationOptions.LoopbackBaseUrl)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + } + builder.Services.AddCors(options => { options.AddPolicy("Dashboard", policy => @@ -345,6 +362,13 @@ try || args.Contains("create-admin") || args.Contains("register-gateway"); + if (simulationOptions.Enabled && !isCliCommand) + { + Log.Warning( + "Simulation mode ENABLED — scenario replay endpoints are exposed. " + + "Do not run this configuration against real patient data."); + } + // Demo data — including the seeded demo users with well-known passwords — // must never be created in production. Seeding:EnableDemoData defaults to // true so local development and the existing verification scripts are @@ -360,7 +384,16 @@ try var redis = scope.ServiceProvider.GetRequiredService(); await DataSeeder.SeedAsync(db, redis); await GatewayRegistrySeeder.SeedAsync(db); - await UserSeeder.SeedAsync(db); + await UserSeeder.SeedAsync( + db, + simulationEnabled: simulationOptions.Enabled, + simulationRunnerPassword: simulationOptions.RunnerPassword); + } + else if (simulationOptions.Enabled && !isCliCommand && !app.Environment.IsEnvironment("Testing")) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await UserSeeder.EnsureSimulationRunnerAsync(db, simulationOptions.RunnerPassword); } if (!app.Environment.IsEnvironment("Testing")) diff --git a/VigilCareClinicalAPI/Services/Simulation/ISimulationClientFactory.cs b/VigilCareClinicalAPI/Services/Simulation/ISimulationClientFactory.cs new file mode 100644 index 0000000..60380fc --- /dev/null +++ b/VigilCareClinicalAPI/Services/Simulation/ISimulationClientFactory.cs @@ -0,0 +1,6 @@ +using VigilCare.Simulation; + +public interface ISimulationClientFactory +{ + Task CreateAsync(CancellationToken ct = default); +} diff --git a/VigilCareClinicalAPI/Services/Simulation/RunStateReplayObserver.cs b/VigilCareClinicalAPI/Services/Simulation/RunStateReplayObserver.cs new file mode 100644 index 0000000..f41dd14 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Simulation/RunStateReplayObserver.cs @@ -0,0 +1,39 @@ +using VigilCare.Simulation; + +public sealed class RunStateReplayObserver : IReplayObserver +{ + private readonly SimulationRunState _state; + + public RunStateReplayObserver(SimulationRunState state) => _state = state; + + public void Header(string name, string? description) { } + + public void Info(string message) { } + + public void Event(string simTime, string description) => + _state.NoteEvent(description); + + public void Waiting(double deltaMinutes, int delayMs) { } + + public void Warn(string message) { } + + public void Error(string message) { } + + public void DryRun(string message) { } + + public void Completed(ReplayResult result) => SyncFromResult(result); + + public void Progress(double offsetMinutes, int clusterIndex, int clusterCount) => + _state.UpdateOffset(offsetMinutes); + + public void SyncFromResult(ReplayResult result) + { + _state.ApplyResultCounters( + result.ObservationsSent, + result.MedicationsSent, + result.OrdersPlaced); + _state.SetIds( + result.PatientId == Guid.Empty ? null : result.PatientId, + result.EncounterId == Guid.Empty ? null : result.EncounterId); + } +} diff --git a/VigilCareClinicalAPI/Services/Simulation/ScenarioCatalog.cs b/VigilCareClinicalAPI/Services/Simulation/ScenarioCatalog.cs new file mode 100644 index 0000000..d370a5a --- /dev/null +++ b/VigilCareClinicalAPI/Services/Simulation/ScenarioCatalog.cs @@ -0,0 +1,79 @@ +using VigilCare.Simulation; + +public interface IScenarioCatalog +{ + IReadOnlyList ListScenarios(); + ScenarioFile? GetById(string scenarioId); +} + +public sealed class ScenarioCatalog : IScenarioCatalog +{ + private readonly string _directory; + private readonly object _gate = new(); + private IReadOnlyList<(ScenarioFile Scenario, string Path, DateTime LastWriteUtc)> _entries = + Array.Empty<(ScenarioFile, string, DateTime)>(); + + public ScenarioCatalog(Microsoft.Extensions.Options.IOptions options) + { + _directory = options.Value.ScenarioDirectory; + } + + public IReadOnlyList ListScenarios() + { + RefreshIfNeeded(); + return _entries.Select(e => e.Scenario).ToList(); + } + + public ScenarioFile? GetById(string scenarioId) + { + RefreshIfNeeded(); + return _entries + .Select(e => e.Scenario) + .FirstOrDefault(s => string.Equals( + s.Scenario.Id, scenarioId, StringComparison.OrdinalIgnoreCase)); + } + + private void RefreshIfNeeded() + { + lock (_gate) + { + if (!Directory.Exists(_directory)) + { + _entries = Array.Empty<(ScenarioFile, string, DateTime)>(); + return; + } + + var disk = Directory.EnumerateFiles(_directory, "*.json") + .Where(p => !string.Equals( + Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase)) + .Select(p => (Path: p, LastWriteUtc: File.GetLastWriteTimeUtc(p))) + .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var unchanged = _entries.Count == disk.Count + && _entries.Zip(disk, (cached, onDisk) => + cached.Path == onDisk.Path && cached.LastWriteUtc == onDisk.LastWriteUtc) + .All(eq => eq); + + if (unchanged) + return; + + var loaded = new List<(ScenarioFile Scenario, string Path, DateTime LastWriteUtc)>(); + foreach (var file in disk) + { + try + { + loaded.Add((ScenarioLoader.Load(file.Path), file.Path, file.LastWriteUtc)); + } + catch + { + // Skip corrupt files — catalogue must stay resilient. + } + } + + _entries = loaded + .OrderBy(e => e.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/VigilCareClinicalAPI/Services/Simulation/SimulationClientFactory.cs b/VigilCareClinicalAPI/Services/Simulation/SimulationClientFactory.cs new file mode 100644 index 0000000..418e798 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Simulation/SimulationClientFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; +using VigilCare.Simulation; + +public sealed class SimulationClientFactory : ISimulationClientFactory +{ + private readonly IHttpClientFactory _http; + private readonly SimulationOptions _options; + + public SimulationClientFactory(IHttpClientFactory http, IOptions options) + { + _http = http; + _options = options.Value; + } + + public async Task CreateAsync(CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_options.RunnerPassword)) + throw new InvalidOperationException( + "Simulation:RunnerPassword is required when Simulation:Enabled is true."); + + var http = _http.CreateClient("simulation-loopback"); + var client = new VigilCareApiClient(http); + await client.LoginAsync(_options.RunnerUsername, _options.RunnerPassword); + return client; + } +} diff --git a/VigilCareClinicalAPI/Services/Simulation/SimulationRunState.cs b/VigilCareClinicalAPI/Services/Simulation/SimulationRunState.cs new file mode 100644 index 0000000..7393f09 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Simulation/SimulationRunState.cs @@ -0,0 +1,126 @@ +public sealed class SimulationRunState +{ + private readonly object _gate = new(); + private DateTimeOffset? _completedAt; + + public Guid RunId { get; init; } + public string ScenarioId { get; init; } = null!; + public string ScenarioName { get; init; } = null!; + public double Speed { get; init; } + public string StartedByUserId { get; init; } = null!; + public DateTimeOffset StartedAt { get; init; } + public double TotalOffsetMinutes { get; init; } + public string PatientDisplayName { get; init; } = null!; + + public SimulationRunStatus Status { get; private set; } = SimulationRunStatus.Pending; + public Guid? PatientId { get; private set; } + public Guid? EncounterId { get; private set; } + public int ObservationsSent { get; private set; } + public int MedicationsSent { get; private set; } + public int OrdersPlaced { get; private set; } + public double LastOffsetMinutes { get; private set; } + public double ProgressPercent { get; private set; } + public string? FailureReason { get; private set; } + + public double ElapsedRealSeconds + { + get + { + lock (_gate) + { + var end = _completedAt ?? DateTimeOffset.UtcNow; + return (end - StartedAt).TotalSeconds; + } + } + } + + public void MarkRunning() + { + lock (_gate) Status = SimulationRunStatus.Running; + } + + public void SetIds(Guid? patientId, Guid? encounterId) + { + lock (_gate) + { + if (patientId.HasValue) PatientId = patientId; + if (encounterId.HasValue) EncounterId = encounterId; + } + } + + public void UpdateOffset(double offsetMinutes) + { + lock (_gate) + { + LastOffsetMinutes = offsetMinutes; + ProgressPercent = TotalOffsetMinutes <= 0 + ? 100 + : Math.Clamp(offsetMinutes / TotalOffsetMinutes * 100.0, 0, 100); + } + } + + public void ApplyResultCounters(int observationsSent, int medicationsSent, int ordersPlaced) + { + lock (_gate) + { + ObservationsSent = observationsSent; + MedicationsSent = medicationsSent; + OrdersPlaced = ordersPlaced; + } + } + + public void NoteEvent(string description) + { + lock (_gate) + { + if (description.StartsWith("MEDICATION", StringComparison.Ordinal)) + MedicationsSent++; + else if (description.StartsWith("ORDER ", StringComparison.Ordinal)) + OrdersPlaced++; + else if (!description.StartsWith("ORDER_RESULT", StringComparison.Ordinal) + && !description.StartsWith("ACK ", StringComparison.Ordinal)) + ObservationsSent++; + } + } + + public void MarkTerminal(SimulationRunStatus status, string? failureReason = null) + { + lock (_gate) + { + Status = status; + FailureReason = failureReason; + _completedAt = DateTimeOffset.UtcNow; + if (status == SimulationRunStatus.Completed) + ProgressPercent = 100; + } + } + + public SimulationRunState Snapshot() + { + lock (_gate) + { + var copy = new SimulationRunState + { + RunId = RunId, + ScenarioId = ScenarioId, + ScenarioName = ScenarioName, + Speed = Speed, + StartedByUserId = StartedByUserId, + StartedAt = StartedAt, + TotalOffsetMinutes = TotalOffsetMinutes, + PatientDisplayName = PatientDisplayName, + }; + copy.Status = Status; + copy.PatientId = PatientId; + copy.EncounterId = EncounterId; + copy.ObservationsSent = ObservationsSent; + copy.MedicationsSent = MedicationsSent; + copy.OrdersPlaced = OrdersPlaced; + copy.LastOffsetMinutes = LastOffsetMinutes; + copy.ProgressPercent = ProgressPercent; + copy.FailureReason = FailureReason; + copy._completedAt = _completedAt; + return copy; + } + } +} diff --git a/VigilCareClinicalAPI/Services/Simulation/SimulationRunner.cs b/VigilCareClinicalAPI/Services/Simulation/SimulationRunner.cs new file mode 100644 index 0000000..9fc3b7c --- /dev/null +++ b/VigilCareClinicalAPI/Services/Simulation/SimulationRunner.cs @@ -0,0 +1,269 @@ +using System.Collections.Concurrent; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using VigilCare.Simulation; + +public interface ISimulationRunner +{ + IReadOnlyList ListRuns(); + SimulationRunState? GetRun(Guid runId); + Task StartAsync( + string scenarioId, double speed, string startedByUserId, CancellationToken ct); + bool Cancel(Guid runId); +} + +public sealed class SimulationRunner : ISimulationRunner, IHostedService +{ + private readonly ConcurrentDictionary _runs = new(); + private readonly ISimulationClientFactory _clientFactory; + private readonly IScenarioCatalog _catalog; + private readonly IServiceScopeFactory _scopeFactory; + private readonly SimulationOptions _options; + private readonly ILogger _logger; + + public SimulationRunner( + ISimulationClientFactory clientFactory, + IScenarioCatalog catalog, + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _clientFactory = clientFactory; + _catalog = catalog; + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + public IReadOnlyList ListRuns() => + _runs.Values + .Select(c => c.State.Snapshot()) + .OrderByDescending(s => s.StartedAt) + .ToList(); + + public SimulationRunState? GetRun(Guid runId) => + _runs.TryGetValue(runId, out var ctx) ? ctx.State.Snapshot() : null; + + public async Task StartAsync( + string scenarioId, double speed, string startedByUserId, CancellationToken ct) + { + if (!_options.Enabled) + throw new ValidationException("Simulation is disabled.", "SIMULATION_DISABLED"); + + if (string.IsNullOrWhiteSpace(scenarioId)) + throw new ValidationException("scenarioId is required.", "SIMULATION_SCENARIO_REQUIRED"); + + if (speed <= 0 || speed > _options.MaxSpeed) + throw new ValidationException( + $"speed must be > 0 and <= {_options.MaxSpeed}.", "SIMULATION_SPEED_INVALID"); + + var scenario = _catalog.GetById(scenarioId) + ?? throw new ValidationException( + $"Unknown scenario '{scenarioId}'.", "SIMULATION_SCENARIO_UNKNOWN"); + + var activeCount = _runs.Values.Count(c => + c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running); + if (activeCount >= _options.MaxConcurrentRuns) + throw new ConflictException( + $"Maximum concurrent simulation runs ({_options.MaxConcurrentRuns}) reached.", + "SIMULATION_CONCURRENCY_LIMIT"); + + var totalOffset = scenario.Events.Count == 0 + ? 0 + : scenario.Events.Max(e => e.OffsetMinutes); + + var runId = Guid.NewGuid(); + var startedAt = DateTimeOffset.UtcNow; + var state = new SimulationRunState + { + RunId = runId, + ScenarioId = scenario.Scenario.Id, + ScenarioName = scenario.Scenario.Name, + Speed = speed, + StartedByUserId = startedByUserId, + StartedAt = startedAt, + TotalOffsetMinutes = totalOffset, + PatientDisplayName = $"{scenario.Patient.FirstName} {scenario.Patient.LastName}", + }; + + await PersistNewRunAsync(state, ct); + + var cts = new CancellationTokenSource(); + var ctx = new RunContext(state, cts, scenario); + if (!_runs.TryAdd(runId, ctx)) + throw new ConflictException("Failed to register simulation run.", "SIMULATION_REGISTER_FAILED"); + + _ = Task.Run(() => ExecuteAsync(ctx, CancellationToken.None), CancellationToken.None); + + return state.Snapshot(); + } + + public bool Cancel(Guid runId) + { + if (!_runs.TryGetValue(runId, out var ctx)) + return false; + + if (ctx.State.Status is SimulationRunStatus.Completed + or SimulationRunStatus.Cancelled + or SimulationRunStatus.Failed) + return true; + + ctx.Cts.Cancel(); + return true; + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + foreach (var ctx in _runs.Values) + { + if (ctx.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running) + ctx.Cts.Cancel(); + } + + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline + && _runs.Values.Any(c => + c.State.Status is SimulationRunStatus.Pending or SimulationRunStatus.Running)) + { + await Task.Delay(50, cancellationToken); + } + } + + private async Task ExecuteAsync(RunContext ctx, CancellationToken _) + { + var runId = ctx.State.RunId; + ctx.State.MarkRunning(); + await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Running); + + try + { + var client = await _clientFactory.CreateAsync(ctx.Cts.Token); + var observer = new RunStateReplayObserver(ctx.State); + var engine = new ReplayEngine( + client, + poller: null, + observer, + onPatientRegistered: (patientId, ct) => MarkPatientSimulatedAsync(patientId, ct)); + + var result = await engine.RunAsync( + ctx.Scenario, + new ReplayOptions(Speed: ctx.State.Speed, Poll: false), + ctx.Cts.Token); + + observer.SyncFromResult(result); + ctx.State.MarkTerminal(SimulationRunStatus.Completed); + await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Completed); + } + catch (OperationCanceledException) + { + ctx.State.MarkTerminal(SimulationRunStatus.Cancelled); + await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Cancelled); + _logger.LogInformation("Simulation run {RunId} cancelled", runId); + } + catch (Exception ex) + { + ctx.State.MarkTerminal(SimulationRunStatus.Failed, ex.Message); + await UpdateRunRowAsync(ctx.State, SimulationRunStatus.Failed, ex.Message); + _logger.LogError(ex, "Simulation run {RunId} failed", runId); + } + finally + { + ctx.Cts.Dispose(); + TrimHistory(); + } + } + + private async Task MarkPatientSimulatedAsync(Guid patientId, CancellationToken ct) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var patient = await db.Patients.FirstOrDefaultAsync(p => p.Id == patientId, ct); + if (patient is null) + return; + + patient.IsSimulated = true; + await db.SaveChangesAsync(ct); + } + + private async Task PersistNewRunAsync(SimulationRunState state, CancellationToken ct) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.SimulationRuns.Add(new SimulationRun + { + Id = state.RunId, + ScenarioId = state.ScenarioId, + ScenarioName = state.ScenarioName, + Speed = state.Speed, + Status = SimulationRunStatus.Pending, + StartedByUserId = state.StartedByUserId, + StartedAt = state.StartedAt, + TotalOffsetMinutes = state.TotalOffsetMinutes, + }); + await db.SaveChangesAsync(ct); + } + + private async Task UpdateRunRowAsync( + SimulationRunState state, + SimulationRunStatus status, + string? failureReason = null) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.SimulationRuns.FirstOrDefaultAsync(r => r.Id == state.RunId); + if (row is null) + return; + + row.Status = status; + row.PatientId = state.PatientId; + row.EncounterId = state.EncounterId; + row.ObservationsSent = state.ObservationsSent; + row.MedicationsSent = state.MedicationsSent; + row.OrdersPlaced = state.OrdersPlaced; + row.LastOffsetMinutes = state.LastOffsetMinutes; + row.TotalOffsetMinutes = state.TotalOffsetMinutes; + row.FailureReason = failureReason ?? state.FailureReason; + if (status is SimulationRunStatus.Completed + or SimulationRunStatus.Cancelled + or SimulationRunStatus.Failed) + { + row.CompletedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to persist simulation run {RunId} status {Status}", + state.RunId, status); + } + } + + private void TrimHistory() + { + var terminal = _runs.Values + .Where(c => c.State.Status is SimulationRunStatus.Completed + or SimulationRunStatus.Cancelled + or SimulationRunStatus.Failed) + .OrderByDescending(c => c.State.StartedAt) + .Skip(_options.RunHistoryLimit) + .ToList(); + + foreach (var old in terminal) + _runs.TryRemove(old.State.RunId, out _); + } + + private sealed class RunContext( + SimulationRunState state, + CancellationTokenSource cts, + ScenarioFile scenario) + { + public SimulationRunState State { get; } = state; + public CancellationTokenSource Cts { get; } = cts; + public ScenarioFile Scenario { get; } = scenario; + } +} diff --git a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj index 2060e52..fd16237 100644 --- a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj +++ b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj @@ -46,6 +46,7 @@ + diff --git a/VigilCareClinicalAPI/appsettings.Production.json b/VigilCareClinicalAPI/appsettings.Production.json index 284b9e6..4c86fc9 100644 --- a/VigilCareClinicalAPI/appsettings.Production.json +++ b/VigilCareClinicalAPI/appsettings.Production.json @@ -39,5 +39,10 @@ "LogListAccess": true }, "Swagger": { "Enabled": false }, - "Seeding": { "EnableDemoData": false } -} \ No newline at end of file + "Seeding": { "EnableDemoData": false }, + "Simulation": { + // Patient-safety gate: never expose scenario replay against real care data. + // Flip only for dedicated training/staging environments with synthetic patients. + "Enabled": false + } +} diff --git a/VigilCareClinicalAPI/appsettings.Testing.json b/VigilCareClinicalAPI/appsettings.Testing.json index da21040..1b7601b 100644 --- a/VigilCareClinicalAPI/appsettings.Testing.json +++ b/VigilCareClinicalAPI/appsettings.Testing.json @@ -22,5 +22,15 @@ "DataLake": { "FlushCount": 3, "FlushIntervalSeconds": 10 + }, + "Simulation": { + "Enabled": true, + "ScenarioDirectory": "../VigilCare.Simulator/Scenarios/List", + "LoopbackBaseUrl": "http://localhost:5270", + "RunnerUsername": "simulation.runner", + "RunnerPassword": "DemoSimulation1!", + "MaxConcurrentRuns": 8, + "MaxSpeed": 600, + "RunHistoryLimit": 50 } } diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index d03a573..35f40e1 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -228,5 +228,14 @@ }, "Seeding": { "EnableDemoData": true + }, + "Simulation": { + "Enabled": false, + "ScenarioDirectory": "Scenarios", + "LoopbackBaseUrl": "http://localhost:5270", + "RunnerUsername": "simulation.runner", + "MaxConcurrentRuns": 8, + "MaxSpeed": 600, + "RunHistoryLimit": 50 } }