Merge pull request #47 from Sea-Haven-Industries/feature/wo-board-completed-date-media
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

feat(work-orders): board completedDate + media categorize contract
This commit is contained in:
Alexandre Brandizzi 2026-08-11 15:55:00 -03:00 • committed by GitHub
commit 6f08967e32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 8179 additions and 430 deletions

View file

@ -52,7 +52,11 @@ public class SyncServiceBehaviorTests
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())) external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>()))
.Returns(items.ToAsyncEnumerable()); .Returns(items.ToAsyncEnumerable());
var service = new SyncService(data.Object, external.Object); var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver
.Setup(r => r.TryResolveFromCustomerAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var service = new SyncService(data.Object, external.Object, accountResolver.Object);
var result = await service.SyncWorkOrdersAsync(CancellationToken.None); var result = await service.SyncWorkOrdersAsync(CancellationToken.None);
Assert.Equal(2, result.Synced); Assert.Equal(2, result.Synced);
@ -61,7 +65,7 @@ public class SyncServiceBehaviorTests
Assert.Equal(3, result.LocationsCreated); Assert.Equal(3, result.LocationsCreated);
Assert.Equal("Updated Title", existingWO.WorkerOrderTitle); Assert.Equal("Updated Title", existingWO.WorkerOrderTitle);
Assert.Equal("In Progress", existingWO.Status); Assert.Equal("In Progress", existingWO.Status);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once); data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.ExternalWorkOrderId == "EXT-2" && w.AccountId == 1)), Times.Once);
} }
[Fact] [Fact]
@ -77,13 +81,18 @@ public class SyncServiceBehaviorTests
var items = new List<Dictionary<string, string?>> var items = new List<Dictionary<string, string?>>
{ {
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<CancellationToken>())).Returns(items.ToAsyncEnumerable()); external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); var accountResolver = new Mock<IWorkOrderAccountResolver>();
accountResolver
.Setup(r => r.TryResolveFromCustomerAsync("Acme", It.IsAny<CancellationToken>()))
.ReturnsAsync(42);
await new SyncService(data.Object, external.Object, accountResolver.Object).SyncWorkOrdersAsync(CancellationToken.None);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w =>
w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2" && w.AccountId == 42)), Times.Once);
} }
[Fact] [Fact]
@ -107,7 +116,7 @@ public class SyncServiceBehaviorTests
}; };
external.Setup(e => e.ScanCommentsAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable()); external.Setup(e => e.ScanCommentsAsync(It.IsAny<CancellationToken>())).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<IWorkOrderAccountResolver>()).SyncCommentsAsync(CancellationToken.None);
Assert.Equal(2, result.Synced); Assert.Equal(2, result.Synced);
Assert.Equal(1, result.Created); Assert.Equal(1, result.Created);
@ -276,6 +285,7 @@ public class WorkOrderServiceBehaviorTests
(quotesData ?? new Mock<IQuotesDataService>()).Object, (quotesData ?? new Mock<IQuotesDataService>()).Object,
Mock.Of<IFileStoragePort>(), Mock.Of<IFileStoragePort>(),
Mock.Of<ICreateWorkOrderValidation>(), Mock.Of<ICreateWorkOrderValidation>(),
Mock.Of<IUpdateWorkOrderValidation>()); Mock.Of<IUpdateWorkOrderValidation>(),
Mock.Of<IWorkOrderAccountResolver>());
} }
} }

View file

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moq; using Moq;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
@ -19,22 +20,25 @@ public class UserControllerTests
private static string Json(object? value) => private static string Json(object? value) =>
JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object));
private static UserController NewController(Mock<IUserService> service, string? userId = null) private static UserController NewController(
Mock<IUserService> service,
string? userId = null,
string? role = null)
{ {
var controller = new UserController(service.Object, Mock.Of<ILogger<UserController>>()); var controller = new UserController(service.Object, Mock.Of<ILogger<UserController>>());
var claims = new List<Claim>();
if (userId != null) 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(claims, "Test"))
{ }
User = new ClaimsPrincipal(new ClaimsIdentity(new[] };
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "Test"))
}
};
}
return controller; return controller;
} }
@ -60,10 +64,13 @@ public class UserControllerTests
public async Task AddUser_Success_ReturnsUpdatedSuccessfully() public async Task AddUser_Success_ReturnsUpdatedSuccessfully()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.AddUserAsync(It.IsAny<AddUserRequestDTO>(), It.IsAny<CancellationToken>())) service.Setup(s => s.AddUserAsync(
It.IsAny<AddUserRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = true }); .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); 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<DataResponse>().Subject; var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200"); response.Status.Should().Be("200");
response.Message.Should().Be("Updated Successfully"); response.Message.Should().Be("Updated Successfully");
service.Verify(s => s.AddUserAsync(
It.IsAny<AddUserRequestDTO>(),
It.Is<ClaimsPrincipal>(p => p.IsInRole("Admin")),
It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact] [Fact]
public async Task AddUser_Failure_ReturnsBadRequestWithServiceError() public async Task AddUser_Failure_ReturnsBadRequestWithServiceError()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.AddUserAsync(It.IsAny<AddUserRequestDTO>(), It.IsAny<CancellationToken>())) service.Setup(s => s.AddUserAsync(
It.IsAny<AddUserRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = "boom" }); .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); var result = await controller.AddUser(new User_DTO(), CancellationToken.None);
@ -90,30 +104,55 @@ public class UserControllerTests
response.Message.Should().Be("boom"); response.Message.Should().Be("boom");
} }
[Fact]
public async Task AddUser_ServiceForbidden_ReturnsForbid()
{
var service = new Mock<IUserService>();
service.Setup(s => s.AddUserAsync(
It.IsAny<AddUserRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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<ForbidResult>();
}
[Fact] [Fact]
public async Task EditUser_NotFound_ReturnsUserNotFoundMessage() public async Task EditUser_NotFound_ReturnsUserNotFoundMessage()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.EditUserAsync(It.IsAny<EditUserRequestDTO>(), It.IsAny<CancellationToken>())) service.Setup(s => s.EditUserAsync(
.ReturnsAsync(false); It.IsAny<EditUserRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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 result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject; var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject; var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("User not found"); response.Message.Should().Be(UserMutationErrors.UserNotFound);
} }
[Fact] [Fact]
public async Task EditUser_Found_ReturnsUpdatedSuccessfully() public async Task EditUser_Found_ReturnsUpdatedSuccessfully()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.EditUserAsync(It.IsAny<EditUserRequestDTO>(), It.IsAny<CancellationToken>())) service.Setup(s => s.EditUserAsync(
.ReturnsAsync(true); It.IsAny<EditUserRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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); var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None);
@ -121,14 +160,36 @@ public class UserControllerTests
ok.Value.Should().BeOfType<DataResponse>(); ok.Value.Should().BeOfType<DataResponse>();
} }
[Fact]
public async Task EditUser_ServiceForbidden_ReturnsForbid()
{
var service = new Mock<IUserService>();
service.Setup(s => s.EditUserAsync(
It.IsAny<EditUserRequestDTO>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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<ForbidResult>();
}
[Fact] [Fact]
public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage() public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) service.Setup(s => s.DeleteUserAsync(
.ReturnsAsync(false); It.IsAny<string>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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); 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() public async Task DeleteUser_Found_DelegatesAndReturnsOk()
{ {
var service = new Mock<IUserService>(); var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>())).ReturnsAsync(true); service.Setup(s => s.DeleteUserAsync(
"5",
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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); var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>(); result.Should().BeOfType<OkObjectResult>();
service.Verify(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>()), Times.Once); service.Verify(s => s.DeleteUserAsync(
"5",
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteUser_Forbidden_ReturnsForbid()
{
var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync(
It.IsAny<string>(),
It.IsAny<ClaimsPrincipal>(),
It.IsAny<CancellationToken>()))
.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<ForbidResult>();
} }
[Fact] [Fact]

View file

@ -5,22 +5,36 @@ using Moq;
using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit; using Xunit;
namespace Api.SeaHavenIndustries.Tests; namespace Api.SeaHavenIndustries.Tests;
public class UserServiceTests 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( private static UserService NewService(
Mock<IUserDataService> userData, Mock<IUserDataService> userData,
Mock<IEmailSender> email, Mock<IEmailSender> email,
out Mock<IUserRoleStore<ApplicationUser>> store) out Mock<IUserRoleStore<ApplicationUser>> store,
Mock<IAccountDataService>? accounts = null)
{ {
var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
store = s; store = s;
return new UserService(manager, userData.Object, email.Object); return new UserService(
manager,
userData.Object,
(accounts ?? new Mock<IAccountDataService>()).Object,
email.Object);
} }
[Fact] [Fact]
@ -39,7 +53,7 @@ public class UserServiceTests
var users = (await service.GetUsersAsync(CancellationToken.None)).ToList(); var users = (await service.GetUsersAsync(CancellationToken.None)).ToList();
users.Should().HaveCount(2); 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].RoleName.Should().Be("Admin");
users[0].Status.Should().Be("Active"); users[0].Status.Should().Be("Active");
users[1].Date.Should().Be(""); users[1].Date.Should().Be("");
@ -47,16 +61,17 @@ public class UserServiceTests
} }
[Fact] [Fact]
public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade() public async Task DeleteUser_NotFound_ReturnsUserNotFoundWithoutCascade()
{ {
var userData = new Mock<IUserDataService>(); var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("missing", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null); userData.Setup(u => u.GetForEditAsync("missing", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var service = NewService(userData, new Mock<IEmailSender>(), out _); var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never); userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
} }
@ -69,12 +84,28 @@ public class UserServiceTests
var service = NewService(userData, new Mock<IEmailSender>(), out _); var service = NewService(userData, new Mock<IEmailSender>(), 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<CancellationToken>()), Times.Once); userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact]
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact] [Fact]
public async Task DeleteCurrentUser_SetsIsDeletedFlag() public async Task DeleteCurrentUser_SetsIsDeletedFlag()
{ {
@ -91,16 +122,20 @@ public class UserServiceTests
} }
[Fact] [Fact]
public async Task EditUser_NotFound_ReturnsFalse() public async Task EditUser_NotFound_ReturnsUserNotFound()
{ {
var userData = new Mock<IUserDataService>(); var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("x", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null); userData.Setup(u => u.GetForEditAsync("x", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var service = NewService(userData, new Mock<IEmailSender>(), out _); var service = NewService(userData, new Mock<IEmailSender>(), 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] [Fact]
@ -140,7 +175,10 @@ public class UserServiceTests
store.Setup(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) store.Setup(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .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(); outcome.Success.Should().BeTrue();
store.Verify(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Once); store.Verify(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Once);
@ -166,12 +204,174 @@ public class UserServiceTests
store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())) store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" })); .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.Success.Should().BeFalse();
outcome.Error.Should().Be("duplicate"); outcome.Error.Should().Be("duplicate");
} }
[Fact]
public async Task AddUser_NonAdmin_CannotSetAccountId_OnSelfOrOthers()
{
var userData = new Mock<IUserDataService>();
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(99)).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
accounts.Verify(a => a.ExistsAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task EditUser_NonAdmin_CannotChangeOwnAccountScope()
{
var existing = IdentityTestHelpers.User("caller-1");
existing.AccountId = 1;
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("caller-1", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(2)).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddUser_Admin_InvalidAccountId_RejectsWithoutCreate()
{
var userData = new Mock<IUserDataService>();
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(404)).ReturnsAsync(false);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
userData.Verify(u => u.GetByIdAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task EditUser_Admin_InvalidAccountId_RejectsWithoutSave()
{
var existing = IdentityTestHelpers.User("u9");
existing.AccountId = 1;
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u9", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(404)).ReturnsAsync(false);
var service = NewService(userData, new Mock<IEmailSender>(), 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<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddUser_Admin_ValidAccountId_PersistsOnCreate()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByIdAsync(It.IsAny<string>())).ReturnsAsync((ApplicationUser?)null);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(10)).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts);
store.As<IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.SetPasswordHashAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
ApplicationUser? created = null;
store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()))
.Callback<ApplicationUser, CancellationToken>((u, _) => created = u)
.ReturnsAsync(IdentityResult.Success);
store.Setup(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u9", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsAsync(10)).ReturnsAsync(true);
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts);
store.Setup(s => s.GetRolesAsync(existing, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "User" });
store.Setup(s => s.RemoveFromRoleAsync(existing, "User", It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
store.Setup(s => s.AddToRoleAsync(existing, "User", It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
private static bool ContainsStrongGeneratedPassword(string body) private static bool ContainsStrongGeneratedPassword(string body)
{ {
const string marker = "Your password is: "; const string marker = "Your password is: ";

View file

@ -48,7 +48,8 @@ public class WorkOrderControllerSearchTests
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
{ {
var advancedSearch = new WorkOrderAdvancedSearchService( var advancedSearch = new WorkOrderAdvancedSearchService(
Mock.Of<IWorkOrderAdvancedSearchDataService>()); Mock.Of<IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
var controller = NewController(advancedSearch); var controller = NewController(advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single /// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// 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 /// 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 /// duplication is collapsed here, so this is the distinct action-relative contract. 47 routes
/// come from 44 actions (Editworkorder and GetWorkorderById each bind two routes). /// come from 45 actions (Editworkorder and GetWorkorderById each bind two routes).
/// </summary> /// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{ {
@ -68,6 +68,7 @@ public class WorkOrderRouteContractTests
"GET {id:int}/media", "GET {id:int}/media",
"PATCH {id:int}/board", "PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/comments/{commentId:int}",
"PATCH {id:int}/media/{mediaId:int}",
"POST AddChecklistItem", "POST AddChecklistItem",
"POST AddComment", "POST AddComment",
"POST AddCommentJson", "POST AddCommentJson",

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
@ -32,6 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpPost] [HttpPost]
[Route("AddUser")] [Route("AddUser")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> AddUser(User_DTO user, CancellationToken cancellationToken) public async Task<IActionResult> AddUser(User_DTO user, CancellationToken cancellationToken)
{ {
try try
@ -41,11 +43,14 @@ namespace Api.SeaHavenIndustries.Controllers
Name = user.Name, Name = user.Name,
Email = user.Email, Email = user.Email,
Contact = user.Contact, 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 (!outcome.Success)
{ {
if (string.Equals(outcome.Error, UserMutationErrors.Forbidden, StringComparison.Ordinal))
return Forbid();
return BadRequest(new Response { Status = "Error", Message = outcome.Error }); return BadRequest(new Response { Status = "Error", Message = outcome.Error });
} }
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
@ -59,6 +64,7 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpPut] [HttpPut]
[Route("EditUser")] [Route("EditUser")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> EditUser(EditUser_DTO user, CancellationToken cancellationToken) public async Task<IActionResult> EditUser(EditUser_DTO user, CancellationToken cancellationToken)
{ {
try try
@ -68,11 +74,16 @@ namespace Api.SeaHavenIndustries.Controllers
Id = user.Id, Id = user.Id,
Name = user.Name, Name = user.Name,
Email = user.Email, Email = user.Email,
Role = user.Role Role = user.Role,
AccountId = user.AccountId
}; };
var succeeded = await _userService.EditUserAsync(dto, cancellationToken); var outcome = await _userService.EditUserAsync(dto, User, cancellationToken);
if (!succeeded) if (!outcome.Success)
return BadRequest(new Response { Status = "Error", Message = "User not found" }); {
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" }); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
} }
catch (Exception ex) catch (Exception ex)
@ -83,14 +94,17 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpDelete] [HttpDelete]
[Route("DeleteUser")] [Route("DeleteUser")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken) public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken)
{ {
string id = req.Id ?? ""; string id = req.Id ?? "";
try try
{ {
var found = await _userService.DeleteUserAsync(id, cancellationToken); var outcome = await _userService.DeleteUserAsync(id, User, cancellationToken);
if (!found) 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 BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
} }
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });

View file

@ -75,9 +75,17 @@ namespace Api.SeaHavenIndustries.Controllers
Search = search Search = search
}; };
var result = await _workOrderBoardService.GetBoardAsync(query, userId); var result = await _workOrderBoardService.GetBoardAsync(query, User, userId);
return Ok(result); 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) catch (ArgumentException ex)
{ {
return BadRequest(ex.Message); return BadRequest(ex.Message);
@ -90,9 +98,17 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _advancedSearchService.SearchAsync(query, userId); var result = await _advancedSearchService.SearchAsync(query, User, userId);
return Ok(result); 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) catch (ArgumentException ex)
{ {
return BadRequest(new Response { Status = "Error", Message = ex.Message }); return BadRequest(new Response { Status = "Error", Message = ex.Message });
@ -154,7 +170,7 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCreateService.CreateAsync(request, actorId); var row = await _boardCreateService.CreateAsync(request, User, actorId);
return Ok(row); return Ok(row);
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -162,6 +178,14 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors }); 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") catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
{ {
return Conflict(new WorkOrderBoardValidationErrorDto return Conflict(new WorkOrderBoardValidationErrorDto
@ -194,9 +218,17 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCancelService.CancelAsync(id, actorId); var row = await _boardCancelService.CancelAsync(id, User, actorId);
return Ok(row); 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") catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{ {
return NotFound(new WorkOrderBoardValidationErrorDto return NotFound(new WorkOrderBoardValidationErrorDto

View file

@ -106,10 +106,13 @@ namespace Api.SeaHavenIndustries.Controllers
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." }); return BadRequest(new Response { Status = "Error", Message = "file is required." });
string? fileUrl = null;
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); 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( var result = await _workOrderCompletionService.UploadCompletionDocAsync(
id, id,
new WorkOrderCompletionDocUploadDto new WorkOrderCompletionDocUploadDto
@ -119,21 +122,37 @@ namespace Api.SeaHavenIndustries.Controllers
WorkOrderVersion = workOrderVersion WorkOrderVersion = workOrderVersion
}, },
fileUrl, fileUrl,
User,
actorId); actorId);
return Ok(result); return Ok(result);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
{ {
TryCompensateUpload(fileUrl);
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); 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") catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
{ {
TryCompensateUpload(fileUrl);
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
} }
catch (WorkOrderBoardValidationException ex) catch (WorkOrderBoardValidationException ex)
{ {
TryCompensateUpload(fileUrl);
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
} }
} }
private void TryCompensateUpload(string? fileUrl)
{
if (!string.IsNullOrWhiteSpace(fileUrl))
_fileStorage.TryDelete(fileUrl);
}
} }
} }

View file

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
@ -19,13 +20,16 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderController : Controller public class WorkOrderController : Controller
{ {
private readonly IWorkOrderService _workOrderService; private readonly IWorkOrderService _workOrderService;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly ILogger<WorkOrderController> _logger; private readonly ILogger<WorkOrderController> _logger;
public WorkOrderController( public WorkOrderController(
IWorkOrderService workOrderService, IWorkOrderService workOrderService,
IWorkOrderAccountResolver accountResolver,
ILogger<WorkOrderController> logger) ILogger<WorkOrderController> logger)
{ {
_workOrderService = workOrderService; _workOrderService = workOrderService;
_accountResolver = accountResolver;
_logger = logger; _logger = logger;
} }
@ -49,10 +53,11 @@ namespace Api.SeaHavenIndustries.Controllers
Attachments = model.Attachments, Attachments = model.Attachments,
BeforPhotoAttachment = model.BeforPhotoAttachment, BeforPhotoAttachment = model.BeforPhotoAttachment,
AfterPhotoAttachment = model.AfterPhotoAttachment, 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 }); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId });
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -60,6 +65,14 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors }); 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) catch (Exception ex)
{ {
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -116,19 +129,27 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("GetWorkOrderList")] [HttpGet("GetWorkOrderList")]
public async Task<IActionResult> GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) public async Task<IActionResult> 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( try
page, pageSize, search, status, assignee, locationId, sortBy, sortDir);
var viewModel = new Pagination_DTO
{ {
Data = data, var accountId = _accountResolver.ResolveAccountFilter(User);
PageNumber = page, var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync(
PageSize = pageSize, page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
};
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")] [Authorize(Roles = "Admin")]
@ -172,10 +193,15 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
var accountId = _accountResolver.ResolveAccountFilter(User);
var result = await _workOrderService.GetFilteredWorkOrdersAsync( 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); return Ok(result);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception) catch (Exception)
{ {
return StatusCode(500, "Internal Server Error"); return StatusCode(500, "Internal Server Error");
@ -186,10 +212,18 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("GetWorkorderById")] [HttpGet("GetWorkorderById")]
public async Task<IActionResult> GetWorkorderById(int id) public async Task<IActionResult> GetWorkorderById(int id)
{ {
var detail = await _workOrderService.GetWorkOrderDetailAsync(id); try
if (detail == null) {
return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); var accountId = _accountResolver.ResolveAccountFilter(User);
return Ok(detail); 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] [HttpGet]
@ -208,10 +242,15 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
var accountId = _accountResolver.ResolveAccountFilter(User);
var result = await _workOrderService.GetFilteredWorkOrders2Async( 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); return Ok(result);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
catch (Exception) catch (Exception)
{ {
var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync(); var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync();
@ -279,9 +318,17 @@ namespace Api.SeaHavenIndustries.Controllers
Document = model.Document 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 }); 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) catch (Exception ex)
{ {
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -302,9 +349,17 @@ namespace Api.SeaHavenIndustries.Controllers
CommentType = model.CommentType CommentType = model.CommentType
}; };
var result = await _workOrderService.AddCommentJsonAsync(input, userId!); var result = await _workOrderService.AddCommentJsonAsync(input, User, userId!);
return Ok(result); 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) catch (Exception ex)
{ {
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) }); return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
@ -315,32 +370,64 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("GetComments")] [Route("GetComments")]
public async Task<IActionResult> GetComments() public async Task<IActionResult> GetComments()
{ {
var data = await _workOrderService.GetCommentsAsync(); try
return Ok(data); {
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] [HttpGet]
[Route("GetCommentsByWorkorderId")] [Route("GetCommentsByWorkorderId")]
public async Task<IActionResult> GetCommentsByWorkorderId(int woid) public async Task<IActionResult> GetCommentsByWorkorderId(int woid)
{ {
var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid); try
return Ok(data); {
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] [HttpGet]
[Route("GetworkordersDD")] [Route("GetworkordersDD")]
public async Task<IActionResult> GetworkordersDD() public async Task<IActionResult> GetworkordersDD()
{ {
var data = await _workOrderService.GetWorkordersDDAsync(); try
return Ok(data); {
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] [HttpGet]
[Route("Getworkorders")] [Route("Getworkorders")]
public async Task<IActionResult> Getworkorders() public async Task<IActionResult> Getworkorders()
{ {
var data = await _workOrderService.GetWorkordersAsync(); try
return Ok(data); {
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 });
}
} }
} }
} }

