using System.Text.Json; using Microsoft.EntityFrameworkCore; public class EncounterService : IEncounterService { // Explicit transition matrix. Every allowed move is listed here. // Any transition not in this dictionary is illegal and throws ConflictException. private static readonly Dictionary> _allowedTransitions = new() { [EncounterStatus.Scheduled] = new() { EncounterStatus.Active, EncounterStatus.Cancelled }, [EncounterStatus.Active] = new() { EncounterStatus.Discharged, EncounterStatus.Cancelled }, [EncounterStatus.Discharged] = new(), [EncounterStatus.Cancelled] = new(), }; private readonly AppDbContext _db; private readonly IQsofaService _qsofa; private readonly IExternalIdentifierService _identifiers; private readonly IAuditService _audit; public EncounterService( AppDbContext db, IQsofaService qsofa, IExternalIdentifierService identifiers, IAuditService audit) { _db = db; _qsofa = qsofa; _identifiers = identifiers; _audit = audit; } public async Task GetByIdAsync(Guid id) { var encounter = await _db.Encounters .Include(e => e.Patient) .Include(e => e.Observations.OrderByDescending(o => o.RecordedAt).Take(10)) .Include(e => e.Alerts.Where(a => a.Status == AlertStatus.Open)) .FirstOrDefaultAsync(e => e.Id == id); if (encounter is null) throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); return encounter; } public async Task> ListAsync( EncounterStatus? status, Department? department, int page, int pageSize) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var query = _db.Encounters .AsNoTracking() .Include(e => e.Patient) .AsQueryable(); if (status.HasValue) query = query.Where(e => e.Status == status.Value); if (department.HasValue) query = query.Where(e => e.Department == department.Value); var total = await query.CountAsync(); var encounters = await query .OrderByDescending(e => e.AdmittedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); if (encounters.Count == 0) return new PagedResult([], page, pageSize, total); var encounterIds = encounters.Select(e => e.Id).ToList(); var news2ByEncounter = (await _db.News2Scores .AsNoTracking() .Where(s => encounterIds.Contains(s.EncounterId)) .OrderByDescending(s => s.CalculatedAt) .ToListAsync()) .GroupBy(s => s.EncounterId) .ToDictionary(g => g.Key, g => g.First()); var openAlertCounts = await _db.ClinicalAlerts .AsNoTracking() .Where(a => encounterIds.Contains(a.EncounterId) && a.Status == AlertStatus.Open) .GroupBy(a => a.EncounterId) .Select(g => new { EncounterId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.EncounterId, x => x.Count); var bundlesByEncounter = (await _db.SepsisBundles .AsNoTracking() .Where(b => encounterIds.Contains(b.EncounterId)) .OrderByDescending(b => b.RecognizedAt) .ToListAsync()) .GroupBy(b => b.EncounterId) .ToDictionary(g => g.Key, g => g.First()); var sofaByEncounter = (await _db.SofaScores .AsNoTracking() .Where(s => encounterIds.Contains(s.EncounterId)) .OrderByDescending(s => s.CalculatedAt) .ToListAsync()) .GroupBy(s => s.EncounterId) .ToDictionary(g => g.Key, g => g.First()); var gcsByEncounter = (await _db.GcsScores .AsNoTracking() .Where(s => encounterIds.Contains(s.EncounterId)) .OrderByDescending(s => s.CalculatedAt) .ToListAsync()) .GroupBy(s => s.EncounterId) .ToDictionary(g => g.Key, g => g.First()); var lastObservationByEncounter = await _db.Observations .AsNoTracking() .Where(o => encounterIds.Contains(o.EncounterId)) .GroupBy(o => o.EncounterId) .Select(g => new { EncounterId = g.Key, LastAt = g.Max(o => o.RecordedAt) }) .ToDictionaryAsync(x => x.EncounterId, x => x.LastAt); var summaries = new List(encounters.Count); foreach (var encounter in encounters) { news2ByEncounter.TryGetValue(encounter.Id, out var news2); bundlesByEncounter.TryGetValue(encounter.Id, out var bundle); openAlertCounts.TryGetValue(encounter.Id, out var openCount); sofaByEncounter.TryGetValue(encounter.Id, out var sofa); gcsByEncounter.TryGetValue(encounter.Id, out var gcs); lastObservationByEncounter.TryGetValue(encounter.Id, out var lastObservationAt); var qsofaScore = await _qsofa.GetActiveCriteriaCountAsync(encounter.Id); summaries.Add(new WardEncounterSummary( encounter.Id, encounter.PatientId, encounter.Patient.Mrn, encounter.Patient.FirstName, encounter.Patient.LastName, encounter.RoomBed, encounter.Department, encounter.Status, news2?.TotalScore, news2?.RiskLevel, qsofaScore, bundle is not null && bundle.ComplianceStatus != SepsisBundleComplianceStatus.Compliant, bundle?.ComplianceStatus, openCount, sofa?.TotalScore, sofa?.DeltaFromBaseline, gcs?.TotalScore, gcs?.Classification, lastObservationAt, encounter.AttendingPhysician, encounter.AdmittedAt)); } return new PagedResult(summaries, page, pageSize, total); } public async Task TransitionStatusAsync( Guid encounterId, EncounterStatus targetStatus, string? dischargeDiagnosis = null) { var encounter = await _db.Encounters .Include(e => e.Patient) .FirstOrDefaultAsync(e => e.Id == encounterId); if (encounter is null) throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); if (!_allowedTransitions[encounter.Status].Contains(targetStatus)) throw new ConflictException( $"Transition to '{targetStatus}' is not permitted from the current status.", "ILLEGAL_STATUS_TRANSITION"); var previousStatus = encounter.Status; encounter.Status = targetStatus; if (targetStatus == EncounterStatus.Discharged) { encounter.DischargedAt = DateTimeOffset.UtcNow; encounter.DischargeDiagnosis = dischargeDiagnosis; } _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "encounter.status.changed", Payload = JsonSerializer.Serialize(new { encounterId, patientId = encounter.PatientId, mrn = encounter.Patient.Mrn, patientName = $"{encounter.Patient.FirstName} {encounter.Patient.LastName}", previousStatus = previousStatus.ToDbString(), newStatus = targetStatus.ToDbString(), department = encounter.Department.ToDbString(), attendingPhysician = encounter.AttendingPhysician, roomBed = encounter.RoomBed, admissionReason = encounter.AdmissionReason, admittedAt = encounter.AdmittedAt, changedAt = DateTimeOffset.UtcNow }), PartitionKey = encounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await _db.SaveChangesAsync(); await _audit.WriteAsync( AuditAction.EncounterStatusChanged, "Encounter", encounterId, previousValue: new { status = previousStatus.ToDbString() }, newValue: new { status = targetStatus.ToDbString(), dischargeDiagnosis }); return new EncounterStatusTransitionResult(encounterId, targetStatus); } public async Task GetTimelineAsync(Guid encounterId) { var encounter = await _db.Encounters.FindAsync(encounterId); if (encounter is null) throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); var events = new List(); events.Add(new { type = "status", timestamp = encounter.AdmittedAt, status = EncounterStatus.Active.ToDbString(), label = "Patient admitted" }); if (encounter.DischargedAt is not null) { events.Add(new { type = "status", timestamp = encounter.DischargedAt.Value, status = EncounterStatus.Discharged.ToDbString(), label = "Patient discharged" }); } var observations = await _db.Observations .Where(o => o.EncounterId == encounterId) .Select(o => new { type = "observation", timestamp = o.RecordedAt, o.ObservationCode, o.Value, o.Unit }) .ToListAsync(); events.AddRange(observations); var alerts = await _db.ClinicalAlerts .Where(a => a.EncounterId == encounterId) .Select(a => new { type = "alert", timestamp = a.TriggeredAt, alertType = a.AlertType.ToString(), severity = a.Severity.ToString(), status = a.Status.ToString() }) .ToListAsync(); events.AddRange(alerts); var orders = await _db.Orders .Where(o => o.EncounterId == encounterId) .Select(o => new { type = "order", timestamp = o.OrderedAt, o.Description, orderType = o.OrderType.ToString(), status = o.Status.ToString() }) .ToListAsync(); events.AddRange(orders); var medications = await _db.MedicationAdministrations .Where(m => m.EncounterId == encounterId) .Select(m => new { type = "medication", timestamp = m.AdministeredAt, m.DrugName, m.Dose, m.DoseUnit, m.Route }) .ToListAsync(); events.AddRange(medications); var timeline = events .OrderByDescending(e => (DateTimeOffset)((dynamic)e).timestamp) .ToList(); return new { encounterId, events = timeline }; } public async Task OpenOrUpdateByIdentifierAsync(FhirEncounterUpsertRequest req) { var existingId = await _identifiers.ResolveInternalIdAsync( ExternalResourceType.Encounter, req.IdentifierSystem, req.IdentifierValue); if (existingId.HasValue) { var existingEncounter = await _db.Encounters .Include(e => e.Patient) .FirstOrDefaultAsync(e => e.Id == existingId.Value) ?? throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); existingEncounter.Department = req.Department; existingEncounter.AttendingPhysician = req.AttendingPhysician; existingEncounter.RoomBed = req.RoomBed; existingEncounter.AdmissionReason = req.AdmissionReason; if (existingEncounter.Status != req.TargetStatus) { await TransitionStatusAsync( existingEncounter.Id, req.TargetStatus, req.DischargeDiagnosis); await _db.Entry(existingEncounter).ReloadAsync(); } return existingEncounter; } // Create new encounter — mirrors PatientService.OpenEncounterAsync var patient = await _db.Patients.FindAsync(req.PatientId); if (patient is null) throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND"); var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = req.PatientId, EncounterType = req.EncounterType, Status = EncounterStatus.Active, Department = req.Department, AttendingPhysician = req.AttendingPhysician, RoomBed = req.RoomBed, AdmissionReason = req.AdmissionReason, AdmittedAt = req.AdmittedAt ?? DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; _db.Encounters.Add(encounter); _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "encounter.status.changed", Payload = JsonSerializer.Serialize(new { encounterId = encounter.Id, patientId = patient.Id, mrn = patient.Mrn, patientName = $"{patient.FirstName} {patient.LastName}", previousStatus = (string?)null, newStatus = encounter.Status.ToDbString(), department = encounter.Department.ToDbString(), attendingPhysician = encounter.AttendingPhysician, roomBed = encounter.RoomBed, admissionReason = encounter.AdmissionReason, admittedAt = encounter.AdmittedAt, changedAt = DateTimeOffset.UtcNow }), PartitionKey = encounter.Id.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await _db.SaveChangesAsync(); await _identifiers.LinkAsync( ExternalResourceType.Encounter, encounter.Id, req.IdentifierSystem, req.IdentifierValue); if (req.TargetStatus == EncounterStatus.Discharged) await TransitionStatusAsync(encounter.Id, EncounterStatus.Discharged, req.DischargeDiagnosis); return encounter; } }