mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 17:02:12 +00:00
Merge pull request #47 from Sea-Haven-Industries/feature/wo-board-completed-date-media
feat(work-orders): board completedDate + media categorize contract
This commit is contained in:
commit
6f08967e32
95 changed files with 8179 additions and 430 deletions
|
|
@ -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>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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: ";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3814
Data.SeaHavenIndustries/Migrations/20260806123838_SH221_WorkOrderAccountScope.Designer.cs
generated
Normal file
3814
Data.SeaHavenIndustries/Migrations/20260806123838_SH221_WorkOrderAccountScope.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
Applied,
|
Applied,
|
||||||
Duplicate,
|
Duplicate,
|
||||||
HashConflict
|
HashConflict,
|
||||||
|
AccountUnresolved
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record WorkOrderWebhookPersistenceResult(
|
public sealed record WorkOrderWebhookPersistenceResult(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
27
SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs
Normal file
27
SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.Services/Helpers/UserMutationErrors.cs
Normal file
10
SeaHaven.Services/Helpers/UserMutationErrors.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs
Normal file
132
SeaHaven.Services/Helpers/WorkOrderMediaAuthorization.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
113
SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
Normal file
113
SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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!@#$%^&*()_-+=<>?";
|
||||||
|
|
|
||||||
73
SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs
Normal file
73
SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs
Normal file
40
SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
685
SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs
Normal file
685
SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs
Normal file
68
SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
74
docs/adr/0001-work-order-single-org-scope.md
Normal file
74
docs/adr/0001-work-order-single-org-scope.md
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue