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 options) { _db = db; _options = options.Value; } // --- Patient --- public async Task 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 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().ToList(), total, count, offset, "Patient"); } // --- Encounter --- public async Task 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 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().ToList(), total, count, offset, "Encounter"); } // --- Observation --- public async Task 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 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().ToList(), total, count, offset, "Observation"); } public async Task 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 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 { new() { Mode = CapabilityStatement.RestfulCapabilityMode.Server, Resource = new List { 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( 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 ApplyObservationDateFilter( IQueryable 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; } }