View file

@ -29,28 +29,50 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id:int}/detail")] [HttpGet("{id:int}/detail")]
public async Task<IActionResult> GetDetail(int id) public async Task<IActionResult> GetDetail(int id)
{ {
var detail = await _workOrderDetailService.GetDetailAsync(id); try
if (detail == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var detail = await _workOrderDetailService.GetDetailAsync(id, User);
return Ok(detail); 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")] [HttpGet("{id:int}/audit")]
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50) public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
{ {
var audit = await _workOrderDetailService.GetAuditAsync(id, limit); try
if (audit == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var audit = await _workOrderDetailService.GetAuditAsync(id, User, limit);
return Ok(audit); 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")] [HttpGet("{id:int}/comments")]
public async Task<IActionResult> GetBoardComments(int id) public async Task<IActionResult> GetBoardComments(int id)
{ {
var comments = await _workOrderCommentService.GetCommentsAsync(id); try
if (comments == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var comments = await _workOrderCommentService.GetCommentsAsync(id, User);
return Ok(comments); 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")] [HttpPost("{id:int}/comments")]
@ -59,13 +81,18 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); 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); return Ok(comment);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{ {
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); 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) catch (WorkOrderBoardValidationException ex)
{ {
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); 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 actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.UpdateCommentAsync( var comment = await _workOrderCommentService.UpdateCommentAsync(
id, commentId, request, actorId); id, commentId, request, User, actorId);
return Ok(comment); return Ok(comment);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions; using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
@ -28,35 +29,100 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpGet("{id:int}/media")] [HttpGet("{id:int}/media")]
public async Task<IActionResult> GetMedia(int id) public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken)
{ {
var media = await _workOrderMediaService.GetMediaAsync(id); try
if (media == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(media); 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")] [HttpPost("{id:int}/media")]
[RequestSizeLimit(30_000_000)] [RequestSizeLimit(30_000_000)]
public async Task<IActionResult> AddMedia( public async Task<IActionResult> AddMedia(
int id, int id,
[FromForm] WorkOrderMediaCategory category, [FromForm] WorkOrderMediaCategory? category,
[FromForm] IFormFile file) [FromForm] IFormFile file,
CancellationToken cancellationToken)
{ {
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." }); 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<IActionResult> UpdateMediaCategory(
int id,
int mediaId,
[FromForm] WorkOrderMediaCategory category,
[FromForm] string? workOrderVersion = null,
CancellationToken cancellationToken = default)
{
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file); var media = await _workOrderMediaService.UpdateMediaCategoryAsync(
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); id, mediaId, category, workOrderVersion, User, actorId, cancellationToken);
return Ok(media); return Ok(media);
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{ {
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); 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) catch (WorkOrderBoardValidationException ex)
{ {
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
@ -64,22 +130,41 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpDelete("{id:int}/media/{mediaId:int}")] [HttpDelete("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> DeleteMedia(int id, int mediaId) public async Task<IActionResult> DeleteMedia(
int id,
int mediaId,
[FromQuery] string? workOrderVersion = null,
CancellationToken cancellationToken = default)
{ {
try try
{ {
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, User, actorId, cancellationToken);
return NoContent(); return NoContent();
} }
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{ {
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); 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) catch (WorkOrderBoardValidationException ex)
{ {
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
} }
} }
private void TryCompensateUpload(string? fileUrl)
{
if (!string.IsNullOrWhiteSpace(fileUrl))
_fileStorage.TryDelete(fileUrl);
}
} }
} }

View file

@ -6,6 +6,7 @@
public string? Email { get; set; } public string? Email { get; set; }
public string? Contact { get; set; } public string? Contact { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public int? AccountId { get; set; }
} }
public class EditUser_DTO public class EditUser_DTO
{ {
@ -13,5 +14,6 @@
public string? Name { get; set; } public string? Name { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public int? AccountId { get; set; }
} }
} }

View file

@ -25,6 +25,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? SignOffSignature { get; set; } public string? SignOffSignature { get; set; }
public List<int>? ContactIds { get; set; } public List<int>? ContactIds { get; set; }
public List<int>? CategoryIds { get; set; } public List<int>? CategoryIds { get; set; }
public string? Customer { get; set; }
public static WorkOrder MapToEntity(Workorder_DTO dto, string userId) public static WorkOrder MapToEntity(Workorder_DTO dto, string userId)
{ {

View file

@ -23,8 +23,7 @@ namespace Api.SeaHavenIndustries.Infrastructure
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var uploadPath = Path.Combine("Assets", "Documents"); var uploadPath = Path.Combine("Assets", "Documents");
var webRoot = _webHostEnvironment.WebRootPath var webRoot = ResolveWebRoot();
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName); var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
@ -39,6 +38,41 @@ namespace Api.SeaHavenIndustries.Infrastructure
var domain = $"{request.Scheme}://{request.Host}"; var domain = $"{request.Scheme}://{request.Host}";
return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; 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 public class DispatchEmailAdapter : IDispatchEmailPort

View file

@ -145,6 +145,24 @@ namespace Data.SeaHavenIndustries
.HasForeignKey(w => w.PrimaryDispatchId) .HasForeignKey(w => w.PrimaryDispatchId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.Entity<WorkOrder>()
.HasOne(w => w.Account)
.WithMany()
.HasForeignKey(w => w.AccountId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<WorkOrder>()
.HasIndex(w => w.AccountId);
builder.Entity<ApplicationUser>()
.HasOne(u => u.Account)
.WithMany()
.HasForeignKey(u => u.AccountId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<ApplicationUser>()
.HasIndex(u => u.AccountId);
builder.Entity<VendorCompany>() builder.Entity<VendorCompany>()
.HasIndex(c => c.NormalizedName) .HasIndex(c => c.NormalizedName)
.IsUnique(); .IsUnique();
@ -299,6 +317,10 @@ namespace Data.SeaHavenIndustries
public string? Initials { get; set; } public string? Initials { get; set; }
public string? Color { get; set; } public string? Color { get; set; }
public int? Type { get; set; } // 1 for users 0 for admin public int? Type { get; set; } // 1 for users 0 for admin
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
public int? AccountId { get; set; }
[ForeignKey(nameof(AccountId))]
public virtual Accounts? Account { get; set; }
public ICollection<Template>? Templates { get; set; } public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; } public ICollection<WorkOrder>? WorkOrders { get; set; }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH221_WorkOrderAccountScope : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AccountId",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AccountId",
table: "AspNetUsers",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_workOrders_AccountId",
table: "workOrders",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_AccountId",
table: "AspNetUsers",
column: "AccountId");
migrationBuilder.AddForeignKey(
name: "FK_AspNetUsers_Accounts_AccountId",
table: "AspNetUsers",
column: "AccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_workOrders_Accounts_AccountId",
table: "workOrders",
column: "AccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
// Best-effort backfill: unique Accounts.Name match against WorkOrder.Customer.
migrationBuilder.Sql(@"
;WITH UniqueAccounts AS (
SELECT
a.[Id],
a.[Name]
FROM [Accounts] a
WHERE a.[Name] IS NOT NULL
AND LTRIM(RTRIM(a.[Name])) <> ''
AND (a.[IsDeleted] IS NULL OR a.[IsDeleted] = 0)
AND a.[Name] IN (
SELECT a2.[Name]
FROM [Accounts] a2
WHERE a2.[Name] IS NOT NULL
AND LTRIM(RTRIM(a2.[Name])) <> ''
AND (a2.[IsDeleted] IS NULL OR a2.[IsDeleted] = 0)
GROUP BY a2.[Name]
HAVING COUNT(*) = 1
)
)
UPDATE wo
SET wo.[AccountId] = ua.[Id]
FROM [workOrders] wo
INNER JOIN UniqueAccounts ua
ON ua.[Name] = wo.[Customer]
WHERE wo.[AccountId] IS NULL
AND wo.[Customer] IS NOT NULL
AND LTRIM(RTRIM(wo.[Customer])) <> '';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AspNetUsers_Accounts_AccountId",
table: "AspNetUsers");
migrationBuilder.DropForeignKey(
name: "FK_workOrders_Accounts_AccountId",
table: "workOrders");
migrationBuilder.DropIndex(
name: "IX_workOrders_AccountId",
table: "workOrders");
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_AccountId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "AccountId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "AccountId",
table: "AspNetUsers");
}
}
}

View file

@ -188,6 +188,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int>("AccessFailedCount") b.Property<int>("AccessFailedCount")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Color") b.Property<string>("Color")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -270,6 +273,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("NormalizedEmail") b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex"); .HasDatabaseName("EmailIndex");
@ -2355,6 +2360,9 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("AfterPhotoAttachment") b.Property<string>("AfterPhotoAttachment")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -2594,6 +2602,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("ExternalWorkOrderId") b.HasIndex("ExternalWorkOrderId")
.IsUnique() .IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
@ -3148,6 +3158,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Contact"); b.Navigation("Contact");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
@ -3554,6 +3574,11 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
.WithMany("WorkOrders") .WithMany("WorkOrders")
.HasForeignKey("AssignTo") .HasForeignKey("AssignTo")
@ -3569,6 +3594,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasForeignKey("PrimaryDispatchId") .HasForeignKey("PrimaryDispatchId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
b.Navigation("AssignToUser"); b.Navigation("AssignToUser");
b.Navigation("Locations"); b.Navigation("Locations");

View file

@ -23,6 +23,10 @@ namespace Data.SeaHavenIndustries
public string? WorkerOrderTitle { get; set; } public string? WorkerOrderTitle { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public string? Customer { get; set; } public string? Customer { get; set; }
/// <summary>CRM account (customer) boundary for server-derived media/tenant scope (SH-221).</summary>
public int? AccountId { get; set; }
[ForeignKey(nameof(AccountId))]
public virtual Accounts? Account { get; set; }
public string? SiteCode { get; set; } public string? SiteCode { get; set; }
public string? Building { get; set; } public string? Building { get; set; }
public string? Severity { get; set; } public string? Severity { get; set; }

View file

@ -66,6 +66,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNotes, w.ServiceNotes,
w.ExtraServices, w.ExtraServices,
w.DocStatus, w.DocStatus,
w.CompletedDate,
w.FlagColor, w.FlagColor,
w.PrimaryDispatchId, w.PrimaryDispatchId,
w.RowVersion, w.RowVersion,
@ -116,6 +117,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNotes, w.ServiceNotes,
w.ExtraServices, w.ExtraServices,
w.DocStatus, w.DocStatus,
w.CompletedDate,
w.FlagColor, w.FlagColor,
w.PrimaryDispatchId, w.PrimaryDispatchId,
w.RowVersion, w.RowVersion,

View file

@ -8,6 +8,13 @@ namespace SeaHaven.DataServices.Helpers
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query) public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
/// <summary>
/// Restricts to the given CRM account. Callers with org-wide scope must not
/// invoke this method (use <see cref="ApplyBaseScope"/> only).
/// </summary>
public static IQueryable<WorkOrder> ApplyAccountScope(IQueryable<WorkOrder> query, int accountId)
=> query.Where(w => w.AccountId == accountId);
/// <summary> /// <summary>
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and /// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
/// <paramref name="currentUserId"/> is set, that takes precedence and /// <paramref name="currentUserId"/> is set, that takes precedence and

View file

@ -96,5 +96,25 @@ namespace SeaHaven.DataServices.Implementation
{ {
return await _context.Accounts.CountAsync(); return await _context.Accounts.CountAsync();
} }
public async Task<int?> TryGetUniqueActiveIdByExactNameAsync(
string? name,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var trimmed = name.Trim();
var matches = await _context.Accounts
.AsNoTracking()
.Where(a =>
a.Name == trimmed
&& (a.IsDeleted == false || a.IsDeleted == null))
.Select(a => a.Id)
.Take(2)
.ToListAsync(cancellationToken);
return matches.Count == 1 ? matches[0] : null;
}
} }
} }

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation namespace SeaHaven.DataServices.Implementation
@ -25,6 +26,20 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(); .ToListAsync();
} }
public async Task<IEnumerable<Comments>> GetAllForAccountAsync(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
return await (
from c in _context.Comments.AsNoTracking().Include(c => c.ApplicationUser)
join w in workOrders on c.WorkerOrderId equals w.Id
orderby c.CreatedDate
select c
).ToListAsync();
}
public async Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId) public async Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId)
{ {
return await _context.Comments return await _context.Comments

View file

@ -17,6 +17,8 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query) public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query)
{ {
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);

View file

@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Implementation
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue); var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()); var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
@ -70,11 +72,12 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(); .ToListAsync();
} }
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId) public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId, int? accountId = null)
{ {
var query = _context.workOrders var query = WorkOrderBoardQueryFilters.ApplyBaseScope(
.AsNoTracking() _context.workOrders.AsNoTracking().Where(w => w.Id == workOrderId));
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false); var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
return rows.FirstOrDefault(); return rows.FirstOrDefault();

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation namespace SeaHaven.DataServices.Implementation
@ -13,10 +14,29 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _context = context;
} }
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) public Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
=> _context.workOrders.FirstOrDefaultAsync( int workOrderId,
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null), int? accountId,
cancellationToken); CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version) public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version; => _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
@ -110,7 +111,8 @@ namespace SeaHaven.DataServices.Implementation
string? assignee = null, string? assignee = null,
int? locationId = null, int? locationId = null,
string? sortBy = "lastUpdated", string? sortBy = "lastUpdated",
string? sortDir = "desc") string? sortDir = "desc",
int? accountId = null)
{ {
search ??= ""; search ??= "";
@ -119,6 +121,9 @@ namespace SeaHaven.DataServices.Implementation
.Include(w => w.AssignToUser) .Include(w => w.AssignToUser)
.Where(w => w.istemplate != true); .Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
if (locationId.HasValue) if (locationId.HasValue)
query = query.Where(w => w.LocationId == locationId.Value); query = query.Where(w => w.LocationId == locationId.Value);
@ -368,10 +373,15 @@ namespace SeaHaven.DataServices.Implementation
return await _context.workOrders.CountAsync(w => w.LocationId == locationId); return await _context.workOrders.CountAsync(w => w.LocationId == locationId);
} }
public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync() public async Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(int? accountId = null)
{ {
return await _context.workOrders IQueryable<WorkOrder> query = _context.workOrders
.Where(w => w.istemplate != true) .Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Include(w => w.Locations) .Include(w => w.Locations)
.Include(w => w.AssignToUser) .Include(w => w.AssignToUser)
.AsSplitQuery() .AsSplitQuery()
@ -403,11 +413,16 @@ namespace SeaHaven.DataServices.Implementation
return ordered; return ordered;
} }
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id) public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null)
{ {
return await _context.workOrders var query = _context.workOrders
.AsNoTracking() .AsNoTracking()
.Where(w => w.Id == id) .Where(w => w.Id == id);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Select(s => new WorkOrderDetailReadModel .Select(s => new WorkOrderDetailReadModel
{ {
Id = s.Id, Id = s.Id,
@ -504,10 +519,15 @@ namespace SeaHaven.DataServices.Implementation
}).FirstOrDefaultAsync(); }).FirstOrDefaultAsync();
} }
public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync() public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null)
{ {
return await _context.workOrders IQueryable<WorkOrder> query = _context.workOrders
.Where(w => w.istemplate != true) .Where(w => w.istemplate != true);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query
.Include(w => w.Locations) .Include(w => w.Locations)
.ToListAsync(); .ToListAsync();
} }

View file

