mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Merge branch 'dev' into fix/ab/sh-287-backend-staging-lane
This commit is contained in:
commit
6db9ad758f
141 changed files with 33668 additions and 129 deletions
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class AccountOwnerDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_MarksOnlyConfiguredUser()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser { Id = "old-owner", UserName = "old@example.com", IsAccountOwner = true },
|
||||
new ApplicationUser { Id = "configured-owner", UserName = "configured@example.com" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new AccountOwnerDataService(context);
|
||||
(await service.EnsureConfiguredOwnerAsync(" configured-owner ", CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
(await context.Users.SingleAsync(user => user.Id == "old-owner")).IsAccountOwner.Should().BeFalse();
|
||||
(await context.Users.SingleAsync(user => user.Id == "configured-owner")).IsAccountOwner.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_WithNoConfiguration_ClearsExistingOwner()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner@example.com", IsAccountOwner = true });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new AccountOwnerDataService(context);
|
||||
(await service.EnsureConfiguredOwnerAsync(null, CancellationToken.None)).Should().BeTrue();
|
||||
(await service.EnsureConfiguredOwnerAsync(" ", CancellationToken.None)).Should().BeFalse();
|
||||
|
||||
(await context.Users.SingleAsync()).IsAccountOwner.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_RejectsUnknownUser()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var service = new AccountOwnerDataService(context);
|
||||
|
||||
var action = () => service.EnsureConfiguredOwnerAsync("missing", CancellationToken.None);
|
||||
|
||||
await action.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("The configured account owner user was not found.");
|
||||
}
|
||||
}
|
||||
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class DashboardControllerTests
|
||||
{
|
||||
private static object Prop(object source, string name) =>
|
||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||
|
||||
private static DashboardController NewController(Mock<IDashboardService> service) =>
|
||||
new(service.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
// Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and
|
||||
// DashboardStatsDTO already carried these three counts; the regression this
|
||||
// pins is the controller's anonymous response silently dropping them, which
|
||||
// is what left the tiles with nothing to read.
|
||||
[Fact]
|
||||
public async Task GetStats_SerialisesKpiCountsOnWireObject()
|
||||
{
|
||||
var service = new Mock<IDashboardService>();
|
||||
service.Setup(s => s.GetStatsAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<DashboardStatsQueryDTO>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DashboardStatsDTO
|
||||
{
|
||||
ScheduledTomorrow = 3,
|
||||
PendingUplifts = 5,
|
||||
AvetaPending = 7
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetStats(
|
||||
new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
|
||||
var body = result.Should().BeOfType<OkObjectResult>().Subject.Value!;
|
||||
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
||||
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
||||
((int)Prop(body, "avetaPending")).Should().Be(7);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,7 +26,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||
|
|
@ -49,7 +49,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_PassesStatesFilterToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>(),
|
||||
|
|
@ -58,7 +58,7 @@ public class LocationControllerTests
|
|||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.VerifyAll();
|
||||
|
|
@ -68,13 +68,13 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
|
||||
}));
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
|
|
@ -200,4 +200,90 @@ public class LocationControllerTests
|
|||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_ForwardsSortParamsToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), TotalCount = 0, Page = 1, PageSize = 10 });
|
||||
|
||||
await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None);
|
||||
|
||||
service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.")
|
||||
}));
|
||||
|
||||
var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Validation Error");
|
||||
response.Message.Should().Contain("sortBy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Name = "STL8",
|
||||
Address1 = "9 River Rd",
|
||||
City = "St. Louis",
|
||||
State = "MO",
|
||||
Zip = "63101",
|
||||
PhoneNumber = "555-0100",
|
||||
Email = "poc@example.com",
|
||||
AccountId = 3,
|
||||
AccountName = "Acme Co",
|
||||
Contacts = new List<SiteContactResponseDTO>
|
||||
{
|
||||
new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" },
|
||||
new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" }
|
||||
}
|
||||
}
|
||||
},
|
||||
TotalCount = 1,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value);
|
||||
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
|
||||
|
||||
row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field");
|
||||
row.GetProperty("Address").GetString().Should().Be("9 River Rd");
|
||||
row.GetProperty("City").GetString().Should().Be("St. Louis");
|
||||
row.GetProperty("State").GetString().Should().Be("MO");
|
||||
row.GetProperty("ZipCode").GetString().Should().Be("63101");
|
||||
row.GetProperty("Phone").GetString().Should().Be("555-0100");
|
||||
row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com");
|
||||
row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field");
|
||||
row.GetProperty("AccountId").GetInt32().Should().Be(3);
|
||||
row.GetProperty("ClientName").GetString().Should().Be("Acme Co");
|
||||
row.GetProperty("AccountName").GetString().Should().Be("Acme Co");
|
||||
var contacts = row.GetProperty("Contacts");
|
||||
contacts.GetArrayLength().Should().Be(2);
|
||||
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
|
||||
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,4 +86,129 @@ public class LocationDataServiceTests
|
|||
totalCount.Should().Be(2);
|
||||
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
||||
}
|
||||
|
||||
private static async Task SeedRegistryAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
ctx.Accounts.AddRange(
|
||||
new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false },
|
||||
new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false });
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 },
|
||||
new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 },
|
||||
new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 },
|
||||
new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 });
|
||||
ctx.Contacts.AddRange(
|
||||
new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" },
|
||||
new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" },
|
||||
new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" },
|
||||
new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true },
|
||||
new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("STL", new[] { 10 }, "site code")]
|
||||
[InlineData("Borealis", new[] { 11, 13 }, "client name")]
|
||||
[InlineData("Prairie", new[] { 11 }, "street address")]
|
||||
[InlineData("Miami", new[] { 13 }, "city")]
|
||||
public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField)
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, term, null, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}");
|
||||
items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("code", 11, 12, 13, 10)]
|
||||
[InlineData("client", 10, 12, 11, 13)]
|
||||
[InlineData("address", 13, 11, 12, 10)]
|
||||
[InlineData("city", 11, 12, 13, 10)]
|
||||
[InlineData("state", 13, 12, 10, 11)]
|
||||
[InlineData("poc", 13, 12, 10, 11)]
|
||||
public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak(
|
||||
string sortBy, params int[] expectedIds)
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc");
|
||||
|
||||
items.Select(l => l.Id).Should().ContainInOrder(expectedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc");
|
||||
|
||||
// DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must
|
||||
// sort last; including the deleted row ("Removed Poc") would instead place it second.
|
||||
items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc");
|
||||
|
||||
items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways");
|
||||
|
||||
items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 20, Name = "HOU1", City = "Dallas" },
|
||||
new Locations { Id = 21, Name = "AUS3", City = "Dallas" },
|
||||
new Locations { Id = 22, Name = "ELP4", City = "Dallas" });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var service = new LocationDataService(ctx);
|
||||
|
||||
var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc");
|
||||
var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc");
|
||||
|
||||
totalCount.Should().Be(3);
|
||||
page1.Select(l => l.Id).Should().ContainInOrder(20, 21);
|
||||
page2.Select(l => l.Id).Should().ContainInOrder(22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
|
||||
|
||||
items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ public class LocationServiceTests
|
|||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
IReadOnlyCollection<string>? captured = default;
|
||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
|
||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
||||
|
|
@ -149,8 +149,8 @@ public class LocationServiceTests
|
|||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
IReadOnlyCollection<string>? captured = default;
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
||||
|
|
@ -173,14 +173,14 @@ public class LocationServiceTests
|
|||
&& e.ErrorMessage.Contains("ZZ")
|
||||
&& e.ErrorMessage.Contains("QQ")
|
||||
&& !e.ErrorMessage.Contains("TX"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
||||
|
|
@ -533,4 +533,97 @@ public class LocationServiceTests
|
|||
again.Should().BeFalse();
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
string? capturedSort = "sentinel";
|
||||
string? capturedDirection = "sentinel";
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
|
||||
{
|
||||
capturedSort = sortBy;
|
||||
capturedDirection = sortDirection;
|
||||
})
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC ");
|
||||
|
||||
capturedSort.Should().Be("poc");
|
||||
capturedDirection.Should().Be("desc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", " ")]
|
||||
public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults(
|
||||
string? sortBy,
|
||||
string? sortDirection)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
string? capturedSort = "sentinel";
|
||||
string? capturedDirection = "sentinel";
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
|
||||
{
|
||||
capturedSort = s;
|
||||
capturedDirection = d;
|
||||
})
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection);
|
||||
|
||||
capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering");
|
||||
capturedDirection.Should().Be("asc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rating")]
|
||||
[InlineData("password; drop table locations")]
|
||||
public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "sortBy"
|
||||
&& e.ErrorMessage.Contains("code")
|
||||
&& e.ErrorMessage.Contains("poc"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ascending")]
|
||||
[InlineData("DESC; drop table locations")]
|
||||
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "sortDirection"
|
||||
&& e.ErrorMessage.Contains("asc")
|
||||
&& e.ErrorMessage.Contains("desc"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccount(ctx, 9, "Acme Co");
|
||||
SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
var row = page.Items.Should().ContainSingle().Subject;
|
||||
row.AccountId.Should().Be(9);
|
||||
row.AccountName.Should().Be("Acme Co");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ public class LocationSiteContactsTests
|
|||
public async Task List_LoadsPageContactsInSingleBatchedRead()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((new List<Locations>
|
||||
{
|
||||
new() { Id = 1, Name = "One" },
|
||||
|
|
|
|||
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal file
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class ServicesRegistryControllerTests
|
||||
{
|
||||
private static ServicesRegistryController NewController(Mock<IServicesRegistryService> service, params string[] roles)
|
||||
{
|
||||
var controller = new ServicesRegistryController(service.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) =>
|
||||
(result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto;
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsOkWithServices()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ServiceDto> { new() { Id = 1, Name = "Leak", IsActive = true } });
|
||||
|
||||
var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
(ok.Value as IEnumerable<ServiceDto>).Should().ContainSingle(s => s.Name == "Leak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException(
|
||||
"WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency."));
|
||||
|
||||
var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid");
|
||||
ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WhenMissing_ReturnsNotFoundWithCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.GetByIdAsync(9, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ServiceDto?)null);
|
||||
|
||||
var result = await NewController(service, "Admin").GetById(9, CancellationToken.None);
|
||||
|
||||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
ErrorValue(notFound)!.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PassesCallerAndReturnsCreated()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true });
|
||||
|
||||
var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
|
||||
|
||||
var created = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
created.StatusCode.Should().Be(StatusCodes.Status201Created);
|
||||
(created.Value as ServiceDto)!.Id.Should().Be(42);
|
||||
service.Verify(s => s.CreateAsync(
|
||||
It.Is<ClaimsPrincipal>(p => p.IsInRole("Scheduler")),
|
||||
It.Is<ServiceInput>(i => i.Name == "Leak"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenForbidden_Returns403WithStableCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."));
|
||||
|
||||
var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenDuplicateName_Returns400WithStableCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."));
|
||||
|
||||
var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
ErrorValue(bad)!.Code.Should().Be("DuplicateName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ReturnsOkWithUpdatedService()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 7, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" });
|
||||
|
||||
var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
(ok.Value as ServiceDto)!.Name.Should().Be("Renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenNotFound_Returns404WithCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
|
||||
|
||||
var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None);
|
||||
|
||||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
ErrorValue(notFound)!.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_ReturnsOk()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
|
||||
var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.Verify(s => s.DeactivateAsync(
|
||||
It.Is<ClaimsPrincipal>(p => p.IsInRole("Admin")), 3, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenForbidden_Returns403WithStableCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 3, It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."));
|
||||
|
||||
var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenNotFound_Returns404WithCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
|
||||
|
||||
var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
}
|
||||
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal file
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
// Global Services registry behavior. The registry has no tenant scope
|
||||
// (no AccountId), authorization is enforced at the service boundary, and the
|
||||
// only allowed supported work-order types are PM, Reactive, and Emergency.
|
||||
public class ServicesRegistryServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ServicesRegistryService NewService(ApplicationDbContext ctx) =>
|
||||
new(new ServicesRegistryDataService(ctx));
|
||||
|
||||
private static ClaimsPrincipal Principal(params string[] roles) =>
|
||||
new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"));
|
||||
|
||||
private static ClaimsPrincipal Admin() => Principal("Admin");
|
||||
private static ClaimsPrincipal Scheduler() => Principal("Scheduler");
|
||||
|
||||
private static ServiceInput Input(
|
||||
string name = "Leak",
|
||||
string? trade = "Plumbing & Water Systems",
|
||||
string? iconKey = "plumbing-water-systems",
|
||||
bool requiresDoc = false,
|
||||
int? templateId = null,
|
||||
List<WorkOrderType>? types = null) => new()
|
||||
{
|
||||
Name = name,
|
||||
Trade = trade,
|
||||
IconKey = iconKey,
|
||||
RequiresCompletionDocument = requiresDoc,
|
||||
CompletionDocTemplateId = templateId,
|
||||
SupportedWorkOrderTypes = types ?? new List<WorkOrderType>
|
||||
{
|
||||
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
|
||||
}
|
||||
};
|
||||
|
||||
private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) =>
|
||||
new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted };
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None);
|
||||
|
||||
dto.Id.Should().BeGreaterThan(0);
|
||||
dto.Name.Should().Be("Leak Detection");
|
||||
dto.Trade.Should().Be("Plumbing & Water Systems");
|
||||
dto.IconKey.Should().Be("plumbing-water-systems");
|
||||
dto.IsActive.Should().BeTrue("new services are always active");
|
||||
dto.CompletionDocTemplate.Should().BeNull();
|
||||
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency });
|
||||
|
||||
var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single();
|
||||
row.NormalizedName.Should().Be("leak detection");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var input = Input(types: null);
|
||||
input.SupportedWorkOrderTypes = null;
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
|
||||
|
||||
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None);
|
||||
var ex = (await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Subject.Single();
|
||||
ex.Code.Should().Be("NameRequired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TradeInvalid");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" hvac ")]
|
||||
[InlineData("HVAC")]
|
||||
[InlineData("hvac")]
|
||||
public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None);
|
||||
dto.Trade.Should().Be("HVAC");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("IconKeyInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
// Distinct actors with different role sets still share one global registry.
|
||||
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
|
||||
|
||||
var all = await service.GetAllAsync(null, null, CancellationToken.None);
|
||||
all.Should().ContainSingle(s => s.Id == first.Id);
|
||||
typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None))
|
||||
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
|
||||
|
||||
ctx.CompletionDocTemplates.Add(Template("Inactive", active: false));
|
||||
ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true));
|
||||
ctx.SaveChanges();
|
||||
|
||||
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None))
|
||||
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
|
||||
|
||||
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None))
|
||||
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var template = Template("Completion Packet");
|
||||
ctx.CompletionDocTemplates.Add(template);
|
||||
ctx.SaveChanges();
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None);
|
||||
|
||||
dto.RequiresCompletionDocument.Should().BeTrue();
|
||||
dto.CompletionDocTemplate.Should().NotBeNull();
|
||||
dto.CompletionDocTemplate!.Id.Should().Be(template.Id);
|
||||
dto.CompletionDocTemplate.Name.Should().Be("Completion Packet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var template = Template("Completion Packet");
|
||||
ctx.CompletionDocTemplates.Add(template);
|
||||
ctx.SaveChanges();
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None);
|
||||
|
||||
dto.RequiresCompletionDocument.Should().BeFalse();
|
||||
dto.CompletionDocTemplate.Should().BeNull();
|
||||
ctx.Services.Single().CompletionDocTemplateId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WorkOrderType.PO)]
|
||||
[InlineData(WorkOrderType.Project)]
|
||||
[InlineData(WorkOrderType.Inspection)]
|
||||
[InlineData(WorkOrderType.AddOn)]
|
||||
[InlineData(WorkOrderType.Other)]
|
||||
public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var input = Input(types: new List<WorkOrderType> { WorkOrderType.PM, unsupported });
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_RewritesFieldsButPreservesActiveState()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
var updated = await service.UpdateAsync(
|
||||
Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None);
|
||||
|
||||
updated.Name.Should().Be("Leak Repair");
|
||||
updated.Trade.Should().Be("HVAC");
|
||||
updated.IconKey.Should().Be("hvac");
|
||||
updated.IsActive.Should().BeFalse("update preserves the persisted active state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenAdminRequestsActiveState_ReactivatesService()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
var input = Input("Inactive service");
|
||||
input.IsActive = true;
|
||||
var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None);
|
||||
|
||||
updated.IsActive.Should().BeTrue();
|
||||
ctx.Services.Single().IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
var input = Input("Inactive service");
|
||||
input.IsActive = true;
|
||||
var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
ctx.Services.Single().IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenNotFound_ThrowsNotFound()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None);
|
||||
|
||||
var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
ctx.Services.Single().Name.Should().Be("Leak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_SoftStateChangeOnly_PreservesRow()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
ctx.Services.Should().HaveCount(1, "deactivation never hard deletes");
|
||||
var byId = await service.GetByIdAsync(created.Id, CancellationToken.None);
|
||||
byId.Should().NotBeNull();
|
||||
byId!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenScheduler_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
ctx.Services.Single().IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenNotFound_ThrowsNotFound()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_FiltersByActiveStateAndWorkOrderType()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List<WorkOrderType> { WorkOrderType.PM }), CancellationToken.None);
|
||||
var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List<WorkOrderType> { WorkOrderType.Reactive }), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None);
|
||||
|
||||
(await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id)
|
||||
.Should().BeEquivalentTo(new[] { pm.Id });
|
||||
(await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id)
|
||||
.Should().BeEquivalentTo(new[] { reactive.Id });
|
||||
|
||||
(await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id)
|
||||
.Should().BeEquivalentTo(new[] { pm.Id });
|
||||
(await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None))
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WhenMissing_ReturnsNull()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
(await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull();
|
||||
}
|
||||
}
|
||||
174
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
174
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class TeamMemberServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_DispatcherRequiresAtLeastOneServiceArea()
|
||||
{
|
||||
var userManager = UserManager();
|
||||
var roleManager = RoleManager();
|
||||
var service = new TeamMemberService(
|
||||
userManager.Object,
|
||||
roleManager.Object,
|
||||
Mock.Of<IUserServiceAreaDataService>(),
|
||||
Mock.Of<ITeamPermissionOverrideDataService>());
|
||||
|
||||
var result = await service.CreateAsync(
|
||||
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Be("At least one service area is required for a Dispatcher.");
|
||||
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RejectsUnknownColorAndPermission()
|
||||
{
|
||||
var service = NewService(out var userManager, out _, out _, out _);
|
||||
|
||||
var badColor = await service.CreateAsync(
|
||||
ValidRequest() with { Color = "#000000" },
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
var badPermission = await service.CreateAsync(
|
||||
ValidRequest() with
|
||||
{
|
||||
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
||||
{
|
||||
["not-a-permission"] = UserPermissionState.Allow
|
||||
}
|
||||
},
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
badColor.Error.Should().Be("Color must be selected from the accessible palette.");
|
||||
badPermission.Error.Should().Be("Unknown permission key: not-a-permission.");
|
||||
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides()
|
||||
{
|
||||
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides);
|
||||
ApplicationUser? created = null;
|
||||
userManager
|
||||
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||
.Callback<ApplicationUser>(user =>
|
||||
{
|
||||
user.Id = "new-user";
|
||||
created = user;
|
||||
})
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager
|
||||
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
userManager
|
||||
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||
|
||||
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
created.Should().NotBeNull();
|
||||
created!.PasswordHash.Should().BeNull();
|
||||
created.EmailConfirmed.Should().BeFalse();
|
||||
created.PendingRegistration.Should().BeTrue();
|
||||
created.PhoneNumber.Should().Be("555-0100");
|
||||
result.Member!.ServiceAreas.Should().Equal("East", "West");
|
||||
areas.Verify(data => data.ReplaceAsync("new-user", It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny<CancellationToken>()), Times.Once);
|
||||
overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
|
||||
{
|
||||
var service = NewService(out var userManager, out var roleManager, out _, out _);
|
||||
userManager
|
||||
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||
userManager
|
||||
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||
.ThrowsAsync(new DbUpdateException("duplicate key", new Exception()));
|
||||
|
||||
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Be("Email is already in use.");
|
||||
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
|
||||
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
private static TeamMemberService NewService(
|
||||
out Mock<UserManager<ApplicationUser>> userManager,
|
||||
out Mock<RoleManager<IdentityRole>> roleManager,
|
||||
out Mock<IUserServiceAreaDataService> areas,
|
||||
out Mock<ITeamPermissionOverrideDataService> overrides)
|
||||
{
|
||||
userManager = UserManager();
|
||||
roleManager = RoleManager();
|
||||
areas = new Mock<IUserServiceAreaDataService>();
|
||||
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
||||
return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.Object);
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> UserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
|
||||
Mock.Of<IPasswordHasher<ApplicationUser>>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
Mock.Of<ILookupNormalizer>(),
|
||||
new IdentityErrorDescriber(),
|
||||
Mock.Of<IServiceProvider>(),
|
||||
Mock.Of<ILogger<UserManager<ApplicationUser>>>());
|
||||
}
|
||||
|
||||
private static Mock<RoleManager<IdentityRole>> RoleManager()
|
||||
{
|
||||
var store = new Mock<IRoleStore<IdentityRole>>();
|
||||
return new Mock<RoleManager<IdentityRole>>(
|
||||
store.Object,
|
||||
Array.Empty<IRoleValidator<IdentityRole>>(),
|
||||
Mock.Of<ILookupNormalizer>(),
|
||||
new IdentityErrorDescriber(),
|
||||
Mock.Of<ILogger<RoleManager<IdentityRole>>>());
|
||||
}
|
||||
|
||||
private static CreateTeamMemberRequestDTO ValidRequest() => new()
|
||||
{
|
||||
Name = "Taylor Dispatcher",
|
||||
Role = "dispatcher",
|
||||
Color = "#F59E0B",
|
||||
Email = "taylor@example.com",
|
||||
Phone = "555-0100",
|
||||
ServiceAreas = new[] { "east", "West" },
|
||||
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
||||
{
|
||||
["deleteSites"] = UserPermissionState.Allow
|
||||
}
|
||||
};
|
||||
|
||||
private static ClaimsPrincipal Admin() =>
|
||||
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class TeamPermissionOverrideDataServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
SeedUserWithRole(context, "u1", "Dispatcher");
|
||||
context.UserPermissionOverrides.Add(new UserPermissionOverride
|
||||
{
|
||||
UserId = "u1",
|
||||
PermissionKey = "deleteSites",
|
||||
State = UserPermissionState.Deny
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await new TeamPermissionOverrideDataService(context)
|
||||
.GetUserAsync("u1", CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.RoleName.Should().Be("Dispatcher");
|
||||
result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetOverrideAsync_UpsertsOneCompositeKey()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
SeedUserWithRole(context, "u1", "Scheduler");
|
||||
await context.SaveChangesAsync();
|
||||
var service = new TeamPermissionOverrideDataService(context);
|
||||
|
||||
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None);
|
||||
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None);
|
||||
|
||||
var rows = await context.UserPermissionOverrides.ToListAsync();
|
||||
rows.Should().ContainSingle();
|
||||
rows[0].State.Should().Be(UserPermissionState.Deny);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName)
|
||||
{
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = userId,
|
||||
UserName = userId,
|
||||
NormalizedUserName = userId.ToUpperInvariant(),
|
||||
Email = userId + "@example.com",
|
||||
NormalizedEmail = (userId + "@example.com").ToUpperInvariant()
|
||||
});
|
||||
context.Roles.Add(new IdentityRole
|
||||
{
|
||||
Id = "role-1",
|
||||
Name = roleName,
|
||||
NormalizedName = roleName.ToUpperInvariant()
|
||||
});
|
||||
context.UserRoles.Add(new IdentityUserRole<string>
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = "role-1"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,56 @@ public class UpliftControllerTests
|
|||
envelope.Status.Should().Be("Success");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_PassesStatusAndTierFiltersToService()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new UpliftListResultDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
await controller.List("Approved", 2, 3, 50);
|
||||
|
||||
service.Verify(
|
||||
x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Approved", 2, 3, 50, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsQueueContractFieldsInEnvelope()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
var item = new UpliftListItemDTO
|
||||
{
|
||||
Id = 7,
|
||||
WorkOrderId = 11,
|
||||
WorkOrderNumber = "WO-77",
|
||||
WorkOrderSite = "SITE-EAST",
|
||||
WorkOrderService = "HVAC",
|
||||
RequestedByName = "Gateway",
|
||||
WorkOrderAutoApprovedTotal = 150m,
|
||||
WorkOrderAdminApprovedTotal = 300m,
|
||||
WorkOrderApprovedExposureTotal = 450m
|
||||
};
|
||||
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new UpliftListResultDTO { Total = 1, Page = 1, PageSize = 25, Items = new[] { item } });
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.List();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
var data = envelope.Data as UpliftListResultDTO;
|
||||
var returned = data!.Items.Should().ContainSingle().Subject;
|
||||
returned.WorkOrderNumber.Should().Be("WO-77");
|
||||
returned.WorkOrderSite.Should().Be("SITE-EAST");
|
||||
returned.WorkOrderService.Should().Be("HVAC");
|
||||
returned.RequestedByName.Should().Be("Gateway");
|
||||
returned.WorkOrderApprovedExposureTotal.Should().Be(450m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_NotFound_Returns404()
|
||||
{
|
||||
|
|
@ -67,6 +117,49 @@ public class UpliftControllerTests
|
|||
nf.Value.Should().BeOfType<Response>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WithReason_DelegatesToService()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
"Policy change",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new UpliftDecisionResultDTO { Id = 7, Status = "Revoked" });
|
||||
|
||||
var controller = NewController(service, "Admin");
|
||||
|
||||
var result = await controller.Revoke(
|
||||
7,
|
||||
new UpliftController.DecisionRequest { Note = "Policy change" });
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.Verify(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
"Policy change",
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WithoutReason_ReturnsBadRequest()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.RevokeAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
7,
|
||||
string.Empty,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("reason required"));
|
||||
|
||||
var controller = NewController(service, "Admin");
|
||||
|
||||
var result = await controller.Revoke(7, null);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
|
||||
{
|
||||
|
|
|
|||
723
Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
Normal file
723
Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
// Behavior tests for the approval queue read contract. These exercise the real
|
||||
// service + data-service layers against an in-memory DbContext so that queue
|
||||
// ordering, filters, flattened work-order fields, exposure aggregation, and
|
||||
// per-tier read authorization are validated through the public contract.
|
||||
public sealed class UpliftQueueReadTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ApprovalsOptions NewOptions() => new()
|
||||
{
|
||||
UpliftTier1MaxUsd = 2500m,
|
||||
Tier1Roles = new[] { "Approver" },
|
||||
Tier2Roles = new[] { "Manager" },
|
||||
Tier1NotificationRecipients = new[] { "tier1@example.com" },
|
||||
Tier2NotificationRecipients = new[] { "tier2@example.com" },
|
||||
EscalationRecipients = new[] { "escalate@example.com" }
|
||||
};
|
||||
|
||||
private static UpliftService NewService(ApplicationDbContext context) =>
|
||||
new(new UpliftDataService(context),
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(NewOptions()));
|
||||
|
||||
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "user-42") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
|
||||
}
|
||||
|
||||
private static DispatchUpliftRequest Request(
|
||||
Dispatch dispatch,
|
||||
string status,
|
||||
DateTime created,
|
||||
DateTime? decided = null,
|
||||
int tier = 1,
|
||||
decimal requested = 100m,
|
||||
string? reason = null,
|
||||
string? requestedBy = null,
|
||||
string? createdBy = null,
|
||||
decimal? currentNte = null) => new()
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = status,
|
||||
CreatedDate = created,
|
||||
DecidedAt = decided,
|
||||
RequiredTier = tier,
|
||||
RequestedNTE = requested,
|
||||
CurrentNTE = currentNte,
|
||||
VendorReason = reason,
|
||||
RequestedByVendorName = requestedBy,
|
||||
createdby = createdBy,
|
||||
NotificationStatus = "Pending"
|
||||
};
|
||||
|
||||
private static async Task<(Vendor Vendor, WorkOrder WorkOrder)> SeedWorkOrderAsync(
|
||||
ApplicationDbContext context,
|
||||
string number,
|
||||
string site,
|
||||
string service,
|
||||
string vendorName = "Gateway")
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = vendorName, IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = number,
|
||||
WorkerOrderNumber = $"legacy-{number}",
|
||||
SiteCode = site,
|
||||
Service = service,
|
||||
WorkerOrderTitle = "Repair"
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
return (vendor, workOrder);
|
||||
}
|
||||
|
||||
private static async Task<Dispatch> SeedDispatchAsync(
|
||||
ApplicationDbContext context, Vendor vendor, WorkOrder workOrder, string number)
|
||||
{
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
DispatchNumber = number,
|
||||
Status = "Completed",
|
||||
NTEAmount = 1000m
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
// --- Ordering ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_PendingStatus_OrdersOldestRequestFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 3)),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Select(i => i.RequestedAt).Should().Equal(
|
||||
new DateTime(2026, 3, 1),
|
||||
new DateTime(2026, 3, 2),
|
||||
new DateTime(2026, 3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ApprovedStatus_OrdersMostRecentlyDecidedFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Select(i => i.DecidedAt).Should().Equal(
|
||||
new DateTime(2026, 3, 20),
|
||||
new DateTime(2026, 3, 15),
|
||||
new DateTime(2026, 3, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 10), decided: new DateTime(2026, 3, 11)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), null, null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Select(i => i.RequestedAt).Should().ContainInOrder(
|
||||
new DateTime(2026, 3, 10),
|
||||
new DateTime(2026, 3, 1));
|
||||
}
|
||||
|
||||
// --- Filters and scoping ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_StatusAndTierFilters_Combine()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), tier: 2));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", 2, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Total.Should().Be(1);
|
||||
result.Items.Should().ContainSingle(i => i.RequiredTier == 2 && i.Status == "Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ExcludesDeletedRequestsAndRequestsOnDeletedDispatches()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
var deletedDispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
DispatchNumber = "DIS-DELETED",
|
||||
Status = "Completed",
|
||||
IsDeleted = true
|
||||
};
|
||||
context.Dispatches.Add(deletedDispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1)),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 3, 2),
|
||||
IsDeleted = true,
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 100m
|
||||
},
|
||||
Request(deletedDispatch, "Pending", new DateTime(2026, 3, 3)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Total.Should().Be(1);
|
||||
result.Items.Should().ContainSingle(i => i.RequestedAt == new DateTime(2026, 3, 1));
|
||||
}
|
||||
|
||||
// --- Flattened queue fields ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsFlattenedWorkOrderAndRequesterFields()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(
|
||||
dispatch, "Pending", new DateTime(2026, 3, 2),
|
||||
tier: 2, requested: 400m, reason: "Scope grew",
|
||||
requestedBy: "Gateway", createdBy: "user-7", currentNte: 100m));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderId.Should().Be(workOrder.Id);
|
||||
item.WorkOrderNumber.Should().Be("WO-77");
|
||||
item.WorkOrderSite.Should().Be("SITE-EAST");
|
||||
item.WorkOrderService.Should().Be("HVAC");
|
||||
item.VendorCompanyName.Should().Be("Gateway");
|
||||
item.RequestedByName.Should().Be("Gateway");
|
||||
item.RequestedAt.Should().Be(new DateTime(2026, 3, 2));
|
||||
item.VendorReason.Should().Be("Scope grew");
|
||||
item.Delta.Should().Be(300m);
|
||||
item.Status.Should().Be("Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_WorkOrderNumber_RendersInternalShNumber_NotTheCrmExternalId()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
// Synced work orders carry the CRM external id in WorkerOrderNumber; the SH
|
||||
// display number the board renders is stamped on InternalWONumber.
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "10000000001",
|
||||
WorkerOrderNumber = "CRM-EXT-77",
|
||||
SiteCode = "SITE-EAST",
|
||||
Service = "HVAC",
|
||||
WorkerOrderTitle = "Repair"
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderNumber.Should().Be("10000000001");
|
||||
item.WorkOrderNumber.Should().NotBe("CRM-EXT-77");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_RequesterLabelFallsBackToCreatingUserName()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(
|
||||
dispatch, "Pending", new DateTime(2026, 3, 2),
|
||||
requestedBy: null, createdBy: "user-7"));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().RequestedByName.Should().Be("Ada Smith");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsEvidenceAttachmentSummary()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
var evidence = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "quote.pdf",
|
||||
StoredFileName = "evidence.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 512,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 1
|
||||
};
|
||||
context.VendorCompletionDocuments.Add(evidence);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 200m,
|
||||
EvidenceDocumentId = evidence.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Single();
|
||||
item.EvidenceDocumentId.Should().Be(evidence.Id);
|
||||
item.EvidenceFileName.Should().Be("quote.pdf");
|
||||
item.EvidenceContentType.Should().Be("application/pdf");
|
||||
item.EvidenceSizeBytes.Should().Be(512);
|
||||
}
|
||||
|
||||
// --- Queue contract fields (SH-208/SH-213) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_MarksWorkOrderClosed_OnlyForTerminalLifecycle()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing");
|
||||
workOrderA.LifecycleStatus = LifecycleStatus.Completed;
|
||||
workOrderB.LifecycleStatus = LifecycleStatus.Canceled;
|
||||
await context.SaveChangesAsync();
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||
var (vendorC, workOrderC) = await SeedWorkOrderAsync(context, "WO-C", "SITE-C", "Electrical");
|
||||
var dispatchC = await SeedDispatchAsync(context, vendorC, workOrderC, "DIS-C");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2)),
|
||||
Request(dispatchB, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4)),
|
||||
Request(dispatchC, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Admin"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-A").WorkOrderClosed.Should().BeTrue();
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-B").WorkOrderClosed.Should().BeTrue();
|
||||
result.Items.Single(i => i.WorkOrderNumber == "WO-C").WorkOrderClosed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ApprovedRow_CarriesDecidedByNameFromDecidingUser()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.Users.Add(new ApplicationUser { Id = "user-9", FirstName = "Grace", LastName = "Hopper" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
Status = "Approved",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
DecidedAt = new DateTime(2026, 3, 2),
|
||||
DecidedByUserId = "user-9",
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 300m,
|
||||
NotificationStatus = "Pending"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().DecidedByName.Should().Be("Grace Hopper");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_AttachmentCount_CountsActiveDocumentsOnTheDispatch()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.VendorCompletionDocuments.AddRange(
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "quote.pdf",
|
||||
StoredFileName = "a.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 512,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "UpliftEvidence",
|
||||
Version = 1
|
||||
},
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "photo.jpg",
|
||||
StoredFileName = "b.bin",
|
||||
ContentType = "image/jpeg",
|
||||
SizeBytes = 2048,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "Completion",
|
||||
Version = 1
|
||||
},
|
||||
new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
OriginalFileName = "old.pdf",
|
||||
StoredFileName = "c.bin",
|
||||
ContentType = "application/pdf",
|
||||
SizeBytes = 128,
|
||||
ScanStatus = "Passed",
|
||||
ReviewStatus = "Approved",
|
||||
Purpose = "Completion",
|
||||
Version = 1,
|
||||
IsDeleted = true
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 3, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
result.Items.Single().AttachmentCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_PendingExposureTotal_SumsGrantedAmountsAcrossAllPendingRequests()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Smith" });
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal rows (createdby null) store the requested new NTE total,
|
||||
// so the pending exposure is the delta: 400 - 100 = 300 and 600 - 250 = 350.
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), requested: 400m, currentNte: 100m),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), requested: 600m, currentNte: 250m),
|
||||
// Work-order-path rows (createdby set) store the requested increment: 90.
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 3), requested: 90m, currentNte: 600m, createdBy: "user-7"),
|
||||
// Non-pending rows never add pending exposure.
|
||||
Request(dispatch, "Approved", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2), requested: 500m, currentNte: 100m));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
// Page 1 with a single row still reports the total across ALL pending rows.
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 1, CancellationToken.None);
|
||||
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.PendingExposureTotal.Should().Be(740m);
|
||||
}
|
||||
|
||||
// --- Approved-on-WO exposure totals ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_AggregatesApprovedExposurePerWorkOrder_ExcludingNonApprovedStatuses()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Gateway-A");
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Plumbing", "Gateway-B");
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal row (createdby null): stores the requested new NTE total,
|
||||
// so the granted amount is 400 - 100 = 300.
|
||||
Request(dispatchA, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 400m, currentNte: 100m),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "NoApprovalRequired",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
RequiredTier = 0,
|
||||
RequestedNTE = 150m
|
||||
},
|
||||
Request(dispatchA, "Pending", new DateTime(2026, 3, 1), requested: 999m),
|
||||
Request(dispatchA, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 999m),
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Withdrawn",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
DecidedAt = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 999m
|
||||
},
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Revoked",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
DecidedAt = new DateTime(2026, 3, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 999m
|
||||
},
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Expired",
|
||||
CreatedDate = new DateTime(2026, 3, 1),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 999m
|
||||
},
|
||||
Request(dispatchB, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 75m, currentNte: 0m));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Approved", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var itemA = result.Items.Single(i => i.WorkOrderNumber == "WO-A");
|
||||
itemA.WorkOrderAutoApprovedTotal.Should().Be(150m);
|
||||
itemA.WorkOrderAdminApprovedTotal.Should().Be(300m);
|
||||
itemA.WorkOrderApprovedExposureTotal.Should().Be(450m);
|
||||
|
||||
var itemB = result.Items.Single(i => i.WorkOrderNumber == "WO-B");
|
||||
itemB.WorkOrderAutoApprovedTotal.Should().Be(0m);
|
||||
itemB.WorkOrderAdminApprovedTotal.Should().Be(75m);
|
||||
itemB.WorkOrderApprovedExposureTotal.Should().Be(75m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_UsesGrantedAmountForVendorPortalAndRequestedAmountForWorkOrderRequests()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-ORIGIN", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-ORIGIN");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(
|
||||
dispatch,
|
||||
"Approved",
|
||||
new DateTime(2026, 3, 1),
|
||||
decided: new DateTime(2026, 3, 2),
|
||||
requested: 400m,
|
||||
currentNte: 100m),
|
||||
Request(
|
||||
dispatch,
|
||||
"Approved",
|
||||
new DateTime(2026, 3, 3),
|
||||
decided: new DateTime(2026, 3, 4),
|
||||
requested: 250m,
|
||||
createdBy: "internal-user",
|
||||
currentNte: 100m));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var exposure = await new UpliftDataService(context)
|
||||
.GetApprovedExposureForWorkOrdersAsync(new[] { workOrder.Id }, CancellationToken.None);
|
||||
|
||||
exposure.Should().ContainSingle().Which.AdminApprovedTotal.Should().Be(550m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_IncludesLinkedDispatchesViaServerDerivedWorkOrderLinks()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var linkedDispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendorA.Id,
|
||||
DispatchNumber = "DIS-LINKED",
|
||||
Status = "Completed"
|
||||
};
|
||||
context.Dispatches.Add(linkedDispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = linkedDispatch.Id,
|
||||
WorkOrderId = workOrderA.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchUpliftRequests.Add(Request(
|
||||
linkedDispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 60m));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
||||
new[] { workOrderA.Id }, CancellationToken.None);
|
||||
|
||||
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrderA.Id).Subject;
|
||||
total.AdminApprovedTotal.Should().Be(60m);
|
||||
total.AutoApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_AdminApprovedSumsGrantedAmountsPerCreationPath()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal rows (createdby null) store the requested new NTE total:
|
||||
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
|
||||
// Work-order-path rows (createdby set) store the granted increment: 200.
|
||||
Request(dispatch, "Approved", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
||||
new[] { workOrder.Id }, CancellationToken.None);
|
||||
|
||||
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
|
||||
// double-count whole NTE totals and report 3500.
|
||||
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
|
||||
total.AdminApprovedTotal.Should().Be(1000m);
|
||||
total.AutoApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal rows (createdby null) store the requested new NTE total:
|
||||
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
|
||||
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
||||
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
|
||||
// Work-order-path rows (createdby set) store the granted increment: 200.
|
||||
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
||||
new[] { workOrder.Id }, CancellationToken.None);
|
||||
|
||||
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
|
||||
// double-count whole NTE totals and report 3500.
|
||||
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
|
||||
total.AutoApprovedTotal.Should().Be(1000m);
|
||||
total.AdminApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
// --- Read authorization (tier roles) ---
|
||||
|
||||
[Fact]
|
||||
public async Task List_CanDecide_ReflectsTierRolesForTheCaller()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-77", "SITE-EAST", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 1), tier: 1),
|
||||
Request(dispatch, "Pending", new DateTime(2026, 3, 2), tier: 2));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var tier1Only = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
var tier2User = await service.ListAsync(UserWithRoles("Manager"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
tier1Only.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeTrue();
|
||||
tier1Only.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeFalse();
|
||||
tier2User.Items.Single(i => i.RequiredTier == 2).CanDecide.Should().BeTrue();
|
||||
tier2User.Items.Single(i => i.RequiredTier == 1).CanDecide.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
|
|||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
|
@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
|
||||
public sealed class UpliftServiceRefusedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RevokeAsync_DelegatesApprovedAdminRevocationToWorkOrderFlow()
|
||||
{
|
||||
var request = new DispatchUpliftRequest
|
||||
{
|
||||
Id = 7,
|
||||
DispatchId = 42,
|
||||
Status = "Approved",
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 900m
|
||||
};
|
||||
var upliftData = new Mock<IUpliftDataService>();
|
||||
upliftData.Setup(data => data.GetByIdAsync(7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(request);
|
||||
upliftData.Setup(data => data.GetWorkOrderIdForUpliftAsync(7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(77);
|
||||
var workOrderFlow = new Mock<IWorkOrderUpliftService>();
|
||||
workOrderFlow.Setup(flow => flow.RevokeAsync(
|
||||
77,
|
||||
7,
|
||||
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WorkOrderUpliftDto { Id = 7, Status = "revoked" });
|
||||
|
||||
var service = new UpliftService(
|
||||
upliftData.Object,
|
||||
Mock.Of<IDispatchDataService>(),
|
||||
Mock.Of<IVendorDocumentStoragePort>(),
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||
workOrderFlow.Object);
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
},
|
||||
"test"));
|
||||
|
||||
var result = await service.RevokeAsync(user, 7, " Policy change ", CancellationToken.None);
|
||||
|
||||
result.Id.Should().Be(7);
|
||||
result.Status.Should().Be("Revoked");
|
||||
workOrderFlow.Verify(flow => flow.RevokeAsync(
|
||||
77,
|
||||
7,
|
||||
It.Is<RevokeWorkOrderUpliftRequestDto>(r => r.Reason == "Policy change"),
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -90,6 +90,23 @@ public class UserServiceTests
|
|||
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade()
|
||||
{
|
||||
var user = IdentityTestHelpers.User("owner");
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||
|
||||
var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), 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]
|
||||
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
|
||||
{
|
||||
|
|
@ -138,6 +155,26 @@ public class UserServiceTests
|
|||
outcome.Error.Should().Be(UserMutationErrors.UserNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation()
|
||||
{
|
||||
var user = IdentityTestHelpers.User("owner");
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||
|
||||
var outcome = await service.EditUserAsync(
|
||||
new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" },
|
||||
Principal("Admin"),
|
||||
CancellationToken.None);
|
||||
|
||||
outcome.Success.Should().BeFalse();
|
||||
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
|
||||
userData.Verify(u => u.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserProfile_MapsCreatedDateToAddedDate()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
|
|||
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Returns200()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SampleRoster());
|
||||
|
||||
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
|
@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Create_ValidationException_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
|
||||
|
||||
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
|
||||
|
|
@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Create_DuplicateName_ReturnsStable409()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new VendorRosterDuplicateNameException(
|
||||
"database detail",
|
||||
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
|
||||
|
|
@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests
|
|||
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_ValidationException_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
|
||||
|
||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
|
@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Reconcile_CompanyNotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||
|
||||
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
|
@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests
|
|||
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
|
||||
};
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
|
||||
|
||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
|
@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||
|
||||
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||
|
|
@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests
|
|||
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_AddsTechnicians_Returns200WithRoster()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(SampleRoster());
|
||||
|
||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||
|
|
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Patch_ValidationException_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
|
||||
|
||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||
|
|
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Patch_CompanyNotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||
|
||||
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
|
||||
|
|
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
|
|||
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||
|
||||
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
|
||||
|
|
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
|
|||
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AreaAssignmentForbidden_Returns403OnEveryMutation()
|
||||
{
|
||||
var service = new Mock<IVendorCompanyRosterService>();
|
||||
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
|
||||
var controller = NewController(service);
|
||||
|
||||
var results = new[]
|
||||
{
|
||||
await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None),
|
||||
await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None),
|
||||
await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None)
|
||||
};
|
||||
|
||||
foreach (var result in results)
|
||||
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
|
||||
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
|
|||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
using var context = NewContext(dbName);
|
||||
context.Areas.AddRange(
|
||||
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
||||
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
|
||||
await context.SaveChangesAsync();
|
||||
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
|
||||
var service = new VendorCompanyRosterDataService(context);
|
||||
|
||||
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Name = "Area Co",
|
||||
Email = "area@example.com",
|
||||
AreaIdProvided = provided,
|
||||
AreaId = areaId,
|
||||
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
|
||||
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
|
||||
assigned.AreaId.Should().Be(2);
|
||||
assigned.AreaName.Should().Be("Central");
|
||||
|
||||
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
|
||||
untouched.AreaId.Should().Be(2);
|
||||
|
||||
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
|
||||
cleared.AreaId.Should().BeNull();
|
||||
cleared.AreaName.Should().BeNull();
|
||||
|
||||
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
|
||||
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using FluentAssertions;
|
|||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using System.Security.Claims;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
|
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
|
||||
public class VendorCompanyRosterServiceTests
|
||||
{
|
||||
private static readonly ClaimsPrincipal Dispatcher = Principal("User");
|
||||
private static readonly ClaimsPrincipal Admin = Principal("Admin");
|
||||
|
||||
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
|
||||
"Test"));
|
||||
|
||||
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
|
||||
|
||||
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
|
||||
|
|
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
|
|||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var dto = new CreateVendorRosterDTO { Name = "Acme" };
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
|
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
|
|||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
|
||||
|
|
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
|
||||
};
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
|
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Name = "Acme",
|
||||
Email = "acme@example.com",
|
||||
Technicians = new List<RosterTechnicianInputDTO>()
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured.Should().NotBeNull();
|
||||
captured!.Technicians.Should().BeEmpty();
|
||||
|
|
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Name = "Acme",
|
||||
Email = "acme@example.com",
|
||||
Notes = notes
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.Notes.Should().Be(notes);
|
||||
}
|
||||
|
|
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Notes = new string('n', 501)
|
||||
};
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(error =>
|
||||
|
|
@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests
|
|||
}
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
|
@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests
|
|||
}
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
|
@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests
|
|||
GoogleMapsUrl = "http://maps.google.com/acme"
|
||||
};
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
|
||||
|
|
@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests
|
|||
{
|
||||
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
|
||||
}
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
|
||||
}
|
||||
|
|
@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests
|
|||
}
|
||||
};
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||
|
|
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Name = "Acme Co",
|
||||
Email = "acme@example.com",
|
||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured.Should().NotBeNull();
|
||||
captured!.CompanyId.Should().Be(7);
|
||||
|
|
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Name = "Acme",
|
||||
Email = "acme@example.com",
|
||||
Notes = notes
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.Notes.Should().Be(notes);
|
||||
}
|
||||
|
|
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Notes = new string('n', 501)
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(error =>
|
||||
|
|
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Email = "acme@example.com"
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||
|
|
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Email = "acme@example.com"
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||
|
|
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Technicians = null!
|
||||
};
|
||||
|
||||
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.Technicians.Should().BeEmpty();
|
||||
}
|
||||
|
|
@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests
|
|||
}
|
||||
};
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
|
@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests
|
|||
{
|
||||
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
|
||||
}
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.Technicians.Should().ContainSingle();
|
||||
captured.Technicians.Single().ContactName.Should().BeEmpty();
|
||||
|
|
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
}
|
||||
|
||||
|
|
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
|
|||
Name = "Acme",
|
||||
Email = "acme@example.com",
|
||||
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||
}, "42", cts.Token);
|
||||
}, "42", Dispatcher, cts.Token);
|
||||
|
||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
||||
}
|
||||
|
|
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
|
|||
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
|
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
|
|||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
|
||||
|
|
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
|
|||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
|
|
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
|
|||
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
|
||||
};
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
|
||||
|
|
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
|
|||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||
|
|
@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests
|
|||
}
|
||||
};
|
||||
|
||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured.Should().NotBeNull();
|
||||
captured!.CompanyId.Should().Be(7);
|
||||
|
|
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
|
|||
{
|
||||
RowVersion = "AAAAAAAAD8I=",
|
||||
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
|
||||
}, "42", CancellationToken.None);
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.CompanyFields!.Notes.Should().Be(notes);
|
||||
}
|
||||
|
|
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
|
|||
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
|
||||
};
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(error =>
|
||||
|
|
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
|
|||
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||
};
|
||||
|
||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
|
||||
// non-null write model made the data layer bump RowVersion and rewrite every
|
||||
|
|
@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests
|
|||
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
|
||||
};
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(
|
||||
|
|
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
|
|||
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
|
||||
};
|
||||
|
||||
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
|
||||
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured!.CompanyFields.Should().NotBeNull();
|
||||
captured.CompanyFields!.City.Should().Be("Norfolk");
|
||||
|
|
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
|
|||
.ReturnsAsync(SampleReadModel(7, 1));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
|
||||
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token);
|
||||
|
||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
|
||||
}
|
||||
|
||||
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
|
||||
{
|
||||
var model = SampleReadModel(7, 1);
|
||||
model.AreaId = areaId;
|
||||
return model;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
|
||||
|
||||
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ValidationException>())
|
||||
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
|
||||
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
|
||||
VendorCompanyRosterWriteModel? captured = null;
|
||||
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||
.ReturnsAsync(ReadModelWithArea(2));
|
||||
|
||||
var result = await NewService(data).CreateRosterAsync(
|
||||
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
|
||||
"42",
|
||||
Admin,
|
||||
cts.Token);
|
||||
|
||||
captured!.AreaId.Should().Be(2);
|
||||
result.AreaId.Should().Be(2);
|
||||
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ReadModelWithArea(1));
|
||||
|
||||
foreach (int? requested in new int?[] { 3, null })
|
||||
{
|
||||
var dto = new ReconcileVendorRosterDTO
|
||||
{
|
||||
RowVersion = "AAAAAAAAD8I=",
|
||||
Name = "Acme",
|
||||
Email = "acme@example.com",
|
||||
AreaId = requested
|
||||
};
|
||||
|
||||
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||
}
|
||||
|
||||
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ReadModelWithArea(1));
|
||||
var captured = new List<VendorCompanyRosterWriteModel>();
|
||||
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
|
||||
.ReturnsAsync(ReadModelWithArea(1));
|
||||
|
||||
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||
{
|
||||
RowVersion = "AAAAAAAAD8I=",
|
||||
Name = "Acme Renamed",
|
||||
Email = "acme@example.com",
|
||||
AreaId = 1
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||
{
|
||||
RowVersion = "AAAAAAAAD8I=",
|
||||
Name = "Acme Renamed",
|
||||
Email = "acme@example.com"
|
||||
}, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
captured.Should().HaveCount(2);
|
||||
captured[0].AreaIdProvided.Should().BeTrue();
|
||||
captured[0].AreaId.Should().Be(1);
|
||||
captured[1].AreaIdProvided.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_AdminClearsAreaToUnassigned()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ReadModelWithArea(1));
|
||||
VendorCompanyRosterWriteModel? captured = null;
|
||||
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||
.ReturnsAsync(ReadModelWithArea(null));
|
||||
|
||||
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||
{
|
||||
RowVersion = "AAAAAAAAD8I=",
|
||||
Name = "Acme",
|
||||
Email = "acme@example.com",
|
||||
AreaId = null
|
||||
}, "42", Admin, CancellationToken.None);
|
||||
|
||||
captured!.AreaIdProvided.Should().BeTrue();
|
||||
captured.AreaId.Should().BeNull();
|
||||
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
|
||||
{
|
||||
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ReadModelWithArea(null));
|
||||
var dto = new AddTechniciansVendorRosterDTO
|
||||
{
|
||||
RowVersion = "AAAAAAAAD8I=",
|
||||
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
|
||||
};
|
||||
|
||||
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
|
||||
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
|
||||
{
|
||||
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
|
||||
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
|
||||
|
||||
omitted.AreaIdProvided.Should().BeFalse();
|
||||
cleared.AreaIdProvided.Should().BeTrue();
|
||||
cleared.AreaId.Should().BeNull();
|
||||
assigned.AreaIdProvided.Should().BeTrue();
|
||||
assigned.AreaId.Should().Be(3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ public class VendorControllerTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +156,7 @@ public class VendorControllerTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token))
|
||||
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
|
||||
{
|
||||
|
|
@ -203,6 +205,7 @@ public class VendorControllerTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
token), Times.Once);
|
||||
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
|
|
|
|||
|
|
@ -470,12 +470,14 @@ public class VendorDataServiceTests
|
|||
search: "alice",
|
||||
trades: new[] { "Plumbing" },
|
||||
locations: new[] { "Chicago, IL" },
|
||||
jobBuckets: new[] { "under-50" }).ToQueryString();
|
||||
jobBuckets: new[] { "under-50" },
|
||||
areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString();
|
||||
|
||||
sql.Should().Contain("GROUP BY");
|
||||
sql.Should().Contain("LEFT JOIN");
|
||||
sql.Should().Contain("TradeSpecialties");
|
||||
sql.Should().Contain("TotalJobs");
|
||||
sql.Should().Contain("AreaId");
|
||||
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
|
||||
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
|
||||
}
|
||||
|
|
@ -1044,4 +1046,69 @@ public class VendorDataServiceTests
|
|||
sql.Should().Contain("SELECT");
|
||||
sql.Should().Contain("VendorCompanies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var east = new Area { Id = 1, Name = "East", NormalizedName = "east" };
|
||||
var west = new Area { Id = 3, Name = "West", NormalizedName = "west" };
|
||||
context.Areas.AddRange(east, west);
|
||||
var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east };
|
||||
var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west };
|
||||
var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" };
|
||||
var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east };
|
||||
context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false },
|
||||
new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false },
|
||||
new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var eastResult = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
|
||||
eastResult.Items.Select(item => item.CompanyName)
|
||||
.Should().BeEquivalentTo("East Co", "East Only");
|
||||
eastResult.TotalCount.Should().Be(2);
|
||||
var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co");
|
||||
eastRow.AreaId.Should().Be(east.Id);
|
||||
eastRow.AreaName.Should().Be("East");
|
||||
eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East");
|
||||
|
||||
var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, isActive: true,
|
||||
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true });
|
||||
eastOrUnassigned.Items.Select(item => item.CompanyName)
|
||||
.Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co");
|
||||
eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull();
|
||||
|
||||
var eastAndBoston = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, isActive: true, locations: new[] { "Boston, MA" },
|
||||
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
|
||||
eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co");
|
||||
|
||||
var matchesNobody = await service.GetCompanyDirectoryPagedAsync(
|
||||
1, 50, isActive: true, areaFilter: new VendorAreaFilter());
|
||||
matchesNobody.TotalCount.Should().Be(0);
|
||||
matchesNobody.Items.Should().BeEmpty();
|
||||
|
||||
var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
|
||||
unfiltered.TotalCount.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveAreasAsync_ExcludesArchivedAreas()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Areas.AddRange(
|
||||
new Area { Id = 1, Name = "East", NormalizedName = "east" },
|
||||
new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None);
|
||||
|
||||
areas.Select(area => area.Name).Should().Equal("East");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ public class VendorServiceTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<VendorAreaFilter?>(),
|
||||
token))
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
|
|
@ -212,6 +213,7 @@ public class VendorServiceTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<VendorAreaFilter?>(),
|
||||
token), Times.Once);
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +230,7 @@ public class VendorServiceTests
|
|||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<VendorAreaFilter?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||
|
||||
|
|
@ -756,6 +759,8 @@ public class VendorServiceTests
|
|||
});
|
||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>());
|
||||
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||
|
||||
|
|
@ -794,6 +799,8 @@ public class VendorServiceTests
|
|||
});
|
||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>());
|
||||
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||
|
||||
|
|
@ -822,6 +829,8 @@ public class VendorServiceTests
|
|||
});
|
||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>());
|
||||
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||
|
||||
|
|
@ -849,6 +858,8 @@ public class VendorServiceTests
|
|||
SortOrder = index + 1
|
||||
})
|
||||
.ToList());
|
||||
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
|
||||
|
||||
|
|
@ -898,4 +909,96 @@ public class VendorServiceTests
|
|||
TradeCatalog.CanonicalTrades,
|
||||
opts => opts.WithStrictOrdering());
|
||||
}
|
||||
|
||||
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new() { Id = 1, Name = "East", NormalizedName = "east" },
|
||||
new() { Id = 3, Name = "West", NormalizedName = "west" }
|
||||
});
|
||||
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<VendorAreaFilter?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
|
||||
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
|
||||
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
|
||||
return data;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
|
||||
{
|
||||
VendorAreaFilter? captured = null;
|
||||
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||
|
||||
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
|
||||
|
||||
captured.Should().NotBeNull();
|
||||
captured!.AreaIds.Should().Equal(1);
|
||||
captured.IncludeUnassigned.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
|
||||
{
|
||||
VendorAreaFilter? captured = null;
|
||||
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||
|
||||
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
|
||||
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
|
||||
1, 10, areas: new[] { "4", "West", "-3" });
|
||||
|
||||
captured.Should().NotBeNull();
|
||||
captured!.AreaIds.Should().BeEmpty();
|
||||
captured.IncludeUnassigned.Should().BeFalse();
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
|
||||
{
|
||||
VendorAreaFilter? captured = new();
|
||||
var data = DirectoryDataWithAreas(filter => captured = filter);
|
||||
|
||||
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
|
||||
|
||||
captured.Should().BeNull();
|
||||
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<VendorCompany>());
|
||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>());
|
||||
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new() { Id = 3, Name = "West", NormalizedName = "west" },
|
||||
new() { Id = 1, Name = "East", NormalizedName = "east" },
|
||||
new() { Id = 4, Name = "california", NormalizedName = "california" }
|
||||
});
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
|
||||
|
||||
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
|
||||
(4, "california"), (1, "East"), (3, "West"));
|
||||
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while
|
||||
/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order,
|
||||
/// and an unknown id keeps the existing "ID not found!" response.
|
||||
/// </summary>
|
||||
public class WorkOrderGetByIdBindingTests
|
||||
{
|
||||
private const int ExistingId = 374;
|
||||
private const int UnknownId = 999_999;
|
||||
|
||||
private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController))
|
||||
.Where(cad => cad.ActionConstraints!
|
||||
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
|
||||
.Any(c => c.HttpMethods.Contains("GET")))
|
||||
.Single(cad => string.Equals(
|
||||
cad.AttributeRouteInfo?.Template?.Trim('/'),
|
||||
$"api/WorkOrder/{relativeTemplate}",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GetWorkorderById", "Query")]
|
||||
[InlineData("{id:int}", "Path")]
|
||||
public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource)
|
||||
{
|
||||
var action = ActionForTemplate(relativeTemplate);
|
||||
|
||||
var id = action.Parameters.Single(p => p.Name == "id");
|
||||
id.BindingInfo.Should().NotBeNull();
|
||||
id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource);
|
||||
}
|
||||
|
||||
private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController()
|
||||
{
|
||||
var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" };
|
||||
var service = new Mock<IWorkOrderService>();
|
||||
service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny<int?>())).ReturnsAsync(existing);
|
||||
service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny<int?>()))
|
||||
.ReturnsAsync((WorkOrderDetailReadModel?)null);
|
||||
|
||||
var controller = new WorkOrderController(
|
||||
service.Object,
|
||||
Mock.Of<IWorkOrderAccountResolver>(),
|
||||
Mock.Of<ILogger<WorkOrderController>>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
return (controller, existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_id_returns_the_same_work_order_through_both_routes()
|
||||
{
|
||||
var (controller, existing) = NewController();
|
||||
|
||||
var byQuery = await controller.GetWorkorderById(ExistingId);
|
||||
var byRoute = await controller.GetWorkorderByRouteId(ExistingId);
|
||||
|
||||
byQuery.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||
byRoute.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_id_keeps_the_not_found_response_through_both_routes()
|
||||
{
|
||||
var (controller, _) = NewController();
|
||||
|
||||
foreach (var result in new[]
|
||||
{
|
||||
await controller.GetWorkorderById(UnknownId),
|
||||
await controller.GetWorkorderByRouteId(UnknownId),
|
||||
})
|
||||
{
|
||||
var body = result.Should().BeOfType<BadRequestObjectResult>().Which.Value
|
||||
.Should().BeOfType<Response>().Subject;
|
||||
body.Status.Should().Be("Error");
|
||||
body.Message.Should().Be("ID not found!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
/// combination is registered more than once by different actions.
|
||||
///
|
||||
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
|
||||
/// GetWorkorderById) and the two shared controller-level base routes.
|
||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder)
|
||||
/// and the two shared controller-level base routes.
|
||||
/// </summary>
|
||||
public class WorkOrderRouteContractTests
|
||||
{
|
||||
|
|
@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests
|
|||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
||||
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||
/// come from 51 actions (Editworkorder binds two routes).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -17,17 +18,159 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("Stats")]
|
||||
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> GetStats(
|
||||
[FromQuery] DashboardStatsQueryDTO query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
|
||||
var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
total = stats.Total,
|
||||
open = stats.Open,
|
||||
notDispatched = stats.NotDispatched,
|
||||
completed = stats.Completed
|
||||
completed = stats.Completed,
|
||||
scheduledTomorrow = stats.ScheduledTomorrow,
|
||||
pendingUplifts = stats.PendingUplifts,
|
||||
avetaPending = stats.AvetaPending,
|
||||
breakdown = stats.Breakdown,
|
||||
dueCount = stats.DueCount,
|
||||
completedDueCount = stats.CompletedDueCount,
|
||||
completionRate = stats.CompletionRate,
|
||||
averageResolutionDays = stats.AverageResolutionDays,
|
||||
statusDistribution = stats.StatusDistribution
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("Workload")]
|
||||
public async Task<IActionResult> GetWorkload(
|
||||
[FromQuery] DashboardStatsQueryDTO query,
|
||||
[FromQuery] int page = 1,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _dashboardService.GetWorkloadAsync(
|
||||
User, query, page, cancellationToken));
|
||||
}
|
||||
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||
when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("Performance")]
|
||||
public async Task<IActionResult> GetPerformance(
|
||||
[FromQuery] DashboardStatsQueryDTO query,
|
||||
[FromQuery] int page = 1,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _dashboardService.GetPerformanceAsync(
|
||||
User, query, page, cancellationToken));
|
||||
}
|
||||
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||
when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("Regions")]
|
||||
public async Task<IActionResult> GetRegions(
|
||||
[FromQuery] DashboardStatsQueryDTO query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken));
|
||||
}
|
||||
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||
when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("Trend")]
|
||||
public async Task<IActionResult> GetTrend(
|
||||
[FromQuery] DashboardTrendQueryDTO query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
granularity = trend.Granularity,
|
||||
today = trend.Today,
|
||||
buckets = trend.Buckets.Select(bucket => new
|
||||
{
|
||||
date = bucket.Date,
|
||||
label = bucket.Label,
|
||||
total = bucket.Total,
|
||||
open = bucket.Open,
|
||||
completed = bucket.Completed,
|
||||
canceled = bucket.Canceled,
|
||||
overdue = bucket.Overdue,
|
||||
isCurrent = bucket.IsCurrent
|
||||
})
|
||||
});
|
||||
}
|
||||
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||
when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("VendorInsights")]
|
||||
public async Task<IActionResult> GetVendorInsights(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken));
|
||||
}
|
||||
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||
when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = ex.Code,
|
||||
message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("GetLocationList")]
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
||||
|
||||
var data = pagedResult.Items.Select(l => new
|
||||
{
|
||||
|
|
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = l.Email,
|
||||
Status = l.Status,
|
||||
AccountId = l.AccountId,
|
||||
ClientName = l.AccountName,
|
||||
AccountName = l.AccountName,
|
||||
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
|
||||
});
|
||||
|
||||
|
|
|
|||
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal file
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/services")]
|
||||
public class ServicesRegistryController : Controller
|
||||
{
|
||||
private readonly IServicesRegistryService _servicesRegistryService;
|
||||
|
||||
public ServicesRegistryController(IServicesRegistryService servicesRegistryService)
|
||||
{
|
||||
_servicesRegistryService = servicesRegistryService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken);
|
||||
return Ok(services);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex)
|
||||
{
|
||||
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken);
|
||||
if (service == null)
|
||||
return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." });
|
||||
|
||||
return Ok(service);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken);
|
||||
return StatusCode(StatusCodes.Status201Created, created);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return MapForbidden(ex);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex)
|
||||
{
|
||||
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken);
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return MapForbidden(ex);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex)
|
||||
{
|
||||
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/deactivate")]
|
||||
public async Task<IActionResult> Deactivate(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken);
|
||||
return Ok(new { message = "Service deactivated" });
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return MapForbidden(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult MapForbidden(ServicesRegistryValidationException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
36
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/team-members")]
|
||||
public sealed class TeamMemberController : ControllerBase
|
||||
{
|
||||
private readonly ITeamMemberService _teamMemberService;
|
||||
|
||||
public TeamMemberController(ITeamMemberService teamMemberService)
|
||||
{
|
||||
_teamMemberService = teamMemberService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
CreateTeamMemberRequestDTO request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _teamMemberService.CreateAsync(request, User, cancellationToken);
|
||||
if (!outcome.Success)
|
||||
{
|
||||
if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal))
|
||||
return Forbid();
|
||||
|
||||
return BadRequest(new { message = outcome.Error });
|
||||
}
|
||||
|
||||
return Ok(outcome.Member);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/User/{userId}/Permissions")]
|
||||
public sealed class TeamPermissionController : ControllerBase
|
||||
{
|
||||
private readonly ITeamPermissionService _permissionService;
|
||||
|
||||
public TeamPermissionController(ITeamPermissionService permissionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetProfile(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
[HttpPut("{permissionKey}")]
|
||||
public async Task<IActionResult> SetOverride(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
[FromBody] SetTeamPermissionOverrideDTO request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.IsDefined(request.State))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." });
|
||||
|
||||
var result = await _permissionService.SetOverrideAsync(
|
||||
userId,
|
||||
permissionKey,
|
||||
request.State,
|
||||
User,
|
||||
cancellationToken);
|
||||
return ToActionResult(result);
|
||||
}
|
||||
|
||||
private static IActionResult ToActionResult(
|
||||
TeamPermissionResult<TeamPermissionProfileDTO> result)
|
||||
{
|
||||
return result.Status switch
|
||||
{
|
||||
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
|
||||
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
|
||||
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
|
||||
new Response { Status = "Error", Message = "User not found." }),
|
||||
TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult(
|
||||
new Response { Status = "Error", Message = "Unknown permission key." }),
|
||||
_ => new BadRequestObjectResult(
|
||||
new Response { Status = "Error", Message = "Unable to update permissions." })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/revoke")]
|
||||
public async Task<IActionResult> Revoke(
|
||||
int id,
|
||||
[FromBody] DecisionRequest? body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _upliftService.RevokeAsync(
|
||||
User,
|
||||
id,
|
||||
body?.Note ?? string.Empty,
|
||||
cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to revoke this uplift") });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be revoked") });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("can-approve")]
|
||||
public IActionResult CanApprove([FromQuery] int tier)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper;
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
|
@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
|
||||
var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
|
||||
return Ok(roster);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
|
|
@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||
}
|
||||
catch (VendorAreaAssignmentForbiddenException)
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||
}
|
||||
catch (VendorRosterDuplicateNameException dup)
|
||||
{
|
||||
return Conflict(new
|
||||
|
|
@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
|
||||
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
|
||||
return Ok(roster);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
|
|
@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||
}
|
||||
catch (VendorAreaAssignmentForbiddenException)
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||
|
|
@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
|
||||
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
|
||||
return Ok(roster);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
|
|
@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||
}
|
||||
catch (VendorAreaAssignmentForbiddenException)
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
new Response { Status = "Error", Message = "Only administrators can assign a vendor company Area." });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[FromQuery] string[]? trades = null,
|
||||
[FromQuery] string[]? locations = null,
|
||||
[FromQuery] string[]? jobBuckets = null,
|
||||
[FromQuery] string[]? areas = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
|
||||
|
|
@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
trades,
|
||||
locations,
|
||||
jobBuckets,
|
||||
areas,
|
||||
cancellationToken);
|
||||
|
||||
var data = pagedResult.Items.Select(v => new
|
||||
|
|
@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
v.Notes,
|
||||
v.IsActive,
|
||||
v.TotalJobs,
|
||||
v.AreaId,
|
||||
v.AreaName,
|
||||
Technicians = v.Technicians.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
|
|
|
|||
|
|
@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
// SH-374: two actions, one per route, so each binds id from the source its route provides.
|
||||
// A single action carrying both routes inferred id as [FromRoute] and the query route got 0.
|
||||
[HttpGet("GetWorkorderById")]
|
||||
public async Task<IActionResult> GetWorkorderById(int id)
|
||||
public Task<IActionResult> GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id);
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public Task<IActionResult> GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id);
|
||||
|
||||
private async Task<IActionResult> GetWorkorderDetail(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -222,6 +222,13 @@ app.UseAuthorization();
|
|||
|
||||
app.MapControllers();
|
||||
|
||||
using (var ownerScope = app.Services.CreateScope())
|
||||
{
|
||||
await ownerScope.ServiceProvider
|
||||
.GetRequiredService<IAccountOwnerDesignationService>()
|
||||
.EnsureConfiguredOwnerAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
//if (app.Environment.IsDevelopment())
|
||||
//{
|
||||
// using var scope = app.Services.CreateScope();
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(w => w.InternalWONumber)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasOne(w => w.ServiceDefinition)
|
||||
.WithMany()
|
||||
.HasForeignKey(w => w.ServiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
|
@ -170,6 +176,12 @@ namespace Data.SeaHavenIndustries
|
|||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.AccountId);
|
||||
|
||||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.IsAccountOwner)
|
||||
.IsUnique()
|
||||
.HasFilter("IsAccountOwner = 1")
|
||||
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner");
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasIndex(c => c.NormalizedName)
|
||||
.IsUnique();
|
||||
|
|
@ -206,6 +218,118 @@ namespace Data.SeaHavenIndustries
|
|||
builder.Entity<Trade>()
|
||||
.HasIndex(t => t.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
// Bounded lengths keep the unique NormalizedName key SQL
|
||||
// Server-indexable (nvarchar(max) is not).
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.Name)
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.NormalizedName)
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.Trade)
|
||||
.HasMaxLength(64);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.Trade);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.IconKey)
|
||||
.HasMaxLength(64);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.IsActive);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasOne(s => s.CompletionDocTemplate)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.CompletionDocTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.CompletionDocTemplateId);
|
||||
|
||||
builder.Entity<ServiceWorkOrderType>()
|
||||
.HasIndex(t => new { t.ServiceId, t.WorkOrderType })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<ServiceWorkOrderType>()
|
||||
.HasOne(t => t.Service)
|
||||
.WithMany(s => s.SupportedWorkOrderTypes)
|
||||
.HasForeignKey(t => t.ServiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// SH-278 Area catalogue: bounded names so the unique key is indexable.
|
||||
builder.Entity<Area>()
|
||||
.Property(a => a.Name)
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Entity<Area>()
|
||||
.Property(a => a.NormalizedName)
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Entity<Area>()
|
||||
.HasIndex(a => a.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasOne(c => c.Area)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.AreaId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Per-person team permission overrides. Composite primary key
|
||||
// (UserId + PermissionKey) plus an explicitly named unique composite
|
||||
// index; missing rows behave as Unset.
|
||||
builder.Entity<UserPermissionOverride>(entity =>
|
||||
{
|
||||
entity.HasKey(o => new { o.UserId, o.PermissionKey });
|
||||
|
||||
entity.Property(o => o.UserId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
entity.Property(o => o.PermissionKey)
|
||||
.HasMaxLength(64);
|
||||
|
||||
entity.Property(o => o.State)
|
||||
.IsRequired();
|
||||
|
||||
entity.HasOne(o => o.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(o => o.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasIndex(o => new { o.UserId, o.PermissionKey })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
});
|
||||
|
||||
builder.Entity<UserServiceArea>(entity =>
|
||||
{
|
||||
entity.HasKey(area => new { area.UserId, area.Area });
|
||||
|
||||
entity.Property(area => area.UserId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
entity.Property(area => area.Area)
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.HasOne(area => area.User)
|
||||
.WithMany(user => user.ServiceAreas)
|
||||
.HasForeignKey(area => area.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(area => new { area.UserId, area.Area })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserServiceAreas_UserId_Area");
|
||||
});
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -228,6 +352,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Trade> Trades { get; set; }
|
||||
public DbSet<Area> Areas { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||
|
|
@ -241,6 +366,8 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
||||
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
||||
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
|
||||
public DbSet<Service> Services { get; set; }
|
||||
public DbSet<ServiceWorkOrderType> ServiceWorkOrderTypes { get; set; }
|
||||
|
||||
// New DbSets for added entities
|
||||
public DbSet<Accounts> Accounts { get; set; }
|
||||
|
|
@ -259,6 +386,8 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<Department> Departments { get; set; }
|
||||
public DbSet<JobTitle> JobTitles { get; set; }
|
||||
public DbSet<Region> Regions { get; set; }
|
||||
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
|
|
@ -338,12 +467,16 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Initials { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public int? Type { get; set; } // 1 for users 0 for admin
|
||||
public bool IsAccountOwner { get; set; }
|
||||
public bool? PendingRegistration { get; set; }
|
||||
public DateTime? PendingRegistrationCreatedDate { get; set; }
|
||||
/// <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<WorkOrder>? WorkOrders { get; set; }
|
||||
public ICollection<UserServiceArea> ServiceAreas { get; set; } = new List<UserServiceArea>();
|
||||
}
|
||||
public class FullAuditEntity
|
||||
{
|
||||
|
|
|
|||
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-person team permission override, keyed by user and canonical
|
||||
/// permission key. Explicit Allow/Deny rows take precedence over role defaults;
|
||||
/// <see cref="UserPermissionState.Unset"/> (and a missing row) falls back to
|
||||
/// the role default.
|
||||
/// </summary>
|
||||
public class UserPermissionOverride
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public string PermissionKey { get; set; } = null!;
|
||||
public UserPermissionState State { get; set; } = UserPermissionState.Unset;
|
||||
public virtual ApplicationUser? User { get; set; }
|
||||
}
|
||||
}
|
||||
9
Data.SeaHavenIndustries/Auth/UserServiceArea.cs
Normal file
9
Data.SeaHavenIndustries/Auth/UserServiceArea.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Data.SeaHavenIndustries;
|
||||
|
||||
/// <summary>Canonical service area assigned to a team member.</summary>
|
||||
public sealed class UserServiceArea
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public string Area { get; set; } = null!;
|
||||
public ApplicationUser User { get; set; } = null!;
|
||||
}
|
||||
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// Tri-state state of a per-person team permission override.
|
||||
/// A missing row behaves the same as <see cref="Unset"/>.
|
||||
/// </summary>
|
||||
public enum UserPermissionState
|
||||
{
|
||||
Unset = 0,
|
||||
Allow = 1,
|
||||
Deny = 2
|
||||
}
|
||||
}
|
||||
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
3977
Data.SeaHavenIndustries/Migrations/20260916140128_SH278_VendorCompanyArea.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,88 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH278_VendorCompanyArea : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AreaId",
|
||||
table: "VendorCompanies",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Areas",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Areas", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompanies_AreaId",
|
||||
table: "VendorCompanies",
|
||||
column: "AreaId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Areas_NormalizedName",
|
||||
table: "Areas",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
// SH-278 v1 seed of the four dispatcher territories confirmed by Product.
|
||||
// Names match the merged SH-273 Sites mapping (East / Central / West /
|
||||
// California). Ids are stable; later renames change only the display name.
|
||||
// Existing vendor companies stay Unassigned (no backfill).
|
||||
migrationBuilder.InsertData(
|
||||
table: "Areas",
|
||||
columns: new[] { "Id", "Name", "NormalizedName", "IsActive" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "East", "east", true },
|
||||
{ 2, "Central", "central", true },
|
||||
{ 3, "West", "west", true },
|
||||
{ 4, "California", "california", true }
|
||||
});
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCompanies_Areas_AreaId",
|
||||
table: "VendorCompanies",
|
||||
column: "AreaId",
|
||||
principalTable: "Areas",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCompanies_Areas_AreaId",
|
||||
table: "VendorCompanies");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Areas");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCompanies_AreaId",
|
||||
table: "VendorCompanies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AreaId",
|
||||
table: "VendorCompanies");
|
||||
}
|
||||
}
|
||||
}
|
||||
4074
Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs
generated
Normal file
4074
Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,162 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH303_ServicesRegistry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Services",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Trade = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
IconKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
RequiresCompletionDocument = table.Column<bool>(type: "bit", nullable: false),
|
||||
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Services", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Services_CompletionDocTemplates_CompletionDocTemplateId",
|
||||
column: x => x.CompletionDocTemplateId,
|
||||
principalTable: "CompletionDocTemplates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServiceWorkOrderTypes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ServiceId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderType = table.Column<int>(type: "int", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServiceWorkOrderTypes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ServiceWorkOrderTypes_Services_ServiceId",
|
||||
column: x => x.ServiceId,
|
||||
principalTable: "Services",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_CompletionDocTemplateId",
|
||||
table: "Services",
|
||||
column: "CompletionDocTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_IsActive",
|
||||
table: "Services",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_NormalizedName",
|
||||
table: "Services",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_Trade",
|
||||
table: "Services",
|
||||
column: "Trade");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServiceWorkOrderTypes_ServiceId_WorkOrderType",
|
||||
table: "ServiceWorkOrderTypes",
|
||||
columns: new[] { "ServiceId", "WorkOrderType" },
|
||||
unique: true);
|
||||
|
||||
// Additive seed: one active service per active Problem dropdown
|
||||
// value already stored in the database (never the prototype's demo
|
||||
// leaves). Idempotent via NormalizedName NOT EXISTS; dropdown rows are
|
||||
// only read, never altered. Repeated source values collapse onto a
|
||||
// deterministic MIN(Value) representative per normalized key.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO [Services] ([Name], [NormalizedName], [Trade], [IconKey], [RequiresCompletionDocument], [CompletionDocTemplateId], [IsActive], [IsDeleted], [createdby])
|
||||
SELECT seeded.[Name], seeded.[NormalizedName], seeded.[Trade],
|
||||
CASE seeded.[Trade]
|
||||
WHEN N'Plumbing & Water Systems' THEN N'plumbing-water-systems'
|
||||
WHEN N'Electrical' THEN N'electrical'
|
||||
WHEN N'HVAC' THEN N'hvac'
|
||||
WHEN N'Fire & Life Safety' THEN N'fire-life-safety'
|
||||
ELSE N'general-building-handyman'
|
||||
END,
|
||||
0, NULL, 1, 0, N'sh303-migration'
|
||||
FROM (
|
||||
SELECT agg.[Name], agg.[NormalizedName],
|
||||
CASE
|
||||
WHEN agg.[Name] IN (N'Leak', N'Clogged') THEN N'Plumbing & Water Systems'
|
||||
WHEN agg.[Name] = N'No Power' THEN N'Electrical'
|
||||
WHEN agg.[Name] IN (N'Not Cooling', N'Not Heating') THEN N'HVAC'
|
||||
WHEN agg.[Name] = N'Alarm Fault' THEN N'Fire & Life Safety'
|
||||
ELSE N'General Building & Handyman'
|
||||
END AS [Trade]
|
||||
FROM (
|
||||
SELECT MIN(src.[Value]) AS [Name],
|
||||
LOWER(LTRIM(RTRIM(src.[Value]))) AS [NormalizedName]
|
||||
FROM [DropdownOptions] src
|
||||
WHERE src.[Category] = N'Problem'
|
||||
AND src.[IsActive] = 1
|
||||
GROUP BY LOWER(LTRIM(RTRIM(src.[Value])))
|
||||
) agg
|
||||
) seeded
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM [Services] s WHERE s.[NormalizedName] = seeded.[NormalizedName]
|
||||
);
|
||||
|
||||
INSERT INTO [ServiceWorkOrderTypes] ([ServiceId], [WorkOrderType], [IsDeleted])
|
||||
SELECT s.[Id], types.[WorkOrderType], 0
|
||||
FROM [Services] s
|
||||
CROSS JOIN (VALUES (2), (6), (3)) AS types([WorkOrderType])
|
||||
WHERE s.[createdby] = N'sh303-migration'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM [ServiceWorkOrderTypes] x
|
||||
WHERE x.[ServiceId] = s.[Id]
|
||||
AND x.[WorkOrderType] = types.[WorkOrderType]
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServiceWorkOrderTypes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Services");
|
||||
}
|
||||
}
|
||||
}
|
||||
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,46 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH327_UserPermissionOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserPermissionOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
PermissionKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
State = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserPermissionOverrides", x => new { x.UserId, x.PermissionKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserPermissionOverrides_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserPermissionOverrides_UserId_PermissionKey",
|
||||
table: "UserPermissionOverrides",
|
||||
columns: new[] { "UserId", "PermissionKey" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserPermissionOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,73 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH187_WorkOrderServiceLink : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ServiceId",
|
||||
table: "workOrders",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ServiceNameSnapshot",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE w
|
||||
SET
|
||||
w.[ServiceNameSnapshot] = LEFT(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')), 200),
|
||||
w.[ServiceId] = s.[Id]
|
||||
FROM [workOrders] w
|
||||
LEFT JOIN [Services] s
|
||||
ON (s.[IsDeleted] <> 1 OR s.[IsDeleted] IS NULL)
|
||||
AND s.[NormalizedName] = LOWER(LTRIM(RTRIM(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')))))
|
||||
WHERE w.[ServiceNameSnapshot] IS NULL;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_ServiceId",
|
||||
table: "workOrders",
|
||||
column: "ServiceId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_workOrders_Services_ServiceId",
|
||||
table: "workOrders",
|
||||
column: "ServiceId",
|
||||
principalTable: "Services",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_workOrders_Services_ServiceId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_ServiceId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ServiceId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ServiceNameSnapshot",
|
||||
table: "workOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
4018
Data.SeaHavenIndustries/Migrations/20260916211933_SH329_PrimaryAccountOwner.Designer.cs
generated
Normal file
4018
Data.SeaHavenIndustries/Migrations/20260916211933_SH329_PrimaryAccountOwner.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH329_PrimaryAccountOwner : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAccountOwner",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUsers_IsAccountOwner",
|
||||
table: "AspNetUsers",
|
||||
column: "IsAccountOwner",
|
||||
unique: true,
|
||||
filter: "IsAccountOwner = 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AspNetUsers_IsAccountOwner",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAccountOwner",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
4056
Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs
generated
Normal file
4056
Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH325_PendingTeamMember : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PendingRegistration",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "PendingRegistrationCreatedDate",
|
||||
table: "AspNetUsers",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserServiceAreas",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
Area = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserServiceAreas", x => new { x.UserId, x.Area });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserServiceAreas_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserServiceAreas_UserId_Area",
|
||||
table: "UserServiceAreas",
|
||||
columns: new[] { "UserId", "Area" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserServiceAreas");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PendingRegistration",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PendingRegistrationCreatedDate",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +220,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Initials")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsAccountOwner")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -246,6 +249,12 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("PendingRegistration")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("PendingRegistrationCreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -275,6 +284,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("IsAccountOwner")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner")
|
||||
.HasFilter("IsAccountOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
|
|
@ -286,6 +300,35 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Area", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Areas");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1762,6 +1805,121 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Regions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("CompletionDocTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("IconKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<bool>("RequiresCompletionDocument")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletionDocTemplateId");
|
||||
|
||||
b.HasIndex("IsActive");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Trade");
|
||||
|
||||
b.ToTable("Services");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("ServiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "WorkOrderType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ServiceWorkOrderTypes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -2043,6 +2201,47 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Trades");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("PermissionKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("UserId", "PermissionKey");
|
||||
|
||||
b.HasIndex("UserId", "PermissionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
|
||||
b.ToTable("UserPermissionOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Area")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.HasKey("UserId", "Area");
|
||||
|
||||
b.HasIndex("UserId", "Area")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserServiceAreas_UserId_Area");
|
||||
|
||||
b.ToTable("UserServiceAreas");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -2237,6 +2436,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("AreaId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -2294,6 +2496,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AreaId");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
|
|
@ -2592,6 +2796,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<int?>("ServiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ServiceNameSnapshot")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("ServiceNotes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
|
@ -2676,6 +2887,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("ServiceId");
|
||||
|
||||
b.HasIndex("SiteCode")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
|
|
@ -3521,6 +3734,27 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CompletionDocTemplateId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("CompletionDocTemplate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Service", "Service")
|
||||
.WithMany("SupportedWorkOrderTypes")
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
|
||||
|
|
@ -3582,6 +3816,28 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("POC");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||
.WithMany("ServiceAreas")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||
|
|
@ -3614,6 +3870,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Area", "Area")
|
||||
.WithMany()
|
||||
.HasForeignKey("AreaId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Area");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
||||
|
|
@ -3663,6 +3929,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasForeignKey("PrimaryDispatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("AssignToUser");
|
||||
|
|
@ -3670,6 +3941,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Locations");
|
||||
|
||||
b.Navigation("PrimaryDispatch");
|
||||
|
||||
b.Navigation("ServiceDefinition");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -3816,6 +4089,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("ServiceAreas");
|
||||
|
||||
b.Navigation("Templates");
|
||||
|
||||
b.Navigation("WorkOrders");
|
||||
|
|
@ -3889,6 +4164,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Employees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||
{
|
||||
b.Navigation("SupportedWorkOrderTypes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
15
Data.SeaHavenIndustries/Models/Area.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
// SH-278: dispatcher territory catalogue for this Sea Haven deployment. Vendor
|
||||
// companies carry zero or one Area by stable id; the display name is separate and
|
||||
// may be renamed without changing assignments. Area is selected independently and
|
||||
// is never derived from a company's State. Rows are organization-wide: vendor
|
||||
// companies are not scoped to a customer Account.
|
||||
public class Area
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string NormalizedName { get; set; } = "";
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
29
Data.SeaHavenIndustries/Models/Service.cs
Normal file
29
Data.SeaHavenIndustries/Models/Service.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
// Global (tenant-scope free) Services registry. Services are never
|
||||
// hard deleted; deactivation is a soft IsActive state change so historical
|
||||
// work-order references remain resolvable.
|
||||
public class Service : FullAuditEntity
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string NormalizedName { get; set; } = "";
|
||||
public string Trade { get; set; } = "";
|
||||
public string IconKey { get; set; } = "";
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public int? CompletionDocTemplateId { get; set; }
|
||||
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public virtual ICollection<ServiceWorkOrderType> SupportedWorkOrderTypes { get; set; } = new List<ServiceWorkOrderType>();
|
||||
}
|
||||
|
||||
// Supported work-order types (PM, Reactive, Emergency) stored as an
|
||||
// explicitly queryable relation so work-order features can filter by type.
|
||||
public class ServiceWorkOrderType : FullAuditEntity
|
||||
{
|
||||
public int ServiceId { get; set; }
|
||||
public virtual Service? Service { get; set; }
|
||||
public WorkOrderType WorkOrderType { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
|
|||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned.
|
||||
public int? AreaId { get; set; }
|
||||
[ForeignKey(nameof(AreaId))]
|
||||
public Area? Area { get; set; }
|
||||
|
||||
[Timestamp]
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ namespace Data.SeaHavenIndustries
|
|||
public string? PONumber { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? VendorCompanyName { get; set; }
|
||||
// Approval queue read contract: flattened work-order context.
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSiteCode { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public string? RequestedByFirstName { get; set; }
|
||||
public string? RequestedByLastName { get; set; }
|
||||
public string? DecidedByFirstName { get; set; }
|
||||
public string? DecidedByLastName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
|
|
@ -97,6 +106,8 @@ namespace Data.SeaHavenIndustries
|
|||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
public int AttachmentCount { get; set; }
|
||||
public bool WorkOrderClosed { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForWorkOrderData : UpliftForDispatchData
|
||||
|
|
@ -130,6 +141,16 @@ namespace Data.SeaHavenIndustries
|
|||
public bool EvidenceScanPassed { get; set; }
|
||||
}
|
||||
|
||||
// Approval queue read contract: per-work-order exposure totals. Approved exposure is
|
||||
// the sum of uplift amounts that were auto-approved or admin-approved on the work
|
||||
// order; pending, rejected, cancelled/withdrawn, and revoked requests are excluded.
|
||||
public class WorkOrderUpliftExposureData
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public decimal AutoApprovedTotal { get; set; }
|
||||
public decimal AdminApprovedTotal { get; set; }
|
||||
}
|
||||
|
||||
// SH-101: internal evidence-download projection. Server-side join enforces that the
|
||||
// returned document is the one linked to this specific uplift request and dispatch.
|
||||
public class UpliftEvidenceDownloadData
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries
|
|||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? Service { get; set; }
|
||||
public int? ServiceId { get; set; }
|
||||
[MaxLength(200)]
|
||||
public string? ServiceNameSnapshot { get; set; }
|
||||
[ForeignKey(nameof(ServiceId))]
|
||||
public virtual Service? ServiceDefinition { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
|
|
|
|||
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Dto;
|
||||
|
||||
public sealed class TeamPermissionUserData
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
public IReadOnlyDictionary<string, UserPermissionState> Overrides { get; init; }
|
||||
= new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ namespace SeaHaven.DataServices.Dto
|
|||
public string? Name { get; init; }
|
||||
public DateTime? CreatedDate { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public bool? PendingRegistration { get; init; }
|
||||
public IReadOnlyList<string> ServiceAreas { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class UserProfileData
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.OriginalDate,
|
||||
w.OriginalWeek,
|
||||
w.WorkOrderType,
|
||||
w.ServiceId,
|
||||
w.ServiceNameSnapshot,
|
||||
w.SiteCode,
|
||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||
WoPocName = w.PocName,
|
||||
|
|
@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.CarriedOver,
|
||||
w.IsAddOn,
|
||||
w.WorkOrderType,
|
||||
w.ServiceId,
|
||||
w.ServiceNameSnapshot,
|
||||
frozenSite?.Code ?? w.SiteCode,
|
||||
frozenSite?.Label ?? w.LocationName,
|
||||
pocName,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,40 @@ namespace SeaHaven.DataServices.Helpers
|
|||
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyRegionFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? regions)
|
||||
{
|
||||
if (regions == null || regions.Count == 0)
|
||||
return query;
|
||||
|
||||
var normalized = regions
|
||||
.Where(region => !string.IsNullOrWhiteSpace(region))
|
||||
.Select(region => region.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (normalized.Count == 0)
|
||||
return query;
|
||||
|
||||
var knownRegions = normalized
|
||||
.Where(region => WorkOrderBoardRegions.Names.Contains(region) && region != "Unmapped/Other")
|
||||
.SelectMany(WorkOrderBoardRegions.StorageValuesFor)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var includeUnmapped = normalized.Contains("Unmapped/Other", StringComparer.Ordinal);
|
||||
var hasKnownRegion = knownRegions.Count > 0;
|
||||
|
||||
if (!hasKnownRegion && !includeUnmapped)
|
||||
return query.Where(_ => false);
|
||||
|
||||
return query.Where(w =>
|
||||
(hasKnownRegion && w.Locations != null && w.Locations.State != null && knownRegions.Contains(w.Locations.State))
|
||||
|| (includeUnmapped && (w.Locations == null
|
||||
|| w.Locations.State == null
|
||||
|| w.Locations.State == ""
|
||||
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyStatusFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<LifecycleStatus>? statuses)
|
||||
|
|
|
|||
82
SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs
Normal file
82
SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardRegions
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Names =
|
||||
["East", "Central", "West", "California", "Unmapped/Other"];
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string[]> RegionStateCodes =
|
||||
new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["East"] = ["CT", "DE", "GA", "ME", "MD", "MA", "NH", "NJ", "NY", "NC", "PA", "RI", "SC", "VT", "VA", "WV"],
|
||||
["Central"] = ["AL", "AR", "FL", "IL", "IN", "IA", "KS", "KY", "LA", "MI", "MN", "MS", "MO", "NE", "ND", "OH", "OK", "SD", "TN", "WI"],
|
||||
["West"] = ["AK", "AZ", "CO", "HI", "ID", "MT", "NV", "NM", "OR", "TX", "UT", "WA", "WY"],
|
||||
["California"] = ["CA"]
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> StateNames =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["AL"] = "ALABAMA",
|
||||
["AK"] = "ALASKA",
|
||||
["AZ"] = "ARIZONA",
|
||||
["AR"] = "ARKANSAS",
|
||||
["CA"] = "CALIFORNIA",
|
||||
["CO"] = "COLORADO",
|
||||
["CT"] = "CONNECTICUT",
|
||||
["DE"] = "DELAWARE",
|
||||
["FL"] = "FLORIDA",
|
||||
["GA"] = "GEORGIA",
|
||||
["HI"] = "HAWAII",
|
||||
["ID"] = "IDAHO",
|
||||
["IL"] = "ILLINOIS",
|
||||
["IN"] = "INDIANA",
|
||||
["IA"] = "IOWA",
|
||||
["KS"] = "KANSAS",
|
||||
["KY"] = "KENTUCKY",
|
||||
["LA"] = "LOUISIANA",
|
||||
["ME"] = "MAINE",
|
||||
["MD"] = "MARYLAND",
|
||||
["MA"] = "MASSACHUSETTS",
|
||||
["MI"] = "MICHIGAN",
|
||||
["MN"] = "MINNESOTA",
|
||||
["MS"] = "MISSISSIPPI",
|
||||
["MO"] = "MISSOURI",
|
||||
["MT"] = "MONTANA",
|
||||
["NE"] = "NEBRASKA",
|
||||
["NV"] = "NEVADA",
|
||||
["NH"] = "NEW HAMPSHIRE",
|
||||
["NJ"] = "NEW JERSEY",
|
||||
["NM"] = "NEW MEXICO",
|
||||
["NY"] = "NEW YORK",
|
||||
["NC"] = "NORTH CAROLINA",
|
||||
["ND"] = "NORTH DAKOTA",
|
||||
["OH"] = "OHIO",
|
||||
["OK"] = "OKLAHOMA",
|
||||
["OR"] = "OREGON",
|
||||
["PA"] = "PENNSYLVANIA",
|
||||
["RI"] = "RHODE ISLAND",
|
||||
["SC"] = "SOUTH CAROLINA",
|
||||
["SD"] = "SOUTH DAKOTA",
|
||||
["TN"] = "TENNESSEE",
|
||||
["TX"] = "TEXAS",
|
||||
["UT"] = "UTAH",
|
||||
["VT"] = "VERMONT",
|
||||
["VA"] = "VIRGINIA",
|
||||
["WA"] = "WASHINGTON",
|
||||
["WV"] = "WEST VIRGINIA",
|
||||
["WI"] = "WISCONSIN",
|
||||
["WY"] = "WYOMING"
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<string> StorageValuesFor(string region)
|
||||
=> RegionStateCodes.TryGetValue(region, out var codes)
|
||||
? codes.SelectMany(code => new[] { code, StateNames[code] }).ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
public static IReadOnlySet<string> KnownStorageValues { get; } =
|
||||
RegionStateCodes.Values
|
||||
.SelectMany(codes => codes.SelectMany(code => new[] { code, StateNames[code] }))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class AccountOwnerDataService : IAccountOwnerDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public AccountOwnerDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureConfiguredOwnerAsync(
|
||||
string? configuredUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerId = string.IsNullOrWhiteSpace(configuredUserId)
|
||||
? null
|
||||
: configuredUserId.Trim();
|
||||
|
||||
if (ownerId is not null)
|
||||
{
|
||||
var configuredUserExists = await _context.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == ownerId, cancellationToken);
|
||||
|
||||
if (!configuredUserExists)
|
||||
throw new InvalidOperationException("The configured account owner user was not found.");
|
||||
}
|
||||
|
||||
var ownerRows = await _context.Users
|
||||
.Where(user => user.IsAccountOwner || (ownerId != null && user.Id == ownerId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var changed = false;
|
||||
foreach (var user in ownerRows)
|
||||
{
|
||||
var shouldBeOwner = ownerId is not null && user.Id == ownerId;
|
||||
if (user.IsAccountOwner != shouldBeOwner)
|
||||
{
|
||||
user.IsAccountOwner = shouldBeOwner;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
|
|
@ -13,9 +15,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken)
|
||||
public async Task<DashboardCounts> GetWorkOrderCountsAsync(
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.workOrders.Where(w => w.istemplate != true);
|
||||
if (accountId is int scopedAccountId)
|
||||
{
|
||||
query = query.Where(w => w.AccountId == scopedAccountId);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);
|
||||
|
|
@ -30,5 +38,196 @@ namespace SeaHaven.DataServices.Implementation
|
|||
Completed = completed
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId is int scopedAccountId)
|
||||
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
|
||||
|
||||
if (dispatcherId != null)
|
||||
query = query.Where(w => w.AssignTo == dispatcherId);
|
||||
|
||||
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||
{
|
||||
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||
query = query.Where(w =>
|
||||
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
|
||||
}
|
||||
|
||||
return await query
|
||||
.Select(w => new DashboardWorkOrder(
|
||||
w.Id,
|
||||
w.AssignTo,
|
||||
w.WorkOrderType,
|
||||
w.LifecycleStatus,
|
||||
w.LegacyStatus ?? w.Status,
|
||||
w.ScheduledDate,
|
||||
w.OriginalDate,
|
||||
w.CompletedDate,
|
||||
w.Locations == null ? null : w.Locations.State,
|
||||
w.RescheduleCount,
|
||||
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|
||||
? null
|
||||
: w.PrimaryDispatch.Vendor.CompanyId,
|
||||
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|
||||
|| w.PrimaryDispatch.Vendor.Company == null
|
||||
? null
|
||||
: w.PrimaryDispatch.Vendor.Company.Name,
|
||||
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
&& w.PrimaryDispatch.Vendor.IsActive,
|
||||
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
&& w.PrimaryDispatch.Vendor.Company != null
|
||||
&& w.PrimaryDispatch.Vendor.Company.IsDeleted == true))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly today,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tomorrow = today.AddDays(1);
|
||||
var dayAfterTomorrow = tomorrow.AddDays(1);
|
||||
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId is int scopedAccountId)
|
||||
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||
if (dispatcherId != null)
|
||||
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
|
||||
|
||||
var scheduledTomorrow = await workOrders
|
||||
.Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue)
|
||||
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var avetaPending = await workOrders
|
||||
.Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue)
|
||||
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)
|
||||
&& w.AvetaRequired
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& (w.LifecycleStatus != null
|
||||
|| ((w.LegacyStatus ?? w.Status) != "Done"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
|
||||
&& !w.workOrderAttachments!.Any(attachment =>
|
||||
(attachment.IsDeleted == null || attachment.IsDeleted == false)
|
||||
&& attachment.Category == WorkOrderMediaCategory.Aveta))
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var pendingUplifts = _context.DispatchUpliftRequests
|
||||
.AsNoTracking()
|
||||
.Where(request => request.Status == "Pending"
|
||||
&& (request.IsDeleted == null || request.IsDeleted == false)
|
||||
&& request.Dispatch != null
|
||||
&& (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false));
|
||||
if (accountId is int account)
|
||||
{
|
||||
pendingUplifts = pendingUplifts.Where(request =>
|
||||
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account)
|
||||
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
|
||||
link.WorkOrder != null && link.WorkOrder.AccountId == account));
|
||||
}
|
||||
if (dispatcherId != null)
|
||||
{
|
||||
pendingUplifts = pendingUplifts.Where(request =>
|
||||
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId)
|
||||
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
|
||||
link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId));
|
||||
}
|
||||
|
||||
return new DashboardKpiCounts(
|
||||
scheduledTomorrow,
|
||||
await pendingUplifts.CountAsync(cancellationToken),
|
||||
avetaPending);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatcherUsers = from user in _context.Users.AsNoTracking()
|
||||
join userRole in _context.UserRoles
|
||||
on user.Id equals userRole.UserId
|
||||
join role in _context.Roles
|
||||
on userRole.RoleId equals role.Id
|
||||
where user.IsDeleted != true
|
||||
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
||||
select new
|
||||
{
|
||||
user.Id,
|
||||
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
|
||||
user.Color
|
||||
};
|
||||
|
||||
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
if (accountId is int scopedAccountId)
|
||||
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||
|
||||
if (dispatcherId != null)
|
||||
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
|
||||
|
||||
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||
{
|
||||
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||
workOrders = workOrders.Where(w =>
|
||||
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
|
||||
}
|
||||
|
||||
var counts = await workOrders
|
||||
.Where(w => w.AssignTo != null)
|
||||
.GroupBy(w => w.AssignTo!)
|
||||
.Select(group => new
|
||||
{
|
||||
DispatcherId = group.Key,
|
||||
TotalCount = group.Count(),
|
||||
OpenCount = group.Count(w =>
|
||||
w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& (w.LifecycleStatus != null
|
||||
|| (w.LegacyStatus ?? w.Status) != "Done"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||||
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var dispatcherList = await dispatcherUsers
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return dispatcherList
|
||||
.GroupJoin(
|
||||
counts,
|
||||
user => user.Id,
|
||||
count => count.DispatcherId,
|
||||
(user, matchingCounts) =>
|
||||
{
|
||||
var count = matchingCounts.SingleOrDefault();
|
||||
return new DashboardDispatcherWorkload(
|
||||
user.Id,
|
||||
user.Name,
|
||||
user.Color,
|
||||
count?.TotalCount ?? 0,
|
||||
count?.OpenCount ?? 0);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
|
|||
int pageSize,
|
||||
string? search,
|
||||
IReadOnlyCollection<string>? states,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
string? sortBy = null,
|
||||
string? sortDirection = null)
|
||||
{
|
||||
var query = _context.Locations.AsNoTracking();
|
||||
IQueryable<Locations> query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Account);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(l =>
|
||||
l.Name!.Contains(search) ||
|
||||
l.City!.Contains(search));
|
||||
l.Address1!.Contains(search) ||
|
||||
l.City!.Contains(search) ||
|
||||
l.Account != null && l.Account.Name!.Contains(search));
|
||||
}
|
||||
|
||||
if (states is { Count: > 0 })
|
||||
|
|
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
||||
}
|
||||
|
||||
query = ApplyListSort(query, sortBy, sortDirection);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderBy(l => l.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
|
||||
{
|
||||
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
|
||||
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
|
||||
{
|
||||
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
|
||||
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
|
||||
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
|
||||
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
|
||||
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
|
||||
"poc" => descending
|
||||
? query.OrderByDescending(FirstActiveContactName)
|
||||
: query.OrderBy(FirstActiveContactName),
|
||||
_ => query.OrderBy(l => l.Name)
|
||||
};
|
||||
|
||||
return ordered.ThenBy(l => l.Id);
|
||||
}
|
||||
|
||||
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
|
||||
l => l.Contacts
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => c.FirstName)
|
||||
.FirstOrDefault();
|
||||
|
||||
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Locations
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class ServicesRegistryDataService : IServicesRegistryDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public ServicesRegistryDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Services
|
||||
.AsNoTracking()
|
||||
.Where(s => s.IsDeleted != true);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(s => s.IsActive == isActive.Value);
|
||||
|
||||
if (workOrderType.HasValue)
|
||||
query = query.Where(s => s.SupportedWorkOrderTypes.Any(t => t.WorkOrderType == workOrderType.Value));
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(s => s.Name)
|
||||
.Select(s => new ServiceRegistryRow
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = s.Name,
|
||||
Trade = s.Trade,
|
||||
IconKey = s.IconKey,
|
||||
RequiresCompletionDocument = s.RequiresCompletionDocument,
|
||||
IsActive = s.IsActive,
|
||||
CompletionDocTemplateId = s.CompletionDocTemplateId,
|
||||
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
|
||||
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Services.AsNoTracking()
|
||||
.Where(s => s.Id == id && s.IsDeleted != true)
|
||||
.Select(s => new ServiceRegistryRow
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = s.Name,
|
||||
Trade = s.Trade,
|
||||
IconKey = s.IconKey,
|
||||
RequiresCompletionDocument = s.RequiresCompletionDocument,
|
||||
IsActive = s.IsActive,
|
||||
CompletionDocTemplateId = s.CompletionDocTemplateId,
|
||||
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
|
||||
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Services
|
||||
.Include(s => s.SupportedWorkOrderTypes)
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Services
|
||||
.AnyAsync(s => s.IsDeleted != true
|
||||
&& s.NormalizedName == normalizedName
|
||||
&& (excludeServiceId == null || s.Id != excludeServiceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.CompletionDocTemplates
|
||||
.Where(t => t.Id == templateId && t.IsActive && t.IsDeleted != true)
|
||||
.Select(t => new LinkedTemplateSummary { Id = t.Id, Name = t.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.Services.AddAsync(service, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueNameViolation(ex))
|
||||
{
|
||||
throw new ServicesRegistryDuplicateNameException(ex);
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
public async Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken)
|
||||
{
|
||||
var desired = supportedWorkOrderTypes.Distinct().ToList();
|
||||
var current = service.SupportedWorkOrderTypes ?? new List<ServiceWorkOrderType>();
|
||||
|
||||
var stale = current.Where(t => !desired.Contains(t.WorkOrderType)).ToList();
|
||||
_context.ServiceWorkOrderTypes.RemoveRange(stale);
|
||||
|
||||
var existingTypes = current.Select(t => t.WorkOrderType).ToHashSet();
|
||||
foreach (var type in desired.Where(t => !existingTypes.Contains(t)))
|
||||
{
|
||||
_context.ServiceWorkOrderTypes.Add(new ServiceWorkOrderType
|
||||
{
|
||||
ServiceId = service.Id,
|
||||
WorkOrderType = type
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueNameViolation(ex))
|
||||
{
|
||||
throw new ServicesRegistryDuplicateNameException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var service = await _context.Services
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
|
||||
if (service == null)
|
||||
return false;
|
||||
|
||||
service.IsActive = false;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsUniqueNameViolation(DbUpdateException exception)
|
||||
{
|
||||
var message = exception.InnerException?.Message ?? exception.Message;
|
||||
return message.Contains("IX_Services_NormalizedName", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("NormalizedName", StringComparison.OrdinalIgnoreCase)
|
||||
&& (message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("unique", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public TeamPermissionOverrideDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<TeamPermissionUserData?> GetUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userExists = await _context.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||
|
||||
if (!userExists)
|
||||
return null;
|
||||
|
||||
var roleName = await (
|
||||
from userRole in _context.UserRoles.AsNoTracking()
|
||||
join role in _context.Roles.AsNoTracking()
|
||||
on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == userId
|
||||
select role.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var overrides = await _context.UserPermissionOverrides
|
||||
.AsNoTracking()
|
||||
.Where(permission => permission.UserId == userId)
|
||||
.ToDictionaryAsync(
|
||||
permission => permission.PermissionKey,
|
||||
permission => permission.State,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
|
||||
return new TeamPermissionUserData
|
||||
{
|
||||
UserId = userId,
|
||||
RoleName = roleName,
|
||||
Overrides = overrides
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await SetOverridesAsync(
|
||||
userId,
|
||||
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[permissionKey] = state
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SetOverridesAsync(
|
||||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var pair in overrides)
|
||||
await SetOverrideCoreAsync(userId, pair.Key, pair.Value, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SetOverrideCoreAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _context.UserPermissionOverrides
|
||||
.SingleOrDefaultAsync(
|
||||
permission => permission.UserId == userId
|
||||
&& permission.PermissionKey == permissionKey,
|
||||
cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.State = state;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var addition = new UserPermissionOverride
|
||||
{
|
||||
UserId = userId,
|
||||
PermissionKey = permissionKey,
|
||||
State = state
|
||||
};
|
||||
await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent write inserted the same composite key between our
|
||||
// existence check and this save. Converge on the caller's intent.
|
||||
_context.Entry(addition).State = EntityState.Detached;
|
||||
|
||||
var winner = await _context.UserPermissionOverrides
|
||||
.SingleAsync(
|
||||
permission => permission.UserId == userId
|
||||
&& permission.PermissionKey == permissionKey,
|
||||
cancellationToken);
|
||||
winner.State = state;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
|
|
@ -24,8 +25,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
from v in vendors.DefaultIfEmpty()
|
||||
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
|
||||
from ev in evidences.DefaultIfEmpty()
|
||||
join wo in _context.workOrders on d.WorkOrderId equals wo.Id into workOrders
|
||||
from wo in workOrders.DefaultIfEmpty()
|
||||
join reqUser in _context.Users on u.createdby equals reqUser.Id into requestUsers
|
||||
from reqUser in requestUsers.DefaultIfEmpty()
|
||||
join decUser in _context.Users on u.DecidedByUserId equals decUser.Id into decisionUsers
|
||||
from decUser in decisionUsers.DefaultIfEmpty()
|
||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||
select new { u, d, v, ev };
|
||||
&& (d.IsDeleted == null || d.IsDeleted == false)
|
||||
select new { u, d, v, ev, wo, reqUser, decUser };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(x => x.u.Status == status);
|
||||
|
|
@ -34,8 +42,18 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// Approval queue read contract: the actionable queue (Pending) surfaces the
|
||||
// oldest request first; the decision log (Approved) surfaces the most
|
||||
// recently decided first. Every other read keeps the historical
|
||||
// newest-request-first order. Id is the deterministic tiebreaker.
|
||||
if (string.Equals(status, "Pending", StringComparison.Ordinal))
|
||||
query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id);
|
||||
else if (string.Equals(status, "Approved", StringComparison.Ordinal))
|
||||
query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id);
|
||||
else
|
||||
query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.u.CreatedDate)
|
||||
.Skip((Math.Max(page, 1) - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new UpliftListItemData
|
||||
|
|
@ -46,6 +64,17 @@ namespace SeaHaven.DataServices.Implementation
|
|||
PONumber = x.d.PONumber,
|
||||
WorkOrderId = x.d.WorkOrderId,
|
||||
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
||||
// The queue renders the internal display number (InternalWONumber, what the
|
||||
// board shows); WorkerOrderNumber holds the CRM external id on synced
|
||||
// work orders and must not leak into the queue display.
|
||||
WorkOrderNumber = x.wo != null ? x.wo.InternalWONumber : null,
|
||||
WorkOrderSiteCode = x.wo != null ? x.wo.SiteCode : null,
|
||||
WorkOrderService = x.wo != null ? x.wo.Service : null,
|
||||
RequestedByVendorName = x.u.RequestedByVendorName,
|
||||
RequestedByFirstName = x.reqUser != null ? x.reqUser.FirstName : null,
|
||||
RequestedByLastName = x.reqUser != null ? x.reqUser.LastName : null,
|
||||
DecidedByFirstName = x.decUser != null ? x.decUser.FirstName : null,
|
||||
DecidedByLastName = x.decUser != null ? x.decUser.LastName : null,
|
||||
CurrentNTE = x.u.CurrentNTE,
|
||||
RequestedNTE = x.u.RequestedNTE,
|
||||
VendorReason = x.u.VendorReason,
|
||||
|
|
@ -60,13 +89,29 @@ namespace SeaHaven.DataServices.Implementation
|
|||
EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null,
|
||||
ExpiresAt = x.u.ExpiresAt,
|
||||
NotificationStatus = x.u.NotificationStatus,
|
||||
NotificationError = x.u.NotificationError
|
||||
NotificationError = x.u.NotificationError,
|
||||
AttachmentCount = _context.VendorCompletionDocuments.Count(document =>
|
||||
document.DispatchId == x.d.Id
|
||||
&& (document.IsDeleted == null || document.IsDeleted == false)),
|
||||
WorkOrderClosed = x.wo != null
|
||||
&& (x.wo.LifecycleStatus == LifecycleStatus.Completed
|
||||
|| x.wo.LifecycleStatus == LifecycleStatus.Canceled)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (total, items);
|
||||
}
|
||||
|
||||
public Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.DispatchUpliftRequests
|
||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Status == "Pending")
|
||||
.SumAsync(u => u.createdby == null
|
||||
? u.RequestedNTE - (u.CurrentNTE ?? 0m)
|
||||
: u.RequestedNTE, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
|
|
@ -200,6 +245,27 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int?> GetWorkOrderIdForUpliftAsync(
|
||||
int upliftRequestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var link = await _context.DispatchUpliftRequests
|
||||
.Where(u => u.Id == upliftRequestId
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& u.Dispatch != null
|
||||
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false))
|
||||
.Select(u => new
|
||||
{
|
||||
PrimaryWorkOrderId = u.Dispatch!.WorkOrderId,
|
||||
LinkedWorkOrderId = u.Dispatch.DispatchWorkOrders!
|
||||
.Select(dispatchWorkOrder => (int?)dispatchWorkOrder.WorkOrderId)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return link?.PrimaryWorkOrderId ?? link?.LinkedWorkOrderId;
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
|
|
@ -252,6 +318,89 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.SumAsync(u => u.RequestedNTE, cancellationToken);
|
||||
}
|
||||
|
||||
// Approval queue read contract: set-based exposure aggregation. Dispatches map to
|
||||
// work orders through the server-derived linkage (primary work order plus linked
|
||||
// work orders), matching the ForWorkOrder scope; approved amounts are summed per
|
||||
// dispatch in SQL and folded into per-work-order totals in memory (three set-based
|
||||
// round trips, no query-per-work-order).
|
||||
public async Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
|
||||
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var workOrderIdsScope = workOrderIds.Distinct().ToList();
|
||||
if (workOrderIdsScope.Count == 0)
|
||||
return Array.Empty<WorkOrderUpliftExposureData>();
|
||||
|
||||
var primaryPairs = await _context.Dispatches
|
||||
.Where(d => (d.IsDeleted == null || d.IsDeleted == false)
|
||||
&& d.WorkOrderId != null
|
||||
&& workOrderIdsScope.Contains(d.WorkOrderId.Value))
|
||||
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var linkedPairs = await _context.DispatchWorkOrders
|
||||
.Where(l => workOrderIdsScope.Contains(l.WorkOrderId)
|
||||
&& l.Dispatch != null
|
||||
&& (l.Dispatch.IsDeleted == null || l.Dispatch.IsDeleted == false))
|
||||
.Select(l => new { l.DispatchId, l.WorkOrderId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var workOrdersByDispatch = primaryPairs
|
||||
.Concat(linkedPairs)
|
||||
.GroupBy(p => p.DispatchId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.WorkOrderId).ToHashSet());
|
||||
|
||||
if (workOrdersByDispatch.Count == 0)
|
||||
return Array.Empty<WorkOrderUpliftExposureData>();
|
||||
|
||||
var dispatchIds = workOrdersByDispatch.Keys.ToList();
|
||||
var sums = await _context.DispatchUpliftRequests
|
||||
.Where(u => (u.IsDeleted == null || u.IsDeleted == false)
|
||||
&& dispatchIds.Contains(u.DispatchId)
|
||||
&& (u.Status == "Approved" || u.Status == "NoApprovalRequired"))
|
||||
.GroupBy(u => u.DispatchId)
|
||||
.Select(g => new
|
||||
{
|
||||
DispatchId = g.Key,
|
||||
// The two creation paths store different meanings in RequestedNTE.
|
||||
// Vendor-portal rows store the requested new NTE total, so the
|
||||
// granted amount is RequestedNTE - CurrentNTE; work-order-path rows
|
||||
// store the granted increment directly. Vendor sessions have no
|
||||
// identity user, so createdby is null only on vendor-portal rows. Summing
|
||||
// granted amounts keeps sequential approvals from double-counting whole
|
||||
// NTE totals. This applies to both buckets.
|
||||
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
||||
.Sum(x => (decimal?)(x.createdby == null
|
||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
||||
: x.RequestedNTE)),
|
||||
AdminApproved = g.Where(x => x.Status == "Approved")
|
||||
.Sum(x => (decimal?)(x.createdby == null
|
||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
||||
: x.RequestedNTE))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var totals = new Dictionary<int, WorkOrderUpliftExposureData>();
|
||||
foreach (var sum in sums)
|
||||
{
|
||||
if (!workOrdersByDispatch.TryGetValue(sum.DispatchId, out var linkedWorkOrders))
|
||||
continue;
|
||||
|
||||
foreach (var workOrderId in linkedWorkOrders)
|
||||
{
|
||||
if (!totals.TryGetValue(workOrderId, out var total))
|
||||
{
|
||||
total = new WorkOrderUpliftExposureData { WorkOrderId = workOrderId };
|
||||
totals[workOrderId] = total;
|
||||
}
|
||||
|
||||
total.AutoApprovedTotal += sum.AutoApproved ?? 0m;
|
||||
total.AdminApprovedTotal += sum.AdminApproved ?? 0m;
|
||||
}
|
||||
}
|
||||
|
||||
return totals.Values.ToList();
|
||||
}
|
||||
|
||||
public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
|
||||
int workOrderId,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -42,11 +42,22 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Select(user => new UserListRowData
|
||||
{
|
||||
Id = user.Id,
|
||||
RoleName = user.PhoneNumber,
|
||||
RoleName = (
|
||||
from userRole in _context.UserRoles
|
||||
join role in _context.Roles on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == user.Id
|
||||
select role.Name).FirstOrDefault() ?? user.PhoneNumber,
|
||||
Email = user.Email,
|
||||
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
|
||||
CreatedDate = user.CreatedDate,
|
||||
Status = user.UniqueName
|
||||
Status = user.UniqueName,
|
||||
Phone = user.Contact ?? user.PhoneNumber,
|
||||
Color = user.Color,
|
||||
PendingRegistration = user.PendingRegistration,
|
||||
ServiceAreas = user.ServiceAreas
|
||||
.OrderBy(area => area.Area)
|
||||
.Select(area => area.Area)
|
||||
.ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
|
@ -93,6 +104,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(user => user.Id == userId)
|
||||
.Select(user => user.IsAccountOwner)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToUpperInvariant();
|
||||
|
|
@ -135,6 +155,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Where(role => role.UserId == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _context.UserPermissionOverrides
|
||||
.Where(permissionOverride => permissionOverride.UserId == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _context.Users
|
||||
.Where(existingUser => existingUser.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class UserServiceAreaDataService : IUserServiceAreaDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public UserServiceAreaDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task ReplaceAsync(
|
||||
string userId,
|
||||
IReadOnlyCollection<string> areas,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _context.UserServiceAreas
|
||||
.Where(area => area.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (existing.Count > 0)
|
||||
_context.UserServiceAreas.RemoveRange(existing);
|
||||
|
||||
await _context.UserServiceAreas.AddRangeAsync(
|
||||
areas.Select(area => new UserServiceArea { UserId = userId, Area = area }),
|
||||
cancellationToken);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetForUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.UserServiceAreas
|
||||
.AsNoTracking()
|
||||
.Where(area => area.UserId == userId)
|
||||
.OrderBy(area => area.Area)
|
||||
.Select(area => area.Area)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation
|
|||
_context = context;
|
||||
}
|
||||
|
||||
public Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken) =>
|
||||
_context.Areas
|
||||
.AsNoTracking()
|
||||
.AnyAsync(area => area.Id == areaId && area.IsActive, cancellationToken);
|
||||
|
||||
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||
int? vendorId,
|
||||
int? companyId,
|
||||
|
|
@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
Zip = c.Zip,
|
||||
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||
Notes = c.Notes,
|
||||
AreaId = c.AreaId,
|
||||
AreaName = c.Area != null ? c.Area.Name : null,
|
||||
RowVersion = c.RowVersion
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
|
@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
company.Zip = roster.Zip;
|
||||
company.GoogleMapsUrl = roster.GoogleMapsUrl;
|
||||
company.Notes = roster.Notes;
|
||||
if (roster.AreaIdProvided)
|
||||
company.AreaId = roster.AreaId;
|
||||
company.LastModificationTime = now;
|
||||
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
||||
if (actorId.HasValue)
|
||||
|
|
@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
Zip = roster.Zip,
|
||||
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||
Notes = roster.Notes,
|
||||
AreaId = roster.AreaId,
|
||||
CreatedDate = now,
|
||||
createdby = roster.ActorUserId
|
||||
};
|
||||
|
|
@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
company.GoogleMapsUrl = fields.GoogleMapsUrl;
|
||||
if (fields.Notes != null)
|
||||
company.Notes = fields.Notes;
|
||||
if (fields.AreaIdProvided)
|
||||
company.AreaId = fields.AreaId;
|
||||
|
||||
// Keep the denormalized company columns on existing technicians aligned
|
||||
// with the company row (same invariant SaveRosterAsync maintains). This
|
||||
|
|
|
|||
|
|
@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return query.OrderBy(c => c.Id);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Areas
|
||||
.AsNoTracking()
|
||||
.Where(a => a.IsActive)
|
||||
.OrderBy(a => a.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
||||
|
||||
|
|
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null,
|
||||
VendorAreaFilter? areaFilter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Phase 1: select and paginate bounded company group keys in SQL.
|
||||
|
|
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
jobBuckets,
|
||||
areaFilter);
|
||||
|
||||
var totalCount = await keyQuery.CountAsync(cancellationToken);
|
||||
|
||||
|
|
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
? new Dictionary<int, VendorCompany>()
|
||||
: await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Area)
|
||||
.Where(c => missingCompanyIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, cancellationToken);
|
||||
|
||||
|
|
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
|
||||
GoogleMapsUrl = primary.GoogleMapsUrl,
|
||||
Notes = linked ? primary.CompanyNotes : null,
|
||||
AreaId = linked ? primary.CompanyAreaId : null,
|
||||
AreaName = linked ? primary.CompanyAreaName : null,
|
||||
IsActive = primary.IsActive,
|
||||
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
|
||||
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
|
||||
|
|
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
TradeSpecialties = null,
|
||||
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||
Notes = company.Notes,
|
||||
AreaId = company.AreaId,
|
||||
AreaName = company.Area?.Name,
|
||||
IsActive = true,
|
||||
TotalJobs = 0,
|
||||
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
|
||||
|
|
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
|
|||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null)
|
||||
IReadOnlyCollection<string>? jobBuckets = null,
|
||||
VendorAreaFilter? areaFilter = null)
|
||||
{
|
||||
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
|
||||
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
|
||||
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
|
||||
|
||||
var grouped = sourceRows
|
||||
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
|
||||
|
|
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
SearchMatch = group.Max(row => row.SearchFlag),
|
||||
CompanyMatch = group.Max(row => row.CompanyFlag),
|
||||
TradeMatch = group.Max(row => row.TradeFlag),
|
||||
LocationMatch = group.Max(row => row.LocationFlag)
|
||||
LocationMatch = group.Max(row => row.LocationFlag),
|
||||
AreaMatch = group.Max(row => row.AreaFlag)
|
||||
});
|
||||
|
||||
// Each facet selects the group when any status-eligible row matches it
|
||||
|
|
@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
if (selectedLocations is { Length: > 0 })
|
||||
grouped = grouped.Where(key => key.LocationMatch == 1);
|
||||
|
||||
if (areaFilter != null)
|
||||
grouped = grouped.Where(key => key.AreaMatch == 1);
|
||||
|
||||
var selectedJobBuckets = jobBuckets?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
|
|
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
string? search,
|
||||
IReadOnlyCollection<string>? companies,
|
||||
IReadOnlyCollection<string>? trades,
|
||||
IReadOnlyCollection<string>? locations)
|
||||
IReadOnlyCollection<string>? locations,
|
||||
VendorAreaFilter? areaFilter)
|
||||
{
|
||||
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
|
||||
|
||||
|
|
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
var locationBody = selectedLocations is { Length: > 0 }
|
||||
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
|
||||
: null;
|
||||
var areaBody = areaFilter == null
|
||||
? null
|
||||
: BuildAreaMatchBody(row, areaFilter);
|
||||
|
||||
var nameKey = Expression.Condition(
|
||||
Expression.NotEqual(
|
||||
|
|
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
|
||||
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
|
||||
row);
|
||||
}
|
||||
|
||||
|
|
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
CompanyCity = v.Company != null ? v.Company.City : null,
|
||||
CompanyState = v.Company != null ? v.Company.State : null,
|
||||
CompanyNotes = v.Company != null ? v.Company.Notes : null,
|
||||
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
|
||||
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
|
||||
ContactName = v.ContactName,
|
||||
Email = v.Email,
|
||||
Phone = v.Phone,
|
||||
|
|
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
TradeSpecialties = v.TradeSpecialties,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||
Notes = v.Company != null ? v.Company.Notes : null,
|
||||
AreaId = v.Company != null ? v.Company.AreaId : null,
|
||||
IsActive = v.IsActive,
|
||||
TotalJobs = (int?)jobCount.TotalJobs ?? 0
|
||||
};
|
||||
|
|
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
TradeSpecialties = null,
|
||||
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||
Notes = c.Notes,
|
||||
AreaId = c.AreaId,
|
||||
IsActive = true,
|
||||
TotalJobs = 0
|
||||
});
|
||||
|
|
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return body!;
|
||||
}
|
||||
|
||||
// SH-278: OR within the Area facet. Selected ids match the company's stable
|
||||
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
|
||||
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
|
||||
// resolvable ids and no Unassigned matches nobody.
|
||||
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
|
||||
{
|
||||
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
|
||||
Expression? body = null;
|
||||
|
||||
if (areaFilter.AreaIds.Count > 0)
|
||||
{
|
||||
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
|
||||
body = OrElse(body, Expression.Call(
|
||||
typeof(Enumerable),
|
||||
nameof(Enumerable.Contains),
|
||||
new[] { typeof(int?) },
|
||||
Expression.Constant(ids),
|
||||
areaId));
|
||||
}
|
||||
|
||||
if (areaFilter.IncludeUnassigned)
|
||||
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
|
||||
|
||||
return body ?? Expression.Constant(false);
|
||||
}
|
||||
|
||||
private static Expression BuildCompanyNameMatchBody(
|
||||
ParameterExpression row,
|
||||
IReadOnlyCollection<string> selectedCompanies)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
else
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface IAccountOwnerDataService
|
||||
{
|
||||
Task<bool> EnsureConfiguredOwnerAsync(string? configuredUserId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -1,10 +1,57 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
public interface IDashboardDataService
|
||||
{
|
||||
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
|
||||
Task<DashboardCounts> GetWorkOrderCountsAsync(
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly today,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record DashboardWorkOrder(
|
||||
int Id,
|
||||
string? AssignTo,
|
||||
WorkOrderType? WorkOrderType,
|
||||
LifecycleStatus? LifecycleStatus,
|
||||
string? LegacyStatus,
|
||||
DateTime? ScheduledDate,
|
||||
DateOnly? OriginalDate,
|
||||
DateTime? CompletedDate,
|
||||
string? LocationState = null,
|
||||
int RescheduleCount = 0,
|
||||
int? VendorCompanyId = null,
|
||||
string? VendorCompanyName = null,
|
||||
bool VendorIsActive = false,
|
||||
bool VendorCompanyIsDeleted = false);
|
||||
|
||||
public sealed record DashboardDispatcherWorkload(
|
||||
string DispatcherId,
|
||||
string DispatcherName,
|
||||
string? Color,
|
||||
int TotalCount,
|
||||
int OpenCount);
|
||||
|
||||
public class DashboardCounts
|
||||
{
|
||||
public int Total { get; set; }
|
||||
|
|
@ -12,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
public int NotDispatched { get; set; }
|
||||
public int Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DashboardKpiCounts(
|
||||
int ScheduledTomorrow,
|
||||
int PendingUplifts,
|
||||
int AvetaPending);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<bool> ExistsAsync(int id);
|
||||
Task<int> CountAsync();
|
||||
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order.
|
||||
/// Sort options trail the token so existing call sites stay source-compatible.
|
||||
/// </summary>
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null);
|
||||
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
// Read-side projection for the global Services registry. The linked
|
||||
// completion template is projected to id/name only — full template
|
||||
// entities are never materialized.
|
||||
public class ServiceRegistryRow
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Trade { get; set; } = "";
|
||||
public string IconKey { get; set; } = "";
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int? CompletionDocTemplateId { get; set; }
|
||||
public string? CompletionDocTemplateName { get; set; }
|
||||
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LinkedTemplateSummary
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public interface IServicesRegistryDataService
|
||||
{
|
||||
Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
|
||||
Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||
Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken);
|
||||
Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken);
|
||||
Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken);
|
||||
Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken);
|
||||
Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface ITeamPermissionOverrideDataService
|
||||
{
|
||||
Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken);
|
||||
Task SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task SetOverridesAsync(
|
||||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -6,11 +6,13 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<decimal> GetPendingExposureAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<int?> GetWorkOrderIdForUpliftAsync(int upliftRequestId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||
// SH-101: server-side join of an uplift request with its linked evidence document.
|
||||
// Returns null when the request, the linked evidence, or the dispatch linkage is absent.
|
||||
|
|
@ -18,6 +20,12 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
// Approval queue read contract: set-based per-work-order exposure totals covering
|
||||
// auto-approved and admin-approved uplift amounts (pending, rejected,
|
||||
// cancelled/withdrawn, and revoked requests are excluded). Work orders absent
|
||||
// from the result have no approved exposure.
|
||||
Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(
|
||||
IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken);
|
||||
Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
// SH-101: active = Pending or ChangesRequested (the only states that block a new request).
|
||||
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken);
|
||||
Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken);
|
||||
Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken);
|
||||
Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken);
|
||||
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
|
||||
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface IUserServiceAreaDataService
|
||||
{
|
||||
Task ReplaceAsync(
|
||||
string userId,
|
||||
IReadOnlyCollection<string> areas,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<string>> GetForUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
public interface IVendorCompanyRosterDataService
|
||||
{
|
||||
// SH-278: true only for an existing, non-archived Area in the catalogue.
|
||||
Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken);
|
||||
|
||||
// Loads the company roster from either a vendor row id and/or a company id,
|
||||
// returning the company plus all non-deleted technician rows.
|
||||
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
// of technician Vendor.TradeSpecialties free text.
|
||||
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
|
||||
|
||||
// SH-278: active (non-archived) Area catalogue rows for vendor facets and for
|
||||
// resolving areas[n] filter ids. Archived Areas are excluded.
|
||||
Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken);
|
||||
|
||||
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
||||
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
||||
// companies; active includes companies with zero non-deleted technicians or at
|
||||
|
|
@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null,
|
||||
VendorAreaFilter? areaFilter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Vendor> AddAsync(Vendor vendor);
|
||||
Task UpdateAsync(Vendor vendor);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
DateOnly DateTo,
|
||||
bool UnscheduledOnly,
|
||||
IReadOnlyList<string>? Sites,
|
||||
IReadOnlyList<string>? Regions,
|
||||
IReadOnlyList<WorkOrderType>? Types,
|
||||
bool Overdue,
|
||||
IReadOnlyList<string>? Dispatchers,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int CarriedOver,
|
||||
bool IsAddOn,
|
||||
WorkOrderType? WorkOrderType,
|
||||
int? ServiceId,
|
||||
string? ServiceNameSnapshot,
|
||||
string? SiteCode,
|
||||
string? LocationName,
|
||||
string? PocName,
|
||||
|
|
|
|||
14
SeaHaven.DataServices/Models/ServicesRegistryModels.cs
Normal file
14
SeaHaven.DataServices/Models/ServicesRegistryModels.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace SeaHaven.DataServices.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised by the registry data service when the database unique name index
|
||||
/// wins a concurrent create or update race.
|
||||
/// </summary>
|
||||
public sealed class ServicesRegistryDuplicateNameException : Exception
|
||||
{
|
||||
public ServicesRegistryDuplicateNameException(Exception innerException)
|
||||
: base("A service name is already in use.", innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
public string? AreaName { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
|
||||
}
|
||||
|
|
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
|
||||
public bool AreaIdProvided { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
public string? ActorUserId { get; set; }
|
||||
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
|
||||
}
|
||||
|
|
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
|
||||
public bool AreaIdProvided { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
}
|
||||
|
||||
// Raised by the roster data service when a technician removed from the snapshot
|
||||
|
|
|
|||
|
|
@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
|
|||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
}
|
||||
|
||||
// SH-278: Area facet filter for the company directory. AreaIds are already
|
||||
// resolved against the active catalogue; IncludeUnassigned selects groups with no
|
||||
// Area (including unlinked legacy vendors). An empty filter matches nobody.
|
||||
public sealed class VendorAreaFilter
|
||||
{
|
||||
public IReadOnlyCollection<int> AreaIds { get; init; } = Array.Empty<int>();
|
||||
public bool IncludeUnassigned { get; init; }
|
||||
}
|
||||
|
||||
// SH-281 company directory: per-row source for the grouped key query. Carries the
|
||||
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
|
||||
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
|
||||
|
|
@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models
|
|||
public int CompanyFlag { get; set; }
|
||||
public int TradeFlag { get; set; }
|
||||
public int LocationFlag { get; set; }
|
||||
public int AreaFlag { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: one row per company group after SQL grouping — the bounded key page.
|
||||
|
|
@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models
|
|||
public int CompanyMatch { get; set; }
|
||||
public int TradeMatch { get; set; }
|
||||
public int LocationMatch { get; set; }
|
||||
public int AreaMatch { get; set; }
|
||||
}
|
||||
|
||||
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
|
||||
|
|
@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models
|
|||
public string? CompanyCity { get; set; }
|
||||
public string? CompanyState { get; set; }
|
||||
public string? CompanyNotes { get; set; }
|
||||
public int? CompanyAreaId { get; set; }
|
||||
public string? CompanyAreaName { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
|
|
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
|
|||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
public string? AreaName { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();
|
||||
|
|
|
|||
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHaven.Services.Tests;
|
||||
|
||||
public class TeamPermissionPolicyTests
|
||||
{
|
||||
private static readonly string[] DispatcherKeys =
|
||||
{
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
TeamPermissionKeys.EditVendors,
|
||||
TeamPermissionKeys.DeactivateVendors,
|
||||
TeamPermissionKeys.CreateSites,
|
||||
TeamPermissionKeys.EditSites,
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
TeamPermissionKeys.EditOthersWorkOrders,
|
||||
TeamPermissionKeys.CancelWorkOrders,
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
};
|
||||
|
||||
private static readonly string[] SchedulerOnlyKeys =
|
||||
{
|
||||
TeamPermissionKeys.DeleteSites,
|
||||
TeamPermissionKeys.CreateServices,
|
||||
TeamPermissionKeys.EditServices,
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||
};
|
||||
|
||||
private static readonly string[] AdminOnlyKeys =
|
||||
{
|
||||
TeamPermissionKeys.ManageTeamMembers,
|
||||
TeamPermissionKeys.ChangeTeamMemberRole,
|
||||
TeamPermissionKeys.DeactivateServices,
|
||||
TeamPermissionKeys.DeleteCompletionDocTemplates,
|
||||
TeamPermissionKeys.DeleteWorkOrders,
|
||||
TeamPermissionKeys.ReviewUplifts
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, UserPermissionState> Overrides(
|
||||
string key, UserPermissionState state) =>
|
||||
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[key] = state
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Registry_Exposes_Exactly_The_22_Prototype_Keys()
|
||||
{
|
||||
TeamPermissionKeys.All.Should().HaveCount(22);
|
||||
TeamPermissionKeys.KnownKeys.Should().HaveCount(22);
|
||||
TeamPermissionKeys.All.Should().OnlyHaveUniqueItems();
|
||||
TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key));
|
||||
TeamPermissionKeys.All.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"manageTeamMembers",
|
||||
"changeTeamMemberRole",
|
||||
"createVendors",
|
||||
"editVendors",
|
||||
"deactivateVendors",
|
||||
"createSites",
|
||||
"editSites",
|
||||
"deleteSites",
|
||||
"createServices",
|
||||
"editServices",
|
||||
"deactivateServices",
|
||||
"createCompletionDocTemplates",
|
||||
"editCompletionDocTemplates",
|
||||
"deleteCompletionDocTemplates",
|
||||
"createWorkOrders",
|
||||
"editOthersWorkOrders",
|
||||
"cancelWorkOrders",
|
||||
"deleteWorkOrders",
|
||||
"requestUplifts",
|
||||
"autoApproveUplifts",
|
||||
"reviewUplifts",
|
||||
"viewAllDispatchersOnDashboard"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys()
|
||||
{
|
||||
TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue();
|
||||
TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue();
|
||||
TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse();
|
||||
TeamPermissionKeys.IsKnown("").Should().BeFalse();
|
||||
TeamPermissionKeys.IsKnown(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_Gets_Exactly_Prototype_Defaults()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal);
|
||||
policy.IsAllowed("Dispatcher", key).Should().Be(expected,
|
||||
$"Dispatcher default for '{key}' should be {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_Gets_Exactly_Prototype_Defaults()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal)
|
||||
|| DispatcherKeys.Contains(key, StringComparer.Ordinal)
|
||||
&& key is not TeamPermissionKeys.RequestUplifts
|
||||
&& key is not TeamPermissionKeys.AutoApproveUplifts;
|
||||
|
||||
policy.IsAllowed("Scheduler", key).Should().Be(expected,
|
||||
$"Scheduler default for '{key}' should be {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_Has_Effective_Access_To_Every_Key()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_Recognition_Is_Case_Insensitive()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue();
|
||||
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue();
|
||||
policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue();
|
||||
|
||||
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse();
|
||||
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Manager")]
|
||||
[InlineData("OfficeAdmin")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role)
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed(role, key).Should().BeFalse(
|
||||
$"unknown/legacy role '{role}' must not hold '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow))
|
||||
.Should().BeFalse($"an unknown role must not be elevated via '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allow_Override_Grants_Key_Outside_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts)
|
||||
.Should().BeFalse();
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deny_Override_Removes_Key_Inside_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors)
|
||||
.Should().BeTrue();
|
||||
policy.IsAllowed(
|
||||
"Dispatcher",
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Dispatcher",
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset))
|
||||
.Should().BeTrue();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset))
|
||||
.Should().BeFalse();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
new Dictionary<string, UserPermissionState>())
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_Is_Immune_To_Overrides()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny))
|
||||
.Should().BeTrue($"Admin must keep '{key}' despite a Deny override");
|
||||
policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Permission_Key_Is_Denied_For_Every_Role()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse();
|
||||
policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse();
|
||||
policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
144
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
144
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHaven.Services.Tests;
|
||||
|
||||
public sealed class TeamPermissionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetProfile_ReturnsEveryKeyAndRoleDefaults()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var result = await Service(data).GetProfileAsync("u1", Admin(), CancellationToken.None);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value!.Permissions.Should().HaveCount(22);
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeFalse();
|
||||
result.Value.Permissions.Should().OnlyContain(p => p.OverrideState == UserPermissionState.Unset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetOverride_ChangesOnlyOnePermissionAndPersistsCanonicalKey()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var result = await Service(data).SetOverrideAsync(
|
||||
"u1", "DELETEsites", UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value!.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeTrue();
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).OverrideState.Should().Be(UserPermissionState.Allow);
|
||||
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
|
||||
data.LastSet.Should().Be(("u1", TeamPermissionKeys.DeleteSites, UserPermissionState.Allow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetOverride_RejectsUnknownKeyBeforeDataAccess()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var result = await Service(data).SetOverrideAsync(
|
||||
"u1", "not-a-permission", UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.InvalidPermissionKey);
|
||||
data.GetCalls.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonAdminCannotReadOrWritePermissions()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Dispatcher");
|
||||
var service = Service(data);
|
||||
|
||||
var read = await service.GetProfileAsync("u1", User("Dispatcher"), CancellationToken.None);
|
||||
var write = await service.SetOverrideAsync("u1", TeamPermissionKeys.CreateSites, UserPermissionState.Deny, User("Dispatcher"), CancellationToken.None);
|
||||
|
||||
read.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
|
||||
write.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
|
||||
data.GetCalls.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownRoleReceivesNoGrantEvenWhenAnOverrideIsSet()
|
||||
{
|
||||
var data = new FakeDataService("u1", "Legacy");
|
||||
var result = await Service(data).SetOverrideAsync(
|
||||
"u1", TeamPermissionKeys.CreateSites, UserPermissionState.Allow, Admin(), CancellationToken.None);
|
||||
|
||||
result.Value!.Permissions.Should().OnlyContain(p => !p.IsGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingUserReturnsNotFound()
|
||||
{
|
||||
var data = new FakeDataService(null, null);
|
||||
var result = await Service(data).GetProfileAsync("missing", Admin(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.NotFound);
|
||||
}
|
||||
|
||||
private static TeamPermissionService Service(FakeDataService data) =>
|
||||
new(data, new TeamPermissionPolicy());
|
||||
|
||||
private static ClaimsPrincipal Admin() => User("Admin");
|
||||
|
||||
private static ClaimsPrincipal User(string role) =>
|
||||
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, role) }, "test"));
|
||||
|
||||
private sealed class FakeDataService : ITeamPermissionOverrideDataService
|
||||
{
|
||||
private readonly string? _userId;
|
||||
private readonly string? _roleName;
|
||||
private readonly Dictionary<string, UserPermissionState> _overrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public FakeDataService(string? userId, string? roleName)
|
||||
{
|
||||
_userId = userId;
|
||||
_roleName = roleName;
|
||||
}
|
||||
|
||||
public int GetCalls { get; private set; }
|
||||
public (string UserId, string PermissionKey, UserPermissionState State)? LastSet { get; private set; }
|
||||
|
||||
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
GetCalls++;
|
||||
return Task.FromResult<TeamPermissionUserData?>(_userId != userId
|
||||
? null
|
||||
: new TeamPermissionUserData
|
||||
{
|
||||
UserId = userId,
|
||||
RoleName = _roleName,
|
||||
Overrides = new Dictionary<string, UserPermissionState>(_overrides, StringComparer.OrdinalIgnoreCase)
|
||||
});
|
||||
}
|
||||
|
||||
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken)
|
||||
{
|
||||
_overrides[permissionKey] = state;
|
||||
LastSet = (userId, permissionKey, state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetOverridesAsync(
|
||||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (permissionKey, state) in overrides)
|
||||
{
|
||||
_overrides[permissionKey] = state;
|
||||
LastSet = (userId, permissionKey, state);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
SeaHaven.Services/Configuration/AccountOwnerOptions.cs
Normal file
8
SeaHaven.Services/Configuration/AccountOwnerOptions.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SeaHaven.Services.Configuration;
|
||||
|
||||
public sealed class AccountOwnerOptions
|
||||
{
|
||||
public const string SectionName = "TeamMembers:AccountOwner";
|
||||
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
31
SeaHaven.Services/Constants/TeamMemberConstants.cs
Normal file
31
SeaHaven.Services/Constants/TeamMemberConstants.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace SeaHaven.Services.Constants;
|
||||
|
||||
public static class TeamMemberConstants
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Roles =
|
||||
new[] { "Dispatcher", "Scheduler", "Admin" };
|
||||
|
||||
public static readonly IReadOnlyList<string> ServiceAreas =
|
||||
new[] { "East", "Central", "West", "California" };
|
||||
|
||||
public static readonly IReadOnlySet<string> Colors = new HashSet<string>(
|
||||
new[]
|
||||
{
|
||||
"#F59E0B", "#0D9488", "#6366F1", "#F43F5E", "#7C3AED",
|
||||
"#0EA5E9", "#16A34A", "#EA580C", "#0891B2", "#C026D3"
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static string? CanonicalRole(string? value) => Canonical(value, Roles);
|
||||
|
||||
public static string? CanonicalArea(string? value) => Canonical(value, ServiceAreas);
|
||||
|
||||
private static string? Canonical(string? value, IReadOnlyList<string> allowed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
return allowed.FirstOrDefault(item =>
|
||||
string.Equals(item, value.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
namespace SeaHaven.Services.Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical, immutable registry of team-members permission keys.
|
||||
/// Exactly the 22 approved prototype keys; stored overrides must reference
|
||||
/// these keys. Keys are stable identifiers and must never be renamed.
|
||||
/// </summary>
|
||||
public static class TeamPermissionKeys
|
||||
{
|
||||
public const string ManageTeamMembers = "manageTeamMembers";
|
||||
public const string ChangeTeamMemberRole = "changeTeamMemberRole";
|
||||
public const string CreateVendors = "createVendors";
|
||||
public const string EditVendors = "editVendors";
|
||||
public const string DeactivateVendors = "deactivateVendors";
|
||||
public const string CreateSites = "createSites";
|
||||
public const string EditSites = "editSites";
|
||||
public const string DeleteSites = "deleteSites";
|
||||
public const string CreateServices = "createServices";
|
||||
public const string EditServices = "editServices";
|
||||
public const string DeactivateServices = "deactivateServices";
|
||||
public const string CreateCompletionDocTemplates = "createCompletionDocTemplates";
|
||||
public const string EditCompletionDocTemplates = "editCompletionDocTemplates";
|
||||
public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates";
|
||||
public const string CreateWorkOrders = "createWorkOrders";
|
||||
public const string EditOthersWorkOrders = "editOthersWorkOrders";
|
||||
public const string CancelWorkOrders = "cancelWorkOrders";
|
||||
public const string DeleteWorkOrders = "deleteWorkOrders";
|
||||
public const string RequestUplifts = "requestUplifts";
|
||||
public const string AutoApproveUplifts = "autoApproveUplifts";
|
||||
public const string ReviewUplifts = "reviewUplifts";
|
||||
public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllKeys = Array.AsReadOnly(new[]
|
||||
{
|
||||
ManageTeamMembers,
|
||||
ChangeTeamMemberRole,
|
||||
CreateVendors,
|
||||
EditVendors,
|
||||
DeactivateVendors,
|
||||
CreateSites,
|
||||
EditSites,
|
||||
DeleteSites,
|
||||
CreateServices,
|
||||
EditServices,
|
||||
DeactivateServices,
|
||||
CreateCompletionDocTemplates,
|
||||
EditCompletionDocTemplates,
|
||||
DeleteCompletionDocTemplates,
|
||||
CreateWorkOrders,
|
||||
EditOthersWorkOrders,
|
||||
CancelWorkOrders,
|
||||
DeleteWorkOrders,
|
||||
RequestUplifts,
|
||||
AutoApproveUplifts,
|
||||
ReviewUplifts,
|
||||
ViewAllDispatchersOnDashboard
|
||||
});
|
||||
|
||||
public static IReadOnlyList<string> All => AllKeys;
|
||||
|
||||
public static IReadOnlySet<string> KnownKeys { get; } =
|
||||
new HashSet<string>(AllKeys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsKnown(string? permissionKey) =>
|
||||
!string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,131 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class DashboardStatsQueryDTO
|
||||
{
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
public DateOnly? DateTo { get; set; }
|
||||
public string? DispatcherId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
||||
{
|
||||
public string? Range { get; set; }
|
||||
public int? Year { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardTrendResponseDTO
|
||||
{
|
||||
public string Granularity { get; init; } = "day";
|
||||
public DateOnly Today { get; init; }
|
||||
public IReadOnlyList<DashboardTrendBucketDTO> Buckets { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class DashboardTrendBucketDTO
|
||||
{
|
||||
public DateOnly Date { get; init; }
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public int Total { get; init; }
|
||||
public int Open { get; init; }
|
||||
public int Completed { get; init; }
|
||||
public int Canceled { get; init; }
|
||||
public int Overdue { get; init; }
|
||||
public bool IsCurrent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardWorkloadResponseDTO
|
||||
{
|
||||
public IReadOnlyList<DashboardWorkloadRowDTO> Items { get; init; } = [];
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalDispatchers { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardWorkloadRowDTO
|
||||
{
|
||||
public string DispatcherId { get; init; } = string.Empty;
|
||||
public string DispatcherName { get; init; } = string.Empty;
|
||||
public string? Color { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
public int OpenCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardPerformanceResponseDTO
|
||||
{
|
||||
public IReadOnlyList<DashboardPerformanceRowDTO> Items { get; init; } = [];
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalDispatchers { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardPerformanceRowDTO
|
||||
{
|
||||
public string DispatcherId { get; init; } = string.Empty;
|
||||
public string DispatcherName { get; init; } = string.Empty;
|
||||
public string? Color { get; init; }
|
||||
public decimal CompletionRate { get; init; }
|
||||
public decimal? AverageResolutionDays { get; init; }
|
||||
public int AssignedCount { get; init; }
|
||||
public int CompletedCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardRegionResponseDTO
|
||||
{
|
||||
public IReadOnlyList<DashboardRegionRowDTO> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class DashboardRegionRowDTO
|
||||
{
|
||||
public string Region { get; init; } = string.Empty;
|
||||
public int WorkOrderCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardVendorInsightsResponseDTO
|
||||
{
|
||||
public IReadOnlyList<DashboardVendorInsightsRowDTO> Items { get; init; } = [];
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalVendors { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardVendorInsightsRowDTO
|
||||
{
|
||||
public int VendorCompanyId { get; init; }
|
||||
public string VendorCompanyName { get; init; } = string.Empty;
|
||||
public decimal CompletionRate { get; init; }
|
||||
public decimal RescheduleRate { get; init; }
|
||||
public decimal? AverageResolutionDays { get; init; }
|
||||
public int TotalJobs { get; init; }
|
||||
}
|
||||
|
||||
public class DashboardStatsDTO
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Open { get; set; }
|
||||
public int NotDispatched { get; set; }
|
||||
public int Completed { get; set; }
|
||||
public int ScheduledTomorrow { get; set; }
|
||||
public int PendingUplifts { get; set; }
|
||||
public int AvetaPending { get; set; }
|
||||
public DashboardBreakdownDTO Breakdown { get; set; } = new();
|
||||
public int DueCount { get; set; }
|
||||
public int CompletedDueCount { get; set; }
|
||||
public decimal CompletionRate { get; set; }
|
||||
public decimal? AverageResolutionDays { get; set; }
|
||||
public IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DashboardStatusBucketDTO
|
||||
{
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardBreakdownDTO
|
||||
{
|
||||
public int PM { get; set; }
|
||||
public int Emergency { get; set; }
|
||||
public int Reactive { get; set; }
|
||||
public int Overdue { get; set; }
|
||||
public int Other { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ namespace SeaHaven.Services.DTOs
|
|||
public string Date { get; set; } = string.Empty;
|
||||
public string? Status { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public bool? PendingRegistration { get; set; }
|
||||
public IReadOnlyList<string> ServiceAreas { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public class UserProfileRowDTO
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Email { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
public string? AccountName { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
|
|
|
|||
39
SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs
Normal file
39
SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class ServiceDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Trade { get; set; } = "";
|
||||
public string IconKey { get; set; } = "";
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public LinkedTemplateSummaryDto? CompletionDocTemplate { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LinkedTemplateSummaryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ServiceInput
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public string? IconKey { get; set; }
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public int? CompletionDocTemplateId { get; set; }
|
||||
public List<WorkOrderType>? SupportedWorkOrderTypes { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class ServicesRegistryValidationErrorDto
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
}
|
||||
33
SeaHaven.Services/DTOs/TeamMemberDTOs.cs
Normal file
33
SeaHaven.Services/DTOs/TeamMemberDTOs.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs;
|
||||
|
||||
public sealed record CreateTeamMemberRequestDTO
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Role { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public IReadOnlyList<string>? ServiceAreas { get; init; }
|
||||
public IReadOnlyDictionary<string, UserPermissionState>? PermissionOverrides { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TeamMemberCreatedDTO
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Role { get; init; }
|
||||
public required string Color { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public required IReadOnlyList<string> ServiceAreas { get; init; }
|
||||
public bool PendingRegistration { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CreateTeamMemberOutcomeDTO
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public TeamMemberCreatedDTO? Member { get; init; }
|
||||
}
|
||||
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs;
|
||||
|
||||
public sealed class TeamPermissionProfileDTO
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
public required IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TeamPermissionValueDTO
|
||||
{
|
||||
public required string PermissionKey { get; init; }
|
||||
public UserPermissionState OverrideState { get; init; }
|
||||
public bool IsGranted { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SetTeamPermissionOverrideDTO
|
||||
{
|
||||
public UserPermissionState State { get; init; }
|
||||
}
|
||||
|
||||
public enum TeamPermissionResultStatus
|
||||
{
|
||||
Success,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
InvalidPermissionKey
|
||||
}
|
||||
|
||||
public sealed class TeamPermissionResult<T>
|
||||
{
|
||||
private TeamPermissionResult(TeamPermissionResultStatus status, T? value)
|
||||
{
|
||||
Status = status;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public TeamPermissionResultStatus Status { get; }
|
||||
public T? Value { get; }
|
||||
public bool IsSuccess => Status == TeamPermissionResultStatus.Success;
|
||||
|
||||
public static TeamPermissionResult<T> Success(T value) =>
|
||||
new(TeamPermissionResultStatus.Success, value);
|
||||
|
||||
public static TeamPermissionResult<T> Failure(TeamPermissionResultStatus status) =>
|
||||
new(status, default);
|
||||
}
|
||||
|
|
@ -8,6 +8,12 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? PONumber { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? VendorCompanyName { get; set; }
|
||||
// Approval queue read contract: flattened work-order context and requester.
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSite { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
public string? RequestedByName { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public decimal Delta { get; set; }
|
||||
|
|
@ -26,6 +32,14 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? NotificationStatus { get; set; }
|
||||
public string? NotificationError { get; set; }
|
||||
public int AttachmentCount { get; set; }
|
||||
public bool WorkOrderClosed { get; set; }
|
||||
// Approved-on-WO exposure totals for this item's work order: auto-approved and
|
||||
// admin-approved amounts; pending, rejected, cancelled/withdrawn, and revoked
|
||||
// requests are excluded.
|
||||
public decimal WorkOrderAutoApprovedTotal { get; set; }
|
||||
public decimal WorkOrderAdminApprovedTotal { get; set; }
|
||||
public decimal WorkOrderApprovedExposureTotal { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftListResultDTO
|
||||
|
|
@ -33,6 +47,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public decimal PendingExposureTotal { get; set; }
|
||||
public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class VendorRosterDTO
|
||||
|
|
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
public string? AreaName { get; set; }
|
||||
public string? RowVersion { get; set; }
|
||||
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
|
||||
}
|
||||
|
|
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// SH-278: optional Area id; null leaves the new company Unassigned. Assigning
|
||||
// an Area requires the Admin role.
|
||||
public int? AreaId { get; set; }
|
||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// SH-278: an omitted areaId leaves the stored Area unchanged, so clients that
|
||||
// predate Area never clear it. An explicit null clears it to Unassigned.
|
||||
private int? _areaId;
|
||||
public int? AreaId
|
||||
{
|
||||
get => _areaId;
|
||||
set
|
||||
{
|
||||
_areaId = value;
|
||||
AreaIdProvided = true;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool AreaIdProvided { get; private set; }
|
||||
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||
}
|
||||
|
||||
|
|
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId.
|
||||
private int? _areaId;
|
||||
public int? AreaId
|
||||
{
|
||||
get => _areaId;
|
||||
set
|
||||
{
|
||||
_areaId = value;
|
||||
AreaIdProvided = true;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool AreaIdProvided { get; private set; }
|
||||
}
|
||||
|
||||
// SH-278: raised when a caller without the Admin role tries to change a vendor
|
||||
// company's Area. Mapped to a stable 403 by the API.
|
||||
public sealed class VendorAreaAssignmentForbiddenException : Exception
|
||||
{
|
||||
public VendorAreaAssignmentForbiddenException()
|
||||
: base("Only administrators can assign a vendor company Area.")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? AreaId { get; set; }
|
||||
public string? AreaName { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue