Files
vigilcare-clinical/VigilCareClinicalAPI/Services/AlertService.cs
T

328 lines
12 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<AlertResponse>> 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<AlertResponse>(
alerts.Select(AlertResponseMapper.ToResponse).ToList(),
page, pageSize, total);
}
public async Task<PagedResult<AlertResponse>> 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<AlertResponse>(
alerts.Select(AlertResponseMapper.ToResponse).ToList(),
page, pageSize, total);
}
public async Task<AlertResponse> 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 AlertResponseMapper.ToResponse(alert);
}
public async Task<AlertResponse> 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 AlertResponseMapper.ToResponse(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<AlertResponse> 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 AlertResponseMapper.ToResponse(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()}";
}
public static class AlertResponseMapper
{
public static AlertResponse ToResponse(ClinicalAlert alert) => new(
alert.Id,
alert.EncounterId,
alert.PatientId,
alert.AlertType,
alert.Severity,
alert.Details,
alert.Status,
alert.TriggeredAt,
alert.AcknowledgedAt,
alert.AcknowledgedBy,
alert.ResolvedAt,
alert.Explanation);
}
}