307 lines
11 KiB
C#
307 lines
11 KiB
C#
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<PagedResult<ClinicalAlert>> 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<ClinicalAlert>(alerts, page, pageSize, total);
|
|
}
|
|
|
|
public async Task<PagedResult<ClinicalAlert>> 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<ClinicalAlert>(alerts, page, pageSize, total);
|
|
}
|
|
|
|
public async Task<ClinicalAlert> 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<ClinicalAlert> 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<IAlertSuppressionService>();
|
|
var options = _services.GetRequiredService<IOptions<SuppressionOptions>>().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;
|
|
}
|
|
|
|
public async Task<AlertFeedback> SubmitFeedbackAsync(
|
|
Guid alertId, AlertFeedbackType type, string? comment)
|
|
{
|
|
if (!_currentUser.IsAuthenticated || _currentUser.UserId is null)
|
|
throw new ValidationException("Authentication required.", "AUTH_REQUIRED");
|
|
|
|
var userId = _currentUser.UserId.Value;
|
|
|
|
var alert = await _db.ClinicalAlerts.FindAsync(alertId);
|
|
if (alert is null)
|
|
throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
|
|
|
if (alert.Status == AlertStatus.Open)
|
|
throw new BadRequestException(
|
|
"Feedback can only be submitted on acknowledged or resolved alerts.",
|
|
"ALERT_NOT_REVIEWABLE");
|
|
|
|
var alreadySubmitted = await _db.AlertFeedbacks
|
|
.AnyAsync(f => f.AlertId == alertId && f.UserId == userId);
|
|
if (alreadySubmitted)
|
|
throw new ConflictException(
|
|
"You have already submitted feedback for this alert.",
|
|
"FEEDBACK_ALREADY_SUBMITTED");
|
|
|
|
var feedback = new AlertFeedback
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
AlertId = alertId,
|
|
UserId = userId,
|
|
FeedbackType = type,
|
|
Comment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
_db.AlertFeedbacks.Add(feedback);
|
|
alert.FeedbackReceived = true;
|
|
await _db.SaveChangesAsync();
|
|
|
|
await _audit.WriteAsync(
|
|
AuditAction.AlertFeedbackSubmitted,
|
|
"ClinicalAlert",
|
|
alert.Id,
|
|
newValue: new { feedbackType = type.ToDbString(), feedback.UserId },
|
|
reason: comment);
|
|
|
|
return feedback;
|
|
}
|
|
|
|
private async Task<int> 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<ClinicalAlert> 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()}";
|
|
}
|
|
} |