@ -14,9 +14,17 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _context = context;
} }
public Task<bool> ExistsAsync(int workOrderId) public Task<bool> ExistsAsync(
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) int workOrderId,
.AnyAsync(w => w.Id == workOrderId); CancellationToken cancellationToken = default,
int? accountId = null)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.AnyAsync(w => w.Id == workOrderId, cancellationToken);
}
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId) public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
{ {
@ -63,13 +71,15 @@ namespace SeaHaven.DataServices.Implementation
return await query.ToListAsync(); return await query.ToListAsync();
} }
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId) public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(
int workOrderId,
CancellationToken cancellationToken = default)
{ {
return await _context.workOrderAttachments return await _context.workOrderAttachments
.AsNoTracking() .AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate) .OrderByDescending(a => a.CreatedDate)
.ToListAsync(); .ToListAsync(cancellationToken);
} }
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId) public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
@ -86,10 +96,16 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(); .ToListAsync();
} }
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId) public async Task<WorkOrder?> GetWorkOrderForMediaAsync(
int workOrderId,
CancellationToken cancellationToken = default,
int? accountId = null)
{ {
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
.FirstOrDefaultAsync(w => w.Id == workOrderId); if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return await query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
} }
} }
} }

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation namespace SeaHaven.DataServices.Implementation
@ -13,8 +14,29 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _context = context;
} }
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) public Task<WorkOrder?> GetWorkOrderForMediaAuthAsync(
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken); int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders);
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
return query.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken) public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
=> _context.workOrderAttachments.FirstOrDefaultAsync( => _context.workOrderAttachments.FirstOrDefaultAsync(
@ -24,6 +46,18 @@ namespace SeaHaven.DataServices.Implementation
public void TrackAttachment(WorkOrderAttachments attachment) public void TrackAttachment(WorkOrderAttachments attachment)
=> _context.workOrderAttachments.Add(attachment); => _context.workOrderAttachments.Add(attachment);
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public void MarkWorkOrderModified(WorkOrder workOrder)
{
// Force a WO update so the RowVersion concurrency token is enforced when only
// attachment rows change (category-only Extra mutations).
var entry = _context.Entry(workOrder);
if (entry.State == EntityState.Unchanged)
entry.Property(w => w.Attachments).IsModified = true;
}
public Task SaveAsync(CancellationToken cancellationToken) public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken); => _context.SaveChangesAsync(cancellationToken);
} }

View file

@ -33,6 +33,13 @@ namespace SeaHaven.DataServices.Implementation
if (workOrder == null) if (workOrder == null)
{ {
var accountId = await TryResolveAccountIdAsync(mutation.Customer, cancellationToken);
if (accountId is not int resolvedAccountId)
{
return new WorkOrderWebhookPersistenceResult(
WorkOrderWebhookPersistenceStatus.AccountUnresolved);
}
var internalNumber = await AllocateInternalNumberAsync(cancellationToken); var internalNumber = await AllocateInternalNumberAsync(cancellationToken);
workOrder = new WorkOrder workOrder = new WorkOrder
{ {
@ -43,6 +50,8 @@ namespace SeaHaven.DataServices.Implementation
WorkerOrderTitle = mutation.Title ?? mutation.Description WorkerOrderTitle = mutation.Title ?? mutation.Description
?? $"Imported work order {mutation.ExternalWorkOrderId}", ?? $"Imported work order {mutation.ExternalWorkOrderId}",
Description = mutation.Description, Description = mutation.Description,
Customer = mutation.Customer,
AccountId = resolvedAccountId,
Source = mutation.Source, Source = mutation.Source,
istemplate = false, istemplate = false,
CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime
@ -284,6 +293,26 @@ namespace SeaHaven.DataServices.Implementation
return true; return true;
} }
private async Task<int?> TryResolveAccountIdAsync(
string? customer,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(customer))
return null;
var trimmed = customer.Trim();
var matches = await _context.Accounts
.AsNoTracking()
.Where(a =>
a.Name == trimmed
&& (a.IsDeleted == false || a.IsDeleted == null))
.Select(a => a.Id)
.Take(2)
.ToListAsync(cancellationToken);
return matches.Count == 1 ? matches[0] : null;
}
private static bool IsNewer( private static bool IsNewer(
DateTimeOffset candidateUpdatedAt, DateTimeOffset candidateUpdatedAt,
string candidateHash, string candidateHash,

View file

@ -13,5 +13,13 @@ namespace SeaHaven.DataServices.Interfaces
Task DeleteAsync(int id); Task DeleteAsync(int id);
Task<bool> ExistsAsync(int id); Task<bool> ExistsAsync(int id);
Task<int> CountAsync(); Task<int> CountAsync();
/// <summary>
/// Returns the id when exactly one non-deleted account has this exact Name
/// (same rule as SH-221 backfill). Null when name is blank, zero matches, or ambiguous.
/// </summary>
Task<int?> TryGetUniqueActiveIdByExactNameAsync(
string? name,
CancellationToken cancellationToken = default);
} }
} }

View file

@ -6,6 +6,11 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<Comments?> GetByIdAsync(int id); Task<Comments?> GetByIdAsync(int id);
Task<IEnumerable<Comments>> GetAllAsync(); Task<IEnumerable<Comments>> GetAllAsync();
/// <summary>
/// Comments linked to non-deleted, non-template work orders.
/// When <paramref name="accountId"/> is set, only that account's WOs; null = org-wide.
/// </summary>
Task<IEnumerable<Comments>> GetAllForAccountAsync(int? accountId);
Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId); Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId);
Task<IEnumerable<Comments>> GetByDispatchIdAsync(int dispatchId); Task<IEnumerable<Comments>> GetByDispatchIdAsync(int dispatchId);
Task<Comments> AddAsync(Comments comment); Task<Comments> AddAsync(Comments comment);

View file

@ -6,7 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query); Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync(); Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId); Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId, int? accountId = null);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
} }
} }

View file

@ -4,7 +4,16 @@ namespace SeaHaven.DataServices.Interfaces
{ {
public interface IWorkOrderCompletionDataService public interface IWorkOrderCompletionDataService
{ {
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task<WorkOrder?> GetWorkOrderForCompletionAuthAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version); void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken); Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken); Task SaveAsync(CancellationToken cancellationToken);

View file

@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces
string? assignee = null, string? assignee = null,
int? locationId = null, int? locationId = null,
string? sortBy = "lastUpdated", string? sortBy = "lastUpdated",
string? sortDir = "desc"); string? sortDir = "desc",
int? accountId = null);
// Command operations // Command operations
Task<WorkOrder> AddAsync(WorkOrder workOrder); Task<WorkOrder> AddAsync(WorkOrder workOrder);
@ -70,7 +71,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<int> CountByStatusAsync(string status); Task<int> CountByStatusAsync(string status);
Task<int> CountByPriorityAsync(string priority); Task<int> CountByPriorityAsync(string priority);
Task<int> CountByLocationAsync(int locationId); Task<int> CountByLocationAsync(int locationId);
Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(); Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(int? accountId = null);
// Batched lookup: server-filters the distinct requested ids, excludes templates, // Batched lookup: server-filters the distinct requested ids, excludes templates,
// eagerly loads the location detail, deduplicates, and returns results in input order. // eagerly loads the location detail, deduplicates, and returns results in input order.
@ -79,7 +80,7 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken); CancellationToken cancellationToken);
// Complex read models // Complex read models
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id); Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null);
Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(); Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(int? accountId = null);
} }
} }

View file

@ -6,13 +6,21 @@ namespace SeaHaven.DataServices.Interfaces
{ {
public interface IWorkOrderDetailDataService public interface IWorkOrderDetailDataService
{ {
Task<bool> ExistsAsync(int workOrderId); Task<bool> ExistsAsync(
int workOrderId,
CancellationToken cancellationToken = default,
int? accountId = null);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId); Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId); Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null); Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId); Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(
int workOrderId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId); Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId); Task<WorkOrder?> GetWorkOrderForMediaAsync(
int workOrderId,
CancellationToken cancellationToken = default,
int? accountId = null);
} }
public interface ICompletionDocTemplateDataService public interface ICompletionDocTemplateDataService

View file

@ -4,9 +4,21 @@ namespace SeaHaven.DataServices.Interfaces
{ {
public interface IWorkOrderMediaDataService public interface IWorkOrderMediaDataService
{ {
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); /// <summary>AsNoTracking base (+ optional account) scoped load for pre-mutation auth.</summary>
Task<WorkOrder?> GetWorkOrderForMediaAuthAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrder?> GetTrackedWorkOrderAsync(
int workOrderId,
int? accountId,
CancellationToken cancellationToken);
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken); Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
void TrackAttachment(WorkOrderAttachments attachment); void TrackAttachment(WorkOrderAttachments attachment);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
void MarkWorkOrderModified(WorkOrder workOrder);
Task SaveAsync(CancellationToken cancellationToken); Task SaveAsync(CancellationToken cancellationToken);
} }
} }

View file

@ -50,7 +50,8 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Applied, Applied,
Duplicate, Duplicate,
HashConflict HashConflict,
AccountUnresolved
} }
public sealed record WorkOrderWebhookPersistenceResult( public sealed record WorkOrderWebhookPersistenceResult(

View file

@ -20,7 +20,8 @@ namespace SeaHaven.DataServices.Interfaces
int Page, int Page,
int PageSize, int PageSize,
string SortBy, string SortBy,
string SortDir); string SortDir,
int? AccountId = null);
public record WorkOrderAdvancedSearchResult( public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows, IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -10,7 +10,8 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyList<WorkOrderType>? Types, IReadOnlyList<WorkOrderType>? Types,
bool Overdue, bool Overdue,
string? Search, string? Search,
string? CurrentUserId); string? CurrentUserId,
int? AccountId = null);
public record WorkOrderBoardRawRow( public record WorkOrderBoardRawRow(
int Id, int Id,
@ -46,6 +47,7 @@ namespace SeaHaven.DataServices.Interfaces
string? ServiceNotes, string? ServiceNotes,
string? ExtraServices, string? ExtraServices,
DocStatus? DocStatus, DocStatus? DocStatus,
DateTime? CompletedDate,
string? FlagColor, string? FlagColor,
int? PrimaryDispatchId, int? PrimaryDispatchId,
byte[]? RowVersion, byte[]? RowVersion,

View file

@ -31,6 +31,7 @@ namespace SeaHaven.Services.DTOs
public string? Email { get; set; } public string? Email { get; set; }
public string? Contact { get; set; } public string? Contact { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public int? AccountId { get; set; }
} }
public class AddUserOutcomeDTO public class AddUserOutcomeDTO
@ -45,6 +46,7 @@ namespace SeaHaven.Services.DTOs
public string? Name { get; set; } public string? Name { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public int? AccountId { get; set; }
} }
public class UserListRowDTO public class UserListRowDTO

View file

@ -56,6 +56,7 @@ namespace SeaHaven.Services.DTOs
public string? ServiceNotes { get; set; } public string? ServiceNotes { get; set; }
public List<string>? ExtraServices { get; set; } public List<string>? ExtraServices { get; set; }
public DocStatus? DocStatus { get; set; } public DocStatus? DocStatus { get; set; }
public DateTime? CompletedDate { get; set; }
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary> /// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
public string? FlagColor { get; set; } public string? FlagColor { get; set; }
public int? PrimaryDispatchId { get; set; } public int? PrimaryDispatchId { get; set; }

View file

@ -15,6 +15,11 @@ namespace SeaHaven.Services.DTOs
public string? WoNumber { get; set; } public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; } public WorkOrderType WorkOrderType { get; set; }
public string? SiteCode { get; set; } public string? SiteCode { get; set; }
/// <summary>
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
/// so AccountId can be resolved server-side.
/// </summary>
public string? Customer { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary> /// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
public string? Trade { get; set; } public string? Trade { get; set; }

View file

@ -80,6 +80,8 @@ namespace SeaHaven.Services.DTOs
public int? LocationId { get; set; } public int? LocationId { get; set; }
public string? AssignTo { get; set; } public string? AssignTo { get; set; }
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
/// <summary>Optional CRM customer name for org-wide AccountId resolution.</summary>
public string? Customer { get; set; }
public static WorkOrder MapToEntity(CreateWorkOrderDTO dto) public static WorkOrder MapToEntity(CreateWorkOrderDTO dto)
{ {
@ -94,6 +96,7 @@ namespace SeaHaven.Services.DTOs
LocationId = dto.LocationId, LocationId = dto.LocationId,
AssignTo = dto.AssignTo, AssignTo = dto.AssignTo,
DueDate = dto.DueDate, DueDate = dto.DueDate,
Customer = string.IsNullOrWhiteSpace(dto.Customer) ? null : dto.Customer.Trim(),
CreatedDate = DateTime.UtcNow CreatedDate = DateTime.UtcNow
}; };
} }

View file

@ -19,7 +19,6 @@ namespace SeaHaven.Services.DTOs
public string? SubTrade { get; set; } public string? SubTrade { get; set; }
public DateOnly? OriginalWeek { get; set; } public DateOnly? OriginalWeek { get; set; }
public DateOnly? OriginalDate { get; set; } public DateOnly? OriginalDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? VendorNotes { get; set; } public string? VendorNotes { get; set; }
} }

View file

@ -18,6 +18,8 @@ namespace SeaHaven.Services.DTOs
public IFormFile? BeforPhotoAttachment { get; set; } public IFormFile? BeforPhotoAttachment { get; set; }
public IFormFile? AfterPhotoAttachment { get; set; } public IFormFile? AfterPhotoAttachment { get; set; }
public IFormFile? SignOffAttachment { get; set; } public IFormFile? SignOffAttachment { get; set; }
/// <summary>Optional CRM customer name for org-wide AccountId resolution.</summary>
public string? Customer { get; set; }
} }
public class UpdateWorkOrderWithDetailsInput public class UpdateWorkOrderWithDetailsInput

View file

@ -0,0 +1,27 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>Server-derived claim type names emitted at token issuance.</summary>
public static class SeaHavenClaimTypes
{
/// <summary>CRM account id from <c>ApplicationUser.AccountId</c> (never from request body).</summary>
public const string AccountId = "account_id";
/// <summary>Explicit org-wide media scope; value <see cref="OrgScopeAll"/>.</summary>
public const string OrgScope = "org_scope";
/// <summary>Signed org-wide elevation (Admin without AccountId).</summary>
public const string OrgScopeAll = "all";
}
/// <summary>Resolved media tenant scope from signed claims (fail-closed when Missing).</summary>
public abstract record MediaAccountScope
{
private MediaAccountScope() { }
public sealed record Account(int AccountId) : MediaAccountScope;
public sealed record OrgWide : MediaAccountScope;
public sealed record Missing : MediaAccountScope;
}
}

View file

@ -0,0 +1,10 @@
namespace SeaHaven.Services.Helpers
{
/// <summary>Stable user-mutation outcome messages (no internal detail).</summary>
public static class UserMutationErrors
{
public const string Forbidden = "Forbidden";
public const string AccountNotFound = "Account not found";
public const string UserNotFound = "User not found";
}
}

View file

@ -15,6 +15,7 @@ namespace SeaHaven.Services.Helpers
public const string ApptDate = "apptDate"; public const string ApptDate = "apptDate";
public const string ApptTime = "apptTime"; public const string ApptTime = "apptTime";
public const string DocStatus = "docStatus"; public const string DocStatus = "docStatus";
public const string CompletedDate = "completedDate";
public const string Pm = "pm"; public const string Pm = "pm";
public const string ServiceNotes = "serviceNotes"; public const string ServiceNotes = "serviceNotes";
public const string ExtraServices = "extraServices"; public const string ExtraServices = "extraServices";
@ -43,6 +44,7 @@ namespace SeaHaven.Services.Helpers
ApptDate, ApptDate,
ApptTime, ApptTime,
DocStatus, DocStatus,
CompletedDate,
Pm, Pm,
ServiceNotes, ServiceNotes,
ExtraServices, ExtraServices,
@ -69,26 +71,27 @@ namespace SeaHaven.Services.Helpers
{ {
var canonical = Canonicalize(field) ?? field; var canonical = Canonicalize(field) ?? field;
return canonical switch return canonical switch
{ {
WoNumber => "InternalWONumber", WoNumber => "InternalWONumber",
WorkOrderType => "WorkOrderType", WorkOrderType => "WorkOrderType",
SiteCode => "SiteCode", SiteCode => "SiteCode",
LifecycleStatus => "LifecycleStatus", LifecycleStatus => "LifecycleStatus",
AssignTo => "AssignTo", AssignTo => "AssignTo",
DueDate => "DueDate", DueDate => "DueDate",
ScheduledDate => "ScheduledDate", ScheduledDate => "ScheduledDate",
TargetWeek => "TargetWeek", TargetWeek => "TargetWeek",
ScheduleWeekOnly => "ScheduleWeekOnly", ScheduleWeekOnly => "ScheduleWeekOnly",
VendorId => "VendorId", VendorId => "VendorId",
ApptDate => "ApptDate", ApptDate => "ApptDate",
ApptTime => "ApptTime", ApptTime => "ApptTime",
DocStatus => "DocStatus", DocStatus => "DocStatus",
Pm => "Trade", CompletedDate => "CompletedDate",
ServiceNotes => "ServiceNotes", Pm => "Trade",
ExtraServices => "ExtraServices", ServiceNotes => "ServiceNotes",
FlagColor => "FlagColor", ExtraServices => "ExtraServices",
_ => canonical FlagColor => "FlagColor",
}; _ => canonical
};
} }
} }
} }

View file

@ -0,0 +1,132 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Claims-derived authorization for work-order media read/mutations.
/// Scope is fail-closed: callers need a valid <see cref="SeaHavenClaimTypes.AccountId"/>
/// or explicit <see cref="SeaHavenClaimTypes.OrgScope"/>=<see cref="SeaHavenClaimTypes.OrgScopeAll"/>.
/// Absence of scope does not elevate. Staff may access any resulting work order;
/// technicians only when <see cref="WorkOrder.AssignTo"/> matches the actor.
/// Delete is staff-only.
/// </summary>
public static class WorkOrderMediaAuthorization
{
private static readonly string[] StaffRoles =
{
"Admin",
"Manager",
"Dispatcher",
"Supervisor"
};
public static MediaAccountScope ResolveMediaScope(ClaimsPrincipal user)
{
if (user is null)
return new MediaAccountScope.Missing();
var accountRaw = user.FindFirstValue(SeaHavenClaimTypes.AccountId);
if (!string.IsNullOrWhiteSpace(accountRaw))
{
if (!int.TryParse(accountRaw, out var accountId) || accountId <= 0)
return new MediaAccountScope.Missing();
return new MediaAccountScope.Account(accountId);
}
var orgScope = user.FindFirstValue(SeaHavenClaimTypes.OrgScope);
if (string.Equals(orgScope, SeaHavenClaimTypes.OrgScopeAll, StringComparison.Ordinal))
return new MediaAccountScope.OrgWide();
return new MediaAccountScope.Missing();
}
public static void EnsureHasMediaScope(ClaimsPrincipal user)
{
if (ResolveMediaScope(user) is MediaAccountScope.Missing)
{
throw Forbidden("You are not allowed to access work order media without account scope.");
}
}
public static void EnsureCanRead(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId, "You are not allowed to view work order media.");
EnsureHasMediaScope(user);
if (IsStaff(user) || user.IsInRole("User"))
return;
throw Forbidden("You are not allowed to view work order media.");
}
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId);
EnsureHasMediaScope(user);
if (IsStaff(user) || user.IsInRole("User"))
return;
throw Forbidden();
}
public static void EnsureCanDelete(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId);
EnsureHasMediaScope(user);
// Technician (User) may upload/categorize assigned media but not delete.
if (IsStaff(user))
return;
throw Forbidden();
}
/// <summary>
/// Caller-scope check after a base (+ account when scoped) work-order load.
/// Staff: any resulting work order.
/// Technician: only when assigned to the caller.
/// Out of caller scope → NotFound (no disclosure).
/// </summary>
public static void EnsureWorkOrderInCallerScope(
ClaimsPrincipal user,
string actorId,
WorkOrder workOrder)
{
if (IsStaff(user))
return;
if (user.IsInRole("User")
&& string.Equals(workOrder.AssignTo, actorId, StringComparison.Ordinal))
{
return;
}
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private static void EnsureAuthenticated(
ClaimsPrincipal user,
string? actorId,
string? forbiddenMessage = null)
{
if (user is null
|| !(user.Identity?.IsAuthenticated ?? false)
|| string.IsNullOrWhiteSpace(actorId))
{
throw Forbidden(forbiddenMessage);
}
}
private static bool IsStaff(ClaimsPrincipal user)
=> StaffRoles.Any(user.IsInRole);
private static WorkOrderBoardValidationException Forbidden(string? message = null)
=> new(
"Forbidden",
message ?? "You are not allowed to mutate work order media.");
}
}

