Files

360 lines
13 KiB
C#

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;
}
}