using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderMediaService : IWorkOrderMediaService { private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; private readonly IFileStoragePort _fileStorage; private readonly IUpliftDataService _upliftData; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, IWorkOrderAuditService auditService, IFileStoragePort fileStorage, IUpliftDataService upliftData) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; _fileStorage = fileStorage; _upliftData = upliftData; } public async Task?> GetMediaAsync( int workOrderId, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanRead(user, actorId); var accountFilter = ResolveAccountFilter(user); if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter)) return null; var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter); if (workOrder == null) return null; WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId!, workOrder); var attachments = await _detailData.GetAttachmentsAsync(workOrderId, cancellationToken); return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); } public async Task GetMediaContentAsync( int workOrderId, int mediaId, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanRead(user, actorId); if (mediaId <= 0) throw MediaNotFound(); var workOrder = await GetMutableWorkOrderForAuthAsync( workOrderId, user, actorId!, cancellationToken); var attachment = await _mediaData.GetAttachmentForReadAsync( mediaId, workOrder.Id, cancellationToken); if (attachment == null || string.IsNullOrWhiteSpace(attachment.Attachments)) throw MediaNotFound(); var content = _fileStorage.OpenRead(attachment.Attachments); if (content == null) throw MediaNotFound(); var fileName = GetSafeFileName(attachment.Attachments); return new WorkOrderMediaContentDto { Content = content, ContentType = GetContentType(fileName), FileName = fileName }; } public async Task EnsureCanMutateMediaAsync( int workOrderId, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default, WorkOrderMediaCategory? category = null) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); // AsNoTracking pre-check so the subsequent AddMediaAsync load is not stale-cached. var workOrder = await GetMutableWorkOrderForAuthAsync(workOrderId, user, actorId!, cancellationToken); EnsureMediaMutationAllowed(workOrder.LifecycleStatus, category ?? WorkOrderMediaCategory.Extra); } public async Task AddMediaAsync( int workOrderId, WorkOrderMediaCategory? category, string fileUrl, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); EnsureMediaMutationAllowed(workOrder.LifecycleStatus, resolvedCategory); if (resolvedCategory == WorkOrderMediaCategory.Completion) { throw new WorkOrderBoardValidationException( "UseCompletionDocEndpoint", "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } if (resolvedCategory == WorkOrderMediaCategory.Before) { var oldBefore = workOrder.BeforPhotoAttachment; workOrder.BeforPhotoAttachment = fileUrl; await _auditService.StageFieldChangedAsync( workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -1, Category = resolvedCategory, Url = fileUrl, IsLegacy = true }; } if (resolvedCategory == WorkOrderMediaCategory.After) { var oldAfter = workOrder.AfterPhotoAttachment; workOrder.AfterPhotoAttachment = fileUrl; await _auditService.StageFieldChangedAsync( workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = -2, Category = resolvedCategory, Url = fileUrl, IsLegacy = true }; } // Photo/video caps are a work-order aggregate shared with the vendor portal // upload, so the count and the insert run under the per-work-order mutation lock. var attachment = await _upliftData.ExecuteWorkOrderMutationAsync( workOrderId, async ct => { await EnsureWithinMediaCountsAsync(workOrderId, fileUrl, ct); var created = new WorkOrderAttachments { WorkorderId = workOrderId, Attachments = fileUrl, Category = category.HasValue ? resolvedCategory : null, CreatedDate = DateTime.UtcNow, createdby = actorId }; _mediaData.TrackAttachment(created); await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", null, FormatMediaAuditValue(null, (created.Category ?? WorkOrderMediaCategory.Extra).ToString()), actorId); await SaveMediaAsync(ct); return created; }, cancellationToken); return new WorkOrderMediaFileDto { Id = attachment.Id, Category = attachment.Category ?? WorkOrderMediaCategory.Extra, Url = fileUrl, UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), IsLegacy = false }; } public async Task UpdateMediaCategoryAsync( int workOrderId, int mediaId, WorkOrderMediaCategory category, string? workOrderVersion, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint."); if (category == WorkOrderMediaCategory.Completion) { throw new WorkOrderBoardValidationException( "UseCompletionDocEndpoint", "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); } var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); ApplyExpectedVersion(workOrder, workOrderVersion); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); var currentCategory = attachment.Category ?? WorkOrderMediaCategory.Extra; EnsureMediaMutationAllowed(workOrder.LifecycleStatus, currentCategory); EnsureMediaMutationAllowed(workOrder.LifecycleStatus, category); var priorCategory = currentCategory.ToString(); if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After) { if (IsDocumentAttachment(attachment.Attachments)) { throw new WorkOrderBoardValidationException( "UnsupportedMediaType", "Documents can only be categorized as Extra or Aveta."); } var url = attachment.Attachments ?? ""; if (category == WorkOrderMediaCategory.Before) workOrder.BeforPhotoAttachment = url; else workOrder.AfterPhotoAttachment = url; attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, category.ToString()), actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = category == WorkOrderMediaCategory.Before ? -1 : -2, Category = category, Url = url, IsLegacy = true }; } attachment.Category = category; _mediaData.MarkWorkOrderModified(workOrder); await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, category.ToString()), actorId); await SaveMediaAsync(cancellationToken); return new WorkOrderMediaFileDto { Id = attachment.Id, Category = attachment.Category ?? WorkOrderMediaCategory.Extra, Url = attachment.Attachments ?? "", UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), IsLegacy = false }; } public async Task DeleteMediaAsync( int workOrderId, int mediaId, string? workOrderVersion, ClaimsPrincipal user, string? actorId, CancellationToken cancellationToken = default) { WorkOrderMediaAuthorization.EnsureCanDelete(user, actorId); if (mediaId <= 0) throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken); ApplyExpectedVersion(workOrder, workOrderVersion); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken); if (attachment == null) throw new WorkOrderBoardValidationException("NotFound", "Media not found."); var currentCategory = attachment.Category ?? WorkOrderMediaCategory.Extra; EnsureMediaMutationAllowed(workOrder.LifecycleStatus, currentCategory); var priorCategory = currentCategory.ToString(); attachment.IsDeleted = true; attachment.DeletionTime = DateTime.UtcNow; attachment.DeleterUserId = actorId; _mediaData.MarkWorkOrderModified(workOrder); await _auditService.StageFieldChangedAsync( workOrderId, "MediaCategory", FormatMediaAuditValue(mediaId, priorCategory), FormatMediaAuditValue(mediaId, "Deleted"), actorId); await SaveMediaAsync(cancellationToken); } private async Task GetMutableWorkOrderForAuthAsync( int workOrderId, ClaimsPrincipal user, string actorId, CancellationToken cancellationToken) { var accountFilter = ResolveAccountFilter(user); var workOrder = await _mediaData.GetWorkOrderForMediaAuthAsync(workOrderId, accountFilter, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); return workOrder; } private async Task GetMutableWorkOrderAsync( int workOrderId, ClaimsPrincipal user, string actorId, CancellationToken cancellationToken) { var accountFilter = ResolveAccountFilter(user); // Fresh tracked load (not the AsNoTracking pre-check entity). var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, accountFilter, cancellationToken); if (workOrder == null) throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder); return workOrder; } private static void EnsureMediaMutationAllowed( LifecycleStatus? status, WorkOrderMediaCategory category) { if (!WorkOrderBoardMutationRules.CanMutateExtraMedia(status, category)) throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); } /// /// Media contract: at most 10 photos and 3 videos per work order, counted over the /// stored attachment rows plus the work order's current vendor-portal documents. Legacy /// Before/After column slots replace in place and are not counted. Documents have no /// per-work-order count limit. /// private async Task EnsureWithinMediaCountsAsync( int workOrderId, string fileUrl, CancellationToken cancellationToken) { var kind = WorkOrderMediaContract.ResolveKind(null, fileUrl); if (kind != WorkOrderMediaContract.UploadKind.Photo && kind != WorkOrderMediaContract.UploadKind.Video) return; var urls = await _mediaData.ListActiveAttachmentUrlsAsync(workOrderId, cancellationToken); var vendorTypes = await _mediaData.ListActiveVendorMediaContentTypesAsync(workOrderId, cancellationToken); var countMessage = WorkOrderMediaContract.ValidateCount(kind, urls, vendorTypes); if (countMessage != null) throw new WorkOrderBoardValidationException("MediaCountExceeded", countMessage); } /// /// Account filter for data queries. Null means org-wide (skip ApplyAccountScope). /// Call only after EnsureCan* has verified scope is not Missing. /// private static int? ResolveAccountFilter(ClaimsPrincipal user) { return WorkOrderMediaAuthorization.ResolveMediaScope(user) switch { MediaAccountScope.Account account => account.AccountId, MediaAccountScope.OrgWide => null, _ => throw new WorkOrderBoardValidationException( "Forbidden", "You are not allowed to access work order media without account scope.") }; } private async Task SaveMediaAsync(CancellationToken cancellationToken) { try { await _mediaData.SaveAsync(cancellationToken); } catch (DbUpdateConcurrencyException) { throw new WorkOrderBoardValidationException( "ConcurrencyConflict", "Work order was modified. Refresh and retry."); } } private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion) { var expected = ParseRowVersion(workOrderVersion); if (expected == null) throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(expected)) throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); _mediaData.SetExpectedWorkOrderVersion(workOrder, expected); } private static byte[]? ParseRowVersion(string? base64) { if (string.IsNullOrWhiteSpace(base64)) return null; try { return Convert.FromBase64String(base64); } catch (FormatException) { throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format."); } } private static string FormatMediaAuditValue(int? mediaId, string category) => mediaId.HasValue ? $"{mediaId.Value}:{category}" : category; private static WorkOrderBoardValidationException MediaNotFound() => new("NotFound", "Media not found."); private static string GetSafeFileName(string fileUrl) { if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) return "download"; var fileName = Path.GetFileName(Uri.UnescapeDataString(uri.AbsolutePath)); if (fileName.Length > 37 && fileName[36] == '_' && Guid.TryParse(fileName[..36], out _)) { fileName = fileName[37..]; } return string.IsNullOrWhiteSpace(fileName) ? "download" : Path.GetFileName(fileName); } private static string GetContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".heic" => "image/heic", ".mp4" => "video/mp4", ".mov" => "video/quicktime", ".pdf" => "application/pdf", ".doc" => "application/msword", ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", _ => "application/octet-stream" }; private static bool IsDocumentAttachment(string? attachment) => GetContentType(GetSafeFileName(attachment ?? string.Empty)) is "application/pdf" or "application/msword" or "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; } }