View file

@ -0,0 +1,113 @@
using Microsoft.AspNetCore.Http;
namespace SeaHaven.Services.Helpers
{
/// <summary>SH-116 media type allowlist for board work-order uploads.</summary>
public static class WorkOrderMediaFileRules
{
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg",
"image/png",
"video/mp4",
"video/quicktime"
};
private static readonly Dictionary<string, HashSet<string>> ExtensionsByContentType =
new(StringComparer.OrdinalIgnoreCase)
{
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" }
};
public static bool IsAllowed(IFormFile file)
{
if (file == null || file.Length <= 0)
return false;
var contentType = (file.ContentType ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType))
return false;
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension)
|| !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions)
|| !allowedExtensions.Contains(extension))
{
return false;
}
try
{
using var stream = file.OpenReadStream();
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64);
if (headerLength == 0)
return false;
var header = new byte[headerLength];
var read = stream.Read(header, 0, header.Length);
if (read <= 0)
return false;
if (read < header.Length)
Array.Resize(ref header, read);
return MatchesSignature(contentType, header);
}
catch
{
return false;
}
}
public static void EnsureAllowed(IFormFile file)
{
if (!IsAllowed(file))
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"Supported media types are JPG, PNG, MP4, and MOV.");
}
}
internal static bool MatchesSignature(string contentType, byte[] bytes)
{
if (bytes.Length == 0)
return false;
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 8
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
}
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
{
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
}
if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
{
return HasFtypBox(bytes);
}
return false;
}
private static bool HasFtypBox(byte[] bytes)
{
if (bytes.Length < 12)
return false;
// ISO BMFF: [size:4][ftyp:4][major_brand:4]...
return bytes[4] == (byte)'f'
&& bytes[5] == (byte)'t'
&& bytes[6] == (byte)'y'
&& bytes[7] == (byte)'p';
}
}
}

View file

@ -5,6 +5,7 @@ using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration; using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
@ -50,6 +51,19 @@ namespace SeaHaven.Services.Implementation
{ {
authClaims.Add(new Claim(ClaimTypes.Role, userRole)); authClaims.Add(new Claim(ClaimTypes.Role, userRole));
} }
if (user.AccountId.HasValue)
{
authClaims.Add(new Claim(
SeaHavenClaimTypes.AccountId,
user.AccountId.Value.ToString()));
}
else if (userRoles.Contains("Admin"))
{
// Explicit signed org-wide elevation — never elevate via absence of account_id.
authClaims.Add(new Claim(
SeaHavenClaimTypes.OrgScope,
SeaHavenClaimTypes.OrgScopeAll));
}
var token = GetToken(authClaims); var token = GetToken(authClaims);
return new LoginResultDTO return new LoginResultDTO
{ {

View file

@ -9,11 +9,16 @@ namespace SeaHaven.Services.Implementation
{ {
private readonly ISyncDataService _data; private readonly ISyncDataService _data;
private readonly ISyncExternalSource _external; private readonly ISyncExternalSource _external;
private readonly IWorkOrderAccountResolver _accountResolver;
public SyncService(ISyncDataService data, ISyncExternalSource external) public SyncService(
ISyncDataService data,
ISyncExternalSource external,
IWorkOrderAccountResolver accountResolver)
{ {
_data = data; _data = data;
_external = external; _external = external;
_accountResolver = accountResolver;
} }
public async Task<SyncWorkOrdersResult> SyncWorkOrdersAsync(CancellationToken cancellationToken) public async Task<SyncWorkOrdersResult> SyncWorkOrdersAsync(CancellationToken cancellationToken)
@ -42,6 +47,11 @@ namespace SeaHaven.Services.Implementation
if (existing == null) if (existing == null)
{ {
var customer = Get(item, "customer");
var accountId = await _accountResolver.TryResolveFromCustomerAsync(customer, cancellationToken);
if (accountId is not int resolvedAccountId)
continue;
var wo = new WorkOrder var wo = new WorkOrder
{ {
InternalWONumber = (nextInternal++).ToString("D8"), InternalWONumber = (nextInternal++).ToString("D8"),
@ -52,7 +62,8 @@ namespace SeaHaven.Services.Implementation
Status = MapStatus(Get(item, "wo_status")), Status = MapStatus(Get(item, "wo_status")),
Priority = MapSeverityToPriority(Get(item, "severity")), Priority = MapSeverityToPriority(Get(item, "severity")),
Severity = Get(item, "severity"), Severity = Get(item, "severity"),
Customer = Get(item, "customer"), Customer = customer,
AccountId = resolvedAccountId,
SiteCode = Get(item, "site_code"), SiteCode = Get(item, "site_code"),
Building = Get(item, "building"), Building = Get(item, "building"),
LocationId = locationId, LocationId = locationId,

View file

@ -2,7 +2,9 @@ using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace SeaHaven.Services.Implementation namespace SeaHaven.Services.Implementation
@ -11,15 +13,18 @@ namespace SeaHaven.Services.Implementation
{ {
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IUserDataService _userDataService; private readonly IUserDataService _userDataService;
private readonly IAccountDataService _accountDataService;
private readonly IEmailSender _emailSender; private readonly IEmailSender _emailSender;
public UserService( public UserService(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IUserDataService userDataService, IUserDataService userDataService,
IAccountDataService accountDataService,
IEmailSender emailSender) IEmailSender emailSender)
{ {
_userManager = userManager; _userManager = userManager;
_userDataService = userDataService; _userDataService = userDataService;
_accountDataService = accountDataService;
_emailSender = emailSender; _emailSender = emailSender;
} }
@ -37,13 +42,25 @@ namespace SeaHaven.Services.Implementation
}); });
} }
public async Task<AddUserOutcomeDTO> AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken) public async Task<AddUserOutcomeDTO> AddUserAsync(
AddUserRequestDTO dto,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{ {
var authFailure = EnsureAdminCaller(caller);
if (authFailure != null)
return authFailure;
var accountFailure = await EnsureAccountValidAsync(dto.AccountId);
if (accountFailure != null)
return accountFailure;
var model = new ApplicationUser var model = new ApplicationUser
{ {
UserName = dto.Email, UserName = dto.Email,
FirstName = dto.Name, FirstName = dto.Name,
Email = dto.Email, Email = dto.Email,
AccountId = dto.AccountId
}; };
var exist = await _userDataService.GetByIdAsync(model.Id); var exist = await _userDataService.GetByIdAsync(model.Id);
@ -79,7 +96,7 @@ namespace SeaHaven.Services.Implementation
{ {
var exist1 = await _userDataService.GetForEditAsync(model.Id, cancellationToken); var exist1 = await _userDataService.GetForEditAsync(model.Id, cancellationToken);
if (exist1 == null) if (exist1 == null)
return new AddUserOutcomeDTO { Success = false, Error = "User not found" }; return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
var existingRole = await _userManager.GetRolesAsync(exist1); var existingRole = await _userManager.GetRolesAsync(exist1);
@ -92,6 +109,7 @@ namespace SeaHaven.Services.Implementation
exist1.LastName = model.LastName; exist1.LastName = model.LastName;
exist1.Contact = model.Contact; exist1.Contact = model.Contact;
exist1.PhoneNumber = model.PhoneNumber; exist1.PhoneNumber = model.PhoneNumber;
exist1.AccountId = dto.AccountId;
await _userDataService.UpdateUserAsync(exist1, cancellationToken); await _userDataService.UpdateUserAsync(exist1, cancellationToken);
@ -101,11 +119,22 @@ namespace SeaHaven.Services.Implementation
} }
} }
public async Task<bool> EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken) public async Task<AddUserOutcomeDTO> EditUserAsync(
EditUserRequestDTO dto,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{ {
var authFailure = EnsureAdminCaller(caller);
if (authFailure != null)
return authFailure;
var accountFailure = await EnsureAccountValidAsync(dto.AccountId);
if (accountFailure != null)
return accountFailure;
var exist = await _userDataService.GetForEditAsync(dto.Id ?? "", cancellationToken); var exist = await _userDataService.GetForEditAsync(dto.Id ?? "", cancellationToken);
if (exist == null) if (exist == null)
return false; return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
exist.FirstName = dto.Name; exist.FirstName = dto.Name;
if (string.IsNullOrWhiteSpace(dto.Email)) if (string.IsNullOrWhiteSpace(dto.Email))
@ -119,6 +148,7 @@ namespace SeaHaven.Services.Implementation
exist.CreatedDate = DateTime.Now; exist.CreatedDate = DateTime.Now;
exist.UniqueName = "Active"; exist.UniqueName = "Active";
exist.PhoneNumber = dto.Role; exist.PhoneNumber = dto.Role;
exist.AccountId = dto.AccountId;
var existingRole = await _userManager.GetRolesAsync(exist); var existingRole = await _userManager.GetRolesAsync(exist);
if (existingRole != null && existingRole.Any()) if (existingRole != null && existingRole.Any())
@ -128,17 +158,24 @@ namespace SeaHaven.Services.Implementation
await _userManager.AddToRoleAsync(exist, dto.Role ?? ""); await _userManager.AddToRoleAsync(exist, dto.Role ?? "");
await _userDataService.UpdateUserAsync(exist, cancellationToken); await _userDataService.UpdateUserAsync(exist, cancellationToken);
return true; return new AddUserOutcomeDTO { Success = true };
} }
public async Task<bool> DeleteUserAsync(string id, CancellationToken cancellationToken) public async Task<AddUserOutcomeDTO> DeleteUserAsync(
string id,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{ {
var authFailure = EnsureAdminCaller(caller);
if (authFailure != null)
return authFailure;
var data = await _userDataService.GetForEditAsync(id, cancellationToken); var data = await _userDataService.GetForEditAsync(id, cancellationToken);
if (data == null) if (data == null)
return false; return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken); await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
return true; return new AddUserOutcomeDTO { Success = true };
} }
public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken) public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken)
@ -170,6 +207,29 @@ namespace SeaHaven.Services.Implementation
}; };
} }
private static AddUserOutcomeDTO? EnsureAdminCaller(ClaimsPrincipal caller)
{
if (caller is null || !caller.IsInRole("Admin"))
{
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
}
return null;
}
private async Task<AddUserOutcomeDTO?> EnsureAccountValidAsync(int? accountId)
{
if (!accountId.HasValue)
return null;
if (accountId.Value <= 0 || !await _accountDataService.ExistsAsync(accountId.Value))
{
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.AccountNotFound };
}
return null;
}
private static string GenerateRandomPassword(int length = 8) private static string GenerateRandomPassword(int length = 8)
{ {
const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?"; const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?";

View file

@ -0,0 +1,73 @@
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderAccountResolver : IWorkOrderAccountResolver
{
private readonly IAccountDataService _accounts;
public WorkOrderAccountResolver(IAccountDataService accounts)
{
_accounts = accounts;
}
public int? ResolveAccountFilter(ClaimsPrincipal user)
{
return WorkOrderMediaAuthorization.ResolveMediaScope(user) switch
{
MediaAccountScope.Account account => account.AccountId,
MediaAccountScope.OrgWide => null,
_ => throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to access work orders without account scope.")
};
}
public async Task<int> ResolveForAuthenticatedCreateAsync(
ClaimsPrincipal user,
string? customer,
CancellationToken cancellationToken = default)
{
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
{
case MediaAccountScope.Account account:
return account.AccountId;
case MediaAccountScope.OrgWide:
return await ResolveRequiredFromCustomerAsync(customer, cancellationToken);
default:
throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to create work orders without account scope.");
}
}
public Task<int> ResolveForUnauthenticatedCreateAsync(
string? customer,
CancellationToken cancellationToken = default)
=> ResolveRequiredFromCustomerAsync(customer, cancellationToken);
public Task<int?> TryResolveFromCustomerAsync(
string? customer,
CancellationToken cancellationToken = default)
=> _accounts.TryGetUniqueActiveIdByExactNameAsync(customer, cancellationToken);
private async Task<int> ResolveRequiredFromCustomerAsync(
string? customer,
CancellationToken cancellationToken)
{
var resolved = await TryResolveFromCustomerAsync(customer, cancellationToken);
if (resolved is int accountId)
return accountId;
throw new WorkOrderBoardValidationException(
"AccountUnresolved",
"Work order account could not be resolved from customer.");
}
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -12,16 +13,22 @@ namespace SeaHaven.Services.Implementation
private const int MaxPageSize = 100; private const int MaxPageSize = 100;
private readonly IWorkOrderAdvancedSearchDataService _searchDataService; private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) public WorkOrderAdvancedSearchService(
IWorkOrderAdvancedSearchDataService searchDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_searchDataService = searchDataService; _searchDataService = searchDataService;
_accountResolver = accountResolver;
} }
public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync( public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
WorkOrderAdvancedSearchQueryDto query, WorkOrderAdvancedSearchQueryDto query,
ClaimsPrincipal user,
string? currentUserId) string? currentUserId)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user);
var page = query.Page < 0 ? 0 : query.Page; var page = query.Page < 0 ? 0 : query.Page;
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize); var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
@ -49,7 +56,8 @@ namespace SeaHaven.Services.Implementation
page, page,
pageSize, pageSize,
sortBy, sortBy,
query.SortDir ?? "asc"); query.SortDir ?? "asc",
accountId);
var result = await _searchDataService.SearchAsync(dataQuery); var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow; var utcNow = DateTime.UtcNow;

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -23,7 +24,10 @@ namespace SeaHaven.Services.Implementation
_auditService = auditService; _auditService = auditService;
} }
public async Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId) public async Task<WorkOrderBoardRowDto> CancelAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId)
{ {
var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); var workOrder = await _mutationData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None);
@ -32,7 +36,7 @@ namespace SeaHaven.Services.Implementation
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled) if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
{ {
var existing = await _boardService.GetBoardRowAsync(workOrderId); var existing = await _boardService.GetBoardRowAsync(workOrderId, user);
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
} }
@ -48,7 +52,7 @@ namespace SeaHaven.Services.Implementation
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId); await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _mutationData.SaveAsync(CancellationToken.None); await _mutationData.SaveAsync(CancellationToken.None);
var row = await _boardService.GetBoardRowAsync(workOrderId); var row = await _boardService.GetBoardRowAsync(workOrderId, user);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
} }
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
@ -18,27 +19,38 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderBoardService _boardService; private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderBoardCreateValidation _validator; private readonly IWorkOrderBoardCreateValidation _validator;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderBoardCreateService( public WorkOrderBoardCreateService(
IWorkOrderBoardDataService boardDataService, IWorkOrderBoardDataService boardDataService,
IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardMutationDataService mutationData,
IWorkOrderBoardService boardService, IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService, IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator) IWorkOrderBoardCreateValidation validator,
IWorkOrderAccountResolver accountResolver)
{ {
_boardDataService = boardDataService; _boardDataService = boardDataService;
_mutationData = mutationData; _mutationData = mutationData;
_boardService = boardService; _boardService = boardService;
_auditService = auditService; _auditService = auditService;
_validator = validator; _validator = validator;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId) public async Task<WorkOrderBoardRowDto> CreateAsync(
WorkOrderBoardCreateRequestDto request,
ClaimsPrincipal user,
string? actorId)
{ {
var validationResult = await _validator.ValidateAsync(request); var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid) if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors); throw new ValidationException(validationResult.Errors);
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
user,
request.Customer,
CancellationToken.None);
var woNumber = await ResolveWoNumberAsync(request.WoNumber); var woNumber = await ResolveWoNumberAsync(request.WoNumber);
var siteCode = request.SiteCode!.Trim(); var siteCode = request.SiteCode!.Trim();
var primaryService = ResolvePrimaryService(request); var primaryService = ResolvePrimaryService(request);
@ -49,6 +61,7 @@ namespace SeaHaven.Services.Implementation
var pocNotes = TrimOrNull(request.PocNotes); var pocNotes = TrimOrNull(request.PocNotes);
var techPhone = TrimOrNull(request.TechPhone); var techPhone = TrimOrNull(request.TechPhone);
var vendorNotes = TrimOrNull(request.VendorNotes); var vendorNotes = TrimOrNull(request.VendorNotes);
var customer = TrimOrNull(request.Customer);
var hasVendorDispatch = request.VendorId.HasValue var hasVendorDispatch = request.VendorId.HasValue
|| request.ApptDate.HasValue || request.ApptDate.HasValue
|| !string.IsNullOrWhiteSpace(request.ApptTime); || !string.IsNullOrWhiteSpace(request.ApptTime);
@ -65,6 +78,8 @@ namespace SeaHaven.Services.Implementation
WorkerOrderNumber = woNumber, WorkerOrderNumber = woNumber,
WorkOrderType = request.WorkOrderType, WorkOrderType = request.WorkOrderType,
SiteCode = siteCode, SiteCode = siteCode,
Customer = customer,
AccountId = accountId,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Trade = primaryService, Trade = primaryService,
ExtraServices = extraServicesJson, ExtraServices = extraServicesJson,
@ -197,7 +212,7 @@ namespace SeaHaven.Services.Implementation
await _mutationData.SaveAsync(ct); await _mutationData.SaveAsync(ct);
}, CancellationToken.None); }, CancellationToken.None);
var row = await _boardService.GetBoardRowAsync(workOrder.Id); var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded."); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -9,16 +10,22 @@ namespace SeaHaven.Services.Implementation
public class WorkOrderBoardService : IWorkOrderBoardService public class WorkOrderBoardService : IWorkOrderBoardService
{ {
private readonly IWorkOrderBoardDataService _boardDataService; private readonly IWorkOrderBoardDataService _boardDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService) public WorkOrderBoardService(
IWorkOrderBoardDataService boardDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_boardDataService = boardDataService; _boardDataService = boardDataService;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderBoardResponseDto> GetBoardAsync( public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
WorkOrderBoardQueryDto query, WorkOrderBoardQueryDto query,
ClaimsPrincipal user,
string? currentUserId) string? currentUserId)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user);
var weekStart = query.WeekStart; var weekStart = query.WeekStart;
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4); var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
@ -34,7 +41,8 @@ namespace SeaHaven.Services.Implementation
query.Types, query.Types,
query.Overdue, query.Overdue,
query.Search, query.Search,
currentUserId); currentUserId,
accountId);
var result = await _boardDataService.GetBoardRowsAsync(dataQuery); var result = await _boardDataService.GetBoardRowsAsync(dataQuery);
var utcNow = DateTime.UtcNow; var utcNow = DateTime.UtcNow;
@ -65,9 +73,10 @@ namespace SeaHaven.Services.Implementation
}).ToList(); }).ToList();
} }
public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId) public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId, ClaimsPrincipal user)
{ {
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId); var accountId = _accountResolver.ResolveAccountFilter(user);
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId, accountId);
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow); return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
} }
@ -113,6 +122,7 @@ namespace SeaHaven.Services.Implementation
ServiceNotes = row.ServiceNotes, ServiceNotes = row.ServiceNotes,
ExtraServices = ParseExtraServices(row.ExtraServices), ExtraServices = ParseExtraServices(row.ExtraServices),
DocStatus = row.DocStatus, DocStatus = row.DocStatus,
CompletedDate = row.CompletedDate,
FlagColor = row.FlagColor, FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId, PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion, RowVersion = row.RowVersion,

