mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Approve, reject, request-changes, expiry and escalation staged their work order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs requires a real work order, so any uplift on a dispatch without an owning work order failed to save: the decision returned a 500 and the request stayed Pending, and the expiry sweep failed on it every run. The audit now goes to the work order the uplift resolves to through the existing owner-or-linked read that revoke already uses. When none resolves, the status change is saved on the request and no audit row is written.
265 lines
11 KiB
C#
265 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|