feature: Schema, Migrations, Core CRUD, and Redis Threshold Cache
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class AlertThresholdService : IAlertThresholdService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public AlertThresholdService(AppDbContext db, IConnectionMultiplexer redis)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
public async Task<AlertThreshold> CreateAsync(AlertThresholdRequest req)
|
||||
{
|
||||
var exists = await _db.AlertThresholds.AnyAsync(t => t.ObservationCode == req.ObservationCode);
|
||||
if (exists)
|
||||
throw new ConflictException(
|
||||
"A threshold for this observation code already exists.",
|
||||
"THRESHOLD_CODE_CONFLICT");
|
||||
|
||||
var threshold = new AlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ObservationCode = req.ObservationCode,
|
||||
DisplayName = req.DisplayName,
|
||||
Unit = req.Unit,
|
||||
CriticalLow = req.CriticalLow,
|
||||
WarningLow = req.WarningLow,
|
||||
WarningHigh = req.WarningHigh,
|
||||
CriticalHigh = req.CriticalHigh,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.AlertThresholds.Add(threshold);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await InvalidateCacheAsync(threshold.ObservationCode);
|
||||
return threshold;
|
||||
}
|
||||
|
||||
public async Task<List<AlertThreshold>> ListAsync() =>
|
||||
await _db.AlertThresholds.OrderBy(t => t.ObservationCode).ToListAsync();
|
||||
|
||||
public async Task<AlertThreshold> GetByIdAsync(Guid id)
|
||||
{
|
||||
var threshold = await _db.AlertThresholds.FindAsync(id);
|
||||
if (threshold is null)
|
||||
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
|
||||
return threshold;
|
||||
}
|
||||
|
||||
public async Task<AlertThreshold> UpdateAsync(Guid id, AlertThresholdRequest req)
|
||||
{
|
||||
var threshold = await _db.AlertThresholds.FindAsync(id);
|
||||
if (threshold is null)
|
||||
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
|
||||
|
||||
threshold.DisplayName = req.DisplayName;
|
||||
threshold.Unit = req.Unit;
|
||||
threshold.CriticalLow = req.CriticalLow;
|
||||
threshold.WarningLow = req.WarningLow;
|
||||
threshold.WarningHigh = req.WarningHigh;
|
||||
threshold.CriticalHigh = req.CriticalHigh;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await InvalidateCacheAsync(threshold.ObservationCode);
|
||||
return threshold;
|
||||
}
|
||||
|
||||
private async Task InvalidateCacheAsync(string observationCode)
|
||||
{
|
||||
var cache = _redis.GetDatabase();
|
||||
await cache.KeyDeleteAsync($"threshold:{observationCode}");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public interface IAlertThresholdService
|
||||
{
|
||||
Task<AlertThreshold> CreateAsync(AlertThresholdRequest req);
|
||||
Task<List<AlertThreshold>> ListAsync();
|
||||
Task<AlertThreshold> GetByIdAsync(Guid id);
|
||||
Task<AlertThreshold> UpdateAsync(Guid id, AlertThresholdRequest req);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public interface IEncounterService
|
||||
{
|
||||
Task<Encounter> GetByIdAsync(Guid id);
|
||||
Task<EncounterStatusTransitionResult> TransitionStatusAsync(Guid encounterId, EncounterStatus targetStatus);
|
||||
Task<object> GetTimelineAsync(Guid encounterId);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public interface IPatientService
|
||||
{
|
||||
Task<Patient> RegisterAsync(RegisterPatientRequest req);
|
||||
Task<PagedResult<Patient>> ListAsync(string? q, int page, int pageSize);
|
||||
Task<Patient> GetByIdAsync(Guid id);
|
||||
Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class PatientService : IPatientService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public PatientService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||
{
|
||||
var mrn = await GenerateMrnAsync();
|
||||
var patient = new Patient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = mrn,
|
||||
FirstName = req.FirstName,
|
||||
LastName = req.LastName,
|
||||
DateOfBirth = req.DateOfBirth,
|
||||
Gender = req.Gender,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Patients.Add(patient);
|
||||
await _db.SaveChangesAsync();
|
||||
return patient;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Patient>> ListAsync(
|
||||
string? q, int page, int pageSize)
|
||||
{
|
||||
var query = _db.Patients.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
query = query.Where(p =>
|
||||
p.Mrn == q ||
|
||||
EF.Functions.ILike(p.FirstName, $"%{q}%") ||
|
||||
EF.Functions.ILike(p.LastName, $"%{q}%"));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var patients = await query
|
||||
.OrderBy(p => p.LastName).ThenBy(p => p.FirstName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return new PagedResult<Patient>(patients, page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<Patient> GetByIdAsync(Guid id)
|
||||
{
|
||||
var patient = await _db.Patients
|
||||
.Include(p => p.Encounters.Where(e => e.Status == EncounterStatus.Active))
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (patient is null)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
public async Task<Encounter> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
|
||||
{
|
||||
var patient = await _db.Patients.FindAsync(patientId);
|
||||
if (patient is null)
|
||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||
|
||||
var hasActiveOfType = await _db.Encounters.AnyAsync(e =>
|
||||
e.PatientId == patientId &&
|
||||
e.EncounterType == req.EncounterType &&
|
||||
e.Status == EncounterStatus.Active);
|
||||
|
||||
if (hasActiveOfType)
|
||||
throw new ConflictException(
|
||||
"Patient already has an active encounter of this type.",
|
||||
"DUPLICATE_ACTIVE_ENCOUNTER");
|
||||
|
||||
var encounter = new Encounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patientId,
|
||||
EncounterType = req.EncounterType,
|
||||
Status = EncounterStatus.Active,
|
||||
Department = req.Department,
|
||||
AttendingPhysician = req.AttendingPhysician,
|
||||
AdmittedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_db.Encounters.Add(encounter);
|
||||
await _db.SaveChangesAsync();
|
||||
return encounter;
|
||||
}
|
||||
|
||||
private async Task<string> GenerateMrnAsync()
|
||||
{
|
||||
var count = await _db.Patients.CountAsync();
|
||||
return $"MRN-{(count + 1):D6}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user