using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; public class AlertService : IAlertService { private readonly AppDbContext _db; private readonly IServiceProvider _services; private readonly ICurrentUserService _currentUser; private readonly IAuditService _audit; public AlertService( AppDbContext db, IServiceProvider services, ICurrentUserService currentUser, IAuditService audit) { _db = db; _services = services; _currentUser = currentUser; _audit = audit; } 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) { if (!_currentUser.IsAuthenticated) throw new ValidationException("Authentication required.", "AUTH_REQUIRED"); var displayName = _currentUser.DisplayName ?? _currentUser.Username ?? throw new ValidationException("Authenticated user identity missing.", "AUTH_REQUIRED"); 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"); var roleLabel = _currentUser.Role?.ToDbString() ?? "UNKNOWN"; var userId = _currentUser.UserId; var acknowledgmentNote = FormatAcknowledgmentNote(roleLabel, displayName, req.Note); var previousStatus = alert.Status; alert.Status = AlertStatus.Acknowledged; alert.AcknowledgedAt = DateTimeOffset.UtcNow; alert.AcknowledgedBy = $"{displayName} ({roleLabel})"; // 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 = alert.AcknowledgedBy, userId, role = roleLabel, acknowledgedAt = alert.AcknowledgedAt, note = acknowledgmentNote }), 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(); await _audit.WriteAsync( AuditAction.AlertAcknowledged, "ClinicalAlert", alert.Id, previousValue: new { status = previousStatus.ToDbString() }, newValue: new { status = alert.Status.ToDbString(), alert.AcknowledgedBy, role = roleLabel, userId }, reason: acknowledgmentNote); if (alert.AlertType.IsSuppressible()) { await _audit.WriteAsync( AuditAction.SuppressionWindowSet, "ClinicalAlert", alert.Id, newValue: new { alert.AlertType, alert.EncounterId }, reason: acknowledgmentNote); } 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(); await _audit.WriteAsync( AuditAction.AlertResolved, "ClinicalAlert", alert.Id, previousValue: new { status = AlertStatus.Acknowledged.ToDbString() }, newValue: new { status = alert.Status.ToDbString() }); return alert; } public async Task ApplySyncedAcknowledgmentAsync( Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct) { var alert = await _db.ClinicalAlerts.FindAsync([alertId], ct) ?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); alert.Status = AlertStatus.Acknowledged; alert.AcknowledgedAt = ack.AcknowledgedAt; alert.AcknowledgedBy = ack.ClinicianId; _db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "alert.acknowledged", Payload = JsonSerializer.Serialize(new { alertId = alert.Id, encounterId = alert.EncounterId, acknowledgedBy = ack.ClinicianId, acknowledgedAt = ack.AcknowledgedAt, note = ack.Note, syncedFromGateway = true }), PartitionKey = alert.EncounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await _db.SaveChangesAsync(ct); } public async Task ApplySyncedResolutionAsync( Guid alertId, SyncedAlertResolution resolve, CancellationToken ct) { var alert = await _db.ClinicalAlerts.FindAsync([alertId], ct) ?? throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND"); if (alert.Status != AlertStatus.Acknowledged) alert.Status = AlertStatus.Acknowledged; alert.Status = AlertStatus.Resolved; alert.ResolvedAt = resolve.ResolvedAt; await _db.SaveChangesAsync(ct); } private static string FormatAcknowledgmentNote(string roleLabel, string displayName, string? note) { var prefix = $"[{roleLabel}] Acknowledged by {displayName}."; return string.IsNullOrWhiteSpace(note) ? prefix : $"{prefix} {note.Trim()}"; } }