From a0fdd19934ab79c660a41a557e6d2080387150e5 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 21:10:52 -0300 Subject: [PATCH] fix(work-orders): accept image/jpg MIME and expose board MediaCount (#107) Co-authored-by: Alexandre Brandizzi --- .../Helpers/WorkOrderBoardProjection.cs | 5 +- .../Interfaces/WorkOrderBoardModels.cs | 3 +- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 + .../Helpers/WorkOrderMediaFileRules.cs | 14 +++- .../Implementation/WorkOrderBoardService.cs | 1 + .../WorkOrderBoardServiceTests.cs | 75 +++++++++++++++++++ .../WorkOrderPhase6Tests.cs | 7 ++ 7 files changed, 103 insertions(+), 4 deletions(-) diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 84129ed..fcc94b5 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -25,6 +25,8 @@ namespace SeaHaven.DataServices.Helpers HasAvetaDocument = w.workOrderAttachments!.Any(a => (a.IsDeleted == null || a.IsDeleted == false) && a.Category == WorkOrderMediaCategory.Aveta), + MediaCount = w.workOrderAttachments!.Count(a => + a.IsDeleted == null || a.IsDeleted == false), w.OriginalDate, w.OriginalWeek, w.WorkOrderType, @@ -157,7 +159,8 @@ namespace SeaHaven.DataServices.Helpers w.OriginalDate, w.OriginalWeek, isUnscheduled, - w.Severity); + w.Severity, + w.MediaCount); }).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 46f7f65..6e4348b 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -64,7 +64,8 @@ namespace SeaHaven.DataServices.Interfaces DateOnly? OriginalDate, DateOnly? OriginalWeek, bool IsUnscheduled, - string? Severity = null); + string? Severity = null, + int MediaCount = 0); public record WorkOrderBoardQueryResult( IReadOnlyList ScheduledRows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 6f4af86..4ef1c62 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -79,6 +79,8 @@ namespace SeaHaven.Services.DTOs public string? ServiceNotes { get; set; } public List? ExtraServices { get; set; } public DocStatus? DocStatus { get; set; } + /// Live photo/video/document count for the board CompDoc cell (excludes soft-deleted). + public int MediaCount { get; set; } public DateTime? CompletedDate { get; set; } /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar). public string? FlagColor { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index bc03e6c..68381d9 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -8,6 +8,7 @@ namespace SeaHaven.Services.Helpers private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", + "image/jpg", "image/png", "video/mp4", "video/quicktime" @@ -22,15 +23,24 @@ namespace SeaHaven.Services.Helpers ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" } }; + private static string CanonicalContentType(string contentType) + { + if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + return "image/jpeg"; + return contentType; + } + public static bool IsAllowed(IFormFile file) { if (file == null || file.Length <= 0) return false; - var contentType = (file.ContentType ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) + var declaredType = (file.ContentType ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(declaredType) || !AllowedContentTypes.Contains(declaredType)) return false; + var contentType = CanonicalContentType(declaredType); + var extension = Path.GetExtension(file.FileName ?? string.Empty); if (string.IsNullOrWhiteSpace(extension) || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 96f178d..3d73bde 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -137,6 +137,7 @@ namespace SeaHaven.Services.Implementation ExtraServices = ParseExtraServices(row.ExtraServices), AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts), DocStatus = row.DocStatus, + MediaCount = row.MediaCount, CompletedDate = row.CompletedDate, FlagColor = row.FlagColor, Severity = WorkOrderSeverityRules.ParseLevel(row.Severity), diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs index ed3ed09..d1d9343 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -895,6 +895,81 @@ public class WorkOrderBoardServiceTests Assert.False(response.Scheduled[0].HasAvetaDocument); } + [Fact] + public async Task GetBoardAsync_MediaCount_CountsLiveAttachmentsOnly() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.Emergency, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/before.jpg", + Category = WorkOrderMediaCategory.Before + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/extra.jpg", + Category = WorkOrderMediaCategory.Extra + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/removed.jpg", + Category = WorkOrderMediaCategory.Extra, + IsDeleted = true + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Single(response.Scheduled); + Assert.Equal(2, response.Scheduled[0].MediaCount); + } + + [Fact] + public async Task GetBoardAsync_MediaCount_ZeroWithoutAttachments() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + WorkOrderType = WorkOrderType.Emergency, + ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc), + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var boardService = new WorkOrderBoardService( + new WorkOrderBoardDataService(context), + WorkOrderAccountTestHelpers.Resolver(context)); + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Single(response.Scheduled); + Assert.Equal(0, response.Scheduled[0].MediaCount); + } + [Fact] public async Task GetBoardAsync_InvalidWeekWindow_Throws() { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1ee9a4f..b770313 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1748,6 +1748,13 @@ public class WorkOrderMediaFileRulesTests Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg"))); } + [Fact] + public void IsAllowed_ImageJpgAlias_ReturnsTrue() + { + var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; + Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpg"))); + } + [Fact] public void IsAllowed_SpoofedExtension_ReturnsFalse() {