From 47022c7761e5f54e7da89f8ef83f5d4979afcbd3 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:02:40 -0300 Subject: [PATCH 01/15] feat(work-orders): allow Extra Docs PDF/DOC by category --- .../Helpers/WorkOrderMediaFileRules.cs | 71 ++++++++++++++++-- .../WorkOrderPhase6Tests.cs | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index bc03e6c..4786552 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http; +using Data.SeaHavenIndustries.Enums; +using System.IO.Compression; namespace SeaHaven.Services.Helpers { @@ -10,7 +12,10 @@ namespace SeaHaven.Services.Helpers "image/jpeg", "image/png", "video/mp4", - "video/quicktime" + "video/quicktime", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }; private static readonly Dictionary> ExtensionsByContentType = @@ -19,10 +24,16 @@ namespace SeaHaven.Services.Helpers ["image/jpeg"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" }, ["image/png"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png" }, ["video/mp4"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }, - ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" } + ["video/quicktime"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".mov" }, + ["application/pdf"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".pdf" }, + ["application/msword"] = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc" }, + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] = + new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" } }; - public static bool IsAllowed(IFormFile file) + public static bool IsAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { if (file == null || file.Length <= 0) return false; @@ -31,6 +42,13 @@ namespace SeaHaven.Services.Helpers if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType)) return false; + var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; + if (IsDocument(contentType) + && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) + { + return false; + } + var extension = Path.GetExtension(file.FileName ?? string.Empty); if (string.IsNullOrWhiteSpace(extension) || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) @@ -42,7 +60,7 @@ namespace SeaHaven.Services.Helpers try { using var stream = file.OpenReadStream(); - var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64); + var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 512); if (headerLength == 0) return false; @@ -54,6 +72,9 @@ namespace SeaHaven.Services.Helpers if (read < header.Length) Array.Resize(ref header, read); + if (IsDocx(contentType)) + return IsWordDocumentArchive(stream); + return MatchesSignature(contentType, header); } catch @@ -62,13 +83,15 @@ namespace SeaHaven.Services.Helpers } } - public static void EnsureAllowed(IFormFile file) + public static void EnsureAllowed( + IFormFile file, + WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra) { - if (!IsAllowed(file)) + if (!IsAllowed(file, category)) { throw new Exceptions.WorkOrderBoardValidationException( "UnsupportedMediaType", - "Supported media types are JPG, PNG, MP4, and MOV."); + "Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only."); } } @@ -95,9 +118,43 @@ namespace SeaHaven.Services.Helpers return HasFtypBox(bytes); } + if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + return bytes.Length >= 4 && bytes.AsSpan(0, 4).SequenceEqual("%PDF"u8); + + if (contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase)) + { + ReadOnlySpan oleSignature = stackalloc byte[] + { + 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 + }; + return bytes.Length >= oleSignature.Length + && bytes.AsSpan(0, oleSignature.Length).SequenceEqual(oleSignature); + } + return false; } + private static bool IsDocument(string contentType) + => contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) + || contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase) + || IsDocx(contentType); + + private static bool IsDocx(string contentType) + => contentType.Equals( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + StringComparison.OrdinalIgnoreCase); + + private static bool IsWordDocumentArchive(Stream stream) + { + if (!stream.CanSeek) + return false; + + stream.Position = 0; + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + return archive.Entries.Any(entry => + entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)); + } + private static bool HasFtypBox(byte[] bytes) { if (bytes.Length < 12) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 1ee9a4f..a0f81cd 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text; +using System.IO.Compression; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Http; @@ -1741,6 +1742,19 @@ public class WorkOrderMediaFileRulesTests ContentType = contentType }; + private static byte[] DocxBytes(bool includeWordEntry) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(includeWordEntry ? "word/document.xml" : "not-word/content.xml"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(""); + } + + return stream.ToArray(); + } + [Fact] public void IsAllowed_ValidJpeg_ReturnsTrue() { @@ -1790,4 +1804,65 @@ public class WorkOrderMediaFileRulesTests WorkOrderMediaFileRules.EnsureAllowed(FormFile(new byte[] { 0x00 }, "a.png", "image/png"))); Assert.Equal("UnsupportedMediaType", ex.Code); } + + [Theory] + [InlineData(WorkOrderMediaCategory.Extra)] + [InlineData(WorkOrderMediaCategory.Aveta)] + public void EnsureAllowed_PdfForDocumentCategory_Succeeds(WorkOrderMediaCategory category) + { + var pdf = Encoding.ASCII.GetBytes("%PDF-1.7"); + + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(pdf, "report.pdf", "application/pdf"), + category); + } + + [Fact] + public void EnsureAllowed_DocWithOleSignatureForExtra_Succeeds() + { + var doc = new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x00 }; + + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(doc, "report.doc", "application/msword"), + WorkOrderMediaCategory.Extra); + } + + [Fact] + public void EnsureAllowed_RealDocxForExtra_Succeeds() + { + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: true), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra); + } + + [Theory] + [InlineData(WorkOrderMediaCategory.Before)] + [InlineData(WorkOrderMediaCategory.After)] + public void EnsureAllowed_PdfForPhotoCategory_ThrowsUnsupportedMediaType( + WorkOrderMediaCategory category) + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile(Encoding.ASCII.GetBytes("%PDF-1.7"), "report.pdf", "application/pdf"), + category)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } + + [Fact] + public void EnsureAllowed_ZipWithoutWordEntry_ThrowsUnsupportedMediaType() + { + var ex = Assert.Throws(() => + WorkOrderMediaFileRules.EnsureAllowed( + FormFile( + DocxBytes(includeWordEntry: false), + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + WorkOrderMediaCategory.Extra)); + + Assert.Equal("UnsupportedMediaType", ex.Code); + } } From b6b8d2e58f9ca2315a7be169592af8e1dfd8af1a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:06:05 -0300 Subject: [PATCH 02/15] feat(work-orders): cap media uploads at 50MB --- .../WorkOrderMediaControllerTests.cs | 75 +++++++++++++++++++ .../Controllers/WorkOrderMediaController.cs | 13 +++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs new file mode 100644 index 0000000..18d1a31 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -0,0 +1,75 @@ +using Api.SeaHavenIndustries.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderMediaControllerTests +{ + private static WorkOrderMediaController CreateController( + Mock? service = null, + Mock? storage = null) + { + var controller = new WorkOrderMediaController( + (service ?? new Mock()).Object, + (storage ?? new Mock()).Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + return controller; + } + + [Fact] + public void AddMedia_HasFiftyMegabyteRequestLimit() + { + var method = typeof(WorkOrderMediaController).GetMethod(nameof(WorkOrderMediaController.AddMedia)); + var attribute = Assert.Single( + method!.CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + Assert.Equal(50_000_000L, bytes.Value); + } + + [Fact] + public async Task AddMedia_FileOverLimit_ReturnsStableUnprocessableEntity() + { + var file = new Mock(); + file.SetupGet(candidate => candidate.Length).Returns(50_000_001); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("FileTooLarge", error.Code); + Assert.Equal("The uploaded file must not exceed 50 MB.", error.Message); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddMedia_UnsupportedType_ReturnsUnprocessableEntity() + { + var bytes = new byte[] { 1, 2, 3 }; + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "payload.exe") + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(storage: storage); + + var result = await controller.AddMedia(1, null, file, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("UnsupportedMediaType", error.Code); + storage.VerifyNoOtherCalls(); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 0867b1a..b9bc629 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -17,6 +17,8 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderMediaController : Controller { + public const long MaxUploadBytes = 50_000_000; + private readonly IWorkOrderMediaService _workOrderMediaService; private readonly IFileStoragePort _fileStorage; @@ -51,7 +53,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("{id:int}/media")] - [RequestSizeLimit(30_000_000)] + [RequestSizeLimit(MaxUploadBytes)] public async Task AddMedia( int id, [FromForm] WorkOrderMediaCategory? category, @@ -64,7 +66,14 @@ namespace Api.SeaHavenIndustries.Controllers string? fileUrl = null; try { - WorkOrderMediaFileRules.EnsureAllowed(file); + if (file.Length > MaxUploadBytes) + { + throw new WorkOrderBoardValidationException( + "FileTooLarge", + "The uploaded file must not exceed 50 MB."); + } + + WorkOrderMediaFileRules.EnsureAllowed(file, category); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken, category); From 02df093798382c37d939b0587f05357d8a8484ee Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:07:17 -0300 Subject: [PATCH 03/15] feat(work-orders): align completion-doc size with Extra Docs --- .../WorkOrderMediaControllerTests.cs | 16 ++++++++++++++++ .../Controllers/WorkOrderCompletionController.cs | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 18d1a31..01d88fd 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -73,3 +73,19 @@ public class WorkOrderMediaControllerTests storage.VerifyNoOtherCalls(); } } + +public class WorkOrderCompletionControllerUploadLimitTests +{ + [Fact] + public void UploadCompletionDoc_HasFiftyMegabyteRequestLimit() + { + var method = typeof(WorkOrderCompletionController).GetMethod( + nameof(WorkOrderCompletionController.UploadCompletionDoc)); + var attribute = Assert.Single( + method!.CustomAttributes, + candidate => candidate.AttributeType == typeof(RequestSizeLimitAttribute)); + var bytes = Assert.Single(attribute.ConstructorArguments); + + Assert.Equal(50_000_000L, bytes.Value); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index bf90361..f2ea1cc 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -95,7 +95,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("{id:int}/completion-doc")] - [RequestSizeLimit(30_000_000)] + [RequestSizeLimit(50_000_000)] public async Task UploadCompletionDoc( int id, [FromForm] IFormFile file, From bb651bb54714d1d0b130a2a60f128b5ff5f99906 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:08:26 -0300 Subject: [PATCH 04/15] feat(work-orders): add file storage OpenRead port --- .../Infrastructure/PortAdapters.cs | 27 +++++++++++++++++++ SeaHaven.Services/Interfaces/IServicePorts.cs | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index adcc357..a3ebbf3 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -70,6 +70,33 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + public Stream? OpenRead(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + return null; + + try + { + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return null; + + var relativePath = uri.AbsolutePath.TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..", StringComparison.Ordinal) + || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; + } + catch + { + return null; + } + } + private string ResolveWebRoot() => _webHostEnvironment.WebRootPath ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs index 10c1718..c2d00a6 100644 --- a/SeaHaven.Services/Interfaces/IServicePorts.cs +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -24,6 +24,12 @@ namespace SeaHaven.Services.Interfaces /// cannot be resolved or removed; never throws for missing paths. /// bool TryDelete(string fileUrl); + + /// + /// Opens a previously saved file URL for reading. Returns null when the URL or file + /// cannot be safely resolved. + /// + Stream? OpenRead(string fileUrl); } /// From a25fd0bd5d3e5a90cab92104d35644109f480956 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:09:55 -0300 Subject: [PATCH 05/15] feat(work-orders): stream stored WO media safely --- .../FileStorageAdapterTests.cs | 56 +++++++++++++++++++ .../Infrastructure/PortAdapters.cs | 49 +++++++++------- 2 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs diff --git a/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs new file mode 100644 index 0000000..04126b8 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs @@ -0,0 +1,56 @@ +using Api.SeaHavenIndustries.Infrastructure; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class FileStorageAdapterTests : IDisposable +{ + private readonly string _webRoot = Path.Combine(Path.GetTempPath(), $"shoc-storage-{Guid.NewGuid():N}"); + private readonly FileStorageAdapter _adapter; + + public FileStorageAdapterTests() + { + Directory.CreateDirectory(_webRoot); + var environment = new Mock(); + environment.SetupGet(candidate => candidate.WebRootPath).Returns(_webRoot); + environment.SetupGet(candidate => candidate.ContentRootPath).Returns(_webRoot); + _adapter = new FileStorageAdapter(environment.Object, new HttpContextAccessor()); + } + + [Fact] + public void OpenRead_ValidStoredUrl_ReturnsReadableStream() + { + var directory = Path.Combine(_webRoot, "Assets", "Documents"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "report.pdf"), "stored"); + + using var stream = _adapter.OpenRead("https://example.test/Assets/Documents/report.pdf"); + using var reader = new StreamReader(Assert.IsAssignableFrom(stream)); + + Assert.Equal("stored", reader.ReadToEnd()); + } + + [Theory] + [InlineData("https://example.test/Assets/Images/report.pdf")] + [InlineData("https://example.test/Assets/Documents/../secret.txt")] + [InlineData("https://example.test/Assets/Documents/%2e%2e/secret.txt")] + public void OpenRead_UnsafeUrl_ReturnsNull(string fileUrl) + { + Assert.Null(_adapter.OpenRead(fileUrl)); + } + + [Fact] + public void OpenRead_MissingFile_ReturnsNull() + { + Assert.Null(_adapter.OpenRead("https://example.test/Assets/Documents/missing.pdf")); + } + + public void Dispose() + { + if (Directory.Exists(_webRoot)) + Directory.Delete(_webRoot, recursive: true); + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index a3ebbf3..8b4aad2 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -46,18 +46,9 @@ namespace Api.SeaHavenIndustries.Infrastructure try { - if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + if (!TryResolveDocumentPath(fileUrl, out var fullPath)) return false; - var relativePath = uri.AbsolutePath.TrimStart('/'); - if (string.IsNullOrWhiteSpace(relativePath) - || relativePath.Contains("..", StringComparison.Ordinal) - || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); if (!System.IO.File.Exists(fullPath)) return false; @@ -77,18 +68,9 @@ namespace Api.SeaHavenIndustries.Infrastructure try { - if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + if (!TryResolveDocumentPath(fileUrl, out var fullPath)) return null; - var relativePath = uri.AbsolutePath.TrimStart('/'); - if (string.IsNullOrWhiteSpace(relativePath) - || relativePath.Contains("..", StringComparison.Ordinal) - || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null; } catch @@ -97,6 +79,33 @@ namespace Api.SeaHavenIndustries.Infrastructure } } + private bool TryResolveDocumentPath(string fileUrl, out string fullPath) + { + fullPath = string.Empty; + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + return false; + + var relativePath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..", StringComparison.Ordinal) + || !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var webRoot = Path.GetFullPath(ResolveWebRoot()); + var documentsRoot = Path.GetFullPath(Path.Combine(webRoot, "Assets", "Documents")) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var candidatePath = Path.GetFullPath( + Path.Combine(webRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!candidatePath.StartsWith(documentsRoot, StringComparison.OrdinalIgnoreCase)) + return false; + + fullPath = candidatePath; + return true; + } + private string ResolveWebRoot() => _webHostEnvironment.WebRootPath ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); From b4f2c8b763cc6a9ecc39cef96d3e9c2668299bcc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:19:41 -0300 Subject: [PATCH 06/15] feat(work-orders): authorize WO media content reads --- .../WorkOrderMediaControllerTests.cs | 49 ++++++++++ .../WorkOrderMediaDataService.cs | 10 ++ .../Interfaces/IWorkOrderMediaDataService.cs | 5 + SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 7 ++ .../Implementation/WorkOrderMediaService.cs | 73 +++++++++++++- .../Interfaces/IWorkOrderMediaService.cs | 7 ++ .../TestFileStoragePort.cs | 28 ++++++ .../WorkOrderCompletedSelectiveLockTests.cs | 3 +- ...orkOrderMediaConcurrencyRelationalTests.cs | 3 +- .../WorkOrderPhase6Tests.cs | 98 ++++++++++++++++++- 10 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 SeaHavenIndustries.Tests/TestFileStoragePort.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 01d88fd..8c4f826 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -1,9 +1,14 @@ using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; +using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -89,3 +94,47 @@ public class WorkOrderCompletionControllerUploadLimitTests Assert.Equal(50_000_000L, bytes.Value); } } + +public class WorkOrderMediaServiceCancellationTests +{ + [Fact] + public async Task GetMediaContent_ForwardsCancellationTokenToAllDataReads() + { + using var source = new CancellationTokenSource(); + var token = source.Token; + var mediaData = new Mock(MockBehavior.Strict); + mediaData.Setup(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token)) + .ReturnsAsync(new WorkOrder { Id = 1 }); + mediaData.Setup(candidate => candidate.GetAttachmentForReadAsync(10, 1, token)) + .ReturnsAsync(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.test/Assets/Documents/report.pdf" + }); + var storage = new Mock(MockBehavior.Strict); + storage.Setup(candidate => candidate.OpenRead( + "https://example.test/Assets/Documents/report.pdf")) + .Returns(new MemoryStream([1, 2, 3])); + var service = new WorkOrderMediaService( + mediaData.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + storage.Object); + var user = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "actor-1"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll) + }, + "Test")); + + var result = await service.GetMediaContentAsync(1, 10, user, "actor-1", token); + result.Content.Dispose(); + + mediaData.Verify(candidate => candidate.GetWorkOrderForMediaAuthAsync(1, null, token), Times.Once); + mediaData.Verify(candidate => candidate.GetAttachmentForReadAsync(10, 1, token), Times.Once); + mediaData.VerifyNoOtherCalls(); + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs index a1b887c..fd05a45 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderMediaDataService.cs @@ -38,6 +38,16 @@ namespace SeaHaven.DataServices.Implementation return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); } + public Task GetAttachmentForReadAsync( + int mediaId, + int workOrderId, + CancellationToken cancellationToken) + => _context.workOrderAttachments + .AsNoTracking() + .FirstOrDefaultAsync( + a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, + cancellationToken); + public Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) => _context.workOrderAttachments.FirstOrDefaultAsync( a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true, diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs index f8db574..57cbbf3 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderMediaDataService.cs @@ -15,6 +15,11 @@ namespace SeaHaven.DataServices.Interfaces int? accountId, CancellationToken cancellationToken); + Task GetAttachmentForReadAsync( + int mediaId, + int workOrderId, + CancellationToken cancellationToken); + Task GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); void TrackAttachment(WorkOrderAttachments attachment); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 9832b01..c16da2e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -94,6 +94,13 @@ namespace SeaHaven.Services.DTOs public bool IsLegacy { get; set; } } + public sealed class WorkOrderMediaContentDto + { + public required Stream Content { get; init; } + public required string ContentType { get; init; } + public required string FileName { get; init; } + } + public class WorkOrderCompletionDocUploadDto { public string? SignOffName { get; set; } diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs index bcb8872..4a94fa1 100644 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -15,15 +15,18 @@ namespace SeaHaven.Services.Implementation private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderAuditService _auditService; + private readonly IFileStoragePort _fileStorage; public WorkOrderMediaService( IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData, - IWorkOrderAuditService auditService) + IWorkOrderAuditService auditService, + IFileStoragePort fileStorage) { _mediaData = mediaData; _detailData = detailData; _auditService = auditService; + _fileStorage = fileStorage; } public async Task?> GetMediaAsync( @@ -48,6 +51,42 @@ namespace SeaHaven.Services.Implementation 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, @@ -361,5 +400,37 @@ namespace SeaHaven.Services.Implementation 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", + ".mp4" => "video/mp4", + ".mov" => "video/quicktime", + ".pdf" => "application/pdf", + ".doc" => "application/msword", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + _ => "application/octet-stream" + }; } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs index bb105eb..fd24997 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -12,6 +12,13 @@ namespace SeaHaven.Services.Interfaces string? actorId, CancellationToken cancellationToken = default); + Task GetMediaContentAsync( + int workOrderId, + int mediaId, + ClaimsPrincipal user, + string? actorId, + CancellationToken cancellationToken = default); + /// /// Authorizes the caller and validates the work order is mutable before any blob storage write. /// diff --git a/SeaHavenIndustries.Tests/TestFileStoragePort.cs b/SeaHavenIndustries.Tests/TestFileStoragePort.cs new file mode 100644 index 0000000..9d5d44c --- /dev/null +++ b/SeaHavenIndustries.Tests/TestFileStoragePort.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +internal sealed class TestFileStoragePort : IFileStoragePort +{ + private readonly Func _openRead; + + public TestFileStoragePort(Func? openRead = null) + { + _openRead = openRead ?? (_ => null); + } + + public int TryDeleteCalls { get; private set; } + + public Task SaveFileAsync(IFormFile file) + => Task.FromResult("https://example.test/Assets/Documents/saved.bin"); + + public bool TryDelete(string fileUrl) + { + TryDeleteCalls++; + return true; + } + + public Stream? OpenRead(string fileUrl) + => _openRead(fileUrl); +} diff --git a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs index 733c589..a51311c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderCompletedSelectiveLockTests.cs @@ -40,7 +40,8 @@ public class WorkOrderCompletedSelectiveLockTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + new TestFileStoragePort()); return (context, service); } diff --git a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs index b39d3d6..bfde333 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMediaConcurrencyRelationalTests.cs @@ -175,7 +175,8 @@ public class WorkOrderMediaConcurrencyRelationalTests return new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + new TestFileStoragePort()); } private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index a0f81cd..8bf1480 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -10,6 +10,7 @@ using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; @@ -798,7 +799,8 @@ public class WorkOrderCommentServiceTests public class WorkOrderMediaServiceTests { - private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut() + private static (ApplicationDbContext Context, WorkOrderMediaService Service) CreateSut( + IFileStoragePort? fileStorage = null) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -809,7 +811,8 @@ public class WorkOrderMediaServiceTests var service = new WorkOrderMediaService( new WorkOrderMediaDataService(context), new WorkOrderDetailDataService(context), - audit); + audit, + fileStorage ?? new TestFileStoragePort()); return (context, service); } @@ -1731,6 +1734,97 @@ public class WorkOrderMediaServiceTests Assert.Equal("NotFound", ex.Code); } + + [Fact] + public async Task ExtraPdf_ListContentDeleteContent_FollowsSoftDeleteLifecycle() + { + var storage = new TestFileStoragePort(_ => new MemoryStream(Encoding.ASCII.GetBytes("%PDF-1.7"))); + var (context, service) = CreateSut(storage); + var workOrder = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(workOrder); + await context.SaveChangesAsync(); + var fileUrl = + "https://example.test/Assets/Documents/11111111-1111-1111-1111-111111111111_report.pdf"; + + var added = await service.AddMediaAsync( + 1, + WorkOrderMediaCategory.Extra, + fileUrl, + AuthenticatedUser(), + "actor-1"); + var listed = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1"); + + Assert.Contains(listed!, candidate => candidate.Id == added.Id); + var content = await service.GetMediaContentAsync( + 1, + added.Id, + AuthenticatedUser(), + "actor-1"); + using (content.Content) + { + Assert.Equal("application/pdf", content.ContentType); + Assert.Equal("report.pdf", content.FileName); + } + + await service.DeleteMediaAsync( + 1, + added.Id, + ToVersion(workOrder), + AuthenticatedUser(), + "actor-1"); + var ex = await Assert.ThrowsAsync(() => + service.GetMediaContentAsync(1, added.Id, AuthenticatedUser(), "actor-1")); + + Assert.Equal("NotFound", ex.Code); + Assert.Equal(0, storage.TryDeleteCalls); + } + + [Fact] + public async Task GetMediaContent_CrossAccount_ThrowsNotFound() + { + var storage = new TestFileStoragePort(_ => new MemoryStream([1])); + var (context, service) = CreateSut(storage); + context.workOrders.Add(new WorkOrder { Id = 1, AccountId = 20 }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.test/Assets/Documents/report.pdf" + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.GetMediaContentAsync( + 1, + 10, + AuthenticatedUser(accountId: 10), + "actor-1")); + + Assert.Equal("NotFound", ex.Code); + } + + [Fact] + public async Task GetMediaContent_CanceledToken_ForwardsCancellation() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder { Id = 1 }); + await context.SaveChangesAsync(); + using var source = new CancellationTokenSource(); + source.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.GetMediaContentAsync( + 1, + 10, + AuthenticatedUser(), + "actor-1", + source.Token)); + } } public class WorkOrderMediaFileRulesTests From 9d8ae5ec24c48a1e7e1bf4f3131cc0452265b30e Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:23:52 -0300 Subject: [PATCH 07/15] feat(work-orders): add authorized media content GET --- .../WorkOrderMediaControllerTests.cs | 37 +++++++++++++++++++ .../Controllers/WorkOrderMediaController.cs | 21 +++++++++++ 2 files changed, 58 insertions(+) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs index 8c4f826..b377b40 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderMediaControllerTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -77,6 +78,42 @@ public class WorkOrderMediaControllerTests Assert.Equal("UnsupportedMediaType", error.Code); storage.VerifyNoOtherCalls(); } + + [Fact] + public async Task GetMediaContent_DeletedMedia_ReturnsNotFound() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(candidate => candidate.GetMediaContentAsync( + 1, 10, It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new WorkOrderBoardValidationException("NotFound", "Media not found.")); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(service, storage); + + var result = await controller.GetMediaContent(1, 10, CancellationToken.None); + + var response = Assert.IsType(result); + var error = Assert.IsType(response.Value); + Assert.Equal("NotFound", error.Code); + storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); + storage.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteMedia_Success_DoesNotCallTryDelete() + { + var service = new Mock(MockBehavior.Strict); + service.Setup(candidate => candidate.DeleteMediaAsync( + 1, 10, null, It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var storage = new Mock(MockBehavior.Strict); + var controller = CreateController(service, storage); + + var result = await controller.DeleteMedia(1, 10); + + Assert.IsType(result); + storage.Verify(candidate => candidate.TryDelete(It.IsAny()), Times.Never); + storage.VerifyNoOtherCalls(); + } } public class WorkOrderCompletionControllerUploadLimitTests diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index b9bc629..62f81df 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -52,6 +52,27 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/media/{mediaId:int}/content")] + public async Task GetMediaContent(int id, int mediaId, CancellationToken cancellationToken) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var content = await _workOrderMediaService.GetMediaContentAsync( + id, mediaId, User, actorId, cancellationToken); + return File(content.Content, content.ContentType, content.FileName); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + [HttpPost("{id:int}/media")] [RequestSizeLimit(MaxUploadBytes)] public async Task AddMedia( From 143d61354719180782b04f18b4c6721cfc745b4d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 11:25:26 -0300 Subject: [PATCH 08/15] test(work-orders): expect media content route --- .../WorkOrderRouteContractTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index 920f0f4..6411337 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs @@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route - /// duplication is collapsed here, so this is the distinct action-relative contract. 51 routes - /// come from 49 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes + /// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -67,6 +67,7 @@ public class WorkOrderRouteContractTests "GET {id:int}/detail", "GET {id:int}/uplifts", "GET {id:int}/media", + "GET {id:int}/media/{mediaId:int}/content", "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/media/{mediaId:int}", @@ -168,6 +169,24 @@ public class WorkOrderRouteContractTests unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints"); actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count, "the distinct action-relative endpoint count must match the baseline"); + actual.Should().NotContain(endpoint => + endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void WorkOrder_Family_Does_Not_Expose_Documents_Route() + { + ExpectedWorkOrderEndpoints.Should().Contain("GET {id:int}/media/{mediaId:int}/content"); + ExpectedWorkOrderEndpoints.Should().NotContain(endpoint => + endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); + + var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad + && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) + .Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}") + .ToHashSet(StringComparer.Ordinal); + + actual.Should().NotContain(endpoint => + endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); } [Fact] From 335390f7468a5cc26385087da6bbcb5faa610761 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:19:50 -0300 Subject: [PATCH 09/15] feat(work-orders): derive Scheduled from date without assignee --- .../Helpers/WorkOrderBoardMutationRules.cs | 33 +++++++++-- .../WorkOrderBoardMutationRulesTests.cs | 59 +++++++++++++++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs index dde4bfb..e1cec9d 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -46,7 +46,13 @@ namespace SeaHaven.Services.Helpers => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; /// - /// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled. + /// Concrete Schedule On: a calendar day is set and the row is not week-only. + /// + public static bool HasConcreteSchedule(DateTime? scheduledDate, bool? scheduleWeekOnly) + => scheduledDate.HasValue && scheduleWeekOnly != true; + + /// + /// Incomplete or Pending + concrete scheduled date → Scheduled. Assignee is not required. /// Week-only targets do not auto-schedule (same as ). /// public static bool ShouldAutoSchedule( @@ -54,10 +60,27 @@ namespace SeaHaven.Services.Helpers DateTime? scheduledDate, string? assignTo, bool? scheduleWeekOnly = null) - => status == LifecycleStatus.Incomplete - && scheduleWeekOnly != true - && scheduledDate.HasValue - && !string.IsNullOrWhiteSpace(assignTo); + { + _ = assignTo; + return ShouldPromoteToScheduled(status, scheduledDate, scheduleWeekOnly); + } + + public static bool ShouldPromoteToScheduled( + LifecycleStatus? status, + DateTime? scheduledDate, + bool? scheduleWeekOnly = null) + => (status == LifecycleStatus.Incomplete || status == LifecycleStatus.Pending) + && HasConcreteSchedule(scheduledDate, scheduleWeekOnly); + + /// + /// Scheduled without a concrete date (cleared or week-only) → Pending. Later statuses are ignored. + /// + public static bool ShouldDemoteFromScheduled( + LifecycleStatus? status, + DateTime? scheduledDate, + bool? scheduleWeekOnly = null) + => status == LifecycleStatus.Scheduled + && !HasConcreteSchedule(scheduledDate, scheduleWeekOnly); public static bool IsReschedule(DateTime? previousDate, DateTime? newDate) => previousDate.HasValue diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs index 6494004..548a50f 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -27,12 +27,21 @@ public class WorkOrderBoardMutationRulesTests } [Fact] - public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee() + public void ShouldAutoSchedule_WhenIncompleteWithDate_DoesNotRequireAssignee() { Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule( LifecycleStatus.Incomplete, new DateTime(2026, 6, 25), - "dispatcher-1")); + assignTo: null)); + } + + [Fact] + public void ShouldAutoSchedule_WhenPendingWithConcreteDate() + { + Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Pending, + new DateTime(2026, 6, 25), + assignTo: null)); } [Fact] @@ -41,7 +50,7 @@ public class WorkOrderBoardMutationRulesTests Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( LifecycleStatus.Scheduled, new DateTime(2026, 6, 25), - "dispatcher-1")); + assignTo: null)); } [Fact] @@ -50,10 +59,52 @@ public class WorkOrderBoardMutationRulesTests Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( LifecycleStatus.Incomplete, new DateTime(2026, 6, 25), - "dispatcher-1", + assignTo: null, scheduleWeekOnly: true)); } + [Fact] + public void ShouldAutoSchedule_FalseWhenInProgress() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.InProgress, + new DateTime(2026, 6, 25), + assignTo: null)); + } + + [Fact] + public void ShouldDemoteFromScheduled_WhenDateCleared() + { + Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.Scheduled, + scheduledDate: null)); + } + + [Fact] + public void ShouldDemoteFromScheduled_WhenWeekOnly() + { + Assert.True(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.Scheduled, + new DateTime(2026, 6, 25), + scheduleWeekOnly: true)); + } + + [Fact] + public void ShouldDemoteFromScheduled_FalseWhenConcreteDateRemains() + { + Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.Scheduled, + new DateTime(2026, 6, 25))); + } + + [Fact] + public void ShouldDemoteFromScheduled_FalseWhenInProgress() + { + Assert.False(WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + LifecycleStatus.InProgress, + scheduledDate: null)); + } + [Fact] public void CanPatchBoardField_AllowsOnlyFlagColorWhenCompleted() { From e12b3ea54b8cc3574c201205a9f45e006f55164d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:21:14 -0300 Subject: [PATCH 10/15] feat(work-orders): apply schedule lifecycle promote and demote --- .../Helpers/WorkOrderDerivedFields.cs | 57 +++++++++++++------ .../WorkOrderDerivedFieldsTests.cs | 52 +++++++++++++++-- 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs index c7ac473..9ef534c 100644 --- a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -50,25 +50,50 @@ namespace SeaHaven.Services.Helpers } /// - /// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled. - /// Week-only targets do not auto-schedule. + /// Promote Incomplete/Pending to Scheduled, or demote Scheduled to Pending, from Schedule On. + /// Assignee is not required. Week-only is not a concrete date. + /// + public static List ApplyLifecycleFromSchedule(WorkOrder workOrder) + { + var changes = new List(); + LifecycleStatus? next = null; + if (WorkOrderBoardMutationRules.ShouldPromoteToScheduled( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly)) + { + next = LifecycleStatus.Scheduled; + } + else if (WorkOrderBoardMutationRules.ShouldDemoteFromScheduled( + workOrder.LifecycleStatus, + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly)) + { + next = LifecycleStatus.Pending; + } + + if (next == null || workOrder.LifecycleStatus == next) + return changes; + + var old = workOrder.LifecycleStatus?.ToString(); + workOrder.LifecycleStatus = next; + workOrder.Status = GetLifecycleStatusLabel(next); + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.StatusChanged( + "LifecycleStatus", + old, + next.Value.ToString())); + return changes; + } + + /// + /// Applies schedule-driven lifecycle (promote or demote). True when the row becomes Scheduled. /// public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder) { - if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete) - return false; - - if (workOrder.ScheduleWeekOnly == true) - return false; - - if (workOrder.ScheduledDate == null) - return false; - - if (string.IsNullOrWhiteSpace(workOrder.AssignTo)) - return false; - - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - return true; + var before = workOrder.LifecycleStatus; + ApplyLifecycleFromSchedule(workOrder); + return before != LifecycleStatus.Scheduled + && workOrder.LifecycleStatus == LifecycleStatus.Scheduled; } /// diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 2dd8c6b..eadc960 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -83,19 +83,48 @@ public class WorkOrderDerivedFieldsTests } [Fact] - public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled() + public void ApplyAutoSchedule_WhenDateWithoutAssignee_PromotesToScheduled() { var wo = new WorkOrder { LifecycleStatus = LifecycleStatus.Incomplete, - ScheduledDate = new DateTime(2026, 6, 25), - AssignTo = "dispatcher-1" + ScheduledDate = new DateTime(2026, 6, 25) }; Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo)); Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus); } + [Fact] + public void ApplyLifecycleFromSchedule_PendingWithDate_PromotesToScheduled() + { + var wo = new WorkOrder + { + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = new DateTime(2026, 6, 25) + }; + + var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo); + Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus); + Assert.Single(changes); + Assert.Equal(AuditActionType.StatusChanged, changes[0].Action); + } + + [Fact] + public void ApplyLifecycleFromSchedule_ScheduledWithoutDate_DemotesToPending() + { + var wo = new WorkOrder + { + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = null + }; + + var changes = WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo); + Assert.Equal(LifecycleStatus.Pending, wo.LifecycleStatus); + Assert.Single(changes); + Assert.Equal(LifecycleStatus.Pending.ToString(), changes[0].NewValue); + } + [Fact] public void ApplyAutoSchedule_WeekOnly_DoesNotPromote() { @@ -103,7 +132,6 @@ public class WorkOrderDerivedFieldsTests { LifecycleStatus = LifecycleStatus.Incomplete, ScheduledDate = new DateTime(2026, 6, 25), - AssignTo = "dispatcher-1", ScheduleWeekOnly = true, TargetWeek = new DateOnly(2026, 6, 22) }; @@ -112,13 +140,25 @@ public class WorkOrderDerivedFieldsTests Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus); } + [Fact] + public void ApplyLifecycleFromSchedule_InProgress_UnchangedWhenDateCleared() + { + var wo = new WorkOrder + { + LifecycleStatus = LifecycleStatus.InProgress, + ScheduledDate = null + }; + + Assert.Empty(WorkOrderDerivedFields.ApplyLifecycleFromSchedule(wo)); + Assert.Equal(LifecycleStatus.InProgress, wo.LifecycleStatus); + } + [Fact] public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules() { var wo = new WorkOrder { - ScheduledDate = new DateTime(2026, 6, 25), - AssignTo = "dispatcher-1" + ScheduledDate = new DateTime(2026, 6, 25) }; WorkOrderDerivedFields.SetInitialLifecycleStatus(wo); From ae9122243d4a68037a508ee6832ab05f10f57081 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:22:11 -0300 Subject: [PATCH 11/15] feat(work-orders): run schedule status side-effects on board field mutations --- .../Helpers/WorkOrderBoardFieldMutations.cs | 16 +--------- .../WorkOrderBoardFieldMutationsTests.cs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs index c3a59a0..b9f785f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -62,21 +62,7 @@ namespace SeaHaven.Services.Helpers } public static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( - workOrder.LifecycleStatus, - workOrder.ScheduledDate, - workOrder.AssignTo, - workOrder.ScheduleWeekOnly)) - return changes; - - var old = workOrder.LifecycleStatus?.ToString(); - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); - changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); - return changes; - } + => WorkOrderDerivedFields.ApplyLifecycleFromSchedule(workOrder); public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value) { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs index aa83201..61eab70 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardFieldMutationsTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using SeaHaven.Services.Helpers; namespace SeaHavenIndustries.Tests; @@ -50,4 +51,33 @@ public class WorkOrderBoardFieldMutationsTests Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), workOrder.ScheduledStart); Assert.Equal(new DateTime(2026, 6, 25, 11, 0, 0), workOrder.ScheduledEnd); } + + [Fact] + public void ApplyScheduledDate_IncompleteWithoutAssignee_PromotesToScheduled() + { + var workOrder = new WorkOrder { LifecycleStatus = LifecycleStatus.Incomplete }; + + var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate( + workOrder, + new DateTime(2026, 6, 25)); + + Assert.Equal(LifecycleStatus.Scheduled, workOrder.LifecycleStatus); + Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged); + } + + [Fact] + public void ApplyScheduledDate_ClearsScheduled_DemotesToPending() + { + var workOrder = new WorkOrder + { + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 25) + }; + + var changes = WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, parsed: null); + + Assert.Equal(LifecycleStatus.Pending, workOrder.LifecycleStatus); + Assert.Contains(changes, c => c.Action == AuditActionType.StatusChanged + && c.NewValue == LifecycleStatus.Pending.ToString()); + } } From f3f9ac1b58cbe7ee2f26eb1dff155ac2612d319c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:23:13 -0300 Subject: [PATCH 12/15] feat(work-orders): derive lifecycle on board create and schedule PATCH --- .../WorkOrderBoardCreateService.cs | 2 ++ .../WorkOrderBoardUpdateService.cs | 20 ++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 03e4062..0305e1e 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -175,6 +175,8 @@ namespace SeaHaven.Services.Implementation if (request.ScheduledDate.HasValue) changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date)); + changes.AddRange(WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder)); + Dispatch? dispatch = null; if (hasVendorDispatch) { diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 631b2c0..b94762d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -637,6 +637,7 @@ namespace SeaHaven.Services.Implementation var changes = new List { change }; if (change.HasChanged) AppendIsAddOnRecalculation(workOrder, changes); + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); return changes; } @@ -785,21 +786,12 @@ namespace SeaHaven.Services.Implementation } private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( - workOrder.LifecycleStatus, - workOrder.ScheduledDate, - workOrder.AssignTo, - workOrder.ScheduleWeekOnly)) - return changes; + => WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder) + .Select(ToFieldChange) + .ToList(); - var old = workOrder.LifecycleStatus?.ToString(); - workOrder.LifecycleStatus = LifecycleStatus.Scheduled; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); - changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); - return changes; - } + private static FieldChange ToFieldChange(WorkOrderBoardFieldMutations.BoardFieldChange change) + => new(change.FieldName, change.OldValue, change.NewValue, change.Action, change.DispatchId, change.HasChanged); private static bool RowVersionsMatch(byte[]? current, byte[] expected) => current != null && current.AsSpan().SequenceEqual(expected); From 12354f9bf7ef4eb4a9a1867f096118b94c95055b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 8 Sep 2026 16:24:45 -0300 Subject: [PATCH 13/15] test(work-orders): cover SH-186 schedule status derivation --- .../WorkOrderBoardCreateServiceTests.cs | 39 +++++++- .../WorkOrderBoardUpdateServiceTests.cs | 99 ++++++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index cdf7559..0da2c56 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -106,6 +106,40 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("DuplicateWoNumber", ex.Code); } + [Fact] + public async Task Create_AutoSchedulesWhenDateWithoutAssignee() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + ScheduledDate = new DateTime(2026, 6, 25) + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); + } + + [Fact] + public async Task Create_PendingWithDate_PromotesToScheduled() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = new DateTime(2026, 6, 25) + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + [Fact] public async Task Create_AutoSchedulesWhenDateAndAssignee() { @@ -140,6 +174,7 @@ public class WorkOrderBoardCreateServiceTests Assert.True(result.ScheduleWeekOnly); Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); } [Fact] @@ -695,7 +730,7 @@ public class WorkOrderBoardCreateServiceTests { var (_, service) = CreateSut(); - await Assert.ThrowsAsync(() => + var ex = await Assert.ThrowsAsync(() => service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, @@ -703,6 +738,8 @@ public class WorkOrderBoardCreateServiceTests LocationId = 1, LifecycleStatus = LifecycleStatus.Scheduled, }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index f37686e..967b115 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -62,7 +62,6 @@ public class WorkOrderBoardUpdateServiceTests { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, - AssignTo = "user-a", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }; context.workOrders.Add(wo); @@ -83,6 +82,104 @@ public class WorkOrderBoardUpdateServiceTests Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString()); } + [Fact] + public async Task PatchField_PendingWithDate_PromotesToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Pending, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + + [Fact] + public async Task PatchField_ClearsScheduledDate_DemotesToPending() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus); + Assert.Null(result.ScheduledDate); + } + + [Fact] + public async Task PatchField_ScheduleWeekOnly_DemotesScheduledToPending() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 25), + ScheduleWeekOnly = false, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduleWeekOnly, + Value = "true", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus); + Assert.True(result.ScheduleWeekOnly); + } + + [Fact] + public async Task PatchField_InProgress_ClearDate_DoesNotChangeStatus() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.InProgress, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus); + } + [Fact] public async Task PatchField_IncrementsRescheduleCount() { From 8b4aec300f14ca9c1cd2734a54d3cda5a3ef3b15 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 9 Sep 2026 10:23:22 -0300 Subject: [PATCH 14/15] feat(work-orders): re-derive lifecycle when board status is patched Scheduled still requires a concrete date, and Incomplete/Pending with a date must promote even when only lifecycleStatus is sent. --- .../WorkOrderBoardCreateLifecycleRules.cs | 2 +- .../WorkOrderBoardUpdateService.cs | 33 +++- .../WorkOrderBoardCreateServiceTests.cs | 20 +++ .../WorkOrderBoardUpdateServiceTests.cs | 157 ++++++++++++++++++ 4 files changed, 202 insertions(+), 10 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs index 0f06f3a..f16fde2 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardCreateLifecycleRules.cs @@ -33,7 +33,7 @@ namespace SeaHaven.Services.Helpers } if (requested == LifecycleStatus.Scheduled - && (!scheduledDate.HasValue || scheduleWeekOnly == true)) + && !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly)) { throw new WorkOrderBoardValidationException( "ScheduledRequiresDate", diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index b94762d..49a63cb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -183,7 +183,7 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField), WorkOrderBoardFieldNames.Severity => new List { ApplySeverity(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, - WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField), WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), WorkOrderBoardFieldNames.DueDate => new List { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) }, WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), @@ -503,7 +503,7 @@ namespace SeaHaven.Services.Implementation return normalized; } - private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) + private static List ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) { var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value); if (parsed == null) @@ -516,16 +516,31 @@ namespace SeaHaven.Services.Implementation "Replace the provisional SH work order number before marking the work order Completed."); } + if (parsed == LifecycleStatus.Scheduled + && !WorkOrderBoardMutationRules.HasConcreteSchedule( + workOrder.ScheduledDate, + workOrder.ScheduleWeekOnly)) + { + throw new WorkOrderBoardValidationException( + "ScheduledRequiresDate", + "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true."); + } + + var changes = new List(); var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; - if (workOrder.LifecycleStatus == parsed) - return FieldChange.Unchanged(auditField); + if (workOrder.LifecycleStatus != parsed) + { + workOrder.LifecycleStatus = parsed; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); + if (workOrder.LegacyStatus == null && workOrder.Status != null) + workOrder.LegacyStatus = workOrder.Status; - workOrder.LifecycleStatus = parsed; - workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); - if (workOrder.LegacyStatus == null && workOrder.Status != null) - workOrder.LegacyStatus = workOrder.Status; + changes.Add(FieldChange.StatusChanged(auditField, old, parsed.ToString())); + } - return FieldChange.StatusChanged(auditField, old, parsed.ToString()); + // why: Incomplete/Pending patched while a concrete date already exists must still promote. + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; } private static List ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 0da2c56..dd2fd10 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -742,6 +742,26 @@ public class WorkOrderBoardCreateServiceTests Assert.Equal("ScheduledRequiresDate", ex.Code); } + [Fact] + public async Task Create_WithScheduledLifecycleWeekOnly_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 7, 15), + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 7, 13), + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); + } + [Fact] public async Task Create_EmergencyWithSeverity_Persists() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 967b115..7f29f3b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -128,6 +128,9 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus); Assert.Null(result.ScheduledDate); + var audits = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString()); + Assert.Contains(audits, a => a.FieldName == "LifecycleStatus" && a.NewValue == LifecycleStatus.Pending.ToString()); } [Fact] @@ -154,6 +157,136 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(LifecycleStatus.Pending, result.LifecycleStatus); Assert.True(result.ScheduleWeekOnly); + var audits = await context.WorkOrderAuditLogs.ToListAsync(); + Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString()); + } + + [Fact] + public async Task PatchField_ScheduleWeekOnlyFalse_PromotesPendingToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = new DateTime(2026, 12, 15), + ScheduleWeekOnly = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduleWeekOnly, + Value = "false", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.False(result.ScheduleWeekOnly); + } + + [Fact] + public async Task PatchField_LifecycleStatusScheduledWithoutDate_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); + Assert.Equal(LifecycleStatus.Incomplete, (await context.workOrders.SingleAsync()).LifecycleStatus); + } + + [Fact] + public async Task PatchField_LifecycleStatusScheduledWithWeekOnly_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Pending, + ScheduledDate = DateTime.UtcNow.Date.AddDays(14), + ScheduleWeekOnly = true, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("ScheduledRequiresDate", ex.Code); + Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus); + } + + [Fact] + public async Task PatchField_LifecycleStatusScheduledWithConcreteDate_Persists() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = DateTime.UtcNow.Date.AddDays(14), + ScheduleWeekOnly = false, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + } + + [Fact] + public async Task PatchField_LifecycleStatusPendingWithConcreteDate_PromotesToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = DateTime.UtcNow.Date.AddDays(14), + ScheduleWeekOnly = false, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Pending", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); } [Fact] @@ -180,6 +313,30 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(LifecycleStatus.InProgress, result.LifecycleStatus); } + [Fact] + public async Task PatchField_EnRoute_ClearDate_DoesNotChangeStatus() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.EnRoute, + ScheduledDate = new DateTime(2026, 6, 25), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.EnRoute, result.LifecycleStatus); + } + [Fact] public async Task PatchField_IncrementsRescheduleCount() { From e849991dcf31584276b4f34c205cdd296291346d Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 9 Sep 2026 17:09:26 -0300 Subject: [PATCH 15/15] test(work-orders): seed scheduled concurrency scenario --- SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs index d1014b6..0aa2a17 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -46,6 +46,7 @@ public class WorkOrderBoardConcurrencyTests { Id = 1, LifecycleStatus = LifecycleStatus.Incomplete, + ScheduledDate = new DateTime(2027, 6, 25), PrimaryDispatchId = 10, RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 } });