namespace SeaHaven.Services.Helpers { /// /// Client-confirmed media contract (2026-09-22): photos up to 10 MB (JPEG/PNG/HEIC), /// videos up to 100 MB and 90 seconds (MP4/MOV), at most 10 photos and 3 videos per work /// order, across the dispatcher media modal, the completion-doc media tab and the vendor /// portal upload. Documents (PDF/DOC/DOCX) keep the 50 MB document cap. Duration is read /// from the MP4/MOV movie header (); the browser pre-checks /// it and the server enforces it, and unreadable metadata never blocks an upload. /// public static class WorkOrderMediaContract { public const long MaxPhotoBytes = 10_000_000; public const long MaxVideoBytes = 100_000_000; public const long MaxDocumentBytes = 50_000_000; public const int MaxPhotosPerWorkOrder = 10; public const int MaxVideosPerWorkOrder = 3; public const int MaxVideoDurationSeconds = 90; /// /// Request-level ceiling for media endpoints (RequestSizeLimit / multipart limit). It sits /// above plus multipart overhead so an oversize file reaches the /// per-kind check and gets its generic message instead of a framework 413. The EB nginx /// proxy (.platform/nginx/conf.d/01_upload_body_size.conf) must stay above this value. /// public const long MaxUploadRequestBytes = 110_000_000; public enum UploadKind { Photo, Video, Document, Unknown } private static readonly Dictionary KindByContentType = new(StringComparer.OrdinalIgnoreCase) { ["image/jpeg"] = UploadKind.Photo, ["image/jpg"] = UploadKind.Photo, ["image/png"] = UploadKind.Photo, ["image/heic"] = UploadKind.Photo, ["video/mp4"] = UploadKind.Video, ["video/quicktime"] = UploadKind.Video, ["application/pdf"] = UploadKind.Document, ["application/msword"] = UploadKind.Document, ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = UploadKind.Document, }; private static readonly Dictionary KindByExtension = new(StringComparer.OrdinalIgnoreCase) { [".jpg"] = UploadKind.Photo, [".jpeg"] = UploadKind.Photo, [".png"] = UploadKind.Photo, [".heic"] = UploadKind.Photo, [".mp4"] = UploadKind.Video, [".mov"] = UploadKind.Video, [".pdf"] = UploadKind.Document, [".doc"] = UploadKind.Document, [".docx"] = UploadKind.Document, }; /// /// Classifies an upload. Pass the validated (resolved) content type: it decides whenever /// it is an allowlisted type, so a misleading file name cannot move a file into a larger /// size class. The extension only decides when no allowlisted type is known (for example /// counting stored attachment URLs). /// public static UploadKind ResolveKind(string? contentType, string? fileName) { var type = (contentType ?? string.Empty).Trim(); if (KindByContentType.TryGetValue(type, out var byType)) return byType; var extension = Path.GetExtension(fileName ?? string.Empty); return KindByExtension.TryGetValue(extension, out var byExtension) ? byExtension : UploadKind.Unknown; } /// /// Per-work-order count check across both upload surfaces: dispatcher attachments /// (classified by stored URL) and vendor-portal documents (classified by validated /// content type). Returns the stable rejection message, or null when there is room. /// public static string? ValidateCount( UploadKind kind, IEnumerable attachmentUrls, IEnumerable vendorContentTypes) { if (kind != UploadKind.Photo && kind != UploadKind.Video) return null; var (photos, videos) = CountKinds(attachmentUrls, vendorContentTypes); if (kind == UploadKind.Photo && photos >= MaxPhotosPerWorkOrder) return "A work order can have at most 10 photos."; if (kind == UploadKind.Video && videos >= MaxVideosPerWorkOrder) return "A work order can have at most 3 videos."; return null; } /// /// Photos and videos on a work order: dispatcher attachments (kind from the stored URL) /// plus current vendor-portal documents (kind from the validated content type). /// public static (int Photos, int Videos) CountKinds( IEnumerable attachmentUrls, IEnumerable vendorContentTypes) { var kinds = attachmentUrls.Select(url => ResolveKind(null, url)) .Concat(vendorContentTypes.Select(type => ResolveKind(type, null))) .ToList(); return (kinds.Count(k => k == UploadKind.Photo), kinds.Count(k => k == UploadKind.Video)); } /// /// 90-second video limit, read from the MP4/QuickTime movie header. A video whose /// duration cannot be read is not blocked. Returns the stable message or null. /// public static string? ValidateVideoDuration(Stream? content) { var seconds = VideoDurationProbe.TryReadDurationSeconds(content); return seconds > MaxVideoDurationSeconds ? $"Videos must be {MaxVideoDurationSeconds} seconds or shorter." : null; } /// Stable, generic per-kind size message; never echoes file metadata. public static string? ValidateSize(long length, UploadKind kind) { return kind switch { UploadKind.Photo when length > MaxPhotoBytes => "Photos must be 10 MB or smaller.", UploadKind.Video when length > MaxVideoBytes => "Videos must be 100 MB or smaller.", UploadKind.Document when length > MaxDocumentBytes => "Documents must be 50 MB or smaller.", UploadKind.Unknown when length > MaxVideoBytes => "Files must be 100 MB or smaller.", _ => null }; } public static string? ValidateSize(string? contentType, string? fileName, long length) => ValidateSize(length, ResolveKind(contentType, fileName)); } }