shoc-backend/SeaHaven.Services/Implementation/UpliftLifecycleService.cs

266 lines
11 KiB
C#
Raw Permalink Normal View History

using Data.SeaHavenIndustries;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class UpliftLifecycleService : IUpliftLifecycleService
{
private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData;
private readonly IUserDataService _userData;
private readonly IEmailSender _emailSender;
private readonly FrontendOptions _frontendOptions;
private readonly ApprovalsOptions _approvalsOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<UpliftLifecycleService> _logger;
public UpliftLifecycleService(
IUpliftDataService upliftData,
IDispatchDataService dispatchData,
IUserDataService userData,
IEmailSender emailSender,
IOptions<FrontendOptions> frontendOptions,
IOptions<ApprovalsOptions> approvalsOptions,
TimeProvider timeProvider,
ILogger<UpliftLifecycleService> logger)
{
_upliftData = upliftData;
_dispatchData = dispatchData;
_userData = userData;
_emailSender = emailSender;
_frontendOptions = frontendOptions.Value;
_approvalsOptions = approvalsOptions.Value;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<int> ExpireDueAsync(CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var due = await _upliftData.GetDueForExpiryAsync(now, 50, cancellationToken);
var expired = 0;
foreach (var req in due)
{
if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Expired))
continue;
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null)
{
_logger.LogError("Uplift expiry skipped for orphan request {UpliftId}; dispatch {DispatchId} was not found.", req.Id, req.DispatchId);
continue;
}
var previous = UpliftStatus.ToCanonical(req.Status);
req.Status = UpliftStatus.Expired;
req.DecidedAt = now;
req.LastModificationTime = now;
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = previous,
NewValue = UpliftStatus.Expired,
Action = "uplift_expired",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
expired++;
}
return expired;
}
public async Task<int> SendDueInitialNotificationsAsync(CancellationToken cancellationToken)
{
var due = await _upliftData.GetDueForInitialNotificationAsync(50, cancellationToken);
var sent = 0;
foreach (var req in due)
{
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null) continue;
var recipients = await ResolveInitialRecipientsAsync(dispatch, req, cancellationToken);
if (recipients.Count == 0)
{
await PersistNotificationOutcomeAsync(req, delivered: false, "No notification recipients are configured for this dispatch.", cancellationToken);
continue;
}
var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, req.RequestedByVendorName ?? "Vendor", _frontendOptions.FrontendBaseUrl ?? string.Empty);
var delivered = await SendToAllAsync(recipients, subject, body);
await PersistNotificationOutcomeAsync(req, delivered, delivered ? null : "The uplift notification could not be delivered.", cancellationToken);
if (delivered) sent++;
}
return sent;
}
public async Task<int> EscalateDueAsync(CancellationToken cancellationToken)
{
var candidates = await _upliftData.GetDueForEscalationAsync(50, cancellationToken);
var escalationThreshold = _approvalsOptions.EffectiveEscalation;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var escalated = 0;
foreach (var req in candidates)
{
if (req.InitialNotificationSentAt == null || req.EscalatedAt != null)
continue;
if (now - req.InitialNotificationSentAt.Value < escalationThreshold)
continue;
var recipients = await ResolveEscalationRecipientsAsync(req, cancellationToken);
if (recipients.Count == 0)
continue;
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
if (dispatch == null) continue;
var (subject, body) = UpliftNotificationMessage.BuildEscalation(dispatch, req, req.RequestedByVendorName ?? "Vendor", _frontendOptions.FrontendBaseUrl ?? string.Empty);
var delivered = await SendToAllAsync(recipients, subject, body);
if (!delivered)
{
// Escalation send failure is logged only; it does not alter workflow status.
_logger.LogWarning("Uplift escalation delivery failed for request {UpliftId}", req.Id);
continue;
}
req.EscalatedAt = now;
req.LastModificationTime = now;
await StageAuditAsync(req.Id, workOrderId => new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = "Pending",
NewValue = "Escalated",
Action = "uplift_escalated",
CreatedAt = now
}, cancellationToken);
await _upliftData.SaveChangesAsync(cancellationToken);
escalated++;
}
return escalated;
}
private async Task<HashSet<string>> ResolveInitialRecipientsAsync(Dispatch dispatch, DispatchUpliftRequest req, CancellationToken cancellationToken)
{
var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
if (!string.IsNullOrWhiteSpace(dispatcherUserId))
{
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
if (!string.IsNullOrWhiteSpace(dispatcherEmail))
recipients.Add(dispatcherEmail);
}
var tierRecipients = req.RequiredTier == 2
? _approvalsOptions.Tier2NotificationRecipients
: _approvalsOptions.Tier1NotificationRecipients;
if (tierRecipients != null)
{
foreach (var recipient in tierRecipients)
{
if (!string.IsNullOrWhiteSpace(recipient))
recipients.Add(recipient.Trim());
}
}
return recipients;
}
private Task<HashSet<string>> ResolveEscalationRecipientsAsync(DispatchUpliftRequest req, CancellationToken cancellationToken)
{
var recipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_approvalsOptions.EscalationRecipients != null)
{
foreach (var recipient in _approvalsOptions.EscalationRecipients)
{
if (!string.IsNullOrWhiteSpace(recipient))
recipients.Add(recipient.Trim());
}
}
var tierRecipients = req.RequiredTier == 2
? _approvalsOptions.Tier2NotificationRecipients
: _approvalsOptions.Tier1NotificationRecipients;
if (tierRecipients != null)
{
foreach (var recipient in tierRecipients)
{
if (!string.IsNullOrWhiteSpace(recipient))
recipients.Add(recipient.Trim());
}
}
return Task.FromResult(recipients);
}
private async Task<bool> SendToAllAsync(HashSet<string> recipients, string subject, string body)
{
try
{
foreach (var recipient in recipients)
{
var delivered = await _emailSender.SendEmailAsync(recipient, subject, body);
if (!delivered)
return false;
}
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
// Never leak addresses or exception details; the persisted signal is safe text.
return false;
}
}
private async Task PersistNotificationOutcomeAsync(DispatchUpliftRequest req, bool delivered, string? errorMessage, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
if (delivered)
{
req.InitialNotificationSentAt = now;
req.NotificationStatus = UpliftNotificationStatus.Sent;
req.NotificationError = null;
}
else
{
req.NotificationStatus = UpliftNotificationStatus.Error;
req.NotificationError = errorMessage;
}
req.LastModificationTime = now;
await _upliftData.SaveChangesAsync(cancellationToken);
}
// The audit goes to the work order the uplift's dispatch belongs to (owner, else the
// one it is linked to). With neither, the status change is recorded on the request
// alone; a work-order id of 0 would fail the required audit foreign key and abort
// the sweep on every run.
private async Task StageAuditAsync(
int upliftId,
Func<int, WorkOrderAuditLog> buildEntry,
CancellationToken cancellationToken)
{
var workOrderId = await _upliftData.GetWorkOrderIdForUpliftAsync(upliftId, cancellationToken);
if (workOrderId is int resolved)
await _dispatchData.StageAuditLogAsync(buildEntry(resolved), cancellationToken);
}
}
}