diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs index 343d5f7..dccbbc6 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -52,7 +52,11 @@ public class SyncServiceBehaviorTests external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())) .Returns(items.ToAsyncEnumerable()); - var service = new SyncService(data.Object, external.Object); + var accountResolver = new Mock(); + accountResolver + .Setup(r => r.TryResolveFromCustomerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + var service = new SyncService(data.Object, external.Object, accountResolver.Object); var result = await service.SyncWorkOrdersAsync(CancellationToken.None); Assert.Equal(2, result.Synced); @@ -61,7 +65,7 @@ public class SyncServiceBehaviorTests Assert.Equal(3, result.LocationsCreated); Assert.Equal("Updated Title", existingWO.WorkerOrderTitle); Assert.Equal("In Progress", existingWO.Status); - data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once); + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.ExternalWorkOrderId == "EXT-2" && w.AccountId == 1)), Times.Once); } [Fact] @@ -77,13 +81,18 @@ public class SyncServiceBehaviorTests var items = new List> { - new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" } + new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2", ["customer"] = "Acme" } }; external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); - await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); + var accountResolver = new Mock(); + accountResolver + .Setup(r => r.TryResolveFromCustomerAsync("Acme", It.IsAny())) + .ReturnsAsync(42); + await new SyncService(data.Object, external.Object, accountResolver.Object).SyncWorkOrdersAsync(CancellationToken.None); - data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => + w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2" && w.AccountId == 42)), Times.Once); } [Fact] @@ -107,7 +116,7 @@ public class SyncServiceBehaviorTests }; external.Setup(e => e.ScanCommentsAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); - var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None); + var result = await new SyncService(data.Object, external.Object, Mock.Of()).SyncCommentsAsync(CancellationToken.None); Assert.Equal(2, result.Synced); Assert.Equal(1, result.Created); @@ -276,6 +285,7 @@ public class WorkOrderServiceBehaviorTests (quotesData ?? new Mock()).Object, Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); } } diff --git a/Api.SeaHavenIndustries.Tests/UserControllerTests.cs b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs index 620e461..c6b06b6 100644 --- a/Api.SeaHavenIndustries.Tests/UserControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using System.Security.Claims; using System.Text.Json; @@ -19,22 +20,25 @@ public class UserControllerTests private static string Json(object? value) => JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); - private static UserController NewController(Mock service, string? userId = null) + private static UserController NewController( + Mock service, + string? userId = null, + string? role = null) { var controller = new UserController(service.Object, Mock.Of>()); + var claims = new List(); if (userId != null) + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + if (role != null) + claims.Add(new Claim(ClaimTypes.Role, role)); + + controller.ControllerContext = new ControllerContext { - controller.ControllerContext = new ControllerContext + HttpContext = new DefaultHttpContext { - HttpContext = new DefaultHttpContext - { - User = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(ClaimTypes.NameIdentifier, userId) - }, "Test")) - } - }; - } + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) + } + }; return controller; } @@ -60,10 +64,13 @@ public class UserControllerTests public async Task AddUser_Success_ReturnsUpdatedSuccessfully() { var service = new Mock(); - service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + service.Setup(s => s.AddUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new AddUserOutcomeDTO { Success = true }); - var controller = NewController(service); + var controller = NewController(service, "admin-1", "Admin"); var result = await controller.AddUser(new User_DTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); @@ -71,16 +78,23 @@ public class UserControllerTests var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("200"); response.Message.Should().Be("Updated Successfully"); + service.Verify(s => s.AddUserAsync( + It.IsAny(), + It.Is(p => p.IsInRole("Admin")), + It.IsAny()), Times.Once); } [Fact] public async Task AddUser_Failure_ReturnsBadRequestWithServiceError() { var service = new Mock(); - service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + service.Setup(s => s.AddUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = "boom" }); - var controller = NewController(service); + var controller = NewController(service, "admin-1", "Admin"); var result = await controller.AddUser(new User_DTO(), CancellationToken.None); @@ -90,30 +104,55 @@ public class UserControllerTests response.Message.Should().Be("boom"); } + [Fact] + public async Task AddUser_ServiceForbidden_ReturnsForbid() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden }); + + var controller = NewController(service, "tech-1", "User"); + + var result = await controller.AddUser( + new User_DTO { Name = "N", Email = "n@x.com", Role = "User", AccountId = 99 }, + CancellationToken.None); + + result.Should().BeOfType(); + } + [Fact] public async Task EditUser_NotFound_ReturnsUserNotFoundMessage() { var service = new Mock(); - service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + service.Setup(s => s.EditUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound }); - var controller = NewController(service); + var controller = NewController(service, "admin-1", "Admin"); var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; - response.Message.Should().Be("User not found"); + response.Message.Should().Be(UserMutationErrors.UserNotFound); } [Fact] public async Task EditUser_Found_ReturnsUpdatedSuccessfully() { var service = new Mock(); - service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); + service.Setup(s => s.EditUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = true }); - var controller = NewController(service); + var controller = NewController(service, "admin-1", "Admin"); var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); @@ -121,14 +160,36 @@ public class UserControllerTests ok.Value.Should().BeOfType(); } + [Fact] + public async Task EditUser_ServiceForbidden_ReturnsForbid() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden }); + + var controller = NewController(service, "tech-1", "User"); + + var result = await controller.EditUser( + new EditUser_DTO { Id = "tech-1", AccountId = 42, Email = "me@x.com", Role = "User" }, + CancellationToken.None); + + result.Should().BeOfType(); + } + [Fact] public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage() { var service = new Mock(); - service.Setup(s => s.DeleteUserAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + service.Setup(s => s.DeleteUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound }); - var controller = NewController(service); + var controller = NewController(service, "admin-1", "Admin"); var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None); @@ -141,14 +202,38 @@ public class UserControllerTests public async Task DeleteUser_Found_DelegatesAndReturnsOk() { var service = new Mock(); - service.Setup(s => s.DeleteUserAsync("5", It.IsAny())).ReturnsAsync(true); + service.Setup(s => s.DeleteUserAsync( + "5", + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = true }); - var controller = NewController(service); + var controller = NewController(service, "admin-1", "Admin"); var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None); result.Should().BeOfType(); - service.Verify(s => s.DeleteUserAsync("5", It.IsAny()), Times.Once); + service.Verify(s => s.DeleteUserAsync( + "5", + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteUser_Forbidden_ReturnsForbid() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden }); + + var controller = NewController(service, "tech-1", "User"); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None); + + result.Should().BeOfType(); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs index 8047ad7..d2364bb 100644 --- a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -5,22 +5,36 @@ using Moq; using SeaHaven.DataServices.Dto; 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; public class UserServiceTests { + private static ClaimsPrincipal Principal(params string[] roles) + { + var claims = roles.Select(r => new Claim(ClaimTypes.Role, r)).ToList(); + claims.Add(new Claim(ClaimTypes.NameIdentifier, "caller-1")); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + } + private static UserService NewService( Mock userData, Mock email, - out Mock> store) + out Mock> store, + Mock? accounts = null) { var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); store = s; - return new UserService(manager, userData.Object, email.Object); + return new UserService( + manager, + userData.Object, + (accounts ?? new Mock()).Object, + email.Object); } [Fact] @@ -39,7 +53,7 @@ public class UserServiceTests var users = (await service.GetUsersAsync(CancellationToken.None)).ToList(); users.Should().HaveCount(2); - users[0].Date.Should().Be("Jan 05 2026"); + users[0].Date.Should().Be(new DateTime(2026, 1, 5).ToString("MMM dd yyyy")); users[0].RoleName.Should().Be("Admin"); users[0].Status.Should().Be("Active"); users[1].Date.Should().Be(""); @@ -47,16 +61,17 @@ public class UserServiceTests } [Fact] - public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade() + public async Task DeleteUser_NotFound_ReturnsUserNotFoundWithoutCascade() { var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("missing", It.IsAny())).ReturnsAsync((ApplicationUser?)null); var service = NewService(userData, new Mock(), out _); - var found = await service.DeleteUserAsync("missing", CancellationToken.None); + var outcome = await service.DeleteUserAsync("missing", Principal("Admin"), CancellationToken.None); - found.Should().BeFalse(); + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.UserNotFound); userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -69,12 +84,28 @@ public class UserServiceTests var service = NewService(userData, new Mock(), out _); - var found = await service.DeleteUserAsync("u1", CancellationToken.None); + var outcome = await service.DeleteUserAsync("u1", Principal("Admin"), CancellationToken.None); - found.Should().BeTrue(); + outcome.Success.Should().BeTrue(); userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); } + [Fact] + public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + var outcome = await service.DeleteUserAsync("u1", Principal("User"), CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.Forbidden); + userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task DeleteCurrentUser_SetsIsDeletedFlag() { @@ -91,16 +122,20 @@ public class UserServiceTests } [Fact] - public async Task EditUser_NotFound_ReturnsFalse() + public async Task EditUser_NotFound_ReturnsUserNotFound() { var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("x", It.IsAny())).ReturnsAsync((ApplicationUser?)null); var service = NewService(userData, new Mock(), out _); - var succeeded = await service.EditUserAsync(new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, CancellationToken.None); + var outcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, + Principal("Admin"), + CancellationToken.None); - succeeded.Should().BeFalse(); + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.UserNotFound); } [Fact] @@ -140,7 +175,10 @@ public class UserServiceTests store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + var outcome = await service.AddUserAsync( + new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, + Principal("Admin"), + CancellationToken.None); outcome.Success.Should().BeTrue(); store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); @@ -166,12 +204,174 @@ public class UserServiceTests store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" })); - var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + var outcome = await service.AddUserAsync( + new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, + Principal("Admin"), + CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be("duplicate"); } + [Fact] + public async Task AddUser_NonAdmin_CannotSetAccountId_OnSelfOrOthers() + { + var userData = new Mock(); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(99)).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out _, accounts); + + var selfOutcome = await service.AddUserAsync( + new AddUserRequestDTO { Name = "Me", Email = "me@x.com", Role = "User", AccountId = 99 }, + Principal("User"), + CancellationToken.None); + + var otherOutcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "other-1", Name = "Other", Email = "o@x.com", Role = "Dispatcher", AccountId = 99 }, + Principal("Dispatcher"), + CancellationToken.None); + + selfOutcome.Success.Should().BeFalse(); + selfOutcome.Error.Should().Be(UserMutationErrors.Forbidden); + otherOutcome.Success.Should().BeFalse(); + otherOutcome.Error.Should().Be(UserMutationErrors.Forbidden); + + userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + accounts.Verify(a => a.ExistsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EditUser_NonAdmin_CannotChangeOwnAccountScope() + { + var existing = IdentityTestHelpers.User("caller-1"); + existing.AccountId = 1; + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("caller-1", It.IsAny())).ReturnsAsync(existing); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(2)).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out _, accounts); + + var outcome = await service.EditUserAsync( + new EditUserRequestDTO + { + Id = "caller-1", + Name = "Alice", + Email = "alice@example.com", + Role = "User", + AccountId = 2 + }, + Principal("User"), + CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.Forbidden); + existing.AccountId.Should().Be(1); + userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddUser_Admin_InvalidAccountId_RejectsWithoutCreate() + { + var userData = new Mock(); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(404)).ReturnsAsync(false); + + var service = NewService(userData, new Mock(), out var store, accounts); + + var outcome = await service.AddUserAsync( + new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "User", AccountId = 404 }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.AccountNotFound); + store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + userData.Verify(u => u.GetByIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EditUser_Admin_InvalidAccountId_RejectsWithoutSave() + { + var existing = IdentityTestHelpers.User("u9"); + existing.AccountId = 1; + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u9", It.IsAny())).ReturnsAsync(existing); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(404)).ReturnsAsync(false); + + var service = NewService(userData, new Mock(), out _, accounts); + + var outcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "u9", Name = "N", Email = "n@x.com", Role = "User", AccountId = 404 }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be(UserMutationErrors.AccountNotFound); + existing.AccountId.Should().Be(1); + userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddUser_Admin_ValidAccountId_PersistsOnCreate() + { + var userData = new Mock(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(10)).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out var store, accounts); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + ApplicationUser? created = null; + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => created = u) + .ReturnsAsync(IdentityResult.Success); + store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var outcome = await service.AddUserAsync( + new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "User", AccountId = 10 }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeTrue(); + created.Should().NotBeNull(); + created!.AccountId.Should().Be(10); + accounts.Verify(a => a.ExistsAsync(10), Times.Once); + } + + [Fact] + public async Task EditUser_Admin_ValidAccountId_PersistsOnEdit() + { + var existing = IdentityTestHelpers.User("u9"); + existing.AccountId = 1; + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u9", It.IsAny())).ReturnsAsync(existing); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(10)).ReturnsAsync(true); + + var service = NewService(userData, new Mock(), out var store, accounts); + store.Setup(s => s.GetRolesAsync(existing, It.IsAny())) + .ReturnsAsync(new List { "User" }); + store.Setup(s => s.RemoveFromRoleAsync(existing, "User", It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.AddToRoleAsync(existing, "User", It.IsAny())) + .Returns(Task.CompletedTask); + + var outcome = await service.EditUserAsync( + new EditUserRequestDTO { Id = "u9", Name = "N", Email = "n@x.com", Role = "User", AccountId = 10 }, + Principal("Admin"), + CancellationToken.None); + + outcome.Success.Should().BeTrue(); + existing.AccountId.Should().Be(10); + userData.Verify(u => u.UpdateUserAsync(existing, It.IsAny()), Times.Once); + } + private static bool ContainsStrongGeneratedPassword(string body) { const string marker = "Your password is: "; diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index c2a11aa..d02febc 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -48,7 +48,8 @@ public class WorkOrderControllerSearchTests public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() { var advancedSearch = new WorkOrderAdvancedSearchService( - Mock.Of()); + Mock.Of(), + Mock.Of()); var controller = NewController(advancedSearch); var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs index dd1d0c5..5870f16 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. 46 routes - /// come from 44 actions (Editworkorder and GetWorkorderById each bind two routes). + /// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes + /// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { @@ -68,6 +68,7 @@ public class WorkOrderRouteContractTests "GET {id:int}/media", "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", + "PATCH {id:int}/media/{mediaId:int}", "POST AddChecklistItem", "POST AddComment", "POST AddCommentJson", diff --git a/Api.SeaHavenIndustries/Controllers/UserController.cs b/Api.SeaHavenIndustries/Controllers/UserController.cs index 1540c5c..6ad653f 100644 --- a/Api.SeaHavenIndustries/Controllers/UserController.cs +++ b/Api.SeaHavenIndustries/Controllers/UserController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -32,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] [Route("AddUser")] + [Authorize(Roles = "Admin")] public async Task AddUser(User_DTO user, CancellationToken cancellationToken) { try @@ -41,11 +43,14 @@ namespace Api.SeaHavenIndustries.Controllers Name = user.Name, Email = user.Email, Contact = user.Contact, - Role = user.Role + Role = user.Role, + AccountId = user.AccountId }; - var outcome = await _userService.AddUserAsync(dto, cancellationToken); + var outcome = await _userService.AddUserAsync(dto, User, cancellationToken); if (!outcome.Success) { + if (string.Equals(outcome.Error, UserMutationErrors.Forbidden, StringComparison.Ordinal)) + return Forbid(); return BadRequest(new Response { Status = "Error", Message = outcome.Error }); } return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); @@ -59,6 +64,7 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPut] [Route("EditUser")] + [Authorize(Roles = "Admin")] public async Task EditUser(EditUser_DTO user, CancellationToken cancellationToken) { try @@ -68,11 +74,16 @@ namespace Api.SeaHavenIndustries.Controllers Id = user.Id, Name = user.Name, Email = user.Email, - Role = user.Role + Role = user.Role, + AccountId = user.AccountId }; - var succeeded = await _userService.EditUserAsync(dto, cancellationToken); - if (!succeeded) - return BadRequest(new Response { Status = "Error", Message = "User not found" }); + var outcome = await _userService.EditUserAsync(dto, User, cancellationToken); + if (!outcome.Success) + { + if (string.Equals(outcome.Error, UserMutationErrors.Forbidden, StringComparison.Ordinal)) + return Forbid(); + return BadRequest(new Response { Status = "Error", Message = outcome.Error }); + } return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -83,14 +94,17 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete] [Route("DeleteUser")] + [Authorize(Roles = "Admin")] public async Task DeleteUser(EditUser_DTO req, CancellationToken cancellationToken) { string id = req.Id ?? ""; try { - var found = await _userService.DeleteUserAsync(id, cancellationToken); - if (!found) + var outcome = await _userService.DeleteUserAsync(id, User, cancellationToken); + if (!outcome.Success) { + if (string.Equals(outcome.Error, UserMutationErrors.Forbidden, StringComparison.Ordinal)) + return Forbid(); return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); } return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index 85f6698..33cec3f 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -75,9 +75,17 @@ namespace Api.SeaHavenIndustries.Controllers Search = search }; - var result = await _workOrderBoardService.GetBoardAsync(query, userId); + var result = await _workOrderBoardService.GetBoardAsync(query, User, userId); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (ArgumentException ex) { return BadRequest(ex.Message); @@ -90,9 +98,17 @@ namespace Api.SeaHavenIndustries.Controllers try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await _advancedSearchService.SearchAsync(query, userId); + var result = await _advancedSearchService.SearchAsync(query, User, userId); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (ArgumentException ex) { return BadRequest(new Response { Status = "Error", Message = ex.Message }); @@ -154,7 +170,7 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardCreateService.CreateAsync(request, actorId); + var row = await _boardCreateService.CreateAsync(request, User, actorId); return Ok(row); } catch (ValidationException vex) @@ -162,6 +178,14 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber") { return Conflict(new WorkOrderBoardValidationErrorDto @@ -194,9 +218,17 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var row = await _boardCancelService.CancelAsync(id, actorId); + var row = await _boardCancelService.CancelAsync(id, User, actorId); return Ok(row); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") { return NotFound(new WorkOrderBoardValidationErrorDto diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index e253cbd..bf90361 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs @@ -106,10 +106,13 @@ namespace Api.SeaHavenIndustries.Controllers if (file == null || file.Length == 0) return BadRequest(new Response { Status = "Error", Message = "file is required." }); + string? fileUrl = null; try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await _fileStorage.SaveFileAsync(file); + await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, User, actorId); + + fileUrl = await _fileStorage.SaveFileAsync(file); var result = await _workOrderCompletionService.UploadCompletionDocAsync( id, new WorkOrderCompletionDocUploadDto @@ -119,21 +122,37 @@ namespace Api.SeaHavenIndustries.Controllers WorkOrderVersion = workOrderVersion }, fileUrl, + User, actorId); return Ok(result); } catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") { + TryCompensateUpload(fileUrl); return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "Forbidden") + { + TryCompensateUpload(fileUrl); + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") { + TryCompensateUpload(fileUrl); return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } catch (WorkOrderBoardValidationException ex) { + TryCompensateUpload(fileUrl); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } } + + private void TryCompensateUpload(string? fileUrl) + { + if (!string.IsNullOrWhiteSpace(fileUrl)) + _fileStorage.TryDelete(fileUrl); + } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9d99e2e..c7928de 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -19,13 +20,16 @@ namespace Api.SeaHavenIndustries.Controllers public class WorkOrderController : Controller { private readonly IWorkOrderService _workOrderService; + private readonly IWorkOrderAccountResolver _accountResolver; private readonly ILogger _logger; public WorkOrderController( IWorkOrderService workOrderService, + IWorkOrderAccountResolver accountResolver, ILogger logger) { _workOrderService = workOrderService; + _accountResolver = accountResolver; _logger = logger; } @@ -49,10 +53,11 @@ namespace Api.SeaHavenIndustries.Controllers Attachments = model.Attachments, BeforPhotoAttachment = model.BeforPhotoAttachment, AfterPhotoAttachment = model.AfterPhotoAttachment, - SignOffAttachment = model.SignOffAttachment + SignOffAttachment = model.SignOffAttachment, + Customer = model.Customer }; - var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, userId!); + var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, User, userId!); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId }); } catch (ValidationException vex) @@ -60,6 +65,14 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new Response { Status = "Error", Message = ex.Message }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -116,19 +129,27 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetWorkOrderList")] public async Task GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) { - var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( - page, pageSize, search, status, assignee, locationId, sortBy, sortDir); - - var viewModel = new Pagination_DTO + try { - Data = data, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) - }; + var accountId = _accountResolver.ResolveAccountFilter(User); + var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId); - return Ok(viewModel); + var viewModel = new Pagination_DTO + { + Data = data, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + + return Ok(viewModel); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [Authorize(Roles = "Admin")] @@ -172,10 +193,15 @@ namespace Api.SeaHavenIndustries.Controllers { try { + var accountId = _accountResolver.ResolveAccountFilter(User); var result = await _workOrderService.GetFilteredWorkOrdersAsync( - assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize, accountId); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } catch (Exception) { return StatusCode(500, "Internal Server Error"); @@ -186,10 +212,18 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetWorkorderById")] public async Task GetWorkorderById(int id) { - var detail = await _workOrderService.GetWorkOrderDetailAsync(id); - if (detail == null) - return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); - return Ok(detail); + try + { + var accountId = _accountResolver.ResolveAccountFilter(User); + var detail = await _workOrderService.GetWorkOrderDetailAsync(id, accountId); + if (detail == null) + return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); + return Ok(detail); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet] @@ -208,10 +242,15 @@ namespace Api.SeaHavenIndustries.Controllers { try { + var accountId = _accountResolver.ResolveAccountFilter(User); var result = await _workOrderService.GetFilteredWorkOrders2Async( - assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize, accountId); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } catch (Exception) { var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync(); @@ -279,9 +318,17 @@ namespace Api.SeaHavenIndustries.Controllers Document = model.Document }; - var saved = await _workOrderService.AddCommentAsync(input, userId!); + var saved = await _workOrderService.AddCommentAsync(input, User, userId!); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved }); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -302,9 +349,17 @@ namespace Api.SeaHavenIndustries.Controllers CommentType = model.CommentType }; - var result = await _workOrderService.AddCommentJsonAsync(input, userId!); + var result = await _workOrderService.AddCommentJsonAsync(input, User, userId!); return Ok(result); } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); @@ -315,32 +370,64 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetComments")] public async Task GetComments() { - var data = await _workOrderService.GetCommentsAsync(); - return Ok(data); + try + { + var data = await _workOrderService.GetCommentsAsync(User); + return Ok(data); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet] [Route("GetCommentsByWorkorderId")] public async Task GetCommentsByWorkorderId(int woid) { - var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid); - return Ok(data); + try + { + var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid, User); + if (data == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(data); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet] [Route("GetworkordersDD")] public async Task GetworkordersDD() { - var data = await _workOrderService.GetWorkordersDDAsync(); - return Ok(data); + try + { + var accountId = _accountResolver.ResolveAccountFilter(User); + var data = await _workOrderService.GetWorkordersDDAsync(accountId); + return Ok(data); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet] [Route("Getworkorders")] public async Task Getworkorders() { - var data = await _workOrderService.GetWorkordersAsync(); - return Ok(data); + try + { + var accountId = _accountResolver.ResolveAccountFilter(User); + var data = await _workOrderService.GetWorkordersAsync(accountId); + return Ok(data); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs index 3701db9..8f0d654 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderDetailController.cs @@ -29,28 +29,50 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id:int}/detail")] public async Task GetDetail(int id) { - var detail = await _workOrderDetailService.GetDetailAsync(id); - if (detail == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(detail); + try + { + var detail = await _workOrderDetailService.GetDetailAsync(id, User); + if (detail == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(detail); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("{id:int}/audit")] public async Task GetAudit(int id, [FromQuery] int limit = 50) { - var audit = await _workOrderDetailService.GetAuditAsync(id, limit); - if (audit == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(audit); + try + { + var audit = await _workOrderDetailService.GetAuditAsync(id, User, limit); + if (audit == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(audit); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("{id:int}/comments")] public async Task GetBoardComments(int id) { - var comments = await _workOrderCommentService.GetCommentsAsync(id); - if (comments == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(comments); + try + { + var comments = await _workOrderCommentService.GetCommentsAsync(id, User); + if (comments == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(comments); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } } [HttpPost("{id:int}/comments")] @@ -59,13 +81,18 @@ namespace Api.SeaHavenIndustries.Controllers try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); + var comment = await _workOrderCommentService.AddCommentAsync(id, request, User, actorId); return Ok(comment); } 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 }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); @@ -82,7 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var comment = await _workOrderCommentService.UpdateCommentAsync( - id, commentId, request, actorId); + id, commentId, request, User, actorId); return Ok(comment); } catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs index 2664d65..55c2dbf 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderMediaController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -28,35 +29,100 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("{id:int}/media")] - public async Task GetMedia(int id) + public async Task GetMedia(int id, CancellationToken cancellationToken) { - var media = await _workOrderMediaService.GetMediaAsync(id); - if (media == null) - return NotFound(new Response { Status = "Error", Message = "Work order not found." }); - return Ok(media); + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var media = await _workOrderMediaService.GetMediaAsync(id, User, actorId, cancellationToken); + if (media == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(media); + } + 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(30_000_000)] public async Task AddMedia( int id, - [FromForm] WorkOrderMediaCategory category, - [FromForm] IFormFile file) + [FromForm] WorkOrderMediaCategory? category, + [FromForm] IFormFile file, + CancellationToken cancellationToken) { if (file == null || file.Length == 0) return BadRequest(new Response { Status = "Error", Message = "file is required." }); + string? fileUrl = null; + try + { + WorkOrderMediaFileRules.EnsureAllowed(file); + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken); + + fileUrl = await _fileStorage.SaveFileAsync(file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, User, actorId, cancellationToken); + return Ok(media); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + TryCompensateUpload(fileUrl); + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + TryCompensateUpload(fileUrl); + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + TryCompensateUpload(fileUrl); + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch + { + TryCompensateUpload(fileUrl); + throw; + } + } + + [HttpPatch("{id:int}/media/{mediaId:int}")] + public async Task UpdateMediaCategory( + int id, + int mediaId, + [FromForm] WorkOrderMediaCategory category, + [FromForm] string? workOrderVersion = null, + CancellationToken cancellationToken = default) + { try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var fileUrl = await _fileStorage.SaveFileAsync(file); - var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + var media = await _workOrderMediaService.UpdateMediaCategoryAsync( + id, mediaId, category, workOrderVersion, User, actorId, cancellationToken); return Ok(media); } 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 }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); @@ -64,22 +130,41 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpDelete("{id:int}/media/{mediaId:int}")] - public async Task DeleteMedia(int id, int mediaId) + public async Task DeleteMedia( + int id, + int mediaId, + [FromQuery] string? workOrderVersion = null, + CancellationToken cancellationToken = default) { try { var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); - await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, User, actorId, cancellationToken); return NoContent(); } 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 }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } catch (WorkOrderBoardValidationException ex) { return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); } } + + private void TryCompensateUpload(string? fileUrl) + { + if (!string.IsNullOrWhiteSpace(fileUrl)) + _fileStorage.TryDelete(fileUrl); + } } } diff --git a/Api.SeaHavenIndustries/DTOs/User_DTO.cs b/Api.SeaHavenIndustries/DTOs/User_DTO.cs index 8d1e68c..4a624c8 100644 --- a/Api.SeaHavenIndustries/DTOs/User_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/User_DTO.cs @@ -6,6 +6,7 @@ public string? Email { get; set; } public string? Contact { get; set; } public string? Role { get; set; } + public int? AccountId { get; set; } } public class EditUser_DTO { @@ -13,5 +14,6 @@ public string? Name { get; set; } public string? Email { get; set; } public string? Role { get; set; } + public int? AccountId { get; set; } } } diff --git a/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs b/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs index 32d6532..a1b2d18 100644 --- a/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs @@ -25,6 +25,7 @@ namespace Api.SeaHavenIndustries.DTOs public string? SignOffSignature { get; set; } public List? ContactIds { get; set; } public List? CategoryIds { get; set; } + public string? Customer { get; set; } public static WorkOrder MapToEntity(Workorder_DTO dto, string userId) { diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index 308dd86..adcc357 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -23,8 +23,7 @@ namespace Api.SeaHavenIndustries.Infrastructure var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uploadPath = Path.Combine("Assets", "Documents"); - var webRoot = _webHostEnvironment.WebRootPath - ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); + var webRoot = ResolveWebRoot(); var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); @@ -39,6 +38,41 @@ namespace Api.SeaHavenIndustries.Infrastructure var domain = $"{request.Scheme}://{request.Host}"; return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; } + + public bool TryDelete(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + return false; + + try + { + if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) + 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; + + System.IO.File.Delete(fullPath); + return true; + } + catch + { + return false; + } + } + + private string ResolveWebRoot() + => _webHostEnvironment.WebRootPath + ?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot"); } public class DispatchEmailAdapter : IDispatchEmailPort diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 541c2d7..f804b72 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -145,6 +145,24 @@ namespace Data.SeaHavenIndustries .HasForeignKey(w => w.PrimaryDispatchId) .OnDelete(DeleteBehavior.Restrict); + builder.Entity() + .HasOne(w => w.Account) + .WithMany() + .HasForeignKey(w => w.AccountId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(w => w.AccountId); + + builder.Entity() + .HasOne(u => u.Account) + .WithMany() + .HasForeignKey(u => u.AccountId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(u => u.AccountId); + builder.Entity() .HasIndex(c => c.NormalizedName) .IsUnique(); @@ -299,6 +317,10 @@ namespace Data.SeaHavenIndustries public string? Initials { get; set; } public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin + /// Optional CRM account membership for server-derived media scope (SH-221). + public int? AccountId { get; set; } + [ForeignKey(nameof(AccountId))] + public virtual Accounts? Account { get; set; } public ICollection