feature: Schema, Migrations, Core CRUD, and Redis Threshold Cache

This commit is contained in:
voltsrage
2026-06-16 17:59:16 +08:00
commit 882d4af3e6
63 changed files with 3923 additions and 0 deletions
@@ -0,0 +1,78 @@
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;
public EncounterService(AppDbContext db) => _db = db;
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<EncounterStatusTransitionResult> TransitionStatusAsync(
Guid encounterId, EncounterStatus targetStatus)
{
var encounter = await _db.Encounters.FindAsync(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");
encounter.Status = targetStatus;
if (targetStatus == EncounterStatus.Discharged)
encounter.DischargedAt = 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 };
}
}