View file

@ -153,6 +153,7 @@ namespace SeaHaven.Services.Implementation
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.CompletedDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.CompletedDate = v, () => workOrder.CompletedDate) },
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries; using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions; using SeaHaven.Services.Exceptions;
@ -12,20 +13,26 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderDetailDataService _detailData;
private readonly ICommentDataService _commentData; private readonly ICommentDataService _commentData;
private readonly IUserDataService _userDataService; private readonly IUserDataService _userDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderCommentService( public WorkOrderCommentService(
IWorkOrderDetailDataService detailData, IWorkOrderDetailDataService detailData,
ICommentDataService commentData, ICommentDataService commentData,
IUserDataService userDataService) IUserDataService userDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_detailData = detailData; _detailData = detailData;
_commentData = commentData; _commentData = commentData;
_userDataService = userDataService; _userDataService = userDataService;
_accountResolver = accountResolver;
} }
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId) public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(
int workOrderId,
ClaimsPrincipal user)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
return null; return null;
var comments = await _detailData.GetCommentsAsync(workOrderId); var comments = await _detailData.GetCommentsAsync(workOrderId);
@ -36,10 +43,12 @@ namespace SeaHaven.Services.Implementation
public async Task<WorkOrderCommentDto> AddCommentAsync( public async Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId, int workOrderId,
WorkOrderCommentCreateDto request, WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId, string? actorId,
string? documentUrl = null) string? documentUrl = null)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (string.IsNullOrWhiteSpace(request.Text)) if (string.IsNullOrWhiteSpace(request.Text))
@ -65,9 +74,12 @@ namespace SeaHaven.Services.Implementation
int workOrderId, int workOrderId,
int commentId, int commentId,
WorkOrderCommentCreateDto request, WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId) string? actorId)
{ {
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId, CancellationToken.None, accountId);
if (workOrder == null) if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
@ -14,17 +15,20 @@ namespace SeaHaven.Services.Implementation
private readonly ICompletionDocTemplateDataService _templateData; private readonly ICompletionDocTemplateDataService _templateData;
private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService; private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderCompletionService( public WorkOrderCompletionService(
IWorkOrderCompletionDataService completionData, IWorkOrderCompletionDataService completionData,
ICompletionDocTemplateDataService templateData, ICompletionDocTemplateDataService templateData,
IWorkOrderDetailDataService detailData, IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService) IWorkOrderAuditService auditService,
IWorkOrderAccountResolver accountResolver)
{ {
_completionData = completionData; _completionData = completionData;
_templateData = templateData; _templateData = templateData;
_detailData = detailData; _detailData = detailData;
_auditService = auditService; _auditService = auditService;
_accountResolver = accountResolver;
} }
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
@ -39,13 +43,34 @@ namespace SeaHaven.Services.Implementation
return row == null ? null : WorkOrderDetailService.MapTemplate(row); return row == null ? null : WorkOrderDetailService.MapTemplate(row);
} }
public async Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(actorId) || user?.Identity?.IsAuthenticated != true)
{
throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to upload completion documents.");
}
// AsNoTracking pre-check so UploadCompletionDocAsync load is not stale-cached.
await GetMutableWorkOrderForAuthAsync(workOrderId, user, cancellationToken);
}
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync( public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId, int workOrderId,
WorkOrderCompletionDocUploadDto request, WorkOrderCompletionDocUploadDto request,
string fileUrl, string fileUrl,
string? actorId) ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{ {
var workOrder = await _completionData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _completionData.GetTrackedWorkOrderAsync(
workOrderId, accountId, cancellationToken);
if (workOrder == null) if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
@ -77,7 +102,7 @@ namespace SeaHaven.Services.Implementation
if (oldDocStatus != DocStatus.Yes.ToString()) if (oldDocStatus != DocStatus.Yes.ToString())
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId); await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
await _completionData.SaveAsync(CancellationToken.None); await _completionData.SaveAsync(cancellationToken);
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
@ -143,6 +168,23 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("NotFound", "Template not found."); throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
} }
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
int workOrderId,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _completionData.GetWorkOrderForCompletionAuthAsync(
workOrderId, accountId, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
return workOrder;
}
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request) private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
{ {
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey)) if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers; using SeaHaven.Services.Helpers;
@ -11,25 +12,29 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderDetailDataService _detailData;
private readonly ICompletionDocTemplateDataService _templateData; private readonly ICompletionDocTemplateDataService _templateData;
private readonly IUserDataService _userDataService; private readonly IUserDataService _userDataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderDetailService( public WorkOrderDetailService(
IWorkOrderBoardService boardService, IWorkOrderBoardService boardService,
IWorkOrderDetailDataService detailData, IWorkOrderDetailDataService detailData,
ICompletionDocTemplateDataService templateData, ICompletionDocTemplateDataService templateData,
IUserDataService userDataService) IUserDataService userDataService,
IWorkOrderAccountResolver accountResolver)
{ {
_boardService = boardService; _boardService = boardService;
_detailData = detailData; _detailData = detailData;
_templateData = templateData; _templateData = templateData;
_userDataService = userDataService; _userDataService = userDataService;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId) public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId, ClaimsPrincipal user)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
return null; return null;
var boardRow = await _boardService.GetBoardRowAsync(workOrderId); var boardRow = await _boardService.GetBoardRowAsync(workOrderId, user);
if (boardRow == null) if (boardRow == null)
return null; return null;
@ -38,7 +43,8 @@ namespace SeaHaven.Services.Implementation
var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50); var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50);
var attachments = await _detailData.GetAttachmentsAsync(workOrderId); var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); var workOrder = await _detailData.GetWorkOrderForMediaAsync(
workOrderId, CancellationToken.None, accountId);
var template = await _templateData.ResolveForWorkOrderAsync( var template = await _templateData.ResolveForWorkOrderAsync(
extended?.Trade ?? boardRow.Pm, extended?.Trade ?? boardRow.Pm,
@ -59,9 +65,13 @@ namespace SeaHaven.Services.Implementation
}; };
} }
public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50) public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(
int workOrderId,
ClaimsPrincipal user,
int limit = 50)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) var accountId = _accountResolver.ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, CancellationToken.None, accountId))
return null; return null;
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);

View file

@ -12,17 +12,20 @@ namespace SeaHaven.Services.Implementation
private readonly ISyncFieldMergePolicy _mergePolicy; private readonly ISyncFieldMergePolicy _mergePolicy;
private readonly IWorkOrderFieldLockService _fieldLocks; private readonly IWorkOrderFieldLockService _fieldLocks;
private readonly IWorkOrderAuditService _audit; private readonly IWorkOrderAuditService _audit;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderIngestService( public WorkOrderIngestService(
IWorkOrderIngestDataService ingestData, IWorkOrderIngestDataService ingestData,
ISyncFieldMergePolicy mergePolicy, ISyncFieldMergePolicy mergePolicy,
IWorkOrderFieldLockService fieldLocks, IWorkOrderFieldLockService fieldLocks,
IWorkOrderAuditService audit) IWorkOrderAuditService audit,
IWorkOrderAccountResolver accountResolver)
{ {
_ingestData = ingestData; _ingestData = ingestData;
_mergePolicy = mergePolicy; _mergePolicy = mergePolicy;
_fieldLocks = fieldLocks; _fieldLocks = fieldLocks;
_audit = audit; _audit = audit;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync( public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
@ -45,13 +48,21 @@ namespace SeaHaven.Services.Implementation
continue; continue;
} }
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken); try
nextSeed = updatedSeed; {
result.Results.Add(upsert); var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
if (upsert.Created) nextSeed = updatedSeed;
result.Created++; result.Results.Add(upsert);
else if (upsert.Created)
result.Updated++; result.Created++;
else
result.Updated++;
}
catch (Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "AccountUnresolved")
{
result.Skipped++;
}
} }
await _ingestData.SaveAsync(cancellationToken); await _ingestData.SaveAsync(cancellationToken);
@ -72,6 +83,8 @@ namespace SeaHaven.Services.Implementation
if (existing == null) if (existing == null)
{ {
var accountId = await _accountResolver.ResolveForUnauthenticatedCreateAsync(
item.Customer, cancellationToken);
var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed); var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed);
var wo = new WorkOrder var wo = new WorkOrder
{ {
@ -84,6 +97,7 @@ namespace SeaHaven.Services.Implementation
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity), Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
Severity = item.Severity, Severity = item.Severity,
Customer = item.Customer, Customer = item.Customer,
AccountId = accountId,
SiteCode = item.SiteCode, SiteCode = item.SiteCode,
Building = item.Building, Building = item.Building,
LocationId = locationId, LocationId = locationId,

View file

@ -1,5 +1,7 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions; using SeaHaven.Services.Exceptions;
@ -12,70 +14,98 @@ namespace SeaHaven.Services.Implementation
{ {
private readonly IWorkOrderMediaDataService _mediaData; private readonly IWorkOrderMediaDataService _mediaData;
private readonly IWorkOrderDetailDataService _detailData; private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAuditService _auditService;
public WorkOrderMediaService(IWorkOrderMediaDataService mediaData, IWorkOrderDetailDataService detailData) public WorkOrderMediaService(
IWorkOrderMediaDataService mediaData,
IWorkOrderDetailDataService detailData,
IWorkOrderAuditService auditService)
{ {
_mediaData = mediaData; _mediaData = mediaData;
_detailData = detailData; _detailData = detailData;
_auditService = auditService;
} }
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId) public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
var accountFilter = ResolveAccountFilter(user);
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken, accountFilter))
return null; return null;
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken, accountFilter);
if (workOrder == null) if (workOrder == null)
return null; return null;
var attachments = await _detailData.GetAttachmentsAsync(workOrderId); WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId!, workOrder);
var attachments = await _detailData.GetAttachmentsAsync(workOrderId, cancellationToken);
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
} }
public async Task EnsureCanMutateMediaAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
// AsNoTracking pre-check so the subsequent AddMediaAsync load is not stale-cached.
await GetMutableWorkOrderForAuthAsync(workOrderId, user, actorId!, cancellationToken);
}
public async Task<WorkOrderMediaFileDto> AddMediaAsync( public async Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId, int workOrderId,
WorkOrderMediaCategory category, WorkOrderMediaCategory? category,
string fileUrl, string fileUrl,
string? actorId) ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (workOrder == null) var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) if (resolvedCategory == WorkOrderMediaCategory.Completion)
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
if (category == WorkOrderMediaCategory.Completion)
{ {
throw new WorkOrderBoardValidationException( throw new WorkOrderBoardValidationException(
"UseCompletionDocEndpoint", "UseCompletionDocEndpoint",
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
} }
if (category == WorkOrderMediaCategory.Before) if (resolvedCategory == WorkOrderMediaCategory.Before)
{ {
var oldBefore = workOrder.BeforPhotoAttachment;
workOrder.BeforPhotoAttachment = fileUrl; workOrder.BeforPhotoAttachment = fileUrl;
await _mediaData.SaveAsync(CancellationToken.None); await _auditService.StageFieldChangedAsync(
workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto return new WorkOrderMediaFileDto
{ {
Id = -1, Id = -1,
Category = category, Category = resolvedCategory,
Url = fileUrl, Url = fileUrl,
IsLegacy = true IsLegacy = true
}; };
} }
if (category == WorkOrderMediaCategory.After) if (resolvedCategory == WorkOrderMediaCategory.After)
{ {
var oldAfter = workOrder.AfterPhotoAttachment;
workOrder.AfterPhotoAttachment = fileUrl; workOrder.AfterPhotoAttachment = fileUrl;
await _mediaData.SaveAsync(CancellationToken.None); await _auditService.StageFieldChangedAsync(
workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto return new WorkOrderMediaFileDto
{ {
Id = -2, Id = -2,
Category = category, Category = resolvedCategory,
Url = fileUrl, Url = fileUrl,
IsLegacy = true IsLegacy = true
}; };
@ -85,48 +115,241 @@ namespace SeaHaven.Services.Implementation
{ {
WorkorderId = workOrderId, WorkorderId = workOrderId,
Attachments = fileUrl, Attachments = fileUrl,
Category = category, Category = category.HasValue ? resolvedCategory : null,
CreatedDate = DateTime.UtcNow, CreatedDate = DateTime.UtcNow,
createdby = actorId createdby = actorId
}; };
_mediaData.TrackAttachment(attachment); _mediaData.TrackAttachment(attachment);
await _mediaData.SaveAsync(CancellationToken.None); await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto return new WorkOrderMediaFileDto
{ {
Id = attachment.Id, Id = attachment.Id,
Category = category, Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
Url = fileUrl, Url = fileUrl,
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
IsLegacy = false IsLegacy = false
}; };
} }
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) public async Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
int workOrderId,
int mediaId,
WorkOrderMediaCategory category,
string? workOrderVersion,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{ {
WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint.");
if (category == WorkOrderMediaCategory.Completion)
{
throw new WorkOrderBoardValidationException(
"UseCompletionDocEndpoint",
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
}
var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
ApplyExpectedVersion(workOrder, workOrderVersion);
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString();
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
{
var url = attachment.Attachments ?? "";
if (category == WorkOrderMediaCategory.Before)
workOrder.BeforPhotoAttachment = url;
else
workOrder.AfterPhotoAttachment = url;
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, category.ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
Id = category == WorkOrderMediaCategory.Before ? -1 : -2,
Category = category,
Url = url,
IsLegacy = true
};
}
attachment.Category = category;
_mediaData.MarkWorkOrderModified(workOrder);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, category.ToString()),
actorId);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
Id = attachment.Id,
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
Url = attachment.Attachments ?? "",
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
IsLegacy = false
};
}
public async Task DeleteMediaAsync(
int workOrderId,
int mediaId,
string? workOrderVersion,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
WorkOrderMediaAuthorization.EnsureCanDelete(user, actorId);
if (mediaId <= 0) if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
if (!await _detailData.ExistsAsync(workOrderId)) var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); ApplyExpectedVersion(workOrder, workOrderVersion);
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, CancellationToken.None); var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken);
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
var priorCategory = (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString();
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
_mediaData.MarkWorkOrderModified(workOrder);
await _auditService.StageFieldChangedAsync(
workOrderId,
"MediaCategory",
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, "Deleted"),
actorId);
await SaveMediaAsync(cancellationToken);
}
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
int workOrderId,
ClaimsPrincipal user,
string actorId,
CancellationToken cancellationToken)
{
var accountFilter = ResolveAccountFilter(user);
var workOrder = await _mediaData.GetWorkOrderForMediaAuthAsync(workOrderId, accountFilter, cancellationToken);
if (workOrder == null) if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder);
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, CancellationToken.None); return workOrder;
if (attachment == null)
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
attachment.IsDeleted = true;
attachment.DeletionTime = DateTime.UtcNow;
attachment.DeleterUserId = actorId;
await _mediaData.SaveAsync(CancellationToken.None);
} }
private async Task<WorkOrder> GetMutableWorkOrderAsync(
int workOrderId,
ClaimsPrincipal user,
string actorId,
CancellationToken cancellationToken)
{
var accountFilter = ResolveAccountFilter(user);
// Fresh tracked load (not the AsNoTracking pre-check entity).
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, accountFilter, cancellationToken);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder);
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
return workOrder;
}
/// <summary>
/// Account filter for data queries. Null means org-wide (skip ApplyAccountScope).
/// Call only after EnsureCan* has verified scope is not Missing.
/// </summary>
private static int? ResolveAccountFilter(ClaimsPrincipal user)
{
return WorkOrderMediaAuthorization.ResolveMediaScope(user) switch
{
MediaAccountScope.Account account => account.AccountId,
MediaAccountScope.OrgWide => null,
_ => throw new WorkOrderBoardValidationException(
"Forbidden",
"You are not allowed to access work order media without account scope.")
};
}
private async Task SaveMediaAsync(CancellationToken cancellationToken)
{
try
{
await _mediaData.SaveAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
throw new WorkOrderBoardValidationException(
"ConcurrencyConflict",
"Work order was modified. Refresh and retry.");
}
}
private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion)
{
var expected = ParseRowVersion(workOrderVersion);
if (expected == null)
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(expected))
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
_mediaData.SetExpectedWorkOrderVersion(workOrder, expected);
}
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
}
}
private static string FormatMediaAuditValue(int? mediaId, string category)
=> mediaId.HasValue ? $"{mediaId.Value}:{category}" : category;
} }
} }

View file

@ -1,9 +1,11 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using FluentValidation; using FluentValidation;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation; using SeaHaven.Services.Validation;
@ -18,6 +20,7 @@ namespace SeaHaven.Services.Implementation
private readonly IFileStoragePort _fileStorage; private readonly IFileStoragePort _fileStorage;
private readonly ICreateWorkOrderValidation _createValidator; private readonly ICreateWorkOrderValidation _createValidator;
private readonly IUpdateWorkOrderValidation _updateValidator; private readonly IUpdateWorkOrderValidation _updateValidator;
private readonly IWorkOrderAccountResolver _accountResolver;
public WorkOrderService( public WorkOrderService(
IWorkOrderDataService workOrderDataService, IWorkOrderDataService workOrderDataService,
@ -26,7 +29,8 @@ namespace SeaHaven.Services.Implementation
IQuotesDataService quotesDataService, IQuotesDataService quotesDataService,
IFileStoragePort fileStorage, IFileStoragePort fileStorage,
ICreateWorkOrderValidation createValidator, ICreateWorkOrderValidation createValidator,
IUpdateWorkOrderValidation updateValidator) IUpdateWorkOrderValidation updateValidator,
IWorkOrderAccountResolver accountResolver)
{ {
_workOrderDataService = workOrderDataService; _workOrderDataService = workOrderDataService;
_commentDataService = commentDataService; _commentDataService = commentDataService;
@ -35,6 +39,7 @@ namespace SeaHaven.Services.Implementation
_fileStorage = fileStorage; _fileStorage = fileStorage;
_createValidator = createValidator; _createValidator = createValidator;
_updateValidator = updateValidator; _updateValidator = updateValidator;
_accountResolver = accountResolver;
} }
public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id) public async Task<WorkOrderDTO?> GetWorkOrderByIdAsync(int id)
@ -67,13 +72,17 @@ namespace SeaHaven.Services.Implementation
}; };
} }
public async Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId) public async Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, ClaimsPrincipal user, string userId)
{ {
var validationResult = await _createValidator.ValidateAsync(dto); var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid) if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors); throw new ValidationException(validationResult.Errors);
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
user, dto.Customer, CancellationToken.None);
var workOrder = CreateWorkOrderDTO.MapToEntity(dto); var workOrder = CreateWorkOrderDTO.MapToEntity(dto);
workOrder.AccountId = accountId;
workOrder.createdby = userId; workOrder.createdby = userId;
workOrder.CreatedDate = DateTime.UtcNow; workOrder.CreatedDate = DateTime.UtcNow;
workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync(); workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync();
@ -124,23 +133,23 @@ namespace SeaHaven.Services.Implementation
public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync( public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
int page, int pageSize, string? search, string? status, string? assignee, int page, int pageSize, string? search, string? status, string? assignee,
int? locationId, string? sortBy, string? sortDir) int? locationId, string? sortBy, string? sortDir, int? accountId = null)
{ {
return await _workOrderDataService.GetWorkOrderListPagedAsync( return await _workOrderDataService.GetWorkOrderListPagedAsync(
page, pageSize, search, status, assignee, locationId, sortBy, sortDir); page, pageSize, search, status, assignee, locationId, sortBy, sortDir, accountId);
} }
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id) public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null)
{ {
return await _workOrderDataService.GetWorkOrderDetailAsync(id); return await _workOrderDataService.GetWorkOrderDetailAsync(id, accountId);
} }
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync( public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string[]? status, string[]? duedate, string search,
string sort, string sortby, int page, int pageSize) string sort, string sortby, int page, int pageSize, int? accountId = null)
{ {
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
var data = allWorkOrders.AsEnumerable(); var data = allWorkOrders.AsEnumerable();
@ -222,7 +231,7 @@ namespace SeaHaven.Services.Implementation
public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async( public async Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string[]? status, string[]? duedate, string search,
string sort, string sortby, int page, int pageSize) string sort, string sortby, int page, int pageSize, int? accountId = null)
{ {
location ??= Array.Empty<int>(); location ??= Array.Empty<int>();
priority ??= Array.Empty<string>(); priority ??= Array.Empty<string>();
@ -230,7 +239,7 @@ namespace SeaHaven.Services.Implementation
duedate ??= Array.Empty<string>(); duedate ??= Array.Empty<string>();
search ??= string.Empty; search ??= string.Empty;
var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
var totalCount = allWorkOrders.Count; var totalCount = allWorkOrders.Count;
var currentDate = DateTime.UtcNow.Date; var currentDate = DateTime.UtcNow.Date;
@ -354,7 +363,10 @@ namespace SeaHaven.Services.Implementation
}; };
} }
public async Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId) public async Task<int> CreateWorkOrderWithDetailsAsync(
CreateWorkOrderWithDetailsInput input,
ClaimsPrincipal user,
string userId)
{ {
var createDto = new CreateWorkOrderDTO var createDto = new CreateWorkOrderDTO
{ {
@ -364,10 +376,11 @@ namespace SeaHaven.Services.Implementation
Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var p) ? p : WorkOrderPriority.Medium, Priority = Enum.TryParse<WorkOrderPriority>(input.Priority, out var p) ? p : WorkOrderPriority.Medium,
LocationId = input.LocationId, LocationId = input.LocationId,
AssignTo = input.AssignTo, AssignTo = input.AssignTo,
DueDate = input.DueDate DueDate = input.DueDate,
Customer = input.Customer
}; };
var workOrderId = await CreateWorkOrderAsync(createDto, userId); var workOrderId = await CreateWorkOrderAsync(createDto, user, userId);
if (input.ContactIds != null && input.ContactIds.Any()) if (input.ContactIds != null && input.ContactIds.Any())
await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds); await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds);
@ -427,8 +440,10 @@ namespace SeaHaven.Services.Implementation
return true; return true;
} }
public async Task<Comments> AddCommentAsync(AddCommentInput input, string userId) public async Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId)
{ {
await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user);
var comment = new Comments var comment = new Comments
{ {
UserId = userId, UserId = userId,
@ -446,8 +461,10 @@ namespace SeaHaven.Services.Implementation
return await _commentDataService.AddAsync(comment); return await _commentDataService.AddAsync(comment);
} }
public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId) public async Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId)
{ {
await EnsureWorkOrderInAccountScopeAsync(input.WorkorderId, user);
var comment = new Comments var comment = new Comments
{ {
UserId = userId, UserId = userId,
@ -457,7 +474,7 @@ namespace SeaHaven.Services.Implementation
}; };
var saved = await _commentDataService.AddAsync(comment); var saved = await _commentDataService.AddAsync(comment);
var user = await _userDataService.GetByIdAsync(userId); var displayUser = await _userDataService.GetByIdAsync(userId);
return new CommentResult return new CommentResult
{ {
@ -465,13 +482,14 @@ namespace SeaHaven.Services.Implementation
CreatedDate = saved.CreatedDate, CreatedDate = saved.CreatedDate,
Commenttext = saved.Commenttext, Commenttext = saved.Commenttext,
CommentType = saved.CommentType, CommentType = saved.CommentType,
UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" UserName = displayUser != null ? (displayUser.FirstName + " " + displayUser.LastName).Trim() : ""
}; };
} }
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync() public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(ClaimsPrincipal user)
{ {
var comments = await _commentDataService.GetAllAsync(); var accountId = _accountResolver.ResolveAccountFilter(user);
var comments = await _commentDataService.GetAllForAccountAsync(accountId);
return comments.Select(s => new CommentListItemReadModel return comments.Select(s => new CommentListItemReadModel
{ {
Id = s.Id, Id = s.Id,
@ -481,8 +499,15 @@ namespace SeaHaven.Services.Implementation
}).ToList(); }).ToList();
} }
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid) public async Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(
int woid,
ClaimsPrincipal user)
{ {
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(woid, accountId);
if (workOrder == null)
return null;
var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); var comments = await _commentDataService.GetByWorkOrderIdAsync(woid);
return comments.Select(s => new CommentListItemReadModel return comments.Select(s => new CommentListItemReadModel
{ {
@ -493,15 +518,23 @@ namespace SeaHaven.Services.Implementation
}).ToList(); }).ToList();
} }
public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync() private async Task EnsureWorkOrderInAccountScopeAsync(int workOrderId, ClaimsPrincipal user)
{ {
var data = await _workOrderDataService.GetAllAsync(); var accountId = _accountResolver.ResolveAccountFilter(user);
return data.Where(w => w.istemplate != true).ToList(); var workOrder = await _workOrderDataService.GetWorkOrderDetailAsync(workOrderId, accountId);
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
} }
public async Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync() public async Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null)
{ {
var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); var data = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(accountId);
return data;
}
public async Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null)
{
var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(accountId);
return workOrders.Select(s => new WorkorderFilterVM return workOrders.Select(s => new WorkorderFilterVM
{ {
Id = s.Id, Id = s.Id,

View file

@ -161,6 +161,12 @@ namespace SeaHaven.Services.Implementation
"Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.", "Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.",
mutation!.DeliveryId); mutation!.DeliveryId);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict); return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict);
case WorkOrderWebhookPersistenceStatus.AccountUnresolved:
Invalid.Add(1);
_logger.LogWarning(
"Work-order webhook delivery {DeliveryId} could not resolve account from customer.",
mutation!.DeliveryId);
return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope);
default: default:
throw new InvalidOperationException("Unknown persistence result."); throw new InvalidOperationException("Unknown persistence result.");
} }

View file

@ -18,6 +18,12 @@ namespace SeaHaven.Services.Interfaces
public interface IFileStoragePort public interface IFileStoragePort
{ {
Task<string> SaveFileAsync(IFormFile file); Task<string> SaveFileAsync(IFormFile file);
/// <summary>
/// Best-effort delete of a previously saved file URL. Returns false when the file
/// cannot be resolved or removed; never throws for missing paths.
/// </summary>
bool TryDelete(string fileUrl);
} }
/// <summary> /// <summary>

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
@ -5,9 +6,9 @@ namespace SeaHaven.Services.Interfaces
public interface IUserService public interface IUserService
{ {
Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken); Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken);
Task<AddUserOutcomeDTO> AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken); Task<AddUserOutcomeDTO> AddUserAsync(AddUserRequestDTO dto, ClaimsPrincipal caller, CancellationToken cancellationToken);
Task<bool> EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken); Task<AddUserOutcomeDTO> EditUserAsync(EditUserRequestDTO dto, ClaimsPrincipal caller, CancellationToken cancellationToken);
Task<bool> DeleteUserAsync(string id, CancellationToken cancellationToken); Task<AddUserOutcomeDTO> DeleteUserAsync(string id, ClaimsPrincipal caller, CancellationToken cancellationToken);
Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken); Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyList<UserProfileRowDTO>> GetUserProfileAsync(string userId, CancellationToken cancellationToken); Task<IReadOnlyList<UserProfileRowDTO>> GetUserProfileAsync(string userId, CancellationToken cancellationToken);
} }

View file

@ -0,0 +1,40 @@
using System.Security.Claims;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Server-derived work-order account scope (SH-221): claims for authenticated callers,
/// unique Accounts.Name ↔ Customer for org-wide / unauthenticated creates.
/// </summary>
public interface IWorkOrderAccountResolver
{
/// <summary>
/// Account filter for reads. Null = org-wide (skip ApplyAccountScope).
/// Throws Forbidden when scope is Missing.
/// </summary>
int? ResolveAccountFilter(ClaimsPrincipal user);
/// <summary>
/// Authenticated create: claim account_id, or org-wide Customer unique match.
/// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved.
/// </summary>
Task<int> ResolveForAuthenticatedCreateAsync(
ClaimsPrincipal user,
string? customer,
CancellationToken cancellationToken = default);
/// <summary>
/// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved.
/// </summary>
Task<int> ResolveForUnauthenticatedCreateAsync(
string? customer,
CancellationToken cancellationToken = default);
/// <summary>
/// Best-effort Customer lookup (no throw). Null when blank, missing, or ambiguous.
/// </summary>
Task<int?> TryResolveFromCustomerAsync(
string? customer,
CancellationToken cancellationToken = default);
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
@ -6,6 +7,7 @@ namespace SeaHaven.Services.Interfaces
{ {
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync( Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
WorkOrderAdvancedSearchQueryDto query, WorkOrderAdvancedSearchQueryDto query,
ClaimsPrincipal user,
string? currentUserId); string? currentUserId);
} }
} }

View file

@ -1,9 +1,10 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderBoardCancelService public interface IWorkOrderBoardCancelService
{ {
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId); Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, ClaimsPrincipal user, string? actorId);
} }
} }

View file

@ -1,9 +1,13 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderBoardCreateService public interface IWorkOrderBoardCreateService
{ {
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId); Task<WorkOrderBoardRowDto> CreateAsync(
WorkOrderBoardCreateRequestDto request,
ClaimsPrincipal user,
string? actorId);
} }
} }

View file

@ -1,11 +1,15 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderBoardService public interface IWorkOrderBoardService
{ {
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); Task<WorkOrderBoardResponseDto> GetBoardAsync(
WorkOrderBoardQueryDto query,
ClaimsPrincipal user,
string? currentUserId);
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync(); Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId); Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId, ClaimsPrincipal user);
} }
} }

View file

@ -1,16 +1,22 @@
using Data.SeaHavenIndustries.Enums; using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderCommentService public interface IWorkOrderCommentService
{ {
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId); Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId, ClaimsPrincipal user);
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); Task<WorkOrderCommentDto> AddCommentAsync(
int workOrderId,
WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId,
string? documentUrl = null);
Task<WorkOrderCommentDto> UpdateCommentAsync( Task<WorkOrderCommentDto> UpdateCommentAsync(
int workOrderId, int workOrderId,
int commentId, int commentId,
WorkOrderCommentCreateDto request, WorkOrderCommentCreateDto request,
ClaimsPrincipal user,
string? actorId); string? actorId);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -7,11 +8,18 @@ namespace SeaHaven.Services.Interfaces
{ {
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id); Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderCompletionDto> UploadCompletionDocAsync( Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
int workOrderId, int workOrderId,
WorkOrderCompletionDocUploadDto request, WorkOrderCompletionDocUploadDto request,
string fileUrl, string fileUrl,
string? actorId); ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request); Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id); Task DeleteTemplateAsync(int id);

View file

@ -1,10 +1,14 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderDetailService public interface IWorkOrderDetailService
{ {
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId); Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId, ClaimsPrincipal user);
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50); Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(
int workOrderId,
ClaimsPrincipal user,
int limit = 50);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -5,8 +6,44 @@ namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderMediaService public interface IWorkOrderMediaService
{ {
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId); Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); int workOrderId,
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
/// <summary>
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
/// </summary>
Task EnsureCanMutateMediaAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderMediaFileDto> AddMediaAsync(
int workOrderId,
WorkOrderMediaCategory? category,
string fileUrl,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<WorkOrderMediaFileDto> UpdateMediaCategoryAsync(
int workOrderId,
int mediaId,
WorkOrderMediaCategory category,
string? workOrderVersion,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task DeleteMediaAsync(
int workOrderId,
int mediaId,
string? workOrderVersion,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -12,7 +13,7 @@ namespace SeaHaven.Services.Interfaces
Task<WorkOrderDTO?> GetWorkOrderByIdWithDetailsAsync(int id); Task<WorkOrderDTO?> GetWorkOrderByIdWithDetailsAsync(int id);
Task<IEnumerable<WorkOrderDTO>> GetAllWorkOrdersAsync(); Task<IEnumerable<WorkOrderDTO>> GetAllWorkOrdersAsync();
Task<PagedResult<WorkOrderDTO>> GetWorkOrdersPagedAsync(int page, int pageSize, string? search = null, string? status = null, string? priority = null); Task<PagedResult<WorkOrderDTO>> GetWorkOrdersPagedAsync(int page, int pageSize, string? search = null, string? status = null, string? priority = null);
Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId); Task<int> CreateWorkOrderAsync(CreateWorkOrderDTO dto, ClaimsPrincipal user, string userId);
Task<bool> UpdateWorkOrderAsync(int id, UpdateWorkOrderDTO dto, string userId); Task<bool> UpdateWorkOrderAsync(int id, UpdateWorkOrderDTO dto, string userId);
Task<bool> DeleteWorkOrderAsync(int id, string userId); Task<bool> DeleteWorkOrderAsync(int id, string userId);
Task<bool> WorkOrderExistsAsync(int id); Task<bool> WorkOrderExistsAsync(int id);
@ -21,24 +22,26 @@ namespace SeaHaven.Services.Interfaces
Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync( Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
int page, int pageSize, string? search, string? status, string? assignee, int page, int pageSize, string? search, string? status, string? assignee,
int? locationId, string? sortBy, string? sortDir); int? locationId, string? sortBy, string? sortDir, int? accountId = null);
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id); Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id, int? accountId = null);
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync( Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrdersAsync(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize,
int? accountId = null);
Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async( Task<FilteredWorkOrderPageDTO> GetFilteredWorkOrders2Async(
string[] assignto, int[]? location, string[]? priority, string[] assignto, int[]? location, string[]? priority,
string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize,
int? accountId = null);
Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId); Task<WorkOrderDTO?> ChangeStatusAsync(int id, string status, string userId);
Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId); Task<ChangeAssignmentResult?> ChangeAssignmentAsync(int id, string? assignTo, string userId);
Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId); Task<int> CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, ClaimsPrincipal user, string userId);
Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); Task<bool> UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId);
Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId); Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId);
Task<Comments> AddCommentAsync(AddCommentInput input, string userId); Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, string userId); Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(); Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(ClaimsPrincipal user);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsByWorkorderIdAsync(int woid); Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(int woid, ClaimsPrincipal user);
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(); Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(); Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);
} }
} }

View file

