360 lines
13 KiB
C#
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;
|
|
}
|
|
} |