using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; public class AlertService : IAlertService { private readonly AppDbContext _db; private readonly IServiceProvider _services; public AlertService(AppDbContext db, IServiceProvider services) { _db = db; _services = services; } public async Task> ListByEncounterAsync( Guid encounterId, AlertStatus? status, int page, int pageSize) { var query = _db.ClinicalAlerts .AsNoTracking() .Where(a => a.EncounterId == encounterId); if (status.HasValue) query = query.Where(a => a.Status == status.Value); var total = await query.CountAsync(); var alerts = await query .OrderByDescending(a => a.TriggeredAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult(alerts, page, pageSize, total); } public async Task> ListGlobalAsync( AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize) { var query = _db.ClinicalAlerts .AsNoTracking() .Include(a => a.Encounter) .AsQueryable(); if (status.HasValue) query = query.Where(a => a.Status == status.Value); if (severity.HasValue) query = query.Where(a => a.Severity == severity.Value); if (department.HasValue) query = query.Where(a => a.Encounter.Department == department.Value); var total = await query.CountAsync(); var alerts = await query .OrderByDescending(a => a.TriggeredAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult(alerts, page, pageSize, total); } public async Task GetByIdAsync(Guid id) { var alert = await _db.ClinicalAlerts .AsNoTracking() .Include(a => a.Encounter) .FirstOrDefaultAsync(a => a.Id == id); if (alert is null) throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); return alert; } public async Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req) { var alert = await _db.ClinicalAlerts.FindAsync(id); if (alert is null) throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); if (alert.Status != AlertStatus.Open && alert.Status != AlertStatus.Escalated) throw new ConflictException( $"Alert cannot be acknowledged from status '{alert.Status}'.", "ALERT_NOT_ACKNOWLEDGEABLE"); alert.Status = AlertStatus.Acknowledged; alert.AcknowledgedAt = DateTimeOffset.UtcNow; alert.AcknowledgedBy = req.ClinicianId; // Write an outbox event so the Kafka consumer (Phase 6) can cancel the // pending RabbitMQ escalation timer when it sees this acknowledgment. _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "alert.acknowledged", Payload = JsonSerializer.Serialize(new { alertId = alert.Id, encounterId = alert.EncounterId, acknowledgedBy = req.ClinicianId, acknowledgedAt = alert.AcknowledgedAt, note = req.Note }), PartitionKey = alert.EncounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); if (alert.AlertType.IsSuppressible()) { var suppression = _services.GetRequiredService(); var options = _services.GetRequiredService>().Value; var windowMinutes = await ResolveSuppressionWindowMinutesAsync( alert.AlertType, options.DefaultWindowMinutes); await suppression.SetSuppressionAsync( alert.EncounterId, alert.AlertType, TimeSpan.FromMinutes(windowMinutes)); } await _db.SaveChangesAsync(); return alert; } private async Task ResolveSuppressionWindowMinutesAsync( AlertType alertType, int defaultWindowMinutes) { if (alertType == AlertType.News2Warning) return defaultWindowMinutes; var observationCode = alertType.ObservationCodeForWarning(); if (observationCode is null) return defaultWindowMinutes; var overrideMinutes = await _db.AlertThresholds .AsNoTracking() .Where(t => t.ObservationCode == observationCode) .Select(t => t.SuppressionWindowMinutes) .FirstOrDefaultAsync(); return overrideMinutes ?? defaultWindowMinutes; } public async Task ResolveAsync(Guid id) { var alert = await _db.ClinicalAlerts.FindAsync(id); if (alert is null) throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); if (alert.Status != AlertStatus.Acknowledged) throw new ConflictException( "Alert must be acknowledged before it can be resolved.", "ALERT_NOT_ACKNOWLEDGED"); alert.Status = AlertStatus.Resolved; alert.ResolvedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); return alert; } }