@ -0,0 +1,685 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace SeaHavenIndustries.Tests;
public class WorkOrderAccountScopeTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task BoardCreate_WithAccountClaim_StampsAccountId()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher");
var row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
},
user,
"actor-1");
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal(7, wo.AccountId);
}
[Fact]
public async Task BoardCreate_MissingScope_ThrowsForbidden()
{
await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
},
WorkOrderAccountTestHelpers.MissingScope(),
"actor-1"));
Assert.Equal("Forbidden", ex.Code);
Assert.Empty(context.workOrders);
}
[Fact]
public async Task BoardCreate_OrgWide_WithUniqueCustomer_StampsAccountId()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer");
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var row = await create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL",
Customer = "Unique Customer"
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1");
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal(3, wo.AccountId);
Assert.Equal("Unique Customer", wo.Customer);
}
[Fact]
public async Task BoardCreate_OrgWide_UnresolvedCustomer_ThrowsAccountUnresolved()
{
await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1"));
Assert.Equal("AccountUnresolved", ex.Code);
Assert.Empty(context.workOrders);
}
[Fact]
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 8, 10),
istemplate = false
},
new WorkOrder
{
Id = 2,
InternalWONumber = "00000000002",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 8, 11),
istemplate = false
},
new WorkOrder
{
Id = 3,
InternalWONumber = "00000000003",
AccountId = null,
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 8, 12),
istemplate = false
});
await context.SaveChangesAsync();
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver);
var scoped = await boardService.GetBoardAsync(
new WorkOrderBoardQueryDto
{
WeekStart = new DateOnly(2026, 8, 10),
WeekEnd = new DateOnly(2026, 8, 14)
},
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"),
"disp-1");
Assert.Single(scoped.Scheduled);
Assert.Equal(1, scoped.Scheduled[0].Id);
var orgWide = await boardService.GetBoardAsync(
new WorkOrderBoardQueryDto
{
WeekStart = new DateOnly(2026, 8, 10),
WeekEnd = new DateOnly(2026, 8, 14)
},
WorkOrderAccountTestHelpers.OrgWideAdmin(),
"admin-1");
Assert.Equal(3, orgWide.Scheduled.Count);
}
[Fact]
public async Task Detail_CrossAccount_ReturnsNull()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.Add(new WorkOrder
{
Id = 10,
InternalWONumber = "00000000010",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Incomplete,
istemplate = false
});
await context.SaveChangesAsync();
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), resolver);
var detail = new WorkOrderDetailService(
boardService,
new WorkOrderDetailDataService(context),
new CompletionDocTemplateDataService(context),
new UserDataService(context),
resolver);
var result = await detail.GetDetailAsync(
10,
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Null(result);
}
[Fact]
public async Task Ingest_UnresolvedCustomer_SkipsCreate()
{
await using var context = CreateContext();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var ingest = new WorkOrderIngestService(
new WorkOrderIngestDataService(context),
new SyncFieldMergePolicy(fieldLocks),
fieldLocks,
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var result = await ingest.UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto
{
ExternalWorkOrderId = "EXT-1",
Description = "No account",
Customer = "Unknown Customer"
}
});
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Created);
Assert.Empty(context.workOrders);
}
[Fact]
public async Task Ingest_UniqueCustomer_StampsAccountId()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 9, "Ingest Customer");
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var ingest = new WorkOrderIngestService(
new WorkOrderIngestDataService(context),
new SyncFieldMergePolicy(fieldLocks),
fieldLocks,
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var result = await ingest.UpsertBatchAsync(new[]
{
new WorkOrderIngestPayloadDto
{
ExternalWorkOrderId = "EXT-9",
Description = "Has account",
Customer = "Ingest Customer"
}
});
Assert.Equal(1, result.Created);
var wo = Assert.Single(context.workOrders);
Assert.Equal(9, wo.AccountId);
}
[Fact]
public async Task ResolveAccountFilter_MissingScope_ThrowsForbidden()
{
await using var context = CreateContext();
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var ex = Assert.Throws<WorkOrderBoardValidationException>(() =>
resolver.ResolveAccountFilter(WorkOrderAccountTestHelpers.MissingScope()));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task LegacyList_ScopedUser_HidesOtherAccountAndNullAccountRows()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var scoped = (await data.GetAllWithDetailsAsync(accountId: 1)).ToList();
var dd = await data.GetNonTemplateWorkOrdersWithLocationsAsync(accountId: 1);
Assert.Single(scoped);
Assert.Equal(1, scoped[0].Id);
Assert.Single(dd);
Assert.Equal(1, dd[0].Id);
}
[Fact]
public async Task LegacyList_OrgWide_IncludesNullAccountRows()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var orgWide = (await data.GetAllWithDetailsAsync(accountId: null)).ToList();
Assert.Equal(3, orgWide.Count);
}
[Fact]
public async Task LegacyDetail_CrossAccount_ReturnsNull()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var detail = await data.GetWorkOrderDetailAsync(2, accountId: 1);
Assert.Null(detail);
}
[Fact]
public async Task LegacyDetail_SameAccount_ReturnsRow()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var detail = await data.GetWorkOrderDetailAsync(1, accountId: 1);
Assert.NotNull(detail);
Assert.Equal(1, detail!.Id);
}
[Fact]
public async Task LegacyDetail_OrgWide_CanReadNullAccountRow()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var data = new WorkOrderDataService(context);
var detail = await data.GetWorkOrderDetailAsync(3, accountId: null);
Assert.NotNull(detail);
Assert.Equal(3, detail!.Id);
}
[Fact]
public async Task LegacyFiltered_ScopedUser_HidesOtherAccountAndNullAccountRows()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
var service = CreateLegacyReadService(context);
var page = await service.GetFilteredWorkOrdersAsync(
Array.Empty<string>(), null, null, null, null,
search: "", sort: "", sortby: "", page: 1, pageSize: 50, accountId: 1);
Assert.Equal(1, page.TotalCount);
Assert.Single(page.Data);
Assert.Equal(1, page.Data.First().Id);
}
private static WorkOrderService CreateLegacyReadService(ApplicationDbContext context)
{
return new WorkOrderService(
new WorkOrderDataService(context),
new CommentDataService(context),
new UserDataService(context),
new QuotesDataService(context),
fileStorage: null!,
new CreateWorkOrderValidation(),
new UpdateWorkOrderValidation(),
WorkOrderAccountTestHelpers.Resolver(context));
}
private static async Task SeedThreeAccountRowsAsync(ApplicationDbContext context)
{
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
InternalWONumber = "00000000001",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
},
new WorkOrder
{
Id = 2,
InternalWONumber = "00000000002",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
},
new WorkOrder
{
Id = 3,
InternalWONumber = "00000000003",
AccountId = null,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
await context.SaveChangesAsync();
}
[Fact]
public async Task Comments_CrossAccount_GetAddUpdate_ReturnNotFound()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.Users.Add(new ApplicationUser
{
Id = "author-1",
UserName = "author",
FirstName = "Ann",
LastName = "Author"
});
context.workOrders.Add(new WorkOrder
{
Id = 20,
InternalWONumber = "00000000020",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
context.Comments.Add(new Comments
{
Id = 5,
WorkerOrderId = 20,
UserId = "author-1",
Commenttext = "Secret",
CommentType = "General",
RecordType = "WorkOrder",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
Assert.Null(await service.GetCommentsAsync(20, scoped));
var addEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.AddCommentAsync(20, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "disp-1"));
Assert.Equal("NotFound", addEx.Code);
var updateEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateCommentAsync(
20, 5, new WorkOrderCommentCreateDto { Text = "Nope" }, scoped, "author-1"));
Assert.Equal("NotFound", updateEx.Code);
}
[Fact]
public async Task Comments_SameAccount_AddSucceeds()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
context.Users.Add(new ApplicationUser
{
Id = "disp-1",
UserName = "disp",
FirstName = "Dee",
LastName = "Spatch"
});
context.workOrders.Add(new WorkOrder
{
Id = 21,
InternalWONumber = "00000000021",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
istemplate = false
});
await context.SaveChangesAsync();
var service = new WorkOrderCommentService(
new WorkOrderDetailDataService(context),
new CommentDataService(context),
new UserDataService(context),
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var result = await service.AddCommentAsync(
21, new WorkOrderCommentCreateDto { Text = "In scope" }, scoped, "disp-1");
Assert.Equal("In scope", result.Text);
Assert.Equal("disp-1", result.AuthorId);
}
[Fact]
public async Task LegacyCommentsByWorkOrder_CrossAccount_ReturnsNull()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
context.Comments.Add(new Comments
{
WorkerOrderId = 2,
Commenttext = "Other account",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = CreateLegacyReadService(context);
var result = await service.GetCommentsByWorkorderIdAsync(
2, WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Null(result);
}
[Fact]
public async Task CompletionDoc_CrossAccount_EnsureAndUpload_ThrowNotFound()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B");
context.workOrders.Add(new WorkOrder
{
Id = 30,
InternalWONumber = "00000000030",
AccountId = 2,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 },
istemplate = false
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
var ensureEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.EnsureCanUploadCompletionDocAsync(30, scoped, "disp-1"));
Assert.Equal("NotFound", ensureEx.Code);
var uploadEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UploadCompletionDocAsync(
30,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
},
"https://example.com/should-not-stick.pdf",
scoped,
"disp-1"));
Assert.Equal("NotFound", uploadEx.Code);
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == 30);
Assert.Null(wo.SignOffAttachment);
Assert.Equal(DocStatus.No, wo.DocStatus);
}
[Fact]
public async Task CompletionDoc_SameAccount_EnsureAllowsUpload()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A");
var rowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
context.workOrders.Add(new WorkOrder
{
Id = 31,
InternalWONumber = "00000000031",
AccountId = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
DocStatus = DocStatus.No,
RowVersion = rowVersion,
istemplate = false
});
await context.SaveChangesAsync();
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
audit,
WorkOrderAccountTestHelpers.Resolver(context));
var scoped = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher");
await service.EnsureCanUploadCompletionDocAsync(31, scoped, "disp-1");
var result = await service.UploadCompletionDocAsync(
31,
new WorkOrderCompletionDocUploadDto
{
WorkOrderVersion = Convert.ToBase64String(rowVersion)
},
"https://example.com/ok.pdf",
scoped,
"disp-1");
Assert.Equal(DocStatus.Yes, result.DocStatus);
Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment);
}
[Fact]
public async Task LegacyGetComments_ScopedUser_HidesOtherAccountComments()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
context.Comments.AddRange(
new Comments
{
WorkerOrderId = 1,
Commenttext = "Account A note",
Documents = "a.pdf",
CreatedDate = DateTime.UtcNow
},
new Comments
{
WorkerOrderId = 2,
Commenttext = "Account B secret",
Documents = "b.pdf",
CreatedDate = DateTime.UtcNow
},
new Comments
{
WorkerOrderId = 3,
Commenttext = "Null account note",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = CreateLegacyReadService(context);
var scoped = await service.GetCommentsAsync(
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Single(scoped);
Assert.Equal("Account A note", scoped.Single().Commenttext);
Assert.DoesNotContain(scoped, c => c.Commenttext == "Account B secret");
Assert.DoesNotContain(scoped, c => c.Commenttext == "Null account note");
var orgWide = await service.GetCommentsAsync(WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.Equal(3, orgWide.Count());
var missingEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
Assert.Equal("Forbidden", missingEx.Code);
}
}

View file

@ -0,0 +1,68 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
internal static class WorkOrderAccountTestHelpers
{
public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context)
=> new WorkOrderAccountResolver(new AccountDataService(context));
public static ClaimsPrincipal AccountUser(
string userId = "actor-1",
int accountId = 1,
params string[] roles)
{
var effectiveRoles = roles.Length == 0 ? new[] { "Admin" } : roles;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(SeaHavenClaimTypes.AccountId, accountId.ToString())
};
claims.AddRange(effectiveRoles.Select(r => new Claim(ClaimTypes.Role, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
public static ClaimsPrincipal OrgWideAdmin(string userId = "actor-1")
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Role, "Admin"),
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
public static ClaimsPrincipal MissingScope(string userId = "actor-1", string role = "Dispatcher")
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Role, role)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
public static async Task EnsureAccountAsync(
ApplicationDbContext context,
int id = 1,
string name = "Acme Corp")
{
if (await context.Accounts.AnyAsync(a => a.Id == id))
return;
context.Accounts.Add(new Accounts
{
Id = id,
Name = name,
IsDeleted = false
});
await context.SaveChangesAsync();
}
}

View file

@ -18,7 +18,7 @@ public class WorkOrderBoardCancelServiceTests
var context = new ApplicationDbContext(options); var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit); var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit);
@ -44,7 +44,7 @@ public class WorkOrderBoardCancelServiceTests
context.workOrders.Add(wo); context.workOrders.Add(wo);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var result = await cancel.CancelAsync(1, "actor-1"); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
Assert.Equal("Canceled", result.LifecycleStatusLabel); Assert.Equal("Canceled", result.LifecycleStatusLabel);
@ -65,7 +65,7 @@ public class WorkOrderBoardCancelServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var before = await context.WorkOrderAuditLogs.CountAsync(); var before = await context.WorkOrderAuditLogs.CountAsync();
var result = await cancel.CancelAsync(1, "actor-1"); var result = await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
var after = await context.WorkOrderAuditLogs.CountAsync(); var after = await context.WorkOrderAuditLogs.CountAsync();
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
@ -84,7 +84,7 @@ public class WorkOrderBoardCancelServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1")); var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
Assert.Equal("CancelNotAllowed", ex.Code); Assert.Equal("CancelNotAllowed", ex.Code);
} }
@ -100,7 +100,7 @@ public class WorkOrderBoardCancelServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1")); var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1"));
Assert.Equal("CancelNotAllowed", ex.Code); Assert.Equal("CancelNotAllowed", ex.Code);
} }
@ -117,7 +117,7 @@ public class WorkOrderBoardCancelServiceTests
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await cancel.CancelAsync(1, "actor-1"); await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged"); var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged");
Assert.Equal("Canceled", log.NewValue); Assert.Equal("Canceled", log.NewValue);
@ -137,7 +137,7 @@ public class WorkOrderBoardCancelServiceTests
context.workOrders.Add(wo); context.workOrders.Add(wo);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await cancel.CancelAsync(1, "actor-1"); await cancel.CancelAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin(), "actor-1");
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto

View file

@ -34,10 +34,12 @@ public class WorkOrderBoardCreateRelationalTests
NormalizedEmail = "ACTOR@TEST.LOCAL" NormalizedEmail = "ACTOR@TEST.LOCAL"
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardCreateService( var service = new WorkOrderBoardCreateService(
@ -45,14 +47,15 @@ public class WorkOrderBoardCreateRelationalTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
Description = "Relational create" Description = "Relational create"
}, actorId); }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId);
Assert.True(result.Id > 0); Assert.True(result.Id > 0);
@ -91,10 +94,12 @@ public class WorkOrderBoardCreateRelationalTests
NormalizedEmail = "ACTOR@TEST.LOCAL" NormalizedEmail = "ACTOR@TEST.LOCAL"
}); });
await context.SaveChangesAsync(); await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var service = new WorkOrderBoardCreateService( var service = new WorkOrderBoardCreateService(
@ -102,7 +107,8 @@ public class WorkOrderBoardCreateRelationalTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
await Assert.ThrowsAsync<DbUpdateException>(() => await Assert.ThrowsAsync<DbUpdateException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto service.CreateAsync(new WorkOrderBoardCreateRequestDto
@ -111,7 +117,7 @@ public class WorkOrderBoardCreateRelationalTests
SiteCode = "BK5", SiteCode = "BK5",
Description = "Should roll back", Description = "Should roll back",
PocContactId = 999_999 PocContactId = 999_999
}, actorId)); }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId));
// Fresh context on the same connection so we observe committed state only. // Fresh context on the same connection so we observe committed state only.
await using var verify = new SqliteBoardTestDbContext(options); await using var verify = new SqliteBoardTestDbContext(options);

View file

@ -19,11 +19,12 @@ public class WorkOrderBoardCreateServiceTests
var context = new ApplicationDbContext(options); var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var validator = new WorkOrderBoardCreateValidation(); var validator = new WorkOrderBoardCreateValidation();
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator); var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver);
return (context, service); return (context, service);
} }
@ -36,7 +37,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("BK5", result.SiteCode); Assert.Equal("BK5", result.SiteCode);
@ -55,7 +56,7 @@ public class WorkOrderBoardCreateServiceTests
WoNumber = "12345", WoNumber = "12345",
WorkOrderType = WorkOrderType.PO, WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL" SiteCode = "DAL"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("00000012345", result.WoNumber); Assert.Equal("00000012345", result.WoNumber);
} }
@ -73,7 +74,7 @@ public class WorkOrderBoardCreateServiceTests
WoNumber = "99999", WoNumber = "99999",
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code); Assert.Equal("DuplicateWoNumber", ex.Code);
} }
@ -89,7 +90,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
AssignTo = "dispatcher-1", AssignTo = "dispatcher-1",
ScheduledDate = new DateTime(2026, 6, 25) ScheduledDate = new DateTime(2026, 6, 25)
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
@ -106,7 +107,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
ScheduleWeekOnly = true, ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22) TargetWeek = new DateOnly(2026, 6, 22)
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.True(result.ScheduleWeekOnly); Assert.True(result.ScheduleWeekOnly);
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
@ -123,7 +124,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
ScheduleWeekOnly = true ScheduleWeekOnly = true
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
} }
[Fact] [Fact]
@ -139,7 +140,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
VendorId = 5, VendorId = 5,
ApptDate = new DateTime(2026, 6, 26) ApptDate = new DateTime(2026, 6, 26)
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(5, result.VendorId); Assert.Equal(5, result.VendorId);
Assert.NotNull(result.PrimaryDispatchId); Assert.NotNull(result.PrimaryDispatchId);
@ -155,7 +156,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
Description = "Test WO" Description = "Test WO"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync(); var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Contains(locks, l => l.FieldName == "SiteCode"); Assert.Contains(locks, l => l.FieldName == "SiteCode");
@ -173,7 +174,7 @@ public class WorkOrderBoardCreateServiceTests
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var logs = await context.WorkOrderAuditLogs.ToListAsync(); var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(logs, l => l.Action == "Create"); Assert.Contains(logs, l => l.Action == "Create");
@ -194,7 +195,7 @@ public class WorkOrderBoardCreateServiceTests
PrimaryService = "HVAC PM", PrimaryService = "HVAC PM",
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" }, ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
ServiceNotes = "Unit on roof" ServiceNotes = "Unit on roof"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("HVAC PM", result.Pm); Assert.Equal("HVAC PM", result.Pm);
Assert.Equal("Unit on roof", result.ServiceNotes); Assert.Equal("Unit on roof", result.ServiceNotes);
@ -220,7 +221,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "CHI", SiteCode = "CHI",
Trade = "Legacy Trade", Trade = "Legacy Trade",
PrimaryService = "Plumbing" PrimaryService = "Plumbing"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("Plumbing", result.Pm); Assert.Equal("Plumbing", result.Pm);
} }
@ -236,7 +237,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
ExtraServices = new List<string> { "Filter change" } ExtraServices = new List<string> { "Filter change" }
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
} }
[Fact] [Fact]
@ -251,7 +252,7 @@ public class WorkOrderBoardCreateServiceTests
PocName = "Jane Site Lead", PocName = "Jane Site Lead",
PocPhone = "+1 555-0100", PocPhone = "+1 555-0100",
PocNotes = "Call 30 min before" PocNotes = "Call 30 min before"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("Jane Site Lead", result.PocName); Assert.Equal("Jane Site Lead", result.PocName);
Assert.Equal("+1 555-0100", result.PocPhone); Assert.Equal("+1 555-0100", result.PocPhone);
@ -273,7 +274,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
TechPhone = "+1 555-0199", TechPhone = "+1 555-0199",
VendorNotes = "Gate code 4421" VendorNotes = "Gate code 4421"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone); Assert.Equal("+1 555-0199", result.TechPhone);
@ -298,7 +299,7 @@ public class WorkOrderBoardCreateServiceTests
ApptDate = new DateTime(2026, 7, 18), ApptDate = new DateTime(2026, 7, 18),
TechPhone = "+1 555-0199", TechPhone = "+1 555-0199",
VendorNotes = "Park behind dock" VendorNotes = "Park behind dock"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("+1 555-0199", result.TechPhone); Assert.Equal("+1 555-0199", result.TechPhone);
Assert.Equal(55, result.VendorId); Assert.Equal(55, result.VendorId);
@ -322,7 +323,7 @@ public class WorkOrderBoardCreateServiceTests
SiteCode = "BK5", SiteCode = "BK5",
Trade = "HVAC", Trade = "HVAC",
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
}, "actor-1"); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("HVAC", result.Pm); Assert.Equal("HVAC", result.Pm);
Assert.Null(result.PocName); Assert.Null(result.PocName);
@ -346,7 +347,7 @@ public class WorkOrderBoardCreateServiceTests
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5", SiteCode = "BK5",
VendorId = 999 VendorId = 999
}, "actor-1")); }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
Assert.Equal("VendorNotFound", ex.Code); Assert.Equal("VendorNotFound", ex.Code);
} }

View file

