diff --git a/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs b/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs new file mode 100644 index 0000000..d6dd25c --- /dev/null +++ b/VigilCareClinicalAPI.Tests/Fhir/FhirIngestTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Task = System.Threading.Tasks.Task; + +[Collection("Integration")] +public class FhirIngestTests : IAsyncLifetime +{ + private const string ApiKey = "dev-integration-key-change-in-production"; + + private readonly ApiFixture _fixture; + private readonly HttpClient _client; + private static readonly FhirJsonSerializer Serializer = new(); + + public FhirIngestTests(ApiFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task PatientUpsert_IsIdempotent() + { + var patient = BuildPatient("MRN-HOSP-001"); + var json = Serializer.SerializeToString(patient); + + var resp1 = await PostFhirAsync("/fhir/R4/Patient", json); + resp1.StatusCode.Should().Be(HttpStatusCode.Created); + + var resp2 = await PostFhirAsync("/fhir/R4/Patient", json); + resp2.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Observation_HeartRate_Loinc8867_MapsAndIngests() + { + var encounterId = await SeedPatientAndEncounterAsync(); + + var obs = new Hl7.Fhir.Model.Observation + { + Status = ObservationStatus.Final, + Category = new List + { + new("http://terminology.hl7.org/CodeSystem/observation-category", "vital-signs") + }, + Code = new CodeableConcept("http://loinc.org", "8867-4", "Heart rate"), + Subject = new ResourceReference("Patient/test"), + Encounter = new ResourceReference($"Encounter/{encounterId}"), + Effective = new FhirDateTime(DateTimeOffset.UtcNow), + Value = new Quantity(110, "/min", "http://unitsofmeasure.org"), + Identifier = new List + { + new("http://hospital.example/obs", "hr-001") + } + }; + + var resp = await PostFhirAsync("/fhir/R4/Observation", Serializer.SerializeToString(obs)); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = db.Observations.Single(o => o.IdempotencyKey == "hr-001"); + row.ObservationCode.Should().Be("HEART_RATE"); + row.Value.Should().Be(110m); + } + + [Fact] + public async Task Observation_UnknownLoinc_Returns422() + { + var encounterId = await SeedPatientAndEncounterAsync(); + var obs = new Hl7.Fhir.Model.Observation + { + Status = ObservationStatus.Final, + Code = new CodeableConcept("http://loinc.org", "99999-9"), + Encounter = new ResourceReference($"Encounter/{encounterId}"), + Value = new Quantity(1, "1") + }; + + var resp = await PostFhirAsync("/fhir/R4/Observation", Serializer.SerializeToString(obs)); + resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + (await resp.Content.ReadAsStringAsync()).Should().Contain("OperationOutcome"); + } + + [Fact] + public async Task TransactionBundle_PatientEncounterObservation_Succeeds() + { + var bundle = BuildAdmitBundle(); + var resp = await PostFhirAsync("/fhir/R4", Serializer.SerializeToString(bundle)); + resp.StatusCode.Should().Be(HttpStatusCode.OK); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Patients.Should().Contain(p => p.Mrn == "MRN-ADT-001"); + db.Encounters.Should().Contain(e => e.Status == EncounterStatus.Active); + db.Observations.Should().Contain(o => o.ObservationCode == "HEART_RATE"); + } + + private async Task PostFhirAsync(string path, string json) + { + var req = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new StringContent(json, Encoding.UTF8, "application/fhir+json") + }; + req.Headers.Add("X-Api-Key", ApiKey); + return await _client.SendAsync(req); + } + + private static Hl7.Fhir.Model.Patient BuildPatient(string mrn) => new() + { + Identifier = new List + { + new("http://hospital.example/mrn", mrn) + { + Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR") + } + }, + Name = new List { new() { Given = new[] { "Jane" }, Family = "Doe" } }, + BirthDate = "1980-01-15", + Gender = AdministrativeGender.Female + }; + + private async Task SeedPatientAndEncounterAsync() + { + using var scope = _fixture.Services.CreateScope(); + var patients = scope.ServiceProvider.GetRequiredService(); + var patient = await patients.RegisterOrUpdateByIdentifierAsync(new FhirPatientUpsertRequest( + "http://hospital.example/mrn", "MRN-TEST-001", + "Test", "Patient", new DateOnly(1990, 1, 1), "female")); + + var encounters = scope.ServiceProvider.GetRequiredService(); + var encounter = await encounters.OpenOrUpdateByIdentifierAsync(new FhirEncounterUpsertRequest( + "http://hospital.example/visit", "VISIT-001", + patient.Id, EncounterType.Inpatient, Department.Icu, + "Dr. Smith", EncounterStatus.Active)); + + return encounter.Id; + } + + private static Bundle BuildAdmitBundle() + { + var patient = BuildPatient("MRN-ADT-001"); + var encounter = new Hl7.Fhir.Model.Encounter + { + Status = Hl7.Fhir.Model.Encounter.EncounterStatus.InProgress, + Class = new Coding("http://terminology.hl7.org/CodeSystem/v3-ActCode", "IMP"), + Identifier = new List { new("http://hospital.example/visit", "VISIT-ADT-001") }, + Subject = new ResourceReference + { + Identifier = new Identifier("http://hospital.example/mrn", "MRN-ADT-001") + }, + Participant = new List + { + new() + { + Type = new List + { + new("http://terminology.hl7.org/CodeSystem/v3-ParticipationType", "ATND") + }, + Individual = new ResourceReference { Display = "Dr. Admit" } + } + } + }; + var obs = new Hl7.Fhir.Model.Observation + { + Status = ObservationStatus.Final, + Code = new CodeableConcept("http://loinc.org", "8867-4"), + Encounter = new ResourceReference + { + Identifier = new Identifier("http://hospital.example/visit", "VISIT-ADT-001") + }, + Value = new Quantity(88, "/min"), + Identifier = new List { new("http://hospital.example/obs", "adt-hr-1") } + }; + + return new Bundle + { + Type = Bundle.BundleType.Transaction, + Entry = new List + { + new() { Resource = patient, Request = new Bundle.RequestComponent { Method = Bundle.HTTPVerb.POST, Url = "Patient" } }, + new() { Resource = encounter, Request = new Bundle.RequestComponent { Method = Bundle.HTTPVerb.POST, Url = "Encounter" } }, + new() { Resource = obs, Request = new Bundle.RequestComponent { Method = Bundle.HTTPVerb.POST, Url = "Observation" } } + } + }; + } +} diff --git a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs index 001b9bf..9509ebb 100644 --- a/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs +++ b/VigilCareClinicalAPI.Tests/Fixtures/ApiFixture.cs @@ -24,6 +24,7 @@ public class ApiFixture : WebApplicationFactory, IAsyncLifetime ["RabbitMq:Username"] = "guest", ["RabbitMq:Password"] = "guest", ["RabbitMq:PagingAckTimeoutMs"] = "5000", + ["Fhir:ApiKey"] = "dev-integration-key-change-in-production", }); config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false); diff --git a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs index 6840654..fe5061a 100644 --- a/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs +++ b/VigilCareClinicalAPI.Tests/Helpers/DbResetHelper.cs @@ -22,6 +22,7 @@ public static class DbResetHelper DELETE FROM sofa_scores; DELETE FROM news2_scores; DELETE FROM observations; + DELETE FROM external_resource_identifiers; DELETE FROM encounters; DELETE FROM alert_thresholds; DELETE FROM patients; diff --git a/VigilCareClinicalAPI/Configuration/FhirOptions.cs b/VigilCareClinicalAPI/Configuration/FhirOptions.cs new file mode 100644 index 0000000..8611dcb --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/FhirOptions.cs @@ -0,0 +1,48 @@ +public class FhirOptions +{ + public const string Section = "Fhir"; + + /// Shared secret for integration engine authentication (interim until RBAC). + public string? ApiKey { get; set; } + + /// Identifier systems accepted for Patient.identifier (hospital MRNs). + public string[] PatientIdentifierSystems { get; set; } = + [ + "urn:oid:2.16.840.1.113883.4.1", + "http://hospital.example/mrn" + ]; + + /// Identifier systems accepted for Encounter.identifier (visit numbers). + public string[] EncounterIdentifierSystems { get; set; } = + [ + "http://hospital.example/visit" + ]; + + /// System URI VigilCare uses when embedding internal UUIDs in FHIR responses. + public string InternalPatientIdSystem { get; set; } = "http://vigilcare.local/patient-id"; + + public string InternalEncounterIdSystem { get; set; } = "http://vigilcare.local/encounter-id"; + + public string DefaultDepartment { get; set; } = "GENERAL_MEDICINE"; + + public string DefaultEncounterType { get; set; } = "INPATIENT"; + + /// Maps FHIR location/serviceProvider codes to internal department strings. + public Dictionary DepartmentCodeMap { get; set; } = new() + { + ["ICU"] = "ICU", + ["EMER"] = "EMERGENCY", + ["CARD"] = "CARDIOLOGY", + ["SURG"] = "SURGERY", + ["PEDI"] = "PEDIATRICS", + ["MED"] = "GENERAL_MEDICINE" + }; + + /// Maps FHIR Encounter.class ACT codes to internal encounter type strings. + public Dictionary EncounterClassMap { get; set; } = new() + { + ["IMP"] = "INPATIENT", + ["AMB"] = "OUTPATIENT", + ["EMER"] = "EMERGENCY" + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/FhirIngestController.cs b/VigilCareClinicalAPI/Controllers/FhirIngestController.cs new file mode 100644 index 0000000..41e79da --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/FhirIngestController.cs @@ -0,0 +1,150 @@ +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +[ApiController] +[Route("fhir/R4")] +[ServiceFilter(typeof(FhirExceptionFilter))] +public class FhirIngestController : ControllerBase +{ + private static readonly FhirJsonParser Parser = new(); + private static readonly FhirJsonSerializer Serializer = new(); + + private readonly IPatientService _patients; + private readonly IEncounterService _encounters; + private readonly IObservationService _observations; + private readonly IMedicationService _medications; + private readonly IExternalIdentifierService _identifiers; + private readonly PatientFhirMapper _patientMapper; + private readonly EncounterFhirMapper _encounterMapper; + private readonly ObservationFhirMapper _observationMapper; + private readonly MedicationAdministrationFhirMapper _medMapper; + private readonly FhirBundleProcessor _bundleProcessor; + private readonly FhirOptions _options; + private readonly ClinicalMetrics _metrics; + + public FhirIngestController( + IPatientService patients, + IEncounterService encounters, + IObservationService observations, + IMedicationService medications, + IExternalIdentifierService identifiers, + PatientFhirMapper patientMapper, + EncounterFhirMapper encounterMapper, + ObservationFhirMapper observationMapper, + MedicationAdministrationFhirMapper medMapper, + FhirBundleProcessor bundleProcessor, + IOptions options, + ClinicalMetrics metrics) + { + _patients = patients; + _encounters = encounters; + _observations = observations; + _medications = medications; + _identifiers = identifiers; + _patientMapper = patientMapper; + _encounterMapper = encounterMapper; + _observationMapper = observationMapper; + _medMapper = medMapper; + _bundleProcessor = bundleProcessor; + _options = options.Value; + _metrics = metrics; + } + + [HttpPost("Patient")] + [Consumes("application/fhir+json")] + [Produces("application/fhir+json")] + public async Task CreatePatient() + { + var fhir = await ParseBodyAsync(); + var req = _patientMapper.ToUpsertRequest(fhir); + var patient = await _patients.RegisterOrUpdateByIdentifierAsync(req); + var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( + ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems); + var response = _patientMapper.ToFhirResponse(patient, hospitalId); + _metrics.FhirIngestTotal.WithLabels("Patient", "success").Inc(); + return Created($"{Request.Path}/{patient.Id}", Serialize(response)); + } + + [HttpPost("Encounter")] + [Consumes("application/fhir+json")] + [Produces("application/fhir+json")] + public async Task CreateEncounter() + { + var fhir = await ParseBodyAsync(); + var req = await _encounterMapper.ToUpsertRequestAsync(fhir); + var encounter = await _encounters.OpenOrUpdateByIdentifierAsync(req); + var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( + ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems); + var response = _encounterMapper.ToFhirResponse(encounter, hospitalId); + _metrics.FhirIngestTotal.WithLabels("Encounter", "success").Inc(); + return Created($"{Request.Path}/{encounter.Id}", Serialize(response)); + } + + [HttpPost("Observation")] + [Consumes("application/fhir+json")] + [Produces("application/fhir+json")] + public async Task CreateObservation() + { + var fhir = await ParseBodyAsync(); + var mapped = await _observationMapper.ToIngestRequestsAsync(fhir); + + Resource? lastResponse = null; + foreach (var item in mapped) + { + var result = await _observations.IngestAsync(item.EncounterId, item.Request); + lastResponse = new Hl7.Fhir.Model.Observation + { + Id = result.Observation.Id.ToString(), + Status = ObservationStatus.Final, + Code = new CodeableConcept("http://loinc.org", item.Request.ObservationCode), + Value = new Quantity(item.Request.Value, item.Request.Unit) + }; + } + + _metrics.FhirIngestTotal.WithLabels("Observation", "success").Inc(); + return Created(Request.Path.Value!, Serialize(lastResponse!)); + } + + [HttpPost("MedicationAdministration")] + [Consumes("application/fhir+json")] + [Produces("application/fhir+json")] + public async Task CreateMedicationAdministration() + { + var fhir = await ParseBodyAsync(); + var (req, encounterId) = await _medMapper.ToCreateRequestAsync(fhir); + var med = await _medications.CreateAsync(encounterId, req); + var response = new Hl7.Fhir.Model.MedicationAdministration { Id = med.Id.ToString() }; + _metrics.FhirIngestTotal.WithLabels("MedicationAdministration", "success").Inc(); + return Created($"{Request.Path}/{med.Id}", Serialize(response)); + } + + /// Accepts Bundle.type=transaction (ADT admit) or batch. + [HttpPost] + [Consumes("application/fhir+json")] + [Produces("application/fhir+json")] + public async Task ProcessBundle() + { + using var reader = new StreamReader(Request.Body); + var json = await reader.ReadToEndAsync(); + var bundle = Parser.Parse(json); + + if (bundle.Type != Bundle.BundleType.Transaction) + throw new FhirMappingException("Only transaction Bundles are supported.", "not-supported"); + + var responseBundle = await _bundleProcessor.ProcessTransactionAsync(bundle); + _metrics.FhirIngestTotal.WithLabels("Bundle", "success").Inc(); + return Ok(Serialize(responseBundle)); + } + + private async Task ParseBodyAsync() where T : Resource + { + using var reader = new StreamReader(Request.Body); + var json = await reader.ReadToEndAsync(); + return Parser.Parse(json); + } + + private ContentResult Serialize(Resource resource) => + Content(Serializer.SerializeToString(resource), "application/fhir+json"); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs b/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs new file mode 100644 index 0000000..188e9d8 --- /dev/null +++ b/VigilCareClinicalAPI/Controllers/FhirMetadataController.cs @@ -0,0 +1,65 @@ +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.AspNetCore.Mvc; +using static Hl7.Fhir.Model.CapabilityStatement; + +[ApiController] +[Route("fhir/R4")] +[ServiceFilter(typeof(FhirExceptionFilter))] +public class FhirMetadataController : ControllerBase +{ + [HttpGet("metadata")] + [Produces("application/fhir+json")] + public IActionResult Metadata() + { + var capability = new CapabilityStatement + { + Status = PublicationStatus.Active, + Date = DateTimeOffset.UtcNow.ToString("o"), + Kind = CapabilityStatementKind.Instance, + Software = new CapabilityStatement.SoftwareComponent { Name = "VigilCare Clinical" }, + Implementation = new CapabilityStatement.ImplementationComponent + { + Description = "VigilCare Clinical FHIR R4 inbound facade", + Url = $"{Request.Scheme}://{Request.Host}/fhir/R4" + }, + FhirVersion = FHIRVersion.N4_0_1, + Format = new[] { "application/fhir+json" }, + Rest = new List + { + new() + { + Mode = CapabilityStatement.RestfulCapabilityMode.Server, + Resource = new List + { + ResourceCapability("Patient", TypeRestfulInteraction.Create), + ResourceCapability("Encounter", TypeRestfulInteraction.Create), + ResourceCapability("Observation", TypeRestfulInteraction.Create), + ResourceCapability("MedicationAdministration", TypeRestfulInteraction.Create), + new CapabilityStatement.ResourceComponent + { + Type = "Bundle", + Interaction = new List + { + new() { Code = TypeRestfulInteraction.Create } + } + } + } + } + } + }; + + return Content(new FhirJsonSerializer().SerializeToString(capability), "application/fhir+json"); + } + + private static CapabilityStatement.ResourceComponent ResourceCapability( + string type, TypeRestfulInteraction interaction) => + new() + { + Type = type, + Interaction = new List + { + new() { Code = interaction } + } + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Data/AppDbContext.cs b/VigilCareClinicalAPI/Data/AppDbContext.cs index 1ef85d3..740ed4c 100644 --- a/VigilCareClinicalAPI/Data/AppDbContext.cs +++ b/VigilCareClinicalAPI/Data/AppDbContext.cs @@ -18,6 +18,7 @@ public class AppDbContext : DbContext public DbSet MedicationAdministrations => Set(); public DbSet GcsScores => Set(); public DbSet SofaScores => Set(); + public DbSet ExternalResourceIdentifiers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareClinicalAPI/Data/Configurations/ExternalResourceIdentifierConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ExternalResourceIdentifierConfiguration.cs new file mode 100644 index 0000000..0f57779 --- /dev/null +++ b/VigilCareClinicalAPI/Data/Configurations/ExternalResourceIdentifierConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ExternalResourceIdentifierConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("external_resource_identifiers"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(e => e.ResourceType).HasColumnName("resource_type").HasMaxLength(20).IsRequired() + .HasConversion(v => v.ToDbString(), v => ExternalResourceTypeExtensions.FromDbString(v)); + builder.Property(e => e.InternalId).HasColumnName("internal_id").IsRequired(); + builder.Property(e => e.System).HasColumnName("system").HasMaxLength(500).IsRequired(); + builder.Property(e => e.Value).HasColumnName("value").HasMaxLength(200).IsRequired(); + builder.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); + + builder.HasIndex(e => new { e.ResourceType, e.System, e.Value }).IsUnique(); + builder.HasIndex(e => new { e.ResourceType, e.InternalId }); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Entities/ExternalResourceIdentifier.cs b/VigilCareClinicalAPI/Domains/Entities/ExternalResourceIdentifier.cs new file mode 100644 index 0000000..9789257 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Entities/ExternalResourceIdentifier.cs @@ -0,0 +1,9 @@ +public class ExternalResourceIdentifier +{ + public Guid Id { get; set; } + public ExternalResourceType ResourceType { get; set; } + public Guid InternalId { get; set; } + public string System { get; set; } = null!; + public string Value { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Domains/Enums/ExternalResourceType.cs b/VigilCareClinicalAPI/Domains/Enums/ExternalResourceType.cs new file mode 100644 index 0000000..338a979 --- /dev/null +++ b/VigilCareClinicalAPI/Domains/Enums/ExternalResourceType.cs @@ -0,0 +1,22 @@ +public enum ExternalResourceType +{ + Patient, + Encounter +} + +public static class ExternalResourceTypeExtensions +{ + public static string ToDbString(this ExternalResourceType t) => t switch + { + ExternalResourceType.Patient => "PATIENT", + ExternalResourceType.Encounter => "ENCOUNTER", + _ => throw new ArgumentOutOfRangeException(nameof(t)) + }; + + public static ExternalResourceType FromDbString(string v) => v switch + { + "PATIENT" => ExternalResourceType.Patient, + "ENCOUNTER" => ExternalResourceType.Encounter, + _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown external resource type: '{v}'") + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/Codes/FhirUnitConverter.cs b/VigilCareClinicalAPI/Fhir/Codes/FhirUnitConverter.cs new file mode 100644 index 0000000..55de4c8 --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Codes/FhirUnitConverter.cs @@ -0,0 +1,17 @@ +public static class FhirUnitConverter +{ + public static (decimal Value, string Unit) Normalize( + string internalCode, decimal value, string? fhirUnit, bool allowFahrenheit) + { + if (internalCode == "TEMP_C" && allowFahrenheit && + fhirUnit is not null && + (fhirUnit.Equals("[degF]", StringComparison.OrdinalIgnoreCase) || + fhirUnit.Equals("degF", StringComparison.OrdinalIgnoreCase))) + { + var celsius = (value - 32m) * 5m / 9m; + return (Math.Round(celsius, 2), "Cel"); + } + + return (value, fhirUnit ?? "1"); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/Codes/LoincCodeMapper.cs b/VigilCareClinicalAPI/Fhir/Codes/LoincCodeMapper.cs new file mode 100644 index 0000000..0ec3f9a --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Codes/LoincCodeMapper.cs @@ -0,0 +1,51 @@ +public static class LoincCodeMapper +{ + // LOINC → internal observation code. Covers all codes in DataSeeder + PlausibilityValidator. + private static readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase) + { + ["8867-4"] = new("HEART_RATE", "/min"), + ["8310-5"] = new("TEMP_C", "Cel", AllowFahrenheit: true), + ["2823-3"] = new("POTASSIUM_MEQ_L", "mmol/L"), + ["2708-6"] = new("SPO2", "%"), + ["9279-1"] = new("RESP_RATE", "/min"), + ["6690-2"] = new("WBC_K_UL", "10*3/uL"), + ["8480-6"] = new("SYSTOLIC_BP", "mm[Hg]"), + ["8462-4"] = new("DIASTOLIC_BP", "mm[Hg]"), + ["2524-7"] = new("LACTATE_MMOL_L", "mmol/L"), + ["2339-0"] = new("GLUCOSE_MG_DL", "mg/dL"), + ["777-3"] = new("PLATELET_K_UL", "10*3/uL"), + ["1975-2"] = new("BILIRUBIN_MG_DL", "mg/dL"), + ["2160-0"] = new("CREATININE_MG_DL", "mg/dL"), + ["2703-7"] = new("PAO2_MMHG", "mm[Hg]"), + ["3150-0"] = new("FIO2_PCT", "%"), + ["9187-6"] = new("URINE_OUTPUT_ML_H", "mL/h"), + // GCS — often sent as panel with components; also map individual LOINC codes used by some EHRs + ["80288-7"] = new("GCS_EYE", "{score}"), + ["80289-5"] = new("GCS_VERBAL", "{score}"), + ["80290-3"] = new("GCS_MOTOR", "{score}"), + }; + + // SNOMED CT fallbacks for non-LOINC sites + private static readonly Dictionary _snomedMap = new(StringComparer.OrdinalIgnoreCase) + { + ["364075005"] = new("HEART_RATE", "/min"), + ["431314004"] = new("SPO2", "%"), + ["86290005"] = new("RESP_RATE", "/min"), + }; + + public static bool TryMap(string system, string code, out LoincMapping mapping) + { + if (system.Contains("loinc", StringComparison.OrdinalIgnoreCase) && + _map.TryGetValue(code, out mapping!)) + return true; + + if (system.Contains("snomed", StringComparison.OrdinalIgnoreCase) && + _snomedMap.TryGetValue(code, out mapping!)) + return true; + + mapping = null!; + return false; + } + + public static IReadOnlyCollection SupportedLoincCodes => _map.Keys; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/Codes/LoincMapping.cs b/VigilCareClinicalAPI/Fhir/Codes/LoincMapping.cs new file mode 100644 index 0000000..2606549 --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Codes/LoincMapping.cs @@ -0,0 +1,4 @@ +public record LoincMapping( + string InternalCode, + string ExpectedUnit, + bool AllowFahrenheit = false); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/FhirBundleProcessor.cs b/VigilCareClinicalAPI/Fhir/FhirBundleProcessor.cs new file mode 100644 index 0000000..21853ee --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/FhirBundleProcessor.cs @@ -0,0 +1,123 @@ +using Hl7.Fhir.Model; + +public class FhirBundleProcessor +{ + private readonly IPatientService _patients; + private readonly IEncounterService _encounters; + private readonly IObservationService _observations; + private readonly IMedicationService _medications; + private readonly PatientFhirMapper _patientMapper; + private readonly EncounterFhirMapper _encounterMapper; + private readonly ObservationFhirMapper _observationMapper; + private readonly MedicationAdministrationFhirMapper _medMapper; + + public FhirBundleProcessor( + IPatientService patients, + IEncounterService encounters, + IObservationService observations, + IMedicationService medications, + PatientFhirMapper patientMapper, + EncounterFhirMapper encounterMapper, + ObservationFhirMapper observationMapper, + MedicationAdministrationFhirMapper medMapper) + { + _patients = patients; + _encounters = encounters; + _observations = observations; + _medications = medications; + _patientMapper = patientMapper; + _encounterMapper = encounterMapper; + _observationMapper = observationMapper; + _medMapper = medMapper; + } + + public async Task ProcessTransactionAsync(Bundle transaction) + { + var response = new Bundle { Type = Bundle.BundleType.TransactionResponse }; + + // Process in dependency order: Patient → Encounter → Observation/MedAdmin + var entries = transaction.Entry + .OrderBy(e => Priority(e.Resource)) + .ToList(); + + foreach (var entry in entries) + { + var resource = entry.Resource; + try + { + var location = resource switch + { + Hl7.Fhir.Model.Patient p => await ProcessPatientAsync(p), + Hl7.Fhir.Model.Encounter e => await ProcessEncounterAsync(e), + Hl7.Fhir.Model.Observation o => await ProcessObservationAsync(o), + Hl7.Fhir.Model.MedicationAdministration m => await ProcessMedAsync(m), + _ => throw new FhirMappingException( + $"Unsupported resource type in bundle: {resource.TypeName}", "not-supported") + }; + + response.Entry.Add(new Bundle.EntryComponent + { + Response = new Bundle.ResponseComponent + { + Status = "201 Created", + Location = location + } + }); + } + catch (Exception ex) + { + response.Entry.Add(new Bundle.EntryComponent + { + Response = new Bundle.ResponseComponent + { + Status = "422 Unprocessable Entity", + Outcome = FhirOperationOutcomeBuilder.FromException(ex) + } + }); + break; // transaction semantics — stop on first failure + } + } + + return response; + } + + private static int Priority(Resource? r) => r switch + { + Hl7.Fhir.Model.Patient => 0, + Hl7.Fhir.Model.Encounter => 1, + _ => 2 + }; + + private async Task ProcessPatientAsync(Hl7.Fhir.Model.Patient fhir) + { + var req = _patientMapper.ToUpsertRequest(fhir); + var patient = await _patients.RegisterOrUpdateByIdentifierAsync(req); + return $"Patient/{patient.Id}"; + } + + private async Task ProcessEncounterAsync(Hl7.Fhir.Model.Encounter fhir) + { + var req = await _encounterMapper.ToUpsertRequestAsync(fhir); + var encounter = await _encounters.OpenOrUpdateByIdentifierAsync(req); + return $"Encounter/{encounter.Id}"; + } + + private async Task ProcessObservationAsync(Hl7.Fhir.Model.Observation fhir) + { + var mapped = await _observationMapper.ToIngestRequestsAsync(fhir); + Guid? lastId = null; + foreach (var item in mapped) + { + var result = await _observations.IngestAsync(item.EncounterId, item.Request); + lastId = result.Observation.Id; + } + return $"Observation/{lastId}"; + } + + private async Task ProcessMedAsync(Hl7.Fhir.Model.MedicationAdministration fhir) + { + var (req, encounterId) = await _medMapper.ToCreateRequestAsync(fhir); + var med = await _medications.CreateAsync(encounterId, req); + return $"MedicationAdministration/{med.Id}"; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/FhirExceptionFilter.cs b/VigilCareClinicalAPI/Fhir/FhirExceptionFilter.cs new file mode 100644 index 0000000..7aae2ae --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/FhirExceptionFilter.cs @@ -0,0 +1,32 @@ +using Hl7.Fhir.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public class FhirExceptionFilter : IExceptionFilter +{ + private static readonly FhirJsonSerializer Serializer = new(); + + public void OnException(ExceptionContext context) + { + if (!context.HttpContext.Request.Path.StartsWithSegments("/fhir")) + return; + + var outcome = FhirOperationOutcomeBuilder.FromException(context.Exception); + var status = context.Exception switch + { + FhirMappingException fme => fme.HttpStatus, + NotFoundException => 404, + ValidationException => 422, + ConflictException => 409, + _ => 500 + }; + + context.Result = new ContentResult + { + StatusCode = status, + ContentType = "application/fhir+json", + Content = Serializer.SerializeToString(outcome) + }; + context.ExceptionHandled = true; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/FhirMappingException.cs b/VigilCareClinicalAPI/Fhir/FhirMappingException.cs new file mode 100644 index 0000000..d07248f --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/FhirMappingException.cs @@ -0,0 +1,12 @@ +public class FhirMappingException : Exception +{ + public string FhirIssueCode { get; } + public int HttpStatus { get; } + + public FhirMappingException(string message, string fhirIssueCode, int httpStatus = 422) + : base(message) + { + FhirIssueCode = fhirIssueCode; + HttpStatus = httpStatus; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/FhirOperationOutcomeBuilder.cs b/VigilCareClinicalAPI/Fhir/FhirOperationOutcomeBuilder.cs new file mode 100644 index 0000000..c140467 --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/FhirOperationOutcomeBuilder.cs @@ -0,0 +1,31 @@ +using Hl7.Fhir.Model; + +public static class FhirOperationOutcomeBuilder +{ + public static OperationOutcome FromException(Exception ex) => ex switch + { + FhirMappingException fme => Create(fme.HttpStatus, fme.FhirIssueCode, fme.Message), + NotFoundException nfe => Create(404, "not-found", nfe.Message), + ValidationException ve => Create(422, "invalid", ve.Message), + ConflictException ce => Create(409, "conflict", ce.Message), + _ => Create(500, "exception", "An unexpected error occurred.") + }; + + public static OperationOutcome Create(int status, string code, string diagnostics) => + new() + { + Issue = new List + { + new() + { + Severity = status >= 500 + ? OperationOutcome.IssueSeverity.Error + : OperationOutcome.IssueSeverity.Warning, + Code = Enum.TryParse(code, true, out var c) + ? c + : OperationOutcome.IssueType.Processing, + Diagnostics = diagnostics + } + } + }; +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/Mapping/EncounterFhirMapper.cs b/VigilCareClinicalAPI/Fhir/Mapping/EncounterFhirMapper.cs new file mode 100644 index 0000000..5b7c97a --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Mapping/EncounterFhirMapper.cs @@ -0,0 +1,103 @@ +using Hl7.Fhir.Model; +using Microsoft.Extensions.Options; + + +public class EncounterFhirMapper +{ + private readonly FhirReferenceResolver _refs; + private readonly FhirOptions _options; + + public EncounterFhirMapper(FhirReferenceResolver refs, IOptions options) + { + _refs = refs; + _options = options.Value; + } + + public async Task ToUpsertRequestAsync(Hl7.Fhir.Model.Encounter fhir) + { + var identifier = _refs.ExtractPrimaryIdentifier(fhir, _options.EncounterIdentifierSystems) + ?? throw new FhirMappingException("Encounter must include at least one identifier.", "required"); + + if (fhir.Subject is null) + throw new FhirMappingException("Encounter subject is required.", "required"); + + var patientId = await _refs.ResolvePatientReferenceAsync(fhir.Subject); + + var actCode = fhir.Class?.Code ?? "IMP"; + if (!_options.EncounterClassMap.TryGetValue(actCode, out var encounterTypeStr)) + encounterTypeStr = _options.DefaultEncounterType; + + var departmentStr = _options.DefaultDepartment; + var locationCode = fhir.Location?.FirstOrDefault()?.Location?.Display; + if (locationCode is not null) + { + foreach (var (key, value) in _options.DepartmentCodeMap) + { + if (locationCode.Contains(key, StringComparison.OrdinalIgnoreCase)) + { + departmentStr = value; + break; + } + } + } + + var attending = fhir.Participant? + .FirstOrDefault(p => p.Type?.Any(t => + t.Coding?.Any(c => c.Code == "ATND") == true) == true) + ?.Individual?.Display ?? "Unknown"; + + var targetStatus = fhir.Status switch + { + Hl7.Fhir.Model.Encounter.EncounterStatus.Finished => EncounterStatus.Discharged, + Hl7.Fhir.Model.Encounter.EncounterStatus.Cancelled => EncounterStatus.Cancelled, + _ => EncounterStatus.Active + }; + + return new FhirEncounterUpsertRequest( + IdentifierSystem: identifier.System, + IdentifierValue: identifier.Value, + PatientId: patientId, + EncounterType: EncounterTypeExtensions.FromDbString(encounterTypeStr), + Department: DepartmentExtensions.FromDbString(departmentStr), + AttendingPhysician: attending, + TargetStatus: targetStatus, + AdmittedAt: fhir.Period?.StartElement?.ToDateTimeOffset(TimeSpan.Zero), + RoomBed: fhir.Location?.FirstOrDefault()?.Location?.Display, + AdmissionReason: fhir.ReasonCode?.FirstOrDefault()?.Text); + } + + public Hl7.Fhir.Model.Encounter ToFhirResponse(Encounter encounter, (string System, string Value)? hospitalId) + { + var resource = new Hl7.Fhir.Model.Encounter + { + Id = encounter.Id.ToString(), + Status = encounter.Status switch + { + EncounterStatus.Active => Hl7.Fhir.Model.Encounter.EncounterStatus.InProgress, + EncounterStatus.Discharged => Hl7.Fhir.Model.Encounter.EncounterStatus.Finished, + EncounterStatus.Cancelled => Hl7.Fhir.Model.Encounter.EncounterStatus.Cancelled, + _ => Hl7.Fhir.Model.Encounter.EncounterStatus.Unknown + }, + Class = new Coding("http://terminology.hl7.org/CodeSystem/v3-ActCode", "IMP"), + Subject = new ResourceReference($"Patient/{encounter.PatientId}"), + Identifier = new List() + }; + + if (hospitalId is not null) + { + resource.Identifier.Add(new Identifier + { + System = hospitalId.Value.System, + Value = hospitalId.Value.Value + }); + } + + resource.Identifier.Add(new Identifier + { + System = _options.InternalEncounterIdSystem, + Value = encounter.Id.ToString() + }); + + return resource; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Fhir/Mapping/FhirMappingHelpers.cs b/VigilCareClinicalAPI/Fhir/Mapping/FhirMappingHelpers.cs new file mode 100644 index 0000000..904926b --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Mapping/FhirMappingHelpers.cs @@ -0,0 +1,41 @@ +using Hl7.Fhir.Model; + +internal static class FhirMappingHelpers +{ + public static DateTimeOffset? ToUtcDateTimeOffset(this DataType? value) + { + return value switch + { + FhirDateTime fdt => fdt.ToDateTimeOffset(TimeSpan.Zero), + Period p when p.StartElement is not null => p.StartElement.ToDateTimeOffset(TimeSpan.Zero), + Instant i => i.Value, + _ => null + }; + } + + public static bool TryParseResourceReference(string? reference, out string resourceType, out string id) + { + resourceType = ""; + id = ""; + + if (string.IsNullOrWhiteSpace(reference)) + return false; + + var refValue = reference; + if (Uri.TryCreate(refValue, UriKind.Absolute, out var uri)) + refValue = uri.AbsolutePath.TrimStart('/'); + + var slash = refValue.IndexOf('/'); + if (slash <= 0) + return false; + + resourceType = refValue[..slash]; + id = refValue[(slash + 1)..]; + + var historyIdx = id.IndexOf('/'); + if (historyIdx > 0) + id = id[..historyIdx]; + + return !string.IsNullOrWhiteSpace(resourceType) && !string.IsNullOrWhiteSpace(id); + } +} diff --git a/VigilCareClinicalAPI/Fhir/Mapping/FhirReferenceResolver.cs b/VigilCareClinicalAPI/Fhir/Mapping/FhirReferenceResolver.cs new file mode 100644 index 0000000..7833a71 --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Mapping/FhirReferenceResolver.cs @@ -0,0 +1,96 @@ +using Hl7.Fhir.Model; +using Microsoft.Extensions.Options; +using Task = System.Threading.Tasks.Task; + +public class FhirReferenceResolver +{ + private readonly IExternalIdentifierService _identifiers; + private readonly FhirOptions _options; + + public FhirReferenceResolver( + IExternalIdentifierService identifiers, + IOptions options) + { + _identifiers = identifiers; + _options = options.Value; + } + + public async Task ResolvePatientReferenceAsync(ResourceReference reference) + { + if (reference.Identifier is not null) + return await ResolveByIdentifierAsync( + ExternalResourceType.Patient, + reference.Identifier.System, + reference.Identifier.Value, + _options.PatientIdentifierSystems); + + if (FhirMappingHelpers.TryParseResourceReference(reference.Reference, out var type, out var id) + && type == "Patient" + && Guid.TryParse(id, out var guid)) + { + return guid; + } + + throw new FhirMappingException( + "Patient reference must include a resolvable identifier or UUID.", + "required"); + } + + public async Task ResolveEncounterReferenceAsync(ResourceReference reference) + { + if (reference.Identifier is not null) + return await ResolveByIdentifierAsync( + ExternalResourceType.Encounter, + reference.Identifier.System, + reference.Identifier.Value, + _options.EncounterIdentifierSystems); + + if (FhirMappingHelpers.TryParseResourceReference(reference.Reference, out var type, out var id) + && type == "Encounter" + && Guid.TryParse(id, out var guid)) + { + return guid; + } + + throw new FhirMappingException( + "Encounter reference must include a resolvable identifier or UUID.", + "required"); + } + + public (string System, string Value)? ExtractPrimaryIdentifier( + IIdentifiable> resource, string[] acceptedSystems) + { + foreach (var system in acceptedSystems) + { + var match = resource.Identifier? + .FirstOrDefault(i => i.System == system && !string.IsNullOrWhiteSpace(i.Value)); + if (match is not null) + return (match.System!, match.Value!); + } + + return resource.Identifier? + .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i.System) && !string.IsNullOrWhiteSpace(i.Value)) + is { } fallback + ? (fallback.System!, fallback.Value!) + : null; + } + + private async Task ResolveByIdentifierAsync( + ExternalResourceType type, string? system, string? value, string[] acceptedSystems) + { + if (string.IsNullOrWhiteSpace(system) || string.IsNullOrWhiteSpace(value)) + throw new FhirMappingException("Identifier system and value are required.", "required"); + + if (!acceptedSystems.Contains(system)) + throw new FhirMappingException( + $"Identifier system '{system}' is not configured.", "not-supported"); + + var internalId = await _identifiers.ResolveInternalIdAsync(type, system, value); + if (internalId is null) + throw new NotFoundException( + $"{type} with identifier {system}|{value} not found.", + "FHIR_RESOURCE_NOT_FOUND"); + + return internalId.Value; + } +} diff --git a/VigilCareClinicalAPI/Fhir/Mapping/MedicationAdministrationFhirMapper.cs b/VigilCareClinicalAPI/Fhir/Mapping/MedicationAdministrationFhirMapper.cs new file mode 100644 index 0000000..05920c7 --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Mapping/MedicationAdministrationFhirMapper.cs @@ -0,0 +1,37 @@ +using Hl7.Fhir.Model; +using Task = System.Threading.Tasks.Task; + +public class MedicationAdministrationFhirMapper +{ + private readonly FhirReferenceResolver _refs; + + public MedicationAdministrationFhirMapper(FhirReferenceResolver refs) => _refs = refs; + + public async Task<(CreateMedicationAdministrationRequest Request, Guid EncounterId)> ToCreateRequestAsync( + Hl7.Fhir.Model.MedicationAdministration fhir) + { + if (fhir.Context is null) + throw new FhirMappingException( + "MedicationAdministration context is required.", "required"); + + var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Context); + + var drugName = fhir.Medication is CodeableConcept cc + ? cc.Text ?? cc.Coding?.FirstOrDefault()?.Display ?? "Unknown" + : "Unknown"; + + var dose = fhir.Dosage?.Dose as Quantity; + var route = fhir.Dosage?.Route?.Text ?? fhir.Dosage?.Route?.Coding?.FirstOrDefault()?.Display ?? "IV"; + var performer = fhir.Performer?.FirstOrDefault()?.Actor?.Display ?? "Unknown"; + + var req = new CreateMedicationAdministrationRequest( + DrugName: drugName, + Dose: dose?.Value ?? 0m, + DoseUnit: dose?.Unit ?? "mg", + Route: route, + AdministeredAt: fhir.Effective.ToUtcDateTimeOffset(), + AdministeredBy: performer); + + return (req, encounterId); + } +} diff --git a/VigilCareClinicalAPI/Fhir/Mapping/ObservationFhirMapper.cs b/VigilCareClinicalAPI/Fhir/Mapping/ObservationFhirMapper.cs new file mode 100644 index 0000000..8482189 --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Mapping/ObservationFhirMapper.cs @@ -0,0 +1,89 @@ +using Hl7.Fhir.Model; +using Task = System.Threading.Tasks.Task; + +public record MappedObservation(IngestObservationRequest Request, Guid EncounterId); + +public class ObservationFhirMapper +{ + private readonly FhirReferenceResolver _refs; + + public ObservationFhirMapper(FhirReferenceResolver refs) => _refs = refs; + + public async Task> ToIngestRequestsAsync(Hl7.Fhir.Model.Observation fhir) + { + if (fhir.Encounter is null) + throw new FhirMappingException("Observation encounter reference is required.", "required"); + + var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Encounter); + var source = MapSource(fhir); + var recordedAt = fhir.Effective.ToUtcDateTimeOffset() ?? DateTimeOffset.UtcNow; + var idempotencyKey = fhir.Identifier?.FirstOrDefault()?.Value ?? fhir.Id; + + var results = new List(); + + if (fhir.Component?.Count > 0) + { + foreach (var component in fhir.Component) + results.AddRange(MapSingleCoding( + component.Code, component.Value, encounterId, source, recordedAt, + idempotencyKey is null ? null : $"{idempotencyKey}:{component.Code?.Coding?.FirstOrDefault()?.Code}")); + } + else + { + results.AddRange(MapSingleCoding( + fhir.Code, fhir.Value, encounterId, source, recordedAt, idempotencyKey)); + } + + if (results.Count == 0) + throw new FhirMappingException("Observation contains no mappable values.", "invalid"); + + return results; + } + + private static ObservationSource MapSource(Hl7.Fhir.Model.Observation fhir) + { + var category = fhir.Category?.FirstOrDefault()?.Coding?.FirstOrDefault()?.Code; + return category switch + { + "vital-signs" => ObservationSource.Device, + "laboratory" => ObservationSource.Lab, + _ => ObservationSource.Manual + }; + } + + private static List MapSingleCoding( + CodeableConcept? code, + DataType? value, + Guid encounterId, + ObservationSource source, + DateTimeOffset recordedAt, + string? idempotencyKey) + { + var coding = code?.Coding?.FirstOrDefault(c => + !string.IsNullOrWhiteSpace(c.System) && !string.IsNullOrWhiteSpace(c.Code)); + + if (coding is null) + throw new FhirMappingException("Observation code coding is required.", "required"); + + if (!LoincCodeMapper.TryMap(coding.System!, coding.Code!, out var mapping)) + throw new FhirMappingException( + $"Unsupported observation code {coding.System}|{coding.Code}.", + "not-supported"); + + if (value is not Quantity qty) + throw new FhirMappingException("Observation valueQuantity is required.", "required"); + + var (normalizedValue, _) = FhirUnitConverter.Normalize( + mapping.InternalCode, qty.Value ?? 0m, qty.Unit, mapping.AllowFahrenheit); + + var request = new IngestObservationRequest( + ObservationCode: mapping.InternalCode, + Value: normalizedValue, + Unit: mapping.ExpectedUnit, + Source: source, + RecordedAt: recordedAt, + IdempotencyKey: idempotencyKey); + + return [new MappedObservation(request, encounterId)]; + } +} diff --git a/VigilCareClinicalAPI/Fhir/Mapping/PatientFhirMapper.cs b/VigilCareClinicalAPI/Fhir/Mapping/PatientFhirMapper.cs new file mode 100644 index 0000000..291dc9d --- /dev/null +++ b/VigilCareClinicalAPI/Fhir/Mapping/PatientFhirMapper.cs @@ -0,0 +1,77 @@ +using Hl7.Fhir.Model; +using Microsoft.Extensions.Options; + +public class PatientFhirMapper +{ + private readonly FhirReferenceResolver _refs; + private readonly FhirOptions _options; + + public PatientFhirMapper(FhirReferenceResolver refs, IOptions options) + { + _refs = refs; + _options = options.Value; + } + + public FhirPatientUpsertRequest ToUpsertRequest(Hl7.Fhir.Model.Patient fhir) + { + var identifier = _refs.ExtractPrimaryIdentifier(fhir, _options.PatientIdentifierSystems) + ?? throw new FhirMappingException("Patient must include at least one identifier.", "required"); + + var name = fhir.Name?.FirstOrDefault() + ?? throw new FhirMappingException("Patient must include a name.", "required"); + + var given = name.Given?.FirstOrDefault() ?? ""; + var family = name.Family ?? ""; + + if (fhir.BirthDate is null) + throw new FhirMappingException("Patient birthDate is required.", "required"); + + return new FhirPatientUpsertRequest( + IdentifierSystem: identifier.System, + IdentifierValue: identifier.Value, + FirstName: given, + LastName: family, + DateOfBirth: DateOnly.Parse(fhir.BirthDate), + Gender: fhir.Gender?.ToString()?.ToLowerInvariant() ?? "unknown"); + } + + public Hl7.Fhir.Model.Patient ToFhirResponse(Patient patient, (string System, string Value)? hospitalId) + { + var resource = new Hl7.Fhir.Model.Patient + { + Id = patient.Id.ToString(), + Identifier = new List() + }; + + if (hospitalId is not null) + { + resource.Identifier.Add(new Identifier + { + System = hospitalId.Value.System, + Value = hospitalId.Value.Value, + Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR") + }); + } + + resource.Identifier.Add(new Identifier + { + System = _options.InternalPatientIdSystem, + Value = patient.Id.ToString() + }); + + resource.Name.Add(new HumanName + { + Given = new[] { patient.FirstName }, + Family = patient.LastName + }); + resource.BirthDate = patient.DateOfBirth.ToString("yyyy-MM-dd"); + resource.Gender = patient.Gender switch + { + "male" => AdministrativeGender.Male, + "female" => AdministrativeGender.Female, + _ => AdministrativeGender.Unknown + }; + + return resource; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Middlewares/FhirApiKeyMiddleware.cs b/VigilCareClinicalAPI/Middlewares/FhirApiKeyMiddleware.cs new file mode 100644 index 0000000..ef36cbc --- /dev/null +++ b/VigilCareClinicalAPI/Middlewares/FhirApiKeyMiddleware.cs @@ -0,0 +1,43 @@ +using Hl7.Fhir.Serialization; +using Microsoft.Extensions.Options; +using Task = System.Threading.Tasks.Task; + +public class FhirApiKeyMiddleware +{ + private readonly RequestDelegate _next; + private readonly FhirOptions _options; + private static readonly FhirJsonSerializer Serializer = new(); + + public FhirApiKeyMiddleware(RequestDelegate next, IOptions options) + { + _next = next; + _options = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/fhir")) + { + await _next(context); + return; + } + + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + await _next(context); + return; + } + + if (!context.Request.Headers.TryGetValue("X-Api-Key", out var key) || + key != _options.ApiKey) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/fhir+json"; + var outcome = FhirOperationOutcomeBuilder.Create(401, "login", "Invalid or missing API key."); + await context.Response.WriteAsync(Serializer.SerializeToString(outcome)); + return; + } + + await _next(context); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Migrations/20260621045719_AddExternalResourceIdentifiers.Designer.cs b/VigilCareClinicalAPI/Migrations/20260621045719_AddExternalResourceIdentifiers.Designer.cs new file mode 100644 index 0000000..27819ff --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621045719_AddExternalResourceIdentifiers.Designer.cs @@ -0,0 +1,1131 @@ +// +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("20260621045719_AddExternalResourceIdentifiers")] + partial class AddExternalResourceIdentifiers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.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("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("ExternalResourceIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("InternalId") + .HasColumnType("uuid") + .HasColumnName("internal_id"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("resource_type"); + + b.Property("System") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("system"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("ResourceType", "InternalId"); + + b.HasIndex("ResourceType", "System", "Value") + .IsUnique(); + + b.ToTable("external_resource_identifiers", (string)null); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_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") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260621045719_AddExternalResourceIdentifiers.cs b/VigilCareClinicalAPI/Migrations/20260621045719_AddExternalResourceIdentifiers.cs new file mode 100644 index 0000000..12fae2a --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260621045719_AddExternalResourceIdentifiers.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddExternalResourceIdentifiers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "external_resource_identifiers", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + resource_type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + internal_id = table.Column(type: "uuid", nullable: false), + system = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + value = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_external_resource_identifiers", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_external_resource_identifiers_resource_type_internal_id", + table: "external_resource_identifiers", + columns: new[] { "resource_type", "internal_id" }); + + migrationBuilder.CreateIndex( + name: "IX_external_resource_identifiers_resource_type_system_value", + table: "external_resource_identifiers", + columns: new[] { "resource_type", "system", "value" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "external_resource_identifiers"); + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index db40265..7bb669f 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -250,6 +250,52 @@ namespace VigilCareClinicalAPI.Migrations }); }); + 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") diff --git a/VigilCareClinicalAPI/Models/Records/Fhir/FhirEncounterUpsertRequest.cs b/VigilCareClinicalAPI/Models/Records/Fhir/FhirEncounterUpsertRequest.cs new file mode 100644 index 0000000..71d426a --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Fhir/FhirEncounterUpsertRequest.cs @@ -0,0 +1,12 @@ +public record FhirEncounterUpsertRequest( + string IdentifierSystem, + string IdentifierValue, + Guid PatientId, + EncounterType EncounterType, + Department Department, + string AttendingPhysician, + EncounterStatus TargetStatus, + DateTimeOffset? AdmittedAt = null, + string? RoomBed = null, + string? AdmissionReason = null, + string? DischargeDiagnosis = null); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Fhir/FhirPatientUpsertRequest.cs b/VigilCareClinicalAPI/Models/Records/Fhir/FhirPatientUpsertRequest.cs new file mode 100644 index 0000000..c640a74 --- /dev/null +++ b/VigilCareClinicalAPI/Models/Records/Fhir/FhirPatientUpsertRequest.cs @@ -0,0 +1,11 @@ +public record FhirPatientUpsertRequest( + string IdentifierSystem, + string IdentifierValue, + string FirstName, + string LastName, + DateOnly DateOfBirth, + string Gender, + BloodType? BloodType = null, + string? Allergies = null, + string? EmergencyContactName = null, + string? EmergencyContactPhone = null); \ No newline at end of file diff --git a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs index 591074b..ec41176 100644 --- a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs +++ b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs @@ -58,6 +58,16 @@ public sealed class ClinicalMetrics "SOFA scores computed, labeled by whether a delta alert was created.", labelNames: new[] { "has_delta_alert" }); + public readonly Counter FhirIngestTotal = Metrics.CreateCounter( + "fhir_ingest_total", + "FHIR resource ingest operations.", + labelNames: new[] { "resource_type", "outcome" }); + + public readonly Counter FhirMappingErrorsTotal = Metrics.CreateCounter( + "fhir_mapping_errors_total", + "FHIR mapping failures.", + labelNames: new[] { "reason" }); + // --- Histograms --- // Measures the full ingest transaction: Redis cache lookup + alert evaluation + diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 98905ea..b3d11e1 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -83,6 +83,9 @@ try .GetSection(DashboardOptions.Section) .Get() ?? new DashboardOptions(); + builder.Services.Configure( + builder.Configuration.GetSection(FhirOptions.Section)); + builder.Services.AddCors(options => { options.AddPolicy("Dashboard", policy => @@ -122,6 +125,14 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -209,6 +220,7 @@ try } app.UseMiddleware(); + app.UseMiddleware(); app.UseMiddleware(); app.UseCors("Dashboard"); diff --git a/VigilCareClinicalAPI/Services/EncounterService.cs b/VigilCareClinicalAPI/Services/EncounterService.cs index 1749141..1f6ffc6 100644 --- a/VigilCareClinicalAPI/Services/EncounterService.cs +++ b/VigilCareClinicalAPI/Services/EncounterService.cs @@ -15,11 +15,16 @@ public class EncounterService : IEncounterService private readonly AppDbContext _db; private readonly IQsofaService _qsofa; + private readonly IExternalIdentifierService _identifiers; - public EncounterService(AppDbContext db, IQsofaService qsofa) + public EncounterService( + AppDbContext db, + IQsofaService qsofa, + IExternalIdentifierService identifiers) { _db = db; _qsofa = qsofa; + _identifiers = identifiers; } public async Task GetByIdAsync(Guid id) @@ -194,4 +199,89 @@ public class EncounterService : IEncounterService return new { encounterId, events = timeline }; } + + public async Task OpenOrUpdateByIdentifierAsync(FhirEncounterUpsertRequest req) + { + var existingId = await _identifiers.ResolveInternalIdAsync( + ExternalResourceType.Encounter, req.IdentifierSystem, req.IdentifierValue); + + if (existingId.HasValue) + { + var existingEncounter = await _db.Encounters + .Include(e => e.Patient) + .FirstOrDefaultAsync(e => e.Id == existingId.Value) + ?? throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); + + existingEncounter.Department = req.Department; + existingEncounter.AttendingPhysician = req.AttendingPhysician; + existingEncounter.RoomBed = req.RoomBed; + existingEncounter.AdmissionReason = req.AdmissionReason; + + if (existingEncounter.Status != req.TargetStatus) + { + await TransitionStatusAsync( + existingEncounter.Id, req.TargetStatus, req.DischargeDiagnosis); + await _db.Entry(existingEncounter).ReloadAsync(); + } + + return existingEncounter; + } + + // Create new encounter — mirrors PatientService.OpenEncounterAsync + var patient = await _db.Patients.FindAsync(req.PatientId); + if (patient is null) + throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND"); + + var encounter = new Encounter + { + Id = Guid.NewGuid(), + PatientId = req.PatientId, + EncounterType = req.EncounterType, + Status = EncounterStatus.Active, + Department = req.Department, + AttendingPhysician = req.AttendingPhysician, + RoomBed = req.RoomBed, + AdmissionReason = req.AdmissionReason, + AdmittedAt = req.AdmittedAt ?? DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + + _db.Encounters.Add(encounter); + + _db.OutboxEvents.Add(new OutboxEvent + { + Id = Guid.NewGuid(), + Topic = "encounter.status.changed", + Payload = JsonSerializer.Serialize(new + { + encounterId = encounter.Id, + patientId = patient.Id, + mrn = patient.Mrn, + patientName = $"{patient.FirstName} {patient.LastName}", + previousStatus = (string?)null, + newStatus = encounter.Status.ToDbString(), + department = encounter.Department.ToDbString(), + attendingPhysician = encounter.AttendingPhysician, + roomBed = encounter.RoomBed, + admissionReason = encounter.AdmissionReason, + admittedAt = encounter.AdmittedAt, + changedAt = DateTimeOffset.UtcNow + }), + PartitionKey = encounter.Id.ToString(), + CreatedAt = DateTimeOffset.UtcNow + }); + + await _db.SaveChangesAsync(); + + await _identifiers.LinkAsync( + ExternalResourceType.Encounter, + encounter.Id, + req.IdentifierSystem, + req.IdentifierValue); + + if (req.TargetStatus == EncounterStatus.Discharged) + await TransitionStatusAsync(encounter.Id, EncounterStatus.Discharged, req.DischargeDiagnosis); + + return encounter; + } } diff --git a/VigilCareClinicalAPI/Services/ExternalIdentifierService.cs b/VigilCareClinicalAPI/Services/ExternalIdentifierService.cs new file mode 100644 index 0000000..09a01a4 --- /dev/null +++ b/VigilCareClinicalAPI/Services/ExternalIdentifierService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; + +public class ExternalIdentifierService : IExternalIdentifierService +{ + private readonly AppDbContext _db; + + public ExternalIdentifierService(AppDbContext db) => _db = db; + + public async Task ResolveInternalIdAsync( + ExternalResourceType resourceType, string system, string value) + { + var row = await _db.ExternalResourceIdentifiers + .AsNoTracking() + .FirstOrDefaultAsync(e => + e.ResourceType == resourceType && + e.System == system && + e.Value == value); + + return row?.InternalId; + } + + public async Task LinkAsync( + ExternalResourceType resourceType, Guid internalId, string system, string value) + { + var existing = await _db.ExternalResourceIdentifiers + .FirstOrDefaultAsync(e => + e.ResourceType == resourceType && + e.System == system && + e.Value == value); + + if (existing is not null) + { + if (existing.InternalId != internalId) + throw new ConflictException( + $"Identifier {system}|{value} is already linked to a different internal resource.", + "IDENTIFIER_ALREADY_LINKED"); + return; + } + + _db.ExternalResourceIdentifiers.Add(new ExternalResourceIdentifier + { + Id = Guid.NewGuid(), + ResourceType = resourceType, + InternalId = internalId, + System = system, + Value = value, + CreatedAt = DateTimeOffset.UtcNow + }); + + await _db.SaveChangesAsync(); + } + + public async Task<(string System, string Value)?> FindPrimaryIdentifierAsync( + ExternalResourceType resourceType, Guid internalId, string[] acceptedSystems) + { + var rows = await _db.ExternalResourceIdentifiers + .AsNoTracking() + .Where(e => e.ResourceType == resourceType && e.InternalId == internalId) + .ToListAsync(); + + foreach (var system in acceptedSystems) + { + var match = rows.FirstOrDefault(r => r.System == system); + if (match is not null) + return (match.System, match.Value); + } + + return rows.Count > 0 ? (rows[0].System, rows[0].Value) : null; + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs b/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs index 311fdf7..1a9644f 100644 --- a/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs +++ b/VigilCareClinicalAPI/Services/Interfaces/IEncounterService.cs @@ -6,4 +6,5 @@ public interface IEncounterService Task TransitionStatusAsync( Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null); Task GetTimelineAsync(Guid encounterId); + Task OpenOrUpdateByIdentifierAsync(FhirEncounterUpsertRequest req); } diff --git a/VigilCareClinicalAPI/Services/Interfaces/IExternalIdentifierService.cs b/VigilCareClinicalAPI/Services/Interfaces/IExternalIdentifierService.cs new file mode 100644 index 0000000..7897ac3 --- /dev/null +++ b/VigilCareClinicalAPI/Services/Interfaces/IExternalIdentifierService.cs @@ -0,0 +1,11 @@ +public interface IExternalIdentifierService +{ + Task ResolveInternalIdAsync( + ExternalResourceType resourceType, string system, string value); + + Task LinkAsync( + ExternalResourceType resourceType, Guid internalId, string system, string value); + + Task<(string System, string Value)?> FindPrimaryIdentifierAsync( + ExternalResourceType resourceType, Guid internalId, string[] acceptedSystems); +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IPatientService.cs b/VigilCareClinicalAPI/Services/Interfaces/IPatientService.cs index eb94bdb..5be13fc 100644 --- a/VigilCareClinicalAPI/Services/Interfaces/IPatientService.cs +++ b/VigilCareClinicalAPI/Services/Interfaces/IPatientService.cs @@ -4,4 +4,5 @@ public interface IPatientService Task> ListAsync(string? q, int page, int pageSize); Task GetByIdAsync(Guid id); Task OpenEncounterAsync(Guid patientId, OpenEncounterRequest req); + Task RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req); } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/PatientService.cs b/VigilCareClinicalAPI/Services/PatientService.cs index 95a18ef..5a826e6 100644 --- a/VigilCareClinicalAPI/Services/PatientService.cs +++ b/VigilCareClinicalAPI/Services/PatientService.cs @@ -4,8 +4,13 @@ using Microsoft.EntityFrameworkCore; public class PatientService : IPatientService { private readonly AppDbContext _db; + private readonly IExternalIdentifierService _identifiers; - public PatientService(AppDbContext db) => _db = db; + public PatientService(AppDbContext db, IExternalIdentifierService identifiers) + { + _db = db; + _identifiers = identifiers; + } public async Task RegisterAsync(RegisterPatientRequest req) { @@ -122,6 +127,61 @@ public class PatientService : IPatientService return encounter; } + public async Task RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req) + { + var existingId = await _identifiers.ResolveInternalIdAsync( + ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue); + + if (existingId.HasValue) + { + var patient = await _db.Patients.FindAsync(existingId.Value) + ?? throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND"); + + patient.FirstName = req.FirstName; + patient.LastName = req.LastName; + patient.DateOfBirth = req.DateOfBirth; + patient.Gender = req.Gender; + patient.BloodType = req.BloodType; + patient.Allergies = req.Allergies; + patient.EmergencyContactName = req.EmergencyContactName; + patient.EmergencyContactPhone = req.EmergencyContactPhone; + + await _db.SaveChangesAsync(); + return patient; + } + + // Use hospital identifier value as MRN when it fits the column constraint (max 20 chars). + var mrn = req.IdentifierValue.Length <= 20 + ? req.IdentifierValue + : await GenerateMrnAsync(); + + var newPatient = new Patient + { + Id = Guid.NewGuid(), + Mrn = mrn, + FirstName = req.FirstName, + LastName = req.LastName, + DateOfBirth = req.DateOfBirth, + Gender = req.Gender, + BloodType = req.BloodType, + Allergies = req.Allergies, + EmergencyContactName = req.EmergencyContactName, + EmergencyContactPhone = req.EmergencyContactPhone, + CreatedAt = DateTimeOffset.UtcNow + }; + + _db.Patients.Add(newPatient); + await _db.SaveChangesAsync(); + + await _identifiers.LinkAsync( + ExternalResourceType.Patient, + newPatient.Id, + req.IdentifierSystem, + req.IdentifierValue); + + return newPatient; + } + private async Task GenerateMrnAsync() { var count = await _db.Patients.CountAsync(); diff --git a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj index ca1b983..5bcfe1b 100644 --- a/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj +++ b/VigilCareClinicalAPI/VigilCareClinicalAPI.csproj @@ -16,6 +16,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index 019c66e..3617fa4 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -163,5 +163,32 @@ "LabWarningHours": 12, "UseSpO2FiO2Fallback": true, "VasopressorWindowHours": 1 + }, + "Fhir": { + "ApiKey": "dev-integration-key-change-in-production", + "PatientIdentifierSystems": [ + "urn:oid:2.16.840.1.113883.4.1", + "http://hospital.example/mrn" + ], + "EncounterIdentifierSystems": [ + "http://hospital.example/visit" + ], + "InternalPatientIdSystem": "http://vigilcare.local/patient-id", + "InternalEncounterIdSystem": "http://vigilcare.local/encounter-id", + "DefaultDepartment": "GENERAL_MEDICINE", + "DefaultEncounterType": "INPATIENT", + "DepartmentCodeMap": { + "ICU": "ICU", + "EMER": "EMERGENCY", + "CARD": "CARDIOLOGY", + "SURG": "SURGERY", + "PEDI": "PEDIATRICS", + "MED": "GENERAL_MEDICINE" + }, + "EncounterClassMap": { + "IMP": "INPATIENT", + "AMB": "OUTPATIENT", + "EMER": "EMERGENCY" + } } } diff --git a/docs/ext-climate-resilience-roadmap.md b/docs/ext-climate-resilience-roadmap.md index dbdd845..6f3ef27 100644 --- a/docs/ext-climate-resilience-roadmap.md +++ b/docs/ext-climate-resilience-roadmap.md @@ -93,7 +93,7 @@ Implementation order: **20 → 21 → 22 → 23 → 24**. Phase plans for 21 and ## Sync Payload Contract (summary) -Shared types live in **`VigilCare.ClinicalContracts`** (class library referenced by central API and WardGateway). Full field definitions in [phase-20-plan.md](phase-20-plan.md) Step 6. +Shared types live in **`VigilCare.ClinicalContracts`** (class library referenced by central API and WardGateway). Scaffold and manage projects per [dotnet-solution-scaffolding.md](plans/dotnet-solution-scaffolding.md). Full field definitions in [phase-20-plan.md](phase-20-plan.md) Step 1. ```json { diff --git a/docs/integration/mirth-fhir-channels.md b/docs/integration/mirth-fhir-channels.md new file mode 100644 index 0000000..ddaddc5 --- /dev/null +++ b/docs/integration/mirth-fhir-channels.md @@ -0,0 +1,8 @@ +| HL7v2 Message | Mirth Action | FHIR Target | +|---|---|---| +| ADT A01 (Admit) | Map PID → Patient, PV1 → Encounter | `POST /fhir/R4` transaction Bundle | +| ADT A03 (Discharge) | Map PV1 → Encounter.status=finished | `POST /fhir/R4/Encounter` | +| ADT A08 (Update) | Map PID/PV1 changes | `POST /fhir/R4/Patient` + Encounter upsert | +| ORU R01 (Lab result) | Map OBX → Observation (LOINC in OBX-3) | `POST /fhir/R4/Observation` | + +Mirth HTTP Sender destination: `{vigilcare_url}/fhir/R4` with header `X-Api-Key` and content type `application/fhir+json`. \ No newline at end of file diff --git a/scripts/run-phase30-verification.sh b/scripts/run-phase30-verification.sh new file mode 100644 index 0000000..4c061a9 --- /dev/null +++ b/scripts/run-phase30-verification.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo "=== Phase 30 verification ===" + +echo "1. Integration tests" +dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \ + --filter "FullyQualifiedName~FhirIngest" \ + --no-restore + +echo "2. Manual FHIR checks (requires running stack)" +BASE_URL="${BASE_URL:-http://localhost:5270}" +API_KEY="${API_KEY:-dev-integration-key-change-in-production}" + +fhir_post() { + local path="$1" body="$2" + curl -sf -X POST "${BASE_URL}${path}" \ + -H "Content-Type: application/fhir+json" \ + -H "X-Api-Key: ${API_KEY}" \ + -d "${body}" +} + +echo "2a. CapabilityStatement" +curl -sf "${BASE_URL}/fhir/R4/metadata" -H "X-Api-Key: ${API_KEY}" | jq -e '.resourceType == "CapabilityStatement"' + +echo "2b. Admit transaction bundle" +fhir_post "/fhir/R4" '{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "identifier": [{ "system": "http://hospital.example/mrn", "value": "MRN-VERIFY-001" }], + "name": [{ "family": "Verify", "given": ["Phase30"] }], + "birthDate": "1975-06-01", + "gender": "male" + }, + "request": { "method": "POST", "url": "Patient" } + }, + { + "resource": { + "resourceType": "Encounter", + "status": "in-progress", + "class": { "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", "code": "IMP" }, + "identifier": [{ "system": "http://hospital.example/visit", "value": "VISIT-VERIFY-001" }], + "subject": { "identifier": { "system": "http://hospital.example/mrn", "value": "MRN-VERIFY-001" } }, + "participant": [{ "individual": { "display": "Dr. Verify" } }] + }, + "request": { "method": "POST", "url": "Encounter" } + } + ] +}' | jq -e '.type == "transaction-response"' + +echo "Phase 30 verification complete." \ No newline at end of file