using ClinicalInsightsPro.API.Data; using ClinicalInsightsPro.API.DTOs; using ClinicalInsightsPro.API.Services.Interfaces; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace ClinicalInsightsPro.API.Services; public class MedplumFhirService : IMedplumFhirService { private readonly ApplicationDbContext _db; private readonly ILogger _logger; public MedplumFhirService( ApplicationDbContext db, ILogger logger) { _db = db; _logger = logger; } public Task> GetMedplumResourcesAsync(string resourceType, string? patientId = null) { // Offline/demo mode has normalized tables, not the raw Medplum store. // The unified Medplum browser is intentionally live-only so the UI // always shows actual FHIR JSON when the feature is enabled. throw new InvalidOperationException( "The Medplum resource browser requires Fhir:UseLiveMedplum=true. " + "Set MEDPLUM_USE_LIVE=true and configure MEDPLUM_BASE_URL, " + "MEDPLUM_TOKEN_URL, MEDPLUM_CLIENT_ID and MEDPLUM_CLIENT_SECRET."); } public async Task> GetPatientListAsync() { var patients = await _db.Patient .AsNoTracking() .OrderBy(x => x.GivenName) .ThenBy(x => x.FamilyName) .ToListAsync(); return patients.Select(p => new PatientSummaryDto { Id = p.Id, FullName = $"{p.GivenName} {p.FamilyName}", Age = CalculateAge(p.BirthDate), Gender = p.Gender, MedicalRecordNumber = p.MedicalRecordNumber }).ToList(); } public async Task GetPatientChartAsync(string patientId) { var patient = await _db.Patient .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == patientId); if (patient == null) { throw new KeyNotFoundException( $"Patient '{patientId}' was not found."); } var conditions = await _db.Condition .AsNoTracking() .Where(x => x.PatientId == patientId) .OrderByDescending(x => x.RecordedDate) .ToListAsync(); var observations = await _db.Observation .AsNoTracking() .Where(x => x.PatientId == patientId) .OrderByDescending(x => x.EffectiveDateTime) .ToListAsync(); var allergies = await _db.AllergyIntolerance .AsNoTracking() .Where(x => x.PatientId == patientId) .ToListAsync(); var medications = await _db.MedicationRequest .AsNoTracking() .Where(x => x.PatientId == patientId) .ToListAsync(); var encounters = await _db.Encounter .AsNoTracking() .Where(x => x.PatientId == patientId) .OrderByDescending(x => x.PeriodStart) .ToListAsync(); _logger.LogInformation( "Retrieved chart for patient {PatientId}", patientId); return new PatientChartDto { PatientId = patient.Id, FullName = $"{patient.GivenName} {patient.FamilyName}", Age = CalculateAge(patient.BirthDate), MedicalRecordNumber = patient.MedicalRecordNumber, IdentifierSystem = patient.IdentifierSystem, IdentifierValue = patient.IdentifierValue, NameUse = patient.NameUse, Prefix = patient.Prefix, GivenName = patient.GivenName, FamilyName = patient.FamilyName, TelecomSystem = patient.TelecomSystem, TelecomUse = patient.TelecomUse, TelecomValue = patient.TelecomValue, Gender = patient.Gender, BloodGroup = patient.BloodGroup, Email = patient.Email, BirthDate = patient.BirthDate, AddressUse = patient.AddressUse, AddressType = patient.AddressType, AddressLine = patient.AddressLine, City = patient.City, State = patient.State, PostalCode = patient.PostalCode, MaritalStatusSystem = patient.MaritalStatusSystem, MaritalStatusCode = patient.MaritalStatusCode, MaritalStatusDisplay = patient.MaritalStatusDisplay, ContactNameUse = patient.ContactNameUse, ContactPrefix = patient.ContactPrefix, ContactTelecomSystem = patient.ContactTelecomSystem, ContactTelecomUse = patient.ContactTelecomUse, ContactTelecomValue = patient.ContactTelecomValue, ContactAddressUse = patient.ContactAddressUse, ContactAddressType = patient.ContactAddressType, ContactAddressLine = patient.ContactAddressLine, ContactCity = patient.ContactCity, ContactState = patient.ContactState, ContactPostalCode = patient.ContactPostalCode, ContactGender = patient.ContactGender, ContactOrganizationId = patient.ContactOrganizationId, ContactPeriodStart = patient.ContactPeriodStart, ContactPeriodEnd = patient.ContactPeriodEnd, ManagingOrganizationId = patient.ManagingOrganizationId, PhotoContentType = patient.PhotoContentType, PhotoUrl = patient.PhotoUrl, PhotoTitle = patient.PhotoTitle, GeneralPractitionerId = patient.GeneralPractitionerId, Conditions = conditions.Select(c => new ChartConditionDto { Id = c.Id, Code = c.ConditionCode, Display = c.ConditionDisplay, ClinicalStatus = c.ClinicalStatusDisplay, VerificationStatus = c.VerificationStatusDisplay, Severity = c.SeverityDisplay, OnsetDate = c.OnsetDateTime, AbatementDate = c.AbatementDateTime, RecordedDate = c.RecordedDate }).ToList(), Allergies = allergies.Select(a => new ChartAllergyDto { Id = a.Id, Type = a.Type, ClinicalStatus = a.ClinicalStatus, VerificationStatus = a.VerificationStatus, Criticality = a.Criticality, RecorderPractitionerId = a.RecorderPractitionerId }).ToList(), Medications = medications.Select(m => new ChartMedicationDto { Id = m.Id, MedicationCode = m.MedicationCode, MedicationDisplay = m.MedicationDisplay, Status = m.Status, Intent = m.Intent, Priority = m.Priority }).ToList(), Observations = observations.Select(o => new ChartObservationDto { Id = o.Id, Code = o.ObservationCode, Display = o.ObservationDisplay, Status = o.Status, CategoryDisplay = o.CategoryDisplay, InterpretationCode = o.InterpretationCode, InterpretationDisplay = o.InterpretationDisplay, EffectiveDate = o.EffectiveDateTime, NoteText = o.NoteText }).ToList(), Encounters = encounters.Select(e => new ChartEncounterDto { Id = e.Id, Status = e.Status, ClassCode = e.ClassCode, ClassDisplay = e.ClassDisplay, TypeDisplay = e.TypeDisplay, ServiceTypeDisplay = e.ServiceTypeDisplay, PeriodStart = e.PeriodStart, PeriodEnd = e.PeriodEnd }).ToList() }; } private static int CalculateAge(DateTime birthDate) { var today = DateTime.UtcNow; var age = today.Year - birthDate.Year; if (birthDate.Date > today.AddYears(-age)) { age--; } return age; } // ---- Two-way sync additions ---- // This class is the OFFLINE/local implementation (used when // Fhir:UseLiveMedplum=false) — it reads/writes only the local database // and never talks to a real Medplum server, so there is nothing to push // or pull here. These methods exist only so this class still satisfies // IMedplumFhirService; they throw/no-op with a clear message instead of // silently pretending to sync. Set MEDPLUM_USE_LIVE=true in .env to // switch the app over to LiveMedplumFhirService, which implements the // real sync against Medplum's FHIR API. public Task CreatePatientInMedplumAsync(Models.Patient patient) { _logger.LogWarning( "CreatePatientInMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — patient {Id} was saved locally only, not pushed to Medplum. " + "Set MEDPLUM_USE_LIVE=true in .env to enable Medplum sync.", patient.Id); return Task.FromResult(string.Empty); } public Task UpdatePatientInMedplumAsync(Models.Patient patient) { _logger.LogWarning( "UpdatePatientInMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — patient {Id} was updated locally only, not pushed to Medplum.", patient.Id); return Task.CompletedTask; } public Task DeletePatientInMedplumAsync(string medplumId) { _logger.LogWarning( "DeletePatientInMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — nothing was deleted in Medplum."); return Task.CompletedTask; } public Task> PullAllPatientsFromMedplumAsync() { _logger.LogWarning( "PullAllPatientsFromMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — returning an empty list. Set MEDPLUM_USE_LIVE=true in .env " + "to pull real data from Medplum."); return Task.FromResult(new List()); } // ---- Practitioner two-way sync (offline no-ops, mirrors Patient above) ---- public Task CreatePractitionerInMedplumAsync(Models.Practitioner practitioner) { _logger.LogWarning( "CreatePractitionerInMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — practitioner {Id} was saved locally only, not pushed to Medplum. " + "Set MEDPLUM_USE_LIVE=true in .env to enable Medplum sync.", practitioner.Id); return Task.FromResult(string.Empty); } public Task UpdatePractitionerInMedplumAsync(Models.Practitioner practitioner) { _logger.LogWarning( "UpdatePractitionerInMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — practitioner {Id} was updated locally only, not pushed to Medplum.", practitioner.Id); return Task.CompletedTask; } public Task DeletePractitionerInMedplumAsync(string medplumId) { _logger.LogWarning( "DeletePractitionerInMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — nothing was deleted in Medplum."); return Task.CompletedTask; } public Task> PullAllPractitionersFromMedplumAsync() { _logger.LogWarning( "PullAllPractitionersFromMedplumAsync called while running in offline/local mode " + "(Fhir:UseLiveMedplum=false) — returning an empty list. Set MEDPLUM_USE_LIVE=true in .env " + "to pull real data from Medplum."); return Task.FromResult(new List()); } }