feature: HL7 FHIR R4 Integration
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
public class FhirOptions
|
||||
{
|
||||
public const string Section = "Fhir";
|
||||
|
||||
public string BaseUrl { get; set; } = "http://localhost:5217/fhir";
|
||||
public string PublisherName { get; set; } = "VigilCare Records";
|
||||
public string PublisherUrl { get; set; } = "https://vigilcare.local";
|
||||
public string ServerVersion { get; set; } = "1.0.0";
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("fhir/Encounter")]
|
||||
[Produces("application/fhir+json")]
|
||||
[Authorize]
|
||||
public class FhirEncounterController : ControllerBase
|
||||
{
|
||||
private readonly IFhirService _fhir;
|
||||
|
||||
public FhirEncounterController(IFhirService fhir) => _fhir = fhir;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR read: GET /fhir/Encounter/{id}
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Read(string id)
|
||||
{
|
||||
var encounter = await _fhir.GetEncounterAsync(Guid.Parse(id));
|
||||
if (encounter is null)
|
||||
return NotFound(FhirErrorHelper.NotFound("Encounter", id));
|
||||
|
||||
return Ok(encounter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z
|
||||
/// Supports search by patient reference, status, and date range.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] string? patient,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? date,
|
||||
[FromQuery(Name = "_count")] int count = 20,
|
||||
[FromQuery(Name = "_offset")] int offset = 0)
|
||||
{
|
||||
var bundle = await _fhir.SearchEncountersAsync(patient, status, date, count, offset);
|
||||
return Ok(bundle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("fhir")]
|
||||
[Produces("application/fhir+json")]
|
||||
public class FhirMetadataController : ControllerBase
|
||||
{
|
||||
private readonly IFhirService _fhir;
|
||||
|
||||
public FhirMetadataController(IFhirService fhir) => _fhir = fhir;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR metadata: GET /fhir/metadata
|
||||
/// Returns the server's CapabilityStatement describing supported
|
||||
/// resources, interactions, and search parameters.
|
||||
/// No authentication required (FHIR spec requirement).
|
||||
/// </summary>
|
||||
[HttpGet("metadata")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(CapabilityStatement), StatusCodes.Status200OK)]
|
||||
public IActionResult GetMetadata()
|
||||
{
|
||||
return Ok(_fhir.GetCapabilityStatement());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("fhir/Observation")]
|
||||
[Produces("application/fhir+json")]
|
||||
[Authorize]
|
||||
public class FhirObservationController : ControllerBase
|
||||
{
|
||||
private readonly IFhirService _fhir;
|
||||
|
||||
public FhirObservationController(IFhirService fhir) => _fhir = fhir;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR read: GET /fhir/Observation/{id}
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Read(string id)
|
||||
{
|
||||
var observation = await _fhir.GetObservationAsync(Guid.Parse(id));
|
||||
if (observation is null)
|
||||
return NotFound(FhirErrorHelper.NotFound("Observation", id));
|
||||
|
||||
return Ok(observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W
|
||||
/// Supports search by patient reference, LOINC code, date range, and category.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] string? patient,
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? date,
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? encounter,
|
||||
[FromQuery(Name = "_count")] int count = 50,
|
||||
[FromQuery(Name = "_offset")] int offset = 0)
|
||||
{
|
||||
var bundle = await _fhir.SearchObservationsAsync(
|
||||
patient, code, date, category, encounter, count, offset);
|
||||
return Ok(bundle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("fhir/Patient")]
|
||||
[Produces("application/fhir+json")]
|
||||
[Authorize]
|
||||
public class FhirPatientController : ControllerBase
|
||||
{
|
||||
private readonly IFhirService _fhir;
|
||||
|
||||
public FhirPatientController(IFhirService fhir) => _fhir = fhir;
|
||||
|
||||
/// <summary>
|
||||
/// FHIR read: GET /fhir/Patient/{id}
|
||||
/// Returns a single Patient resource by logical ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Read(string id)
|
||||
{
|
||||
var patient = await _fhir.GetPatientAsync(Guid.Parse(id));
|
||||
if (patient is null)
|
||||
return NotFound(FhirErrorHelper.NotFound("Patient", id));
|
||||
|
||||
return Ok(patient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z
|
||||
/// Supports search by name (contains), birthdate (exact), and MRN identifier.
|
||||
/// Returns a FHIR Bundle of type searchset.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(Hl7.Fhir.Model.Bundle), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery] string? name,
|
||||
[FromQuery] string? birthdate,
|
||||
[FromQuery] string? identifier,
|
||||
[FromQuery(Name = "_count")] int count = 20,
|
||||
[FromQuery(Name = "_offset")] int offset = 0)
|
||||
{
|
||||
var bundle = await _fhir.SearchPatientsAsync(name, birthdate, identifier, count, offset);
|
||||
return Ok(bundle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FHIR $everything: GET /fhir/Patient/{id}/$everything
|
||||
/// Returns a Bundle containing the Patient resource, all Encounters,
|
||||
/// and all Observations for the patient.
|
||||
/// </summary>
|
||||
[HttpGet("{id}/$everything")]
|
||||
[ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Everything(string id)
|
||||
{
|
||||
var bundle = await _fhir.GetPatientEverythingAsync(Guid.Parse(id));
|
||||
if (bundle is null)
|
||||
return NotFound(FhirErrorHelper.NotFound("Patient", id));
|
||||
|
||||
return Ok(bundle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Hl7.Fhir.Model;
|
||||
|
||||
public static class FhirErrorHelper
|
||||
{
|
||||
public static OperationOutcome NotFound(string resourceType, string id) =>
|
||||
new()
|
||||
{
|
||||
Issue =
|
||||
{
|
||||
new OperationOutcome.IssueComponent
|
||||
{
|
||||
Severity = OperationOutcome.IssueSeverity.Error,
|
||||
Code = OperationOutcome.IssueType.NotFound,
|
||||
Diagnostics = $"{resourceType}/{id} not found",
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Hl7.Fhir.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using System.Text;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
public class FhirJsonOutputFormatter : TextOutputFormatter
|
||||
{
|
||||
public FhirJsonOutputFormatter()
|
||||
{
|
||||
SupportedMediaTypes.Add("application/fhir+json");
|
||||
SupportedMediaTypes.Add("application/json");
|
||||
SupportedEncodings.Add(Encoding.UTF8);
|
||||
}
|
||||
|
||||
protected override bool CanWriteType(Type? type)
|
||||
{
|
||||
return type != null && typeof(Resource).IsAssignableFrom(type);
|
||||
}
|
||||
|
||||
public override async Task WriteResponseBodyAsync(
|
||||
OutputFormatterWriteContext context, Encoding selectedEncoding)
|
||||
{
|
||||
var resource = context.Object as Resource;
|
||||
if (resource is null) return;
|
||||
|
||||
var serializer = new FhirJsonSerializer(new SerializerSettings { Pretty = true });
|
||||
var json = serializer.SerializeToString(resource);
|
||||
await context.HttpContext.Response.WriteAsync(json, selectedEncoding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using FhirEncounter = Hl7.Fhir.Model.Encounter;
|
||||
|
||||
public static class EncounterMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a VigilCare Encounter entity to a FHIR R4 Encounter resource.
|
||||
///
|
||||
/// Mapping decisions:
|
||||
/// - VigilCare encounter Status ("active", "discharged") maps to FHIR
|
||||
/// Encounter.Status (in-progress, finished).
|
||||
/// - Department maps to Encounter.serviceType using a local CodeSystem.
|
||||
/// Facilities should map to their own department OID or SNOMED CT codes.
|
||||
/// - SourceBatchId is preserved as an extension for traceability.
|
||||
/// </summary>
|
||||
public static FhirEncounter ToFhir(Encounter entity, string baseUrl)
|
||||
{
|
||||
var encounter = new FhirEncounter
|
||||
{
|
||||
Id = entity.Id.ToString(),
|
||||
Meta = new Meta
|
||||
{
|
||||
VersionId = "1",
|
||||
LastUpdated = entity.UpdatedAt,
|
||||
},
|
||||
Status = entity.Status?.ToLowerInvariant() switch
|
||||
{
|
||||
"active" => FhirEncounter.EncounterStatus.InProgress,
|
||||
"discharged" => FhirEncounter.EncounterStatus.Finished,
|
||||
"cancelled" => FhirEncounter.EncounterStatus.Cancelled,
|
||||
_ => FhirEncounter.EncounterStatus.Unknown,
|
||||
},
|
||||
Class = new Coding(
|
||||
"http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||||
"IMP",
|
||||
"inpatient encounter"),
|
||||
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
|
||||
};
|
||||
|
||||
if (entity.AdmissionDate.HasValue)
|
||||
{
|
||||
encounter.Period = new Period
|
||||
{
|
||||
StartElement = new FhirDateTime(entity.AdmissionDate.Value),
|
||||
};
|
||||
}
|
||||
|
||||
if (entity.Department.HasValue)
|
||||
{
|
||||
encounter.ServiceType = new CodeableConcept(
|
||||
"urn:vigilcare:department",
|
||||
entity.Department.Value.ToString(),
|
||||
entity.Department.Value.ToString());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.RoomBed))
|
||||
{
|
||||
encounter.Location.Add(new FhirEncounter.LocationComponent
|
||||
{
|
||||
Location = new ResourceReference { Display = entity.RoomBed },
|
||||
Status = FhirEncounter.EncounterLocationStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.AdmissionReason))
|
||||
{
|
||||
encounter.ReasonCode.Add(new CodeableConcept { Text = entity.AdmissionReason });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.DischargeDiagnosis))
|
||||
{
|
||||
encounter.Diagnosis.Add(new FhirEncounter.DiagnosisComponent
|
||||
{
|
||||
Condition = new ResourceReference { Display = entity.DischargeDiagnosis },
|
||||
Use = new CodeableConcept(
|
||||
"http://terminology.hl7.org/CodeSystem/diagnosis-role",
|
||||
"DD", "Discharge diagnosis"),
|
||||
});
|
||||
}
|
||||
|
||||
if (entity.SourceBatchId.HasValue)
|
||||
{
|
||||
encounter.Extension.Add(new Extension(
|
||||
"urn:vigilcare:source-batch-id",
|
||||
new FhirString(entity.SourceBatchId.Value.ToString())));
|
||||
}
|
||||
|
||||
return encounter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using FhirObservation = Hl7.Fhir.Model.Observation;
|
||||
|
||||
public static class ObservationMapper
|
||||
{
|
||||
private static readonly Dictionary<string, string> LoincToVigilCare = new()
|
||||
{
|
||||
["8867-4"] = "HEART_RATE",
|
||||
["8310-5"] = "TEMP_C",
|
||||
["8480-6"] = "BP_SYSTOLIC",
|
||||
["8462-4"] = "BP_DIASTOLIC",
|
||||
["9279-1"] = "RESP_RATE",
|
||||
["2708-6"] = "SPO2",
|
||||
["2345-7"] = "GLUCOSE_MG_DL",
|
||||
["2823-3"] = "POTASSIUM_MEQ_L",
|
||||
["2951-2"] = "SODIUM_MEQ_L",
|
||||
["2524-7"] = "LACTATE_MMOL_L",
|
||||
["6690-2"] = "WBC_K_UL",
|
||||
["718-7"] = "HEMOGLOBIN_G_DL",
|
||||
["2160-0"] = "CREATININE_MG_DL",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> VitalSignCodes =
|
||||
[
|
||||
"HEART_RATE", "TEMP_C", "BP_SYSTOLIC", "BP_DIASTOLIC", "RESP_RATE", "SPO2"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Maps a VigilCare Observation entity to a FHIR R4 Observation resource.
|
||||
///
|
||||
/// Mapping decisions:
|
||||
/// - ObservationCode maps to LOINC codes where a standard mapping exists.
|
||||
/// Unknown codes use a local CodeSystem with the original code as display.
|
||||
/// - Value + Unit maps to Observation.valueQuantity with UCUM unit codes.
|
||||
/// - RecordedAt maps to effectiveDateTime (when the observation was clinically
|
||||
/// relevant), not issued (when the system recorded it).
|
||||
/// - Source and SourceBatchId are preserved as extensions for provenance.
|
||||
/// </summary>
|
||||
public static FhirObservation ToFhir(Observation entity, string baseUrl)
|
||||
{
|
||||
var observation = new FhirObservation
|
||||
{
|
||||
Id = entity.Id.ToString(),
|
||||
Meta = new Meta
|
||||
{
|
||||
VersionId = "1",
|
||||
LastUpdated = entity.CreatedAt,
|
||||
},
|
||||
Status = ObservationStatus.Final,
|
||||
Subject = new ResourceReference($"Patient/{entity.PatientId}"),
|
||||
Encounter = new ResourceReference($"Encounter/{entity.EncounterId}"),
|
||||
Effective = new FhirDateTime(entity.RecordedAt),
|
||||
Issued = entity.CreatedAt,
|
||||
};
|
||||
|
||||
observation.Code = MapObservationCode(entity.ObservationCode);
|
||||
|
||||
observation.Value = new Quantity
|
||||
{
|
||||
Value = entity.Value,
|
||||
Unit = entity.Unit,
|
||||
System = "http://unitsofmeasure.org",
|
||||
Code = MapToUcum(entity.Unit),
|
||||
};
|
||||
|
||||
var category = IsVitalSign(entity.ObservationCode) ? "vital-signs" : "laboratory";
|
||||
observation.Category.Add(new CodeableConcept(
|
||||
"http://terminology.hl7.org/CodeSystem/observation-category",
|
||||
category));
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.Note))
|
||||
{
|
||||
observation.Note.Add(new Annotation { Text = new Markdown(entity.Note) });
|
||||
}
|
||||
|
||||
observation.Extension.Add(new Extension(
|
||||
"urn:vigilcare:source",
|
||||
new FhirString(entity.Source)));
|
||||
|
||||
if (entity.SourceBatchId.HasValue)
|
||||
{
|
||||
observation.Extension.Add(new Extension(
|
||||
"urn:vigilcare:source-batch-id",
|
||||
new FhirString(entity.SourceBatchId.Value.ToString())));
|
||||
}
|
||||
|
||||
return observation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse LOINC → VigilCare code mapping for FHIR search by LOINC code.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> GetReverseLoincMapping() => LoincToVigilCare;
|
||||
|
||||
/// <summary>
|
||||
/// VigilCare observation codes that represent vital signs (used for category search).
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> GetVitalSignCodes() => VitalSignCodes;
|
||||
|
||||
private static CodeableConcept MapObservationCode(string code) => code switch
|
||||
{
|
||||
"HEART_RATE" => Loinc("8867-4", "Heart rate"),
|
||||
"TEMP_C" => Loinc("8310-5", "Body temperature"),
|
||||
"BP_SYSTOLIC" => Loinc("8480-6", "Systolic blood pressure"),
|
||||
"BP_DIASTOLIC" => Loinc("8462-4", "Diastolic blood pressure"),
|
||||
"RESP_RATE" => Loinc("9279-1", "Respiratory rate"),
|
||||
"SPO2" => Loinc("2708-6", "Oxygen saturation"),
|
||||
"GLUCOSE_MG_DL" => Loinc("2345-7", "Glucose [Mass/volume] in Serum or Plasma"),
|
||||
"POTASSIUM_MEQ_L" => Loinc("2823-3", "Potassium [Moles/volume] in Serum or Plasma"),
|
||||
"SODIUM_MEQ_L" => Loinc("2951-2", "Sodium [Moles/volume] in Serum or Plasma"),
|
||||
"LACTATE_MMOL_L" => Loinc("2524-7", "Lactate [Moles/volume] in Serum or Plasma"),
|
||||
"WBC_K_UL" => Loinc("6690-2", "Leukocytes [#/volume] in Blood"),
|
||||
"HEMOGLOBIN_G_DL" => Loinc("718-7", "Hemoglobin [Mass/volume] in Blood"),
|
||||
"CREATININE_MG_DL" => Loinc("2160-0", "Creatinine [Mass/volume] in Serum or Plasma"),
|
||||
_ => new CodeableConcept("urn:vigilcare:observation-code", code, code),
|
||||
};
|
||||
|
||||
private static CodeableConcept Loinc(string code, string display) =>
|
||||
new("http://loinc.org", code, display);
|
||||
|
||||
private static string MapToUcum(string unit) => unit switch
|
||||
{
|
||||
"bpm" => "/min",
|
||||
"C" => "Cel",
|
||||
"mmHg" => "mm[Hg]",
|
||||
"breaths/min" => "/min",
|
||||
"%" => "%",
|
||||
"mg/dL" => "mg/dL",
|
||||
"mEq/L" => "meq/L",
|
||||
"mmol/L" => "mmol/L",
|
||||
"K/uL" => "10*3/uL",
|
||||
"g/dL" => "g/dL",
|
||||
_ => unit,
|
||||
};
|
||||
|
||||
private static bool IsVitalSign(string code) => VitalSignCodes.Contains(code);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using FhirPatient = Hl7.Fhir.Model.Patient;
|
||||
|
||||
public static class PatientMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a VigilCare Patient entity to a FHIR R4 Patient resource.
|
||||
///
|
||||
/// Mapping decisions:
|
||||
/// - VigilCare stores full name as a single string; FHIR splits into family/given.
|
||||
/// We use HumanName.Text for the full string and attempt to split on the last
|
||||
/// space for family/given when possible.
|
||||
/// - MRN maps to Identifier with system "urn:oid:2.16.840.1.113883.19.5" (example OID).
|
||||
/// Facilities should configure their own OID.
|
||||
/// - BloodType maps to an extension (no standard FHIR element for blood type).
|
||||
/// - AllergiesJson is NOT mapped here — allergies should use AllergyIntolerance
|
||||
/// resources, which are out of scope for Phase 11 v1.
|
||||
/// </summary>
|
||||
public static FhirPatient ToFhir(Patient entity, string baseUrl)
|
||||
{
|
||||
var patient = new FhirPatient
|
||||
{
|
||||
Id = entity.Id.ToString(),
|
||||
Meta = new Meta
|
||||
{
|
||||
VersionId = "1",
|
||||
LastUpdated = entity.UpdatedAt,
|
||||
},
|
||||
Active = true,
|
||||
};
|
||||
|
||||
patient.Identifier.Add(new Identifier
|
||||
{
|
||||
System = "urn:oid:2.16.840.1.113883.19.5",
|
||||
Value = entity.Mrn,
|
||||
Use = Identifier.IdentifierUse.Official,
|
||||
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR", "Medical Record Number"),
|
||||
});
|
||||
|
||||
var name = new HumanName { Text = entity.FullName, Use = HumanName.NameUse.Official };
|
||||
var parts = entity.FullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
name.Family = parts[^1];
|
||||
name.Given = parts[..^1].ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
name.Family = entity.FullName;
|
||||
}
|
||||
patient.Name.Add(name);
|
||||
|
||||
if (entity.DateOfBirth.HasValue)
|
||||
{
|
||||
patient.BirthDate = entity.DateOfBirth.Value.ToString("yyyy-MM-dd");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.Sex))
|
||||
{
|
||||
patient.Gender = entity.Sex.ToLowerInvariant() switch
|
||||
{
|
||||
"male" => AdministrativeGender.Male,
|
||||
"female" => AdministrativeGender.Female,
|
||||
"other" => AdministrativeGender.Other,
|
||||
_ => AdministrativeGender.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.EmergencyContact))
|
||||
{
|
||||
patient.Contact.Add(new FhirPatient.ContactComponent
|
||||
{
|
||||
Relationship = new List<CodeableConcept>
|
||||
{
|
||||
new("http://terminology.hl7.org/CodeSystem/v2-0131", "C", "Emergency Contact")
|
||||
},
|
||||
Name = new HumanName { Text = entity.EmergencyContact },
|
||||
});
|
||||
}
|
||||
|
||||
return patient;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ try
|
||||
builder.Services.Configure<PromotionRetryOptions>(
|
||||
builder.Configuration.GetSection(PromotionRetryOptions.Section));
|
||||
|
||||
builder.Services.Configure<FhirOptions>(builder.Configuration.GetSection(FhirOptions.Section));
|
||||
|
||||
// JWT Authentication
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
|
||||
|
||||
@@ -122,6 +124,7 @@ try
|
||||
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
|
||||
builder.Services.AddScoped<IBatchEventService, BatchEventService>();
|
||||
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
|
||||
builder.Services.AddScoped<IFhirService, FhirService>();
|
||||
|
||||
builder.Services.AddHostedService<MetricsCollectorService>();
|
||||
builder.Services.AddHostedService<PromotionRetryService>();
|
||||
@@ -143,7 +146,10 @@ try
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
builder.Services.AddScoped<ValidationFilter>();
|
||||
builder.Services.AddControllers(options =>
|
||||
options.Filters.AddService<ValidationFilter>());
|
||||
{
|
||||
options.OutputFormatters.Insert(0, new FhirJsonOutputFormatter());
|
||||
options.Filters.AddService<ValidationFilter>();
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddVigilCareRecordsSwagger();
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Globalization;
|
||||
using FhirPatient = Hl7.Fhir.Model.Patient;
|
||||
using FhirEncounter = Hl7.Fhir.Model.Encounter;
|
||||
using FhirObservation = Hl7.Fhir.Model.Observation;
|
||||
|
||||
public class FhirService : IFhirService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly FhirOptions _options;
|
||||
|
||||
public FhirService(AppDbContext db, IOptions<FhirOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
// --- Patient ---
|
||||
|
||||
public async Task<FhirPatient?> GetPatientAsync(Guid id)
|
||||
{
|
||||
var entity = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
|
||||
return entity is null ? null : PatientMapper.ToFhir(entity, _options.BaseUrl);
|
||||
}
|
||||
|
||||
public async Task<Bundle> SearchPatientsAsync(
|
||||
string? name, string? birthdate, string? identifier, int count, int offset)
|
||||
{
|
||||
count = Math.Clamp(count, 1, 100);
|
||||
|
||||
var query = _db.Patients.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
query = query.Where(p => EF.Functions.ILike(p.FullName, $"%{name}%"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(birthdate) && DateOnly.TryParse(birthdate, out var dob))
|
||||
query = query.Where(p => p.DateOfBirth == dob);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
query = query.Where(p => p.Mrn == identifier);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var entities = await query.OrderBy(p => p.FullName).Skip(offset).Take(count).ToListAsync();
|
||||
|
||||
return BuildSearchBundle(
|
||||
entities.Select(e => PatientMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
|
||||
total, count, offset, "Patient");
|
||||
}
|
||||
|
||||
// --- Encounter ---
|
||||
|
||||
public async Task<FhirEncounter?> GetEncounterAsync(Guid id)
|
||||
{
|
||||
var entity = await _db.Encounters.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
|
||||
return entity is null ? null : EncounterMapper.ToFhir(entity, _options.BaseUrl);
|
||||
}
|
||||
|
||||
public async Task<Bundle> SearchEncountersAsync(
|
||||
string? patient, string? status, string? date, int count, int offset)
|
||||
{
|
||||
count = Math.Clamp(count, 1, 100);
|
||||
|
||||
var query = _db.Encounters.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(patient) && Guid.TryParse(patient, out var patientId))
|
||||
query = query.Where(e => e.PatientId == patientId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var fhirStatus = status.ToLowerInvariant();
|
||||
query = fhirStatus switch
|
||||
{
|
||||
"in-progress" => query.Where(e => e.Status == "active"),
|
||||
"finished" => query.Where(e => e.Status == "discharged"),
|
||||
_ => query.Where(e => e.Status == status),
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(date) && TryParseUtcDate(date, out var encounterDay))
|
||||
{
|
||||
var dayEnd = encounterDay.AddDays(1);
|
||||
query = query.Where(e =>
|
||||
e.AdmissionDate != null &&
|
||||
e.AdmissionDate.Value >= encounterDay &&
|
||||
e.AdmissionDate.Value < dayEnd);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var entities = await query.OrderByDescending(e => e.AdmissionDate).Skip(offset).Take(count).ToListAsync();
|
||||
|
||||
return BuildSearchBundle(
|
||||
entities.Select(e => EncounterMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
|
||||
total, count, offset, "Encounter");
|
||||
}
|
||||
|
||||
// --- Observation ---
|
||||
|
||||
public async Task<FhirObservation?> GetObservationAsync(Guid id)
|
||||
{
|
||||
var entity = await _db.Observations.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id);
|
||||
return entity is null ? null : ObservationMapper.ToFhir(entity, _options.BaseUrl);
|
||||
}
|
||||
|
||||
public async Task<Bundle> SearchObservationsAsync(
|
||||
string? patient, string? code, string? date,
|
||||
string? category, string? encounter, int count, int offset)
|
||||
{
|
||||
count = Math.Clamp(count, 1, 200);
|
||||
|
||||
var query = _db.Observations.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(patient) && Guid.TryParse(patient, out var patientId))
|
||||
query = query.Where(o => o.PatientId == patientId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(encounter) && Guid.TryParse(encounter, out var encId))
|
||||
query = query.Where(o => o.EncounterId == encId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
// Accept both LOINC codes (e.g., "8867-4") and VigilCare codes (e.g., "HEART_RATE")
|
||||
var loincToVigilCare = ObservationMapper.GetReverseLoincMapping();
|
||||
var vigilCareCode = loincToVigilCare.GetValueOrDefault(code, code);
|
||||
query = query.Where(o => o.ObservationCode == vigilCareCode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
var isVitalSigns = category.Equals("vital-signs", StringComparison.OrdinalIgnoreCase);
|
||||
var vitalCodes = ObservationMapper.GetVitalSignCodes();
|
||||
query = isVitalSigns
|
||||
? query.Where(o => vitalCodes.Contains(o.ObservationCode))
|
||||
: query.Where(o => !vitalCodes.Contains(o.ObservationCode));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(date))
|
||||
query = ApplyObservationDateFilter(query, date);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var entities = await query.OrderByDescending(o => o.RecordedAt).Skip(offset).Take(count).ToListAsync();
|
||||
|
||||
return BuildSearchBundle(
|
||||
entities.Select(e => ObservationMapper.ToFhir(e, _options.BaseUrl)).Cast<Resource>().ToList(),
|
||||
total, count, offset, "Observation");
|
||||
}
|
||||
|
||||
public async Task<Bundle?> GetPatientEverythingAsync(Guid patientId)
|
||||
{
|
||||
var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == patientId);
|
||||
if (patient is null) return null;
|
||||
|
||||
var encounters = await _db.Encounters.AsNoTracking()
|
||||
.Where(e => e.PatientId == patientId)
|
||||
.ToListAsync();
|
||||
|
||||
var observations = await _db.Observations.AsNoTracking()
|
||||
.Where(o => o.PatientId == patientId)
|
||||
.OrderByDescending(o => o.RecordedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var bundle = new Bundle
|
||||
{
|
||||
Type = Bundle.BundleType.Searchset,
|
||||
Total = 1 + encounters.Count + observations.Count,
|
||||
Meta = new Meta { LastUpdated = DateTimeOffset.UtcNow },
|
||||
};
|
||||
|
||||
bundle.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
FullUrl = $"{_options.BaseUrl}/Patient/{patient.Id}",
|
||||
Resource = PatientMapper.ToFhir(patient, _options.BaseUrl),
|
||||
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Match },
|
||||
});
|
||||
|
||||
foreach (var enc in encounters)
|
||||
{
|
||||
bundle.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
FullUrl = $"{_options.BaseUrl}/Encounter/{enc.Id}",
|
||||
Resource = EncounterMapper.ToFhir(enc, _options.BaseUrl),
|
||||
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Include },
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
bundle.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
FullUrl = $"{_options.BaseUrl}/Observation/{obs.Id}",
|
||||
Resource = ObservationMapper.ToFhir(obs, _options.BaseUrl),
|
||||
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Include },
|
||||
});
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
// --- Bundle builder ---
|
||||
|
||||
private Bundle BuildSearchBundle(
|
||||
List<Resource> resources, int total, int count, int offset, string resourceType)
|
||||
{
|
||||
var bundle = new Bundle
|
||||
{
|
||||
Type = Bundle.BundleType.Searchset,
|
||||
Total = total,
|
||||
Meta = new Meta { LastUpdated = DateTimeOffset.UtcNow },
|
||||
};
|
||||
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
bundle.Entry.Add(new Bundle.EntryComponent
|
||||
{
|
||||
FullUrl = $"{_options.BaseUrl}/{resourceType}/{resource.Id}",
|
||||
Resource = resource,
|
||||
Search = new Bundle.SearchComponent { Mode = Bundle.SearchEntryMode.Match },
|
||||
});
|
||||
}
|
||||
|
||||
// Pagination links
|
||||
var selfUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={offset}";
|
||||
bundle.Link.Add(new Bundle.LinkComponent { Relation = "self", Url = selfUrl });
|
||||
|
||||
if (offset + count < total)
|
||||
{
|
||||
var nextUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={offset + count}";
|
||||
bundle.Link.Add(new Bundle.LinkComponent { Relation = "next", Url = nextUrl });
|
||||
}
|
||||
|
||||
if (offset > 0)
|
||||
{
|
||||
var prevOffset = Math.Max(0, offset - count);
|
||||
var prevUrl = $"{_options.BaseUrl}/{resourceType}?_count={count}&_offset={prevOffset}";
|
||||
bundle.Link.Add(new Bundle.LinkComponent { Relation = "previous", Url = prevUrl });
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
// --- CapabilityStatement ---
|
||||
|
||||
public CapabilityStatement GetCapabilityStatement()
|
||||
{
|
||||
return new CapabilityStatement
|
||||
{
|
||||
Status = PublicationStatus.Active,
|
||||
Date = "2026-06-27",
|
||||
Kind = CapabilityStatementKind.Instance,
|
||||
FhirVersion = FHIRVersion.N4_0_1,
|
||||
Format = new[] { "json" },
|
||||
Software = new CapabilityStatement.SoftwareComponent
|
||||
{
|
||||
Name = _options.PublisherName,
|
||||
Version = _options.ServerVersion,
|
||||
},
|
||||
Implementation = new CapabilityStatement.ImplementationComponent
|
||||
{
|
||||
Description = "VigilCare Records FHIR R4 API — read-only access to promoted clinical data",
|
||||
Url = _options.BaseUrl,
|
||||
},
|
||||
Rest = new List<CapabilityStatement.RestComponent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Mode = CapabilityStatement.RestfulCapabilityMode.Server,
|
||||
Resource = new List<CapabilityStatement.ResourceComponent>
|
||||
{
|
||||
FhirResource("Patient", new[] { "read", "search-type" },
|
||||
new[] { "name", "birthdate", "identifier" }),
|
||||
FhirResource("Encounter", new[] { "read", "search-type" },
|
||||
new[] { "patient", "status", "date" }),
|
||||
FhirResource("Observation", new[] { "read", "search-type" },
|
||||
new[] { "patient", "code", "date", "category", "encounter" }),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static CapabilityStatement.ResourceComponent FhirResource(
|
||||
string type, string[] interactions, string[] searchParams)
|
||||
{
|
||||
var resource = new CapabilityStatement.ResourceComponent
|
||||
{
|
||||
Type = type,
|
||||
};
|
||||
|
||||
foreach (var interaction in interactions)
|
||||
{
|
||||
resource.Interaction.Add(new CapabilityStatement.ResourceInteractionComponent
|
||||
{
|
||||
Code = Enum.Parse<CapabilityStatement.TypeRestfulInteraction>(
|
||||
interaction.Replace("-", ""), ignoreCase: true),
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var param in searchParams)
|
||||
{
|
||||
resource.SearchParam.Add(new CapabilityStatement.SearchParamComponent
|
||||
{
|
||||
Name = param,
|
||||
Type = SearchParamType.String,
|
||||
});
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
private static IQueryable<Observation> ApplyObservationDateFilter(
|
||||
IQueryable<Observation> query, string date)
|
||||
{
|
||||
if (date.StartsWith("gt", StringComparison.OrdinalIgnoreCase)
|
||||
&& TryParseUtcDate(date[2..], out var gt))
|
||||
{
|
||||
return query.Where(o => o.RecordedAt > gt);
|
||||
}
|
||||
|
||||
if (date.StartsWith("lt", StringComparison.OrdinalIgnoreCase)
|
||||
&& TryParseUtcDate(date[2..], out var lt))
|
||||
{
|
||||
return query.Where(o => o.RecordedAt < lt);
|
||||
}
|
||||
|
||||
if (date.StartsWith("ge", StringComparison.OrdinalIgnoreCase)
|
||||
&& TryParseUtcDate(date[2..], out var ge))
|
||||
{
|
||||
return query.Where(o => o.RecordedAt >= ge);
|
||||
}
|
||||
|
||||
if (date.StartsWith("le", StringComparison.OrdinalIgnoreCase)
|
||||
&& TryParseUtcDate(date[2..], out var le))
|
||||
{
|
||||
return query.Where(o => o.RecordedAt <= le);
|
||||
}
|
||||
|
||||
if (TryParseUtcDate(date, out var dayStart))
|
||||
{
|
||||
var dayEnd = dayStart.AddDays(1);
|
||||
return query.Where(o => o.RecordedAt >= dayStart && o.RecordedAt < dayEnd);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static bool TryParseUtcDate(string value, out DateTimeOffset utc)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out utc))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
utc = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Hl7.Fhir.Model;
|
||||
using FhirPatient = Hl7.Fhir.Model.Patient;
|
||||
using FhirEncounter = Hl7.Fhir.Model.Encounter;
|
||||
using FhirObservation = Hl7.Fhir.Model.Observation;
|
||||
|
||||
public interface IFhirService
|
||||
{
|
||||
Task<FhirPatient?> GetPatientAsync(Guid id);
|
||||
Task<Bundle> SearchPatientsAsync(string? name, string? birthdate, string? identifier, int count, int offset);
|
||||
|
||||
Task<FhirEncounter?> GetEncounterAsync(Guid id);
|
||||
Task<Bundle> SearchEncountersAsync(string? patient, string? status, string? date, int count, int offset);
|
||||
|
||||
Task<FhirObservation?> GetObservationAsync(Guid id);
|
||||
Task<Bundle> SearchObservationsAsync(
|
||||
string? patient, string? code, string? date,
|
||||
string? category, string? encounter, int count, int offset);
|
||||
|
||||
Task<Bundle?> GetPatientEverythingAsync(Guid patientId);
|
||||
|
||||
CapabilityStatement GetCapabilityStatement();
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||
|
||||
@@ -70,5 +70,11 @@
|
||||
"MaxDelaySeconds": 900,
|
||||
"MaxRetryAttempts": 10,
|
||||
"BackoffMultiplier": 2.0
|
||||
},
|
||||
"Fhir": {
|
||||
"BaseUrl": "http://localhost:5271/fhir",
|
||||
"PublisherName": "VigilCare Records",
|
||||
"PublisherUrl": "https://vigilcare.local",
|
||||
"ServerVersion": "1.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user