Files
vigilcare-clinical/VigilCareClinicalAPI/Services/EncounterService.cs
T

288 lines
11 KiB
C#

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<EncounterStatus, HashSet<EncounterStatus>> _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;
public EncounterService(
AppDbContext db,
IQsofaService qsofa,
IExternalIdentifierService identifiers)
{
_db = db;
_qsofa = qsofa;
_identifiers = identifiers;
}
public async Task<Encounter> 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<PagedResult<WardEncounterSummary>> 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<WardEncounterSummary>([], 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 summaries = new List<WardEncounterSummary>(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);
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));
}
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
}
public async Task<EncounterStatusTransitionResult> 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<object> 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<object>()
.Concat(alerts.Cast<object>())
.OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp)
.ToList();
return new { encounterId, events = timeline };
}
public async Task<Encounter> 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;
}
}