using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; /// /// FHIR R4 read and search interactions for Patient and Encounter resources. /// [ApiController] [Route("fhir/R4")] [AuthorizePermission(ClinicalPermissions.FhirRead)] [ServiceFilter(typeof(FhirExceptionFilter))] public class FhirReadController : ControllerBase { private static readonly FhirJsonSerializer Serializer = new(); private readonly AppDbContext _db; private readonly IExternalIdentifierService _identifiers; private readonly PatientFhirMapper _patientMapper; private readonly EncounterFhirMapper _encounterMapper; private readonly FhirOptions _options; private readonly ClinicalMetrics _metrics; public FhirReadController( AppDbContext db, IExternalIdentifierService identifiers, PatientFhirMapper patientMapper, EncounterFhirMapper encounterMapper, IOptions options, ClinicalMetrics metrics) { _db = db; _identifiers = identifiers; _patientMapper = patientMapper; _encounterMapper = encounterMapper; _options = options.Value; _metrics = metrics; } /// /// Reads a Patient resource by internal ID. /// [HttpGet("Patient/{id:guid}")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] public async Task ReadPatient(Guid id) { var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id); if (patient is null) throw new NotFoundException("Patient not found.", "FHIR_RESOURCE_NOT_FOUND"); var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems); var resource = _patientMapper.ToFhirResponse(patient, hospitalId); _metrics.FhirReadTotal.WithLabels("Patient", "read", "success").Inc(); return Serialize(resource); } /// /// Searches for Patient resources by identifier (system|value). /// [HttpGet("Patient")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)] public async Task SearchPatient( [FromQuery] string? identifier, [FromQuery] int _count = 20) { _count = Math.Clamp(_count, 1, 100); var bundle = new Bundle { Type = Bundle.BundleType.Searchset, Timestamp = DateTimeOffset.UtcNow }; if (identifier is not null) { var parts = identifier.Split('|', 2); if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0]) && !string.IsNullOrWhiteSpace(parts[1])) { var internalId = await _identifiers.ResolveInternalIdAsync( ExternalResourceType.Patient, parts[0], parts[1]); if (internalId.HasValue) { var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == internalId.Value); if (patient is not null) { var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems); var resource = _patientMapper.ToFhirResponse(patient, hospitalId); bundle.Entry.Add(new Bundle.EntryComponent { FullUrl = $"{Request.Scheme}://{Request.Host}/fhir/R4/Patient/{patient.Id}", Resource = resource }); } } } } else { var patients = await _db.Patients .AsNoTracking() .OrderBy(p => p.LastName).ThenBy(p => p.FirstName) .Take(_count) .ToListAsync(); foreach (var patient in patients) { var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems); var resource = _patientMapper.ToFhirResponse(patient, hospitalId); bundle.Entry.Add(new Bundle.EntryComponent { FullUrl = $"{Request.Scheme}://{Request.Host}/fhir/R4/Patient/{patient.Id}", Resource = resource }); } } bundle.Total = bundle.Entry.Count; _metrics.FhirReadTotal.WithLabels("Patient", "search", "success").Inc(); return Serialize(bundle); } /// /// Reads an Encounter resource by internal ID. /// [HttpGet("Encounter/{id:guid}")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Hl7.Fhir.Model.Encounter), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)] public async Task ReadEncounter(Guid id) { var encounter = await _db.Encounters .AsNoTracking() .Include(e => e.Patient) .FirstOrDefaultAsync(e => e.Id == id); if (encounter is null) throw new NotFoundException("Encounter not found.", "FHIR_RESOURCE_NOT_FOUND"); var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems); var resource = _encounterMapper.ToFhirResponse(encounter, hospitalId); _metrics.FhirReadTotal.WithLabels("Encounter", "read", "success").Inc(); return Serialize(resource); } /// /// Searches for Encounter resources by patient reference. /// [HttpGet("Encounter")] [Produces("application/fhir+json")] [ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)] public async Task SearchEncounter( [FromQuery] string? patient, [FromQuery] string? status, [FromQuery] int _count = 20) { _count = Math.Clamp(_count, 1, 100); var query = _db.Encounters.AsNoTracking().Include(e => e.Patient).AsQueryable(); if (patient is not null && Guid.TryParse(patient, out var patientId)) query = query.Where(e => e.PatientId == patientId); if (status is not null) { var fhirStatus = status.ToLowerInvariant() switch { "in-progress" => EncounterStatus.Active, "finished" => EncounterStatus.Discharged, "cancelled" => EncounterStatus.Cancelled, _ => (EncounterStatus?)null }; if (fhirStatus.HasValue) query = query.Where(e => e.Status == fhirStatus.Value); } var encounters = await query .OrderByDescending(e => e.AdmittedAt) .Take(_count) .ToListAsync(); var bundle = new Bundle { Type = Bundle.BundleType.Searchset, Total = encounters.Count, Timestamp = DateTimeOffset.UtcNow }; foreach (var encounter in encounters) { var hospitalId = await _identifiers.FindPrimaryIdentifierAsync( ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems); var resource = _encounterMapper.ToFhirResponse(encounter, hospitalId); bundle.Entry.Add(new Bundle.EntryComponent { FullUrl = $"{Request.Scheme}://{Request.Host}/fhir/R4/Encounter/{encounter.Id}", Resource = resource }); } _metrics.FhirReadTotal.WithLabels("Encounter", "search", "success").Inc(); return Serialize(bundle); } private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) => new() { Content = Serializer.SerializeToString(resource), ContentType = "application/fhir+json", StatusCode = statusCode }; }