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; public EncounterService(AppDbContext db) => _db = db; 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 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(); 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 observations = await _db.Observations .Where(o => o.EncounterId == encounterId) .OrderByDescending(o => o.RecordedAt) .Select(o => new { type = "observation", timestamp = o.RecordedAt, o.ObservationCode, o.Value, o.Unit }) .ToListAsync(); var alerts = await _db.ClinicalAlerts .Where(a => a.EncounterId == encounterId) .OrderByDescending(a => a.TriggeredAt) .Select(a => new { type = "alert", timestamp = a.TriggeredAt, a.AlertType, a.Severity, a.Status }) .ToListAsync(); var timeline = observations.Cast() .Concat(alerts.Cast()) .OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp) .ToList(); return new { encounterId, events = timeline }; } }