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 _logger; public UpliftLifecycleService( IUpliftDataService upliftData, IDispatchDataService dispatchData, IUserDataService userData, IEmailSender emailSender, IOptions frontendOptions, IOptions approvalsOptions, TimeProvider timeProvider, ILogger logger) { _upliftData = upliftData; _dispatchData = dispatchData; _userData = userData; _emailSender = emailSender; _frontendOptions = frontendOptions.Value; _approvalsOptions = approvalsOptions.Value; _timeProvider = timeProvider; _logger = logger; } public async Task 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 _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch?.WorkOrderId ?? 0, 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 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 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 _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, 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> ResolveInitialRecipientsAsync(Dispatch dispatch, DispatchUpliftRequest req, CancellationToken cancellationToken) { var recipients = new HashSet(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> ResolveEscalationRecipientsAsync(DispatchUpliftRequest req, CancellationToken cancellationToken) { var recipients = new HashSet(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 SendToAllAsync(HashSet 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); } } }