feature: FHIR R4 Inbound Facade

This commit is contained in:
voltsrage
2026-06-21 13:53:38 +08:00
parent 9f96f54007
commit a43db52813
42 changed files with 2873 additions and 3 deletions
@@ -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<AppDbContext>();
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<CodeableConcept>
{
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<Identifier>
{
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<AppDbContext>();
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<AppDbContext>();
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<HttpResponseMessage> 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<Identifier>
{
new("http://hospital.example/mrn", mrn)
{
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR")
}
},
Name = new List<HumanName> { new() { Given = new[] { "Jane" }, Family = "Doe" } },
BirthDate = "1980-01-15",
Gender = AdministrativeGender.Female
};
private async Task<Guid> SeedPatientAndEncounterAsync()
{
using var scope = _fixture.Services.CreateScope();
var patients = scope.ServiceProvider.GetRequiredService<IPatientService>();
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<IEncounterService>();
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<Identifier> { new("http://hospital.example/visit", "VISIT-ADT-001") },
Subject = new ResourceReference
{
Identifier = new Identifier("http://hospital.example/mrn", "MRN-ADT-001")
},
Participant = new List<Hl7.Fhir.Model.Encounter.ParticipantComponent>
{
new()
{
Type = new List<CodeableConcept>
{
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<Identifier> { new("http://hospital.example/obs", "adt-hr-1") }
};
return new Bundle
{
Type = Bundle.BundleType.Transaction,
Entry = new List<Bundle.EntryComponent>
{
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" } }
}
};
}
}
@@ -24,6 +24,7 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
["RabbitMq:Username"] = "guest", ["RabbitMq:Username"] = "guest",
["RabbitMq:Password"] = "guest", ["RabbitMq:Password"] = "guest",
["RabbitMq:PagingAckTimeoutMs"] = "5000", ["RabbitMq:PagingAckTimeoutMs"] = "5000",
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
}); });
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false); config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
@@ -22,6 +22,7 @@ public static class DbResetHelper
DELETE FROM sofa_scores; DELETE FROM sofa_scores;
DELETE FROM news2_scores; DELETE FROM news2_scores;
DELETE FROM observations; DELETE FROM observations;
DELETE FROM external_resource_identifiers;
DELETE FROM encounters; DELETE FROM encounters;
DELETE FROM alert_thresholds; DELETE FROM alert_thresholds;
DELETE FROM patients; DELETE FROM patients;
@@ -0,0 +1,48 @@
public class FhirOptions
{
public const string Section = "Fhir";
/// <summary>Shared secret for integration engine authentication (interim until RBAC).</summary>
public string? ApiKey { get; set; }
/// <summary>Identifier systems accepted for Patient.identifier (hospital MRNs).</summary>
public string[] PatientIdentifierSystems { get; set; } =
[
"urn:oid:2.16.840.1.113883.4.1",
"http://hospital.example/mrn"
];
/// <summary>Identifier systems accepted for Encounter.identifier (visit numbers).</summary>
public string[] EncounterIdentifierSystems { get; set; } =
[
"http://hospital.example/visit"
];
/// <summary>System URI VigilCare uses when embedding internal UUIDs in FHIR responses.</summary>
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";
/// <summary>Maps FHIR location/serviceProvider codes to internal department strings.</summary>
public Dictionary<string, string> DepartmentCodeMap { get; set; } = new()
{
["ICU"] = "ICU",
["EMER"] = "EMERGENCY",
["CARD"] = "CARDIOLOGY",
["SURG"] = "SURGERY",
["PEDI"] = "PEDIATRICS",
["MED"] = "GENERAL_MEDICINE"
};
/// <summary>Maps FHIR Encounter.class ACT codes to internal encounter type strings.</summary>
public Dictionary<string, string> EncounterClassMap { get; set; } = new()
{
["IMP"] = "INPATIENT",
["AMB"] = "OUTPATIENT",
["EMER"] = "EMERGENCY"
};
}
@@ -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<FhirOptions> 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<IActionResult> CreatePatient()
{
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Patient>();
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<IActionResult> CreateEncounter()
{
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Encounter>();
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<IActionResult> CreateObservation()
{
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.Observation>();
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<IActionResult> CreateMedicationAdministration()
{
var fhir = await ParseBodyAsync<Hl7.Fhir.Model.MedicationAdministration>();
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));
}
/// <summary>Accepts Bundle.type=transaction (ADT admit) or batch.</summary>
[HttpPost]
[Consumes("application/fhir+json")]
[Produces("application/fhir+json")]
public async Task<IActionResult> ProcessBundle()
{
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync();
var bundle = Parser.Parse<Bundle>(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<T> ParseBodyAsync<T>() where T : Resource
{
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync();
return Parser.Parse<T>(json);
}
private ContentResult Serialize(Resource resource) =>
Content(Serializer.SerializeToString(resource), "application/fhir+json");
}
@@ -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<CapabilityStatement.RestComponent>
{
new()
{
Mode = CapabilityStatement.RestfulCapabilityMode.Server,
Resource = new List<CapabilityStatement.ResourceComponent>
{
ResourceCapability("Patient", TypeRestfulInteraction.Create),
ResourceCapability("Encounter", TypeRestfulInteraction.Create),
ResourceCapability("Observation", TypeRestfulInteraction.Create),
ResourceCapability("MedicationAdministration", TypeRestfulInteraction.Create),
new CapabilityStatement.ResourceComponent
{
Type = "Bundle",
Interaction = new List<CapabilityStatement.ResourceInteractionComponent>
{
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<CapabilityStatement.ResourceInteractionComponent>
{
new() { Code = interaction }
}
};
}
@@ -18,6 +18,7 @@ public class AppDbContext : DbContext
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>(); public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
public DbSet<GcsScore> GcsScores => Set<GcsScore>(); public DbSet<GcsScore> GcsScores => Set<GcsScore>();
public DbSet<SofaScore> SofaScores => Set<SofaScore>(); public DbSet<SofaScore> SofaScores => Set<SofaScore>();
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ExternalResourceIdentifierConfiguration : IEntityTypeConfiguration<ExternalResourceIdentifier>
{
public void Configure(EntityTypeBuilder<ExternalResourceIdentifier> 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 });
}
}
@@ -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; }
}
@@ -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}'")
};
}
@@ -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");
}
}
@@ -0,0 +1,51 @@
public static class LoincCodeMapper
{
// LOINC → internal observation code. Covers all codes in DataSeeder + PlausibilityValidator.
private static readonly Dictionary<string, LoincMapping> _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<string, LoincMapping> _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<string> SupportedLoincCodes => _map.Keys;
}
@@ -0,0 +1,4 @@
public record LoincMapping(
string InternalCode,
string ExpectedUnit,
bool AllowFahrenheit = false);
@@ -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<Bundle> 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<string> ProcessPatientAsync(Hl7.Fhir.Model.Patient fhir)
{
var req = _patientMapper.ToUpsertRequest(fhir);
var patient = await _patients.RegisterOrUpdateByIdentifierAsync(req);
return $"Patient/{patient.Id}";
}
private async Task<string> 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<string> 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<string> ProcessMedAsync(Hl7.Fhir.Model.MedicationAdministration fhir)
{
var (req, encounterId) = await _medMapper.ToCreateRequestAsync(fhir);
var med = await _medications.CreateAsync(encounterId, req);
return $"MedicationAdministration/{med.Id}";
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<OperationOutcome.IssueComponent>
{
new()
{
Severity = status >= 500
? OperationOutcome.IssueSeverity.Error
: OperationOutcome.IssueSeverity.Warning,
Code = Enum.TryParse<OperationOutcome.IssueType>(code, true, out var c)
? c
: OperationOutcome.IssueType.Processing,
Diagnostics = diagnostics
}
}
};
}
@@ -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<FhirOptions> options)
{
_refs = refs;
_options = options.Value;
}
public async Task<FhirEncounterUpsertRequest> 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<Identifier>()
};
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;
}
}
@@ -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);
}
}
@@ -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<FhirOptions> options)
{
_identifiers = identifiers;
_options = options.Value;
}
public async Task<Guid> 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<Guid> 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<List<Identifier>> 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<Guid> 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;
}
}
@@ -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);
}
}
@@ -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<IReadOnlyList<MappedObservation>> 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<MappedObservation>();
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<MappedObservation> 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)];
}
}
@@ -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<FhirOptions> 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<Identifier>()
};
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;
}
}
@@ -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<FhirOptions> 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddExternalResourceIdentifiers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "external_resource_identifiers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
resource_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
internal_id = table.Column<Guid>(type: "uuid", nullable: false),
system = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
value = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
created_at = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "external_resource_identifiers");
}
}
}
@@ -250,6 +250,52 @@ namespace VigilCareClinicalAPI.Migrations
}); });
}); });
modelBuilder.Entity("ExternalResourceIdentifier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("InternalId")
.HasColumnType("uuid")
.HasColumnName("internal_id");
b.Property<string>("ResourceType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("resource_type");
b.Property<string>("System")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("system");
b.Property<string>("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 => modelBuilder.Entity("GcsScore", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -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);
@@ -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);
@@ -58,6 +58,16 @@ public sealed class ClinicalMetrics
"SOFA scores computed, labeled by whether a delta alert was created.", "SOFA scores computed, labeled by whether a delta alert was created.",
labelNames: new[] { "has_delta_alert" }); 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 --- // --- Histograms ---
// Measures the full ingest transaction: Redis cache lookup + alert evaluation + // Measures the full ingest transaction: Redis cache lookup + alert evaluation +
+12
View File
@@ -83,6 +83,9 @@ try
.GetSection(DashboardOptions.Section) .GetSection(DashboardOptions.Section)
.Get<DashboardOptions>() ?? new DashboardOptions(); .Get<DashboardOptions>() ?? new DashboardOptions();
builder.Services.Configure<FhirOptions>(
builder.Configuration.GetSection(FhirOptions.Section));
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("Dashboard", policy => options.AddPolicy("Dashboard", policy =>
@@ -122,6 +125,14 @@ try
builder.Services.AddScoped<SofaVasopressorResolver>(); builder.Services.AddScoped<SofaVasopressorResolver>();
builder.Services.AddScoped<SofaDetector>(); builder.Services.AddScoped<SofaDetector>();
builder.Services.AddScoped<ISofaService, SofaService>(); builder.Services.AddScoped<ISofaService, SofaService>();
builder.Services.AddScoped<IExternalIdentifierService, ExternalIdentifierService>();
builder.Services.AddScoped<FhirReferenceResolver>();
builder.Services.AddScoped<PatientFhirMapper>();
builder.Services.AddScoped<EncounterFhirMapper>();
builder.Services.AddScoped<ObservationFhirMapper>();
builder.Services.AddScoped<MedicationAdministrationFhirMapper>();
builder.Services.AddScoped<FhirBundleProcessor>();
builder.Services.AddScoped<FhirExceptionFilter>();
builder.Services.AddHostedService<ThresholdCacheLoader>(); builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>(); builder.Services.AddHostedService<KafkaTopicProvisioner>();
@@ -209,6 +220,7 @@ try
} }
app.UseMiddleware<CorrelationIdMiddleware>(); app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<FhirApiKeyMiddleware>();
app.UseMiddleware<ExceptionHandlerMiddleware>(); app.UseMiddleware<ExceptionHandlerMiddleware>();
app.UseCors("Dashboard"); app.UseCors("Dashboard");
@@ -15,11 +15,16 @@ public class EncounterService : IEncounterService
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IQsofaService _qsofa; private readonly IQsofaService _qsofa;
private readonly IExternalIdentifierService _identifiers;
public EncounterService(AppDbContext db, IQsofaService qsofa) public EncounterService(
AppDbContext db,
IQsofaService qsofa,
IExternalIdentifierService identifiers)
{ {
_db = db; _db = db;
_qsofa = qsofa; _qsofa = qsofa;
_identifiers = identifiers;
} }
public async Task<Encounter> GetByIdAsync(Guid id) public async Task<Encounter> GetByIdAsync(Guid id)
@@ -194,4 +199,89 @@ public class EncounterService : IEncounterService
return new { encounterId, events = timeline }; return new { encounterId, events = timeline };
} }
public async Task<Encounter> 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;
}
} }
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
public class ExternalIdentifierService : IExternalIdentifierService
{
private readonly AppDbContext _db;
public ExternalIdentifierService(AppDbContext db) => _db = db;
public async Task<Guid?> 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;
}
}
@@ -6,4 +6,5 @@ public interface IEncounterService
Task<EncounterStatusTransitionResult> TransitionStatusAsync( Task<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null); Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null);
Task<object> GetTimelineAsync(Guid encounterId); Task<object> GetTimelineAsync(Guid encounterId);
Task<Encounter> OpenOrUpdateByIdentifierAsync(FhirEncounterUpsertRequest req);
} }
@@ -0,0 +1,11 @@
public interface IExternalIdentifierService
{
Task<Guid?> 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);
}
@@ -4,4 +4,5 @@ public interface IPatientService
Task<PagedResult<Patient>> ListAsync(string? q, int page, int pageSize); Task<PagedResult<Patient>> ListAsync(string? q, int page, int pageSize);
Task<Patient> GetByIdAsync(Guid id); Task<Patient> GetByIdAsync(Guid id);
Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req); Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req);
Task<Patient> RegisterOrUpdateByIdentifierAsync(FhirPatientUpsertRequest req);
} }
@@ -4,8 +4,13 @@ using Microsoft.EntityFrameworkCore;
public class PatientService : IPatientService public class PatientService : IPatientService
{ {
private readonly AppDbContext _db; 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<Patient> RegisterAsync(RegisterPatientRequest req) public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
{ {
@@ -122,6 +127,61 @@ public class PatientService : IPatientService
return encounter; return encounter;
} }
public async Task<Patient> 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<string> GenerateMrnAsync() private async Task<string> GenerateMrnAsync()
{ {
var count = await _db.Patients.CountAsync(); var count = await _db.Patients.CountAsync();
@@ -16,6 +16,7 @@
<PackageReference Include="Confluent.Kafka" Version="2.14.0" /> <PackageReference Include="Confluent.Kafka" Version="2.14.0" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" /> <PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+27
View File
@@ -163,5 +163,32 @@
"LabWarningHours": 12, "LabWarningHours": 12,
"UseSpO2FiO2Fallback": true, "UseSpO2FiO2Fallback": true,
"VasopressorWindowHours": 1 "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"
}
} }
} }
+1 -1
View File
@@ -93,7 +93,7 @@ Implementation order: **20 → 21 → 22 → 23 → 24**. Phase plans for 21 and
## Sync Payload Contract (summary) ## 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 ```json
{ {
+8
View File
@@ -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`.
+58
View File
@@ -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."