mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
The 10-photo / 3-video cap was a check-then-insert with no lock on both upload surfaces, so two overlapping uploads could both take the last slot. The board media upload and the vendor portal upload now run count, insert and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic to image/heic.
487 lines
20 KiB
C#
487 lines
20 KiB
C#
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<IReadOnlyList<WorkOrderMediaFileDto>?> 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<WorkOrderMediaContentDto> 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<WorkOrderMediaFileDto> 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
|
|
};
|
|
}
|
|
|
|
// SH-116 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<WorkOrderMediaFileDto> 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<WorkOrder> 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<WorkOrder> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// SH-116 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
|
|
/// Call only after EnsureCan* has verified scope is not Missing.
|
|
/// </summary>
|
|
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";
|
|
}
|
|
}
|