@ -21,7 +21,8 @@ public class WorkOrderBoardCreateSyncLockTests
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService( var createService = new WorkOrderBoardCreateService(
@ -29,14 +30,15 @@ public class WorkOrderBoardCreateSyncLockTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
var policy = new SyncFieldMergePolicy(fieldLocks); var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
var syncContext = new WorkOrderSyncContext var syncContext = new WorkOrderSyncContext
@ -66,7 +68,8 @@ public class WorkOrderBoardCreateSyncLockTests
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService( var createService = new WorkOrderBoardCreateService(
@ -74,14 +77,15 @@ public class WorkOrderBoardCreateSyncLockTests
mutationData, mutationData,
boardService, boardService,
audit, audit,
new WorkOrderBoardCreateValidation()); new WorkOrderBoardCreateValidation(),
resolver);
var policy = new SyncFieldMergePolicy(fieldLocks); var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
wo.Customer = "Original"; wo.Customer = "Original";

View file

@ -212,7 +212,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -222,7 +222,7 @@ public class WorkOrderAdvancedSearchServiceTests
Sites = new List<string> { "BK5" }, Sites = new List<string> { "BK5" },
Page = 0, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Single(result.Items); Assert.Single(result.Items);
@ -234,13 +234,13 @@ public class WorkOrderAdvancedSearchServiceTests
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
PageSize = 500 PageSize = 500
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(100, result.PageSize); Assert.Equal(100, result.PageSize);
} }
@ -272,7 +272,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -281,7 +281,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateTo = weekStart.AddDays(4), DateTo = weekStart.AddDays(4),
Page = 0, Page = 0,
PageSize = 1 PageSize = 1
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Single(result.Items); Assert.Single(result.Items);
@ -315,7 +315,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -323,7 +323,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other } Types = new List<WorkOrderType> { WorkOrderType.Other }
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Equal(2, result.Items.First().Id); Assert.Equal(2, result.Items.First().Id);
@ -366,7 +366,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -374,7 +374,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
DateTo = DateOnly.FromDateTime(today.AddMonths(1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Overdue = true Overdue = true
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, result.TotalCount); Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.Items.First().Id); Assert.Equal(1, result.Items.First().Id);
@ -416,7 +416,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -425,7 +425,7 @@ public class WorkOrderAdvancedSearchServiceTests
DateTo = DateOnly.FromDateTime(today.AddMonths(1)), DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
Types = new List<WorkOrderType> { WorkOrderType.Other }, Types = new List<WorkOrderType> { WorkOrderType.Other },
Overdue = true Overdue = true
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue); Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue);
@ -476,7 +476,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -486,7 +486,7 @@ public class WorkOrderAdvancedSearchServiceTests
Overdue = true, Overdue = true,
Page = 0, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
var overdue = Assert.Single(result.Items); var overdue = Assert.Single(result.Items);
Assert.Equal(0, result.Page); Assert.Equal(0, result.Page);
@ -532,7 +532,7 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
@ -542,7 +542,7 @@ public class WorkOrderAdvancedSearchServiceTests
Types = new List<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Other }, Types = new List<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Other },
Page = 0, Page = 0,
PageSize = 50 PageSize = 50
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Equal(0, result.Page); Assert.Equal(0, result.Page);
@ -576,14 +576,14 @@ public class WorkOrderAdvancedSearchServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = weekStart, DateFrom = weekStart,
DateTo = weekStart.AddDays(4) DateTo = weekStart.AddDays(4)
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(2, result.TotalCount); Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, i => i.Id == 2); Assert.Contains(result.Items, i => i.Id == 2);
@ -594,14 +594,14 @@ public class WorkOrderAdvancedSearchServiceTests
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var dataService = new WorkOrderAdvancedSearchDataService(context); var dataService = new WorkOrderAdvancedSearchDataService(context);
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService); var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync( var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
{ {
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek, DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
SortBy = "invalid" SortBy = "invalid"
}, null)); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null));
Assert.Contains("sortBy", ex.Message); Assert.Contains("sortBy", ex.Message);
} }

View file

@ -185,12 +185,12 @@ public class WorkOrderBoardServiceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var dataService = new WorkOrderBoardDataService(context); var dataService = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(dataService); var boardService = new WorkOrderBoardService(dataService, WorkOrderAccountTestHelpers.Resolver(context));
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{ {
WeekStart = weekStart WeekStart = weekStart
}, null); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
Assert.Equal(1, response.Counts.Total); Assert.Equal(1, response.Counts.Total);
Assert.Single(response.Scheduled); Assert.Single(response.Scheduled);
@ -202,12 +202,12 @@ public class WorkOrderBoardServiceTests
public async Task GetBoardAsync_InvalidWeekWindow_Throws() public async Task GetBoardAsync_InvalidWeekWindow_Throws()
{ {
await using var context = CreateContext(); await using var context = CreateContext();
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context)); var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context), WorkOrderAccountTestHelpers.Resolver(context));
await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto
{ {
WeekStart = new DateOnly(2026, 6, 22), WeekStart = new DateOnly(2026, 6, 22),
WeekEnd = new DateOnly(2026, 6, 15) WeekEnd = new DateOnly(2026, 6, 15)
}, null)); }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null));
} }
} }

View file

@ -798,4 +798,59 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("VendorNotFound", ex.Code); Assert.Equal("VendorNotFound", ex.Code);
} }
[Fact]
public async Task PatchField_UpdatesCompletedDate()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
context.WorkOrderContacts.Add(new WorkOrderContacts { Id = 1, WorkorderId = 1, ContactId = 50 });
context.workOrderCategories.Add(new WorkOrderCategories { Id = 1, WorkorderId = 1, CategoryId = 60 });
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.CompletedDate,
Value = "2026-07-20",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(new DateTime(2026, 7, 20), result.CompletedDate);
Assert.Equal(new DateTime(2026, 7, 20), context.workOrders.Single().CompletedDate);
Assert.Equal(1, await context.WorkOrderContacts.CountAsync());
Assert.Equal(1, await context.workOrderCategories.CountAsync());
Assert.Equal(50, context.WorkOrderContacts.Single().ContactId);
Assert.Equal(60, context.workOrderCategories.Single().CategoryId);
}
[Fact]
public async Task PatchField_ClearsCompletedDate_WhenValueEmpty()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
CompletedDate = new DateTime(2026, 7, 1),
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.CompletedDate,
Value = "",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Null(result.CompletedDate);
Assert.Null(context.workOrders.Single().CompletedDate);
}
} }

View file

@ -0,0 +1,253 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Provider-backed media concurrency: two independently tracked contexts share one SQLite
/// connection so EF concurrency tokens and competing writes are exercised (not InMemory).
/// </summary>
public class WorkOrderMediaConcurrencyRelationalTests
{
[Fact]
public async Task CategorizeVsCategorize_SecondWriter_GetsConcurrencyConflict_WithoutPartialAudit()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = CreateOptions(connection);
await using (var seed = new SqliteMediaTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
seed.Users.AddRange(
new ApplicationUser
{
Id = "actor-a",
UserName = "actor-a",
NormalizedUserName = "ACTOR-A",
Email = "a@test.local",
NormalizedEmail = "A@TEST.LOCAL"
},
new ApplicationUser
{
Id = "actor-b",
UserName = "actor-b",
NormalizedUserName = "ACTOR-B",
Email = "b@test.local",
NormalizedEmail = "B@TEST.LOCAL"
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
seed.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await seed.SaveChangesAsync();
}
await using var contextA = new SqliteMediaTestDbContext(options);
await using var contextB = new SqliteMediaTestDbContext(options);
var serviceA = CreateService(contextA);
var serviceB = CreateService(contextB);
var versionA = await LoadVersionAsync(contextA, workOrderId: 1);
var versionB = await LoadVersionAsync(contextB, workOrderId: 1);
Assert.Equal(versionA, versionB);
var winner = await serviceA.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, versionA, Admin("actor-a"), "actor-a");
Assert.Equal(WorkOrderMediaCategory.Before, winner.Category);
var loser = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
serviceB.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.After, versionB, Admin("actor-b"), "actor-b"));
Assert.Equal("ConcurrencyConflict", loser.Code);
await using var verify = new SqliteMediaTestDbContext(options);
var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment);
Assert.True(string.IsNullOrEmpty(wo.AfterPhotoAttachment));
Assert.True(attachment.IsDeleted);
Assert.Single(audits);
Assert.Contains(audits, a => a.NewValue == "10:Before");
Assert.DoesNotContain(audits, a => a.NewValue == "10:After");
}
[Fact]
public async Task CategorizeVsDelete_SecondWriter_GetsConcurrencyConflict_WithoutPartialState()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = CreateOptions(connection);
await using (var seed = new SqliteMediaTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
seed.Users.AddRange(
new ApplicationUser
{
Id = "actor-a",
UserName = "actor-a",
NormalizedUserName = "ACTOR-A",
Email = "a@test.local",
NormalizedEmail = "A@TEST.LOCAL"
},
new ApplicationUser
{
Id = "actor-b",
UserName = "actor-b",
NormalizedUserName = "ACTOR-B",
Email = "b@test.local",
NormalizedEmail = "B@TEST.LOCAL"
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
seed.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await seed.SaveChangesAsync();
}
await using var categorizeContext = new SqliteMediaTestDbContext(options);
await using var deleteContext = new SqliteMediaTestDbContext(options);
var categorizeService = CreateService(categorizeContext);
var deleteService = CreateService(deleteContext);
var categorizeVersion = await LoadVersionAsync(categorizeContext, workOrderId: 1);
var deleteVersion = await LoadVersionAsync(deleteContext, workOrderId: 1);
await categorizeService.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, categorizeVersion, Admin("actor-a"), "actor-a");
var loser = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
deleteService.DeleteMediaAsync(
1, 10, deleteVersion, Admin("actor-b"), "actor-b"));
Assert.Equal("ConcurrencyConflict", loser.Code);
await using var verify = new SqliteMediaTestDbContext(options);
var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment);
Assert.True(attachment.IsDeleted);
Assert.Single(audits);
Assert.Contains(audits, a => a.NewValue == "10:Before");
Assert.DoesNotContain(audits, a => a.NewValue != null && a.NewValue.EndsWith(":Deleted", StringComparison.Ordinal));
}
private static DbContextOptions<ApplicationDbContext> CreateOptions(SqliteConnection connection)
=> new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
private static WorkOrderMediaService CreateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
{
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrderId);
return Convert.ToBase64String(wo.RowVersion!);
}
private static ClaimsPrincipal Admin(string actorId)
=> new(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, actorId),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHaven.Services.Helpers.SeaHavenClaimTypes.OrgScope, SeaHaven.Services.Helpers.SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
private sealed class SqliteMediaTestDbContext : ApplicationDbContext
{
public SqliteMediaTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; keep a byte[] concurrency token and bump on save.
var property = builder.Entity<WorkOrder>().Property(w => w.RowVersion).Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = true;
}
public override int SaveChanges()
{
BumpWorkOrderRowVersions();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpWorkOrderRowVersions();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpWorkOrderRowVersions()
{
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
{
if (entry.State != EntityState.Modified)
continue;
var current = entry.Entity.RowVersion != null && entry.Entity.RowVersion.Length > 0
? (byte[])entry.Entity.RowVersion.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < current.Length; i++)
{
if (++current[i] != 0)
break;
}
entry.Entity.RowVersion = current;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -28,10 +28,11 @@ public class WorkOrderPhase7CoexistenceTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
var payload = new WorkOrderIngestPayloadDto var payload = new WorkOrderIngestPayloadDto
{ {
@ -39,7 +40,8 @@ public class WorkOrderPhase7CoexistenceTests
Description = "Initial", Description = "Initial",
WoStatus = "new", WoStatus = "new",
Severity = "2", Severity = "2",
SiteCode = "BK1" SiteCode = "BK1",
Customer = "Phase7 Customer"
}; };
var create = await ingest.UpsertBatchAsync(new[] { payload }); var create = await ingest.UpsertBatchAsync(new[] { payload });
@ -66,19 +68,20 @@ public class WorkOrderPhase7CoexistenceTests
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData); var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var createService = new WorkOrderBoardCreateService( var createService = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation()); boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
var policy = new SyncFieldMergePolicy(fieldLocks); var policy = new SyncFieldMergePolicy(fieldLocks);
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit); var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{ {
WorkOrderType = WorkOrderType.PM, WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5" SiteCode = "BK5"
}, "dispatcher-1"); }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1");
var wo = await context.workOrders.SingleAsync(); var wo = await context.workOrders.SingleAsync();
wo.ExternalWorkOrderId = "ext-shoc-1"; wo.ExternalWorkOrderId = "ext-shoc-1";
@ -126,7 +129,7 @@ public class WorkOrderPhase7CoexistenceTests
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
await ingest.UpsertBatchAsync(new[] await ingest.UpsertBatchAsync(new[]
{ {
@ -151,15 +154,21 @@ public class WorkOrderPhase7CoexistenceTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "Phase7 Customer");
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks); var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
var result = await ingest.UpsertBatchAsync(new[] var result = await ingest.UpsertBatchAsync(new[]
{ {
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" }, new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" },
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" } new WorkOrderIngestPayloadDto
{
ExternalWorkOrderId = "ext-ok",
Description = "good",
Customer = "Phase7 Customer"
}
}); });
Assert.Equal(1, result.Created); Assert.Equal(1, result.Created);
@ -233,7 +242,7 @@ public class WorkOrderPhase7CoexistenceTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var count = await context.workOrders.CountAsync(); var count = await context.workOrders.CountAsync();
Assert.Equal(1, count); Assert.Equal(1, count);

View file

@ -204,6 +204,8 @@ public sealed class WorkOrderReconciliationTests
.UseInMemoryDatabase($"convergence-{Guid.NewGuid()}") .UseInMemoryDatabase($"convergence-{Guid.NewGuid()}")
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
context.Accounts.Add(new Accounts { Id = 1, Name = "Recon Customer", IsDeleted = false });
await context.SaveChangesAsync();
var data = new WorkOrderWebhookDataService(context); var data = new WorkOrderWebhookDataService(context);
var time = DateTimeOffset.Parse("2026-07-24T12:00:00Z"); var time = DateTimeOffset.Parse("2026-07-24T12:00:00Z");
@ -268,7 +270,8 @@ public sealed class WorkOrderReconciliationTests
WorkerOrderNumber = "123", WorkerOrderNumber = "123",
Source = "procurement", Source = "procurement",
IsStateEvent = true, IsStateEvent = true,
Title = title Title = title,
Customer = "Recon Customer"
}; };
private sealed class MockClient : IProcurementWorkOrderClient private sealed class MockClient : IProcurementWorkOrderClient

View file

@ -252,6 +252,9 @@ public sealed class WorkOrderWebhookDataServiceTests
.Options; .Options;
await using var context = new CountingDbContext(options); await using var context = new CountingDbContext(options);
await context.Database.EnsureCreatedAsync(); await context.Database.EnsureCreatedAsync();
context.Accounts.Add(new Accounts { Id = 1, Name = "Webhook Customer", IsDeleted = false });
await context.SaveChangesAsync();
context.ResetSaveCount();
var data = new WorkOrderWebhookDataService(context); var data = new WorkOrderWebhookDataService(context);
var comment = Mutation( var comment = Mutation(
@ -309,6 +312,8 @@ public sealed class WorkOrderWebhookDataServiceTests
.Options; .Options;
await using var context = new ApplicationDbContext(options); await using var context = new ApplicationDbContext(options);
await context.Database.EnsureCreatedAsync(); await context.Database.EnsureCreatedAsync();
context.Accounts.Add(new Accounts { Id = 1, Name = "Webhook Customer", IsDeleted = false });
await context.SaveChangesAsync();
var data = new WorkOrderWebhookDataService(context); var data = new WorkOrderWebhookDataService(context);
var mutation = Mutation( var mutation = Mutation(
"cancel-delivery", "cancel-delivery",
@ -377,6 +382,7 @@ public sealed class WorkOrderWebhookDataServiceTests
Source = "procurement", Source = "procurement",
IsStateEvent = eventType != "work_order.comment_added", IsStateEvent = eventType != "work_order.comment_added",
Title = title, Title = title,
Customer = "Webhook Customer",
Status = "Open", Status = "Open",
CommentId = commentId, CommentId = commentId,
CommentText = commentId == null ? null : "A comment" CommentText = commentId == null ? null : "A comment"
@ -391,6 +397,8 @@ public sealed class WorkOrderWebhookDataServiceTests
public int SaveCount { get; private set; } public int SaveCount { get; private set; }
public void ResetSaveCount() => SaveCount = 0;
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
SaveCount++; SaveCount++;

View file

@ -291,8 +291,8 @@ public class WorkOrderWeekRolledTests
await service.ProcessWeekRolledAsync(SourceWeekStart); await service.ProcessWeekRolledAsync(SourceWeekStart);
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1); var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row); Assert.NotNull(row);
Assert.Equal(1, row!.CarriedOver); Assert.Equal(1, row!.CarriedOver);
@ -314,8 +314,8 @@ public class WorkOrderWeekRolledTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1); var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row); Assert.NotNull(row);
Assert.True(row!.IsPastDue); Assert.True(row!.IsPastDue);
@ -338,8 +338,8 @@ public class WorkOrderWeekRolledTests
await context.SaveChangesAsync(); await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context); var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData); var boardService = new WorkOrderBoardService(boardData, WorkOrderAccountTestHelpers.Resolver(context));
var row = await boardService.GetBoardRowAsync(1); var row = await boardService.GetBoardRowAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(row); Assert.NotNull(row);
Assert.False(row!.IsPastDue); Assert.False(row!.IsPastDue);

View file

@ -0,0 +1,74 @@
# ADR 0001: Work-order domain uses server-derived account scope
## Status
**Superseded** (historical Proposed single-org ADR). Current contract (PR #47 / SH-221):
- `WorkOrder.AccountId` / `ApplicationUser.AccountId` schema keys (nullable; legacy null fail-closed)
- JWT `account_id` when `ApplicationUser.AccountId` is set
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
- **Reads** (board, list, advanced search, detail, media, board comments,
legacy comments-by-work-order-id, legacy `GET GetComments`): `ApplyBaseScope` +
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
same account filter at service/data entry; authorize before storing blobs
- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId`
from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip
- Missing/malformed scope → **Forbidden** (absence of claim does not elevate)
## Context (historical)
SH-116 requires that cross-tenant, unauthorized, and out-of-scope media access
be rejected without metadata disclosure. An interim Proposed ADR allowed
org-wide staff access via absence of an account claim; that path was rejected
in review (fail-open) and replaced by the contract below.
## Current domain contract (superseding)
1. **Organization boundary** = `ApplyBaseScope` (non-deleted, non-template).
2. **Account boundary** = claim `account_id` → `WorkOrder.AccountId == claim`.
3. **Org-wide** = claim `org_scope=all` only (issued to Admin without
AccountId). Not inferred from missing `account_id`.
4. **Fail-closed** = no valid account or org-scope claim → Forbidden.
5. **Create stamp**:
- Authenticated + `account_id` → stamp claim (ignore client AccountId).
- Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else
`AccountUnresolved`.
- Ingest / webhook / sync → same Customer resolution; unresolved create is
rejected or skipped (no null AccountId on new rows).
6. **Legacy rows** with `AccountId == null` are invisible to account-scoped
callers; only `org_scope=all` may read them.
7. **Authorization at service entry**: staff roles may read/mutate any resulting
work order; role `User` only when `AssignTo == actorId` (media); delete staff-only.
Board comments, legacy comments-by-WO-id, and completion-doc uploads apply the
same account filter before read/write (and before blob storage).
8. **User lifecycle** persists `AccountId` on Admin create/edit so non-Admin
principals can receive `account_id`.
## Consequences
- Cross-account and missing-scope tests are required for create and read paths.
- Dispatcher/Manager/Supervisor/User without AccountId cannot access board,
detail, search, list, or media until AccountId is assigned (or they are Admin
with `org_scope=all`).
- Locations do not carry AccountId in the EF model; Customer name match is the
unauthenticated resolution path.
## Excepted rule
None. Hard rule **server-derived tenant scope**
(`ARCHITECTURE_AND_CODE_QUALITY.md` §2) is enforced via claims + AccountId.
## Review / expiry
Re-review by **2027-02-04**, or when AccountId becomes non-nullable with a
full backfill migration.
## References
- SH-116 — Completion document: fields + media categorization
- SH-221 — Server-derived tenant/customer scope for Work Order domain
- PR: Sea-Haven-Industries/shoc-backend#47
- `WorkOrderBoardQueryFilters.ApplyBaseScope` / `ApplyAccountScope`
- `IWorkOrderAccountResolver` / `WorkOrderMediaAuthorization` / `SeaHavenClaimTypes`
- `ARCHITECTURE_AND_CODE_QUALITY.md` §2, §10