using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class VendorPortalService : IVendorPortalService { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { "application/pdf", "image/jpeg", "image/jpg", "image/png", "image/heic", "video/mp4", "video/quicktime" }; // The browser's File.type is unreliable on mobile (empty or application/octet-stream), // so an extension pairing against the same allowlist resolves the real content type. private static string? ResolveUploadContentType(IFormFile file) { var declaredType = (file.ContentType ?? string.Empty).Trim(); var extension = Path.GetExtension(file.FileName ?? string.Empty); // iOS transcodes a picked HEIC photo to JPEG (named .jpg) but can keep image/heic. var isTranscodedHeic = declaredType.Equals("image/heic", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".heic", StringComparison.OrdinalIgnoreCase); if (AllowedContentTypes.Contains(declaredType) && !isTranscodedHeic) return declaredType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : declaredType; return extension.ToLowerInvariant() switch { ".pdf" => "application/pdf", ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".heic" => "image/heic", ".mp4" => "video/mp4", ".mov" => "video/quicktime", _ => null }; } private const int MaxRefusalReasonLength = 500; private readonly IVendorPortalTokenService _tokens; private readonly IDispatchDataService _dispatchData; private readonly IUpliftDataService _upliftData; private readonly ICommentDataService _commentData; private readonly IUserDataService _userData; private readonly IEmailSender _emailSender; private readonly IVendorDocumentDataService _documentData; private readonly IVendorDocumentStoragePort _documentStorage; private readonly FrontendOptions _frontendOptions; private readonly ApprovalsOptions _approvalsOptions; private readonly VendorDocumentsOptions _documentOptions; private readonly TimeProvider _timeProvider; public VendorPortalService( IVendorPortalTokenService tokens, IDispatchDataService dispatchData, IUpliftDataService upliftData, ICommentDataService commentData, IUserDataService userData, IEmailSender emailSender, IVendorDocumentDataService documentData, IVendorDocumentStoragePort documentStorage, IOptions frontendOptions, IOptions approvalsOptions, IOptions documentOptions, TimeProvider timeProvider) { _tokens = tokens; _dispatchData = dispatchData; _upliftData = upliftData; _commentData = commentData; _userData = userData; _emailSender = emailSender; _documentData = documentData; _documentStorage = documentStorage; _frontendOptions = frontendOptions.Value; _approvalsOptions = approvalsOptions.Value; _documentOptions = documentOptions.Value; _timeProvider = timeProvider; } public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) { return await _tokens.ResolveSessionAsync(token, cancellationToken); } public async Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken) { var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken); return summaries.Select(d => new VendorDispatchSummaryDTO { Id = d.Id, DispatchNumber = d.DispatchNumber, PONumber = d.PONumber, Status = d.Status, NTEAmount = d.NTEAmount, ScheduledDate = d.ScheduledDate, CompletedDate = d.CompletedDate, DispatchedAt = d.DispatchedAt, AcknowledgedAt = d.AcknowledgedAt, WorkOrderTitle = d.WorkOrderTitle, InternalWONumber = d.InternalWONumber, LocationName = d.LocationName, LocationCity = d.LocationCity, LocationState = d.LocationState }); } public async Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken); if (dispatch == null) return null; var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken); var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); var rawDocuments = await _documentData.ListForVendorDispatchAsync(id, session.Id, cancellationToken); var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO { Id = u.Id, CurrentNTE = u.CurrentNTE, RequestedNTE = u.RequestedNTE, VendorReason = u.VendorReason, Status = UpliftStatus.ToCanonical(u.Status), RequiredTier = u.RequiredTier, RequestedByVendorName = u.RequestedByVendorName, RequestedAt = u.CreatedDate, DecidedAt = u.DecidedAt, DecisionNote = u.DecisionNote, DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), EvidenceDocumentId = u.EvidenceDocumentId, EvidenceFileName = u.EvidenceFileName, EvidenceContentType = u.EvidenceContentType, EvidenceSizeBytes = u.EvidenceSizeBytes, EvidenceScanPassed = u.EvidenceScanPassed, ExpiresAt = u.ExpiresAt, NotificationStatus = u.NotificationStatus, NotificationError = u.NotificationError, HasChangesRequested = UpliftStatus.ToCanonical(u.Status) == UpliftStatus.ChangesRequested }).ToList(); var comments = rawComments.Select(c => { var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); string resolvedType; string resolvedCommenter; if (hasShocUser) { resolvedType = "dispatcher"; resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Dispatcher"; } else if (c.CommentType == "customer") { resolvedType = "customer"; resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; } else { resolvedType = "vendor"; resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor"; } return new PortalCommentDTO { Id = c.Id, Commenttext = c.Commenttext, Commenter = resolvedCommenter, CommentType = resolvedType, CreatedDate = c.CreatedDate }; }).ToList(); DispatcherContactDTO? dispatcherContact = null; if (dispatch.WorkOrderId.HasValue) { var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync( dispatch.WorkOrderId.Value, cancellationToken); if (!string.IsNullOrWhiteSpace(dispatcherUserId)) { var profile = await _userData.GetProfileAsync(dispatcherUserId, cancellationToken); if (profile != null) { dispatcherContact = new DispatcherContactDTO { Name = profile.FirstName, Email = profile.Email, Phone = profile.Contact }; } } } PortalMediaCountsDTO? mediaCounts = null; if (dispatch.WorkOrderId is int workOrderId) { // Same basis as the upload check: a new completion version replaces the // dispatch's latest document, so that one is not counted for it. var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( workOrderId, cancellationToken); var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( workOrderId, null, cancellationToken); var latest = await _documentData.GetLatestForDispatchAsync(id, cancellationToken); var completionTypes = latest == null ? vendorTypes : await _documentData.ListActiveContentTypesForWorkOrderAsync( workOrderId, latest.Id, cancellationToken); var (photos, videos) = WorkOrderMediaContract.CountKinds(attachmentUrls, vendorTypes); var (completionPhotos, completionVideos) = WorkOrderMediaContract.CountKinds(attachmentUrls, completionTypes); mediaCounts = new PortalMediaCountsDTO { MaxPhotos = WorkOrderMediaContract.MaxPhotosPerWorkOrder, MaxVideos = WorkOrderMediaContract.MaxVideosPerWorkOrder, Photos = photos, Videos = videos, CompletionPhotos = completionPhotos, CompletionVideos = completionVideos }; } return new VendorDispatchDetailDTO { MediaCounts = mediaCounts, Id = dispatch.Id, DispatchNumber = dispatch.DispatchNumber, PONumber = dispatch.PONumber, Status = dispatch.Status, InvoiceNumber = dispatch.InvoiceNumber, InvoiceStatus = dispatch.InvoiceStatus, PaymentStatus = dispatch.PaymentStatus, NTEAmount = dispatch.NTEAmount, Description = dispatch.Description, ScheduledDate = dispatch.ScheduledDate, CompletedDate = dispatch.CompletedDate, DispatchedAt = dispatch.DispatchedAt, AcknowledgedAt = dispatch.AcknowledgedAt, StatusUpdatedAt = dispatch.LastModificationTime, WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO { Id = dispatch.WorkOrder.Id, InternalWONumber = dispatch.WorkOrder.InternalWONumber, WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle, Description = dispatch.WorkOrder.Description, Priority = dispatch.WorkOrder.Priority, DueDate = dispatch.WorkOrder.DueDate, Trade = dispatch.WorkOrder.Trade, SubTrade = dispatch.WorkOrder.SubTrade, Service = dispatch.WorkOrder.Service, Problem = dispatch.WorkOrder.Problem, SiteCode = dispatch.WorkOrder.SiteCode }, Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO { Name = dispatch.WorkOrder.Locations.Name, Address1 = dispatch.WorkOrder.Locations.Address1, City = dispatch.WorkOrder.Locations.City, State = dispatch.WorkOrder.Locations.State, Zip = dispatch.WorkOrder.Locations.Zip }, DispatcherContact = dispatcherContact, Checklist = checklist.Select(c => new PortalChecklistItemDTO { Id = c.Id, ItemText = c.ItemText, IsCompleted = c.IsCompleted, CompletedBy = c.CompletedBy, CompletedAt = c.CompletedAt }), Signoffs = signoffs.Select(s => new PortalSignoffDTO { Id = s.Id, SignoffType = s.SignoffType, Name = s.Name, SignatureMethod = s.SignatureMethod, SignedAt = s.SignedAt }), Comments = comments, UpliftRequests = upliftRequests, Documents = rawDocuments.Select(document => new VendorPortalDocumentDTO { Id = document.Id, OriginalFileName = document.OriginalFileName, ContentType = document.ContentType, SizeBytes = document.SizeBytes, ScanStatus = document.ScanStatus, ReviewStatus = document.ReviewStatus, RejectionReason = document.RejectionReason, Version = document.Version, ReplacesDocumentId = document.ReplacesDocumentId, CreatedDate = document.CreatedDate, ScannedAt = document.ScannedAt, ReviewedAt = document.ReviewedAt, CanDownload = string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase) && !IsPortalLockedStatus(dispatch.Status) }) }; } public async Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status != "Sent") { throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'"); } var now = DateTime.UtcNow; dispatch.Status = "Acknowledged"; dispatch.AcknowledgedAt = now; dispatch.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = "Sent", NewValue = "Acknowledged", Action = "vendor_accept", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt }; } public async Task RefuseDispatchAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status != "Sent") { throw new InvalidOperationException($"Cannot refuse a dispatch with status '{dispatch.Status}'"); } var reasonText = reason?.Trim(); if (reasonText != null && reasonText.Length > MaxRefusalReasonLength) { throw new InvalidOperationException($"Refusal reason must be {MaxRefusalReasonLength} characters or fewer"); } var now = DateTime.UtcNow; dispatch.Status = "Refused"; dispatch.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = "Sent", NewValue = "Refused", Action = "vendor_refuse", CreatedAt = now }, cancellationToken); if (!string.IsNullOrWhiteSpace(reasonText)) { await _commentData.StageAsync(new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = session.CompanyName, CommentType = "vendor", RecordType = "refusal", Commenttext = reasonText, CreatedDate = now }, cancellationToken); } await _dispatchData.SaveChangesAsync(cancellationToken); return new RefuseDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, RefusedAt = now }; } public async Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); var from = dispatch.Status; if (!IsAllowedVendorTransition(from, to)) { throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed"); } var now = DateTime.UtcNow; dispatch.Status = to; dispatch.LastModificationTime = now; if (to == "Completed") dispatch.CompletedDate = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Status", OldValue = from, NewValue = to, Action = "vendor_status_change", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate }; } public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch"); } var now = DateTime.UtcNow; var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim(); await _commentData.StageAsync(new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = session.CompanyName, CommentType = "vendor", RecordType = "cancel_request", Commenttext = $"Vendor requested cancellation: {reasonText}", CreatedDate = now }, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber}", OldValue = dispatch.Status, NewValue = "Cancel Requested", Action = "vendor_request_cancel", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); } public async Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException("Dispatch is locked"); } var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken); if (item == null) throw new KeyNotFoundException("Checklist item not found"); var now = DateTime.UtcNow; item.IsCompleted = isCompleted; item.CompletedBy = isCompleted ? session.CompanyName : null; item.CompletedAt = isCompleted ? now : null; item.LastModificationTime = now; await _dispatchData.SaveChangesAsync(cancellationToken); return new ChecklistItemResultDTO { Id = item.Id, IsCompleted = item.IsCompleted, CompletedBy = item.CompletedBy, CompletedAt = item.CompletedAt }; } public async Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") { throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches"); } if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) { throw new InvalidOperationException("Name and signature are required"); } var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant(); if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer") { throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'"); } var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType); if (existing) { throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch"); } var now = DateTime.UtcNow; var signoff = new DispatchSignoff { DispatchId = id, SignoffType = normalizedSignoffType, Name = name, Signature = signature, SignatureMethod = signatureMethod ?? "drawn", SignedAt = now, CreatedDate = now }; await _dispatchData.StageSignoffAsync(signoff, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", OldValue = null, NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}", Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff", CreatedAt = now }, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt }; } public async Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (string.IsNullOrWhiteSpace(commentText)) { throw new InvalidOperationException("Comment cannot be empty"); } var now = DateTime.UtcNow; var comment = new Comments { DispatchId = id, WorkerOrderId = dispatch.WorkOrderId, Commenter = session.CompanyName, CommentType = "vendor", RecordType = "comment", Commenttext = commentText, CreatedDate = now }; await _commentData.StageAsync(comment, cancellationToken); await _dispatchData.SaveChangesAsync(cancellationToken); return new CommentResultDTO { Id = comment.Id, Commenttext = comment.Commenttext, Commenter = comment.Commenter, CreatedDate = comment.CreatedDate }; } public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, string? requestKey, int? evidenceDocumentId, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); } if (requestedNTE <= 0) { throw new InvalidOperationException("Requested NTE must be greater than zero"); } var now = _timeProvider.GetUtcNow().UtcDateTime; var current = dispatch.NTEAmount ?? 0m; // NoApprovalRequired: requestedNTE <= current. Do not create an uplift or change NTE; // record an audit event documenting the routed non-uplift outcome. if (requestedNTE <= current) { await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = $"${current:F2}", NewValue = $"${requestedNTE:F2}", Action = "uplift_no_approval_required", ActorType = "vendor", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); return new UpliftRequestResultDTO { Id = 0, Status = UpliftStatus.NoApprovalRequired, RequiredTier = 0, CurrentNTE = current, RequestedNTE = requestedNTE, NoApprovalRequired = true }; } // requestedNTE > current: reason and scanned evidence are mandatory. if (string.IsNullOrWhiteSpace(reason)) { throw new InvalidOperationException("A reason is required when requesting an uplift above the current NTE"); } if (!evidenceDocumentId.HasValue) { throw new InvalidOperationException("Supporting evidence is required when requesting an uplift above the current NTE"); } var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken); if (evidence == null) { throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning"); } // Idempotency: a repeated identical RequestKey returns the same request; mismatched reuse rejects. if (!string.IsNullOrWhiteSpace(requestKey)) { var normalizedKey = requestKey.Trim(); if (normalizedKey.Length > 100) { throw new InvalidOperationException("RequestKey is too long"); } var existing = await _upliftData.GetByRequestKeyAsync(id, normalizedKey, cancellationToken); if (existing != null) { if (existing.RequestedNTE != requestedNTE || existing.EvidenceDocumentId != evidence.Id || !string.Equals(existing.VendorReason ?? "", reason.Trim(), StringComparison.Ordinal)) { throw new InvalidOperationException("RequestKey was already used with different uplift details"); } return new UpliftRequestResultDTO { Id = existing.Id, Status = UpliftStatus.ToCanonical(existing.Status), RequiredTier = existing.RequiredTier, CurrentNTE = existing.CurrentNTE, RequestedNTE = existing.RequestedNTE, NoApprovalRequired = false, EvidenceDocumentId = existing.EvidenceDocumentId, ExpiresAt = existing.ExpiresAt, NotificationStatus = existing.NotificationStatus, IdempotentReplay = true }; } } // At most one active (Pending or ChangesRequested) request per dispatch. var activeExists = await _upliftData.HasActiveAsync(id, cancellationToken); if (activeExists) { throw new InvalidOperationException("An active uplift request already exists for this dispatch"); } var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; var delta = requestedNTE - current; var requiredTier = delta > tier1Max ? 2 : 1; var expiresAt = now + _approvalsOptions.EffectiveExpiration; var req = new DispatchUpliftRequest { DispatchId = id, CurrentNTE = current, RequestedNTE = requestedNTE, VendorReason = reason.Trim(), Status = UpliftStatus.Pending, RequiredTier = requiredTier, RequestedByVendorName = session.CompanyName, EvidenceDocumentId = evidence.Id, RequestKey = string.IsNullOrWhiteSpace(requestKey) ? null : requestKey.Trim(), ExpiresAt = expiresAt, NotificationStatus = UpliftNotificationStatus.Pending, CreatedDate = now }; await _upliftData.StageAsync(req, cancellationToken); await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = $"${current:F2}", NewValue = $"${requestedNTE:F2}", Action = "uplift_requested", ActorType = "vendor", CreatedAt = now }, cancellationToken); try { await _upliftData.SaveChangesAsync(cancellationToken); } catch (SeaHaven.DataServices.Exceptions.UpliftDispatchConflictException) { throw new UpliftConflictException(); } // Notification is best-effort and persisted separately from workflow state: a failure // never destroys the actionable request (the lifecycle sweep retries by sentinel). await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); return new UpliftRequestResultDTO { Id = req.Id, Status = req.Status, RequiredTier = req.RequiredTier, CurrentNTE = req.CurrentNTE, RequestedNTE = req.RequestedNTE, EvidenceDocumentId = req.EvidenceDocumentId, ExpiresAt = req.ExpiresAt, NotificationStatus = req.NotificationStatus }; } // Cancel alias stays route-compatible; canonical stored status is Withdrawn. public async Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) { var result = await WithdrawInternalAsync(session, id, requestId, "cancel", cancellationToken); return new CancelUpliftResultDTO { Id = result.Id, Status = result.Status }; } public Task WithdrawUpliftAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) => WithdrawInternalAsync(session, id, requestId, "withdraw", cancellationToken); private async Task WithdrawInternalAsync(VendorPortalSession session, int id, int requestId, string actionSuffix, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); if (!UpliftStatus.CanTransition(req.Status, UpliftStatus.Withdrawn)) { throw new InvalidOperationException($"Cannot withdraw a '{UpliftStatus.ToCanonical(req.Status)}' uplift request"); } var now = _timeProvider.GetUtcNow().UtcDateTime; var previous = UpliftStatus.ToCanonical(req.Status); req.Status = UpliftStatus.Withdrawn; req.DecidedAt = now; req.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = previous, NewValue = UpliftStatus.Withdrawn, Action = $"uplift_{actionSuffix}", ActorType = "vendor", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); return new WithdrawUpliftResultDTO { Id = req.Id, Status = UpliftStatus.Withdrawn }; } // Vendor revise endpoint on ChangesRequested: updates requested amount/reason/evidence, // recomputes tier/expiry, returns Pending, and audits old/new values while retaining the // same row/history. public async Task ReviseUpliftAsync(VendorPortalSession session, int id, int requestId, decimal requestedNTE, string? reason, int? evidenceDocumentId, CancellationToken cancellationToken) { var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); if (requestedNTE <= 0) { throw new InvalidOperationException("Requested NTE must be greater than zero"); } var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); if (req == null) throw new KeyNotFoundException("Uplift request not found"); if (UpliftStatus.ToCanonical(req.Status) != UpliftStatus.ChangesRequested) { throw new InvalidOperationException($"Cannot revise a '{UpliftStatus.ToCanonical(req.Status)}' uplift request"); } var current = dispatch.NTEAmount ?? 0m; if (requestedNTE <= current) { throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); } if (string.IsNullOrWhiteSpace(reason)) { throw new InvalidOperationException("A reason is required when revising an uplift request"); } if (!evidenceDocumentId.HasValue) { throw new InvalidOperationException("Supporting evidence is required when revising an uplift request"); } var evidence = await _documentData.GetUpliftEvidenceAsync(evidenceDocumentId.Value, id, session.Id, cancellationToken); if (evidence == null) { throw new InvalidOperationException("The selected evidence document is not available or has not passed scanning"); } var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; var now = _timeProvider.GetUtcNow().UtcDateTime; var oldRequested = req.RequestedNTE; var oldReason = req.VendorReason; var oldTier = req.RequiredTier; req.RequestedNTE = requestedNTE; req.VendorReason = reason.Trim(); req.EvidenceDocumentId = evidence.Id; req.CurrentNTE = current; req.RequiredTier = (requestedNTE - current) > tier1Max ? 2 : 1; req.ExpiresAt = now + _approvalsOptions.EffectiveExpiration; req.Status = UpliftStatus.Pending; req.DecisionNote = null; req.DecidedAt = null; req.DecidedByUserId = null; req.NotificationStatus = UpliftNotificationStatus.Pending; req.NotificationError = null; req.InitialNotificationSentAt = null; req.EscalatedAt = null; req.LastModificationTime = now; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", OldValue = $"${oldRequested:F2} (Tier {oldTier})", NewValue = $"${requestedNTE:F2} (Tier {req.RequiredTier})", Action = "uplift_revised", ActorType = "vendor", CreatedAt = now }, cancellationToken); await _upliftData.SaveChangesAsync(cancellationToken); await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); return new ReviseUpliftResultDTO { Id = req.Id, Status = req.Status, RequiredTier = req.RequiredTier, CurrentNTE = req.CurrentNTE, RequestedNTE = req.RequestedNTE, EvidenceDocumentId = req.EvidenceDocumentId, ExpiresAt = req.ExpiresAt, NotificationStatus = req.NotificationStatus }; } public async Task UploadCompletionDocumentAsync( VendorPortalSession session, int dispatchId, IFormFile file, int? replacesDocumentId, string? purpose, CancellationToken cancellationToken) { if (file is null || file.Length == 0) { throw new InvalidOperationException("A completion document file is required."); } // Media contract: photos up to 10 MB (JPEG/PNG/HEIC), videos up to 100 MB // (MP4/MOV). Documents keep the configured VendorDocuments cap. var contentType = ResolveUploadContentType(file); if (contentType == null) { throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted."); } // Classify by the resolved type the signature check validates, never by the file name. var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null); if (kind == WorkOrderMediaContract.UploadKind.Photo && file.Length > WorkOrderMediaContract.MaxPhotoBytes) { throw new InvalidOperationException("Photos must be 10 MB or smaller."); } if (kind == WorkOrderMediaContract.UploadKind.Video && file.Length > WorkOrderMediaContract.MaxVideoBytes) { throw new InvalidOperationException("Videos must be 100 MB or smaller."); } if (kind != WorkOrderMediaContract.UploadKind.Photo && kind != WorkOrderMediaContract.UploadKind.Video && file.Length > _documentOptions.MaxSizeBytes) { throw new InvalidOperationException("The uploaded file exceeds the maximum allowed size."); } var resolvedPurpose = string.IsNullOrWhiteSpace(purpose) ? VendorDocumentPurpose.Completion : purpose.Trim(); if (!VendorDocumentPurpose.IsValid(resolvedPurpose)) { throw new InvalidOperationException("The document purpose is not valid."); } // Uplift evidence is immutable supporting evidence: it never participates in completion // replacement/version semantics. if (resolvedPurpose == VendorDocumentPurpose.UpliftEvidence && replacesDocumentId.HasValue) { throw new InvalidOperationException("Uplift evidence documents cannot replace another document."); } using var buffer = new MemoryStream(); await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); if (!WorkOrderMediaFileRules.MatchesSignature(contentType, bytes)) { throw new InvalidOperationException("The uploaded file signature does not match its declared content type."); } if (kind == WorkOrderMediaContract.UploadKind.Video) { using var content = new MemoryStream(bytes, writable: false); var durationMessage = WorkOrderMediaContract.ValidateVideoDuration(content); if (durationMessage != null) { throw new InvalidOperationException(durationMessage); } } var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch == null) { throw new KeyNotFoundException("Dispatch not found"); } if (IsPortalLockedStatus(dispatch.Status)) { throw new InvalidOperationException("Dispatch is locked"); } var latest = await _documentData.GetLatestForDispatchAsync(dispatchId, cancellationToken); var replacedDocument = latest; if (replacesDocumentId.HasValue) { replacedDocument = await _documentData.GetForVendorDispatchAsync( replacesDocumentId.Value, dispatchId, session.Id, cancellationToken); if (replacedDocument == null) { throw new InvalidOperationException( "The completion document could not be replaced."); } } var version = (latest?.Version ?? 0) + 1; var storedFileName = $"{dispatchId}_{version}_{Guid.NewGuid():N}{GetSafeExtension(file.FileName)}"; var isUpliftEvidence = resolvedPurpose == VendorDocumentPurpose.UpliftEvidence; async Task PersistAsync(CancellationToken ct) { // The 10-photo / 3-video limit is per work order across the dispatcher and // vendor surfaces. A new completion version replaces its predecessor, so that one // does not count against the upload that supersedes it. if (dispatch.WorkOrderId is int workOrderId) { var excluded = resolvedPurpose == VendorDocumentPurpose.Completion ? replacedDocument?.Id : null; var vendorTypes = await _documentData.ListActiveContentTypesForWorkOrderAsync( workOrderId, excluded, ct); var attachmentUrls = await _documentData.ListActiveWorkOrderAttachmentUrlsAsync( workOrderId, ct); var countMessage = WorkOrderMediaContract.ValidateCount(kind, attachmentUrls, vendorTypes); if (countMessage != null) { throw new InvalidOperationException(countMessage); } } var now = DateTime.UtcNow; var created = new VendorCompletionDocument { VendorId = session.Id, DispatchId = dispatchId, WorkOrderId = dispatch.WorkOrderId ?? 0, OriginalFileName = Path.GetFileName(file.FileName), StoredFileName = storedFileName, ContentType = contentType, SizeBytes = bytes.Length, ScanStatus = "Pending", ReviewStatus = "Processing", Version = version, ReplacesDocumentId = replacedDocument?.Id, Purpose = resolvedPurpose, CreatedDate = now }; await _documentData.AddAsync(created, ct); var fieldName = $"Dispatch {dispatch.DispatchNumber} Completion Document"; await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = dispatch.WorkOrderId ?? 0, DispatchId = dispatchId, FieldName = fieldName, OldValue = isUpliftEvidence || replacedDocument == null ? null : $"v{replacedDocument.Version}", NewValue = isUpliftEvidence ? $"evidence {created.OriginalFileName}" : $"v{version}", Action = isUpliftEvidence ? "vendor_uplift_evidence_uploaded" : "vendor_completion_document_uploaded", ActorType = "vendor", CreatedAt = now }, ct); await _documentData.SaveChangesAsync(ct); return created; } // The count and the insert run under the per-work-order mutation lock the dispatcher // media upload also takes, so concurrent uploads cannot both claim the last slot. var document = dispatch.WorkOrderId is int lockedWorkOrderId ? await _upliftData.ExecuteWorkOrderMutationAsync(lockedWorkOrderId, PersistAsync, cancellationToken) : await PersistAsync(cancellationToken); using var stored = new MemoryStream(bytes); await _documentStorage.SaveAsync(session.Id, dispatchId, storedFileName, stored, cancellationToken); return new UploadCompletionDocumentResultDTO { Id = document.Id, Version = document.Version, ScanStatus = document.ScanStatus, ReviewStatus = document.ReviewStatus, Purpose = document.Purpose }; } public async Task DownloadCompletionDocumentAsync( VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken) { var document = await _documentData.GetForVendorDispatchAsync(documentId, dispatchId, session.Id, cancellationToken); if (document == null) { return DownloadCompletionDocumentResultDTO.NotFound(); } if (!string.Equals(document.ScanStatus, "Passed", StringComparison.OrdinalIgnoreCase)) { return DownloadCompletionDocumentResultDTO.Locked(); } var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(dispatchId, session.Id, cancellationToken); if (dispatch != null && IsPortalLockedStatus(dispatch.Status)) { return DownloadCompletionDocumentResultDTO.Locked(); } var content = _documentStorage.OpenRead(session.Id, dispatchId, document.StoredFileName); return DownloadCompletionDocumentResultDTO.Ok(content, document.ContentType, document.OriginalFileName); } public async Task GetDocumentStatusAsync( VendorPortalSession session, int dispatchId, int documentId, CancellationToken cancellationToken) { var document = await _documentData.GetMetadataForVendorDispatchAsync( documentId, dispatchId, session.Id, cancellationToken); if (document == null) { return null; } return new VendorDocumentStatusDTO { Id = document.Id, OriginalFileName = document.OriginalFileName, ScanStatus = document.ScanStatus, ReviewStatus = document.ReviewStatus, Purpose = document.Purpose }; } private static string GetSafeExtension(string fileName) { var extension = Path.GetExtension(fileName); return string.IsNullOrWhiteSpace(extension) ? string.Empty : extension; } private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, 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); } // Tier recipients come from configured Approvals options; dedupe against the dispatcher. 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()); } } if (recipients.Count == 0) { // No one to notify: record a safe persisted signal without leaking internal detail. req.NotificationStatus = UpliftNotificationStatus.Error; req.NotificationError = "No notification recipients are configured for this dispatch."; req.LastModificationTime = _timeProvider.GetUtcNow().UtcDateTime; await _upliftData.SaveChangesAsync(cancellationToken); return; } var frontendBase = _frontendOptions.FrontendBaseUrl ?? string.Empty; var (subject, body) = UpliftNotificationMessage.BuildInitial(dispatch, req, session.CompanyName ?? "Vendor", frontendBase); var now = _timeProvider.GetUtcNow().UtcDateTime; try { foreach (var recipient in recipients) { var delivered = await _emailSender.SendEmailAsync(recipient, subject, body); if (!delivered) throw new InvalidOperationException("The notification provider reported a delivery failure."); } req.InitialNotificationSentAt = now; req.NotificationStatus = UpliftNotificationStatus.Sent; req.NotificationError = null; } catch (OperationCanceledException) { throw; } catch (Exception) { // Persist a safe signal; never store exception text or addresses. req.NotificationStatus = UpliftNotificationStatus.Error; req.NotificationError = "The uplift notification could not be delivered."; } req.LastModificationTime = now; await _upliftData.SaveChangesAsync(cancellationToken); } private static bool IsAllowedVendorTransition(string? from, string? to) { if (from == "Acknowledged" && to == "In Progress") return true; if (from == "In Progress" && to == "Completed") return true; return false; } private static bool IsPortalLockedStatus(string? status) => status is "Verified" or "Cancelled" or "Canceled" or "Refused"; } }