Merge branch 'dev' into fix/ab/sh-287-backend-staging-lane

This commit is contained in:
Alexandre Brandizzi 2026-09-17 14:10:11 -03:00 • committed by GitHub
commit 6db9ad758f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 33668 additions and 129 deletions

View 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.");
}
}

View 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

View file

@ -26,7 +26,7 @@ public class LocationControllerTests
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData() public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
{ {
var service = new Mock<ILocationService>(); 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> .ReturnsAsync(new PagedResult<LocationDTO>
{ {
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } }, Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
@ -49,7 +49,7 @@ public class LocationControllerTests
public async Task GetLocationList_PassesStatesFilterToService() public async Task GetLocationList_PassesStatesFilterToService()
{ {
var service = new Mock<ILocationService>(); 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> .ReturnsAsync(new PagedResult<LocationDTO>
{ {
Items = new List<LocationDTO>(), Items = new List<LocationDTO>(),
@ -58,7 +58,7 @@ public class LocationControllerTests
PageSize = 10 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>(); result.Should().BeOfType<OkObjectResult>();
service.VerifyAll(); service.VerifyAll();
@ -68,13 +68,13 @@ public class LocationControllerTests
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape() public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
{ {
var service = new Mock<ILocationService>(); 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[] .ThrowsAsync(new ValidationException(new[]
{ {
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.") 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 bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject; var response = bad.Value.Should().BeOfType<Response>().Subject;
@ -200,4 +200,90 @@ public class LocationControllerTests
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject; var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found"); 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");
}
} }

View file

@ -86,4 +86,129 @@ public class LocationDataServiceTests
totalCount.Should().Be(2); totalCount.Should().Be(2);
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO"); 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");
}
} }

View file

@ -131,8 +131,8 @@ public class LocationServiceTests
{ {
var data = new Mock<ILocationDataService>(); var data = new Mock<ILocationDataService>();
IReadOnlyCollection<string>? captured = default; IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>())) 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>((_, _, _, states, _) => captured = states) .Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
.ReturnsAsync((new List<Locations>(), 0)); .ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None); 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>(); var data = new Mock<ILocationDataService>();
IReadOnlyCollection<string>? captured = default; 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>())) 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>((_, _, _, stateFilter, _) => captured = stateFilter) .Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
.ReturnsAsync((new List<Locations>(), 0)); .ReturnsAsync((new List<Locations>(), 0));
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None); 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("ZZ")
&& e.ErrorMessage.Contains("QQ") && e.ErrorMessage.Contains("QQ")
&& !e.ErrorMessage.Contains("TX")); && !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] [Fact]
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes() public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
{ {
var data = new Mock<ILocationDataService>(); 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)); .ReturnsAsync((new List<Locations>(), 0));
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All); var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
@ -533,4 +533,97 @@ public class LocationServiceTests
again.Should().BeFalse(); again.Should().BeFalse();
ctx.Locations.Should().BeEmpty(); 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");
}
} }

View file

@ -430,7 +430,7 @@ public class LocationSiteContactsTests
public async Task List_LoadsPageContactsInSingleBatchedRead() public async Task List_LoadsPageContactsInSingleBatchedRead()
{ {
var data = new Mock<ILocationDataService>(); 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> .ReturnsAsync((new List<Locations>
{ {
new() { Id = 1, Name = "One" }, new() { Id = 1, Name = "One" },

View 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>();
}
}

View 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();
}
}

View 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"));
}

View file

@ -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"
});
}
}

View file

@ -52,6 +52,56 @@ public class UpliftControllerTests
envelope.Status.Should().Be("Success"); 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] [Fact]
public async Task Approve_NotFound_Returns404() public async Task Approve_NotFound_Returns404()
{ {
@ -67,6 +117,49 @@ public class UpliftControllerTests
nf.Value.Should().BeOfType<Response>(); 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] [Fact]
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally() public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
{ {

View 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();
}
}

View file

@ -5,6 +5,7 @@ using Microsoft.Extensions.Options;
using Moq; using Moq;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration; using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using Xunit; using Xunit;
@ -13,6 +14,58 @@ namespace Api.SeaHavenIndustries.Tests;
public sealed class UpliftServiceRefusedTests 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] [Fact]
public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest() public async Task ApproveAsync_RefusedDispatch_RejectsWithoutChangingNteOrRequest()
{ {

View file

@ -90,6 +90,23 @@ public class UserServiceTests
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once); userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact]
public async Task DeleteUser_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] [Fact]
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade() public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
{ {
@ -138,6 +155,26 @@ public class UserServiceTests
outcome.Error.Should().Be(UserMutationErrors.UserNotFound); 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] [Fact]
public async Task GetUserProfile_MapsCreatedDateToAddedDate() public async Task GetUserProfile_MapsCreatedDateToAddedDate()
{ {

View file

@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>(); 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] [Fact]
public async Task Create_Returns200() public async Task Create_Returns200()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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()); .ReturnsAsync(SampleRoster());
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); 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() public async Task Create_ValidationException_Returns400()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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.")); .ThrowsAsync(new ValidationException("At least one company phone or email is required."));
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None); 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() public async Task Create_DuplicateName_ReturnsStable409()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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( .ThrowsAsync(new VendorRosterDuplicateNameException(
"database detail", "database detail",
new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); 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); var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>(); 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] [Fact]
public async Task Reconcile_ValidationException_Returns400() public async Task Reconcile_ValidationException_Returns400()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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.")); .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); 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() public async Task Reconcile_CompanyNotFound_Returns404()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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")); .ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); 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 } new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
}; };
var service = new Mock<IVendorCompanyRosterService>(); 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)); .ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None); 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() public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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] ...")); .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); 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); var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>(); 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] [Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster() public async Task Patch_AddsTechnicians_Returns200WithRoster()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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()); .ReturnsAsync(SampleRoster());
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_ValidationException_Returns400() public async Task Patch_ValidationException_Returns400()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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.")); .ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_CompanyNotFound_Returns404() public async Task Patch_CompanyNotFound_Returns404()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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")); .ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{ {
var service = new Mock<IVendorCompanyRosterService>(); 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] ...")); .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); 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);
}
} }

View file

@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
await act.Should().ThrowAsync<OperationCanceledException>(); 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();
}
} }

View file

@ -3,6 +3,7 @@ using FluentAssertions;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Moq; using Moq;
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterServiceTests 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 VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>(); var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme" }; 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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); 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 data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); .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" } } 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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme", Name = "Acme",
Email = "acme@example.com", Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>() Technicians = new List<RosterTechnicianInputDTO>()
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull(); captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty(); captured!.Technicians.Should().BeEmpty();
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme", Name = "Acme",
Email = "acme@example.com", Email = "acme@example.com",
Notes = notes Notes = notes
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes); captured!.Notes.Should().Be(notes);
} }
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501) 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error => .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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); 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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); 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" 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); .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" } new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
} }
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme Co", Name = "Acme Co",
Email = "acme@example.com", Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } } Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull(); captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7); captured!.CompanyId.Should().Be(7);
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme", Name = "Acme",
Email = "acme@example.com", Email = "acme@example.com",
Notes = notes Notes = notes
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes); captured!.Notes.Should().Be(notes);
} }
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501) 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error => .Which.Errors.Should().ContainSingle(error =>
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com" 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com" 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
Technicians = null! Technicians = null!
}; };
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None); await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().BeEmpty(); 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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); 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" } new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
} }
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().ContainSingle(); captured!.Technicians.Should().ContainSingle();
captured.Technicians.Single().ContactName.Should().BeEmpty(); captured.Technicians.Single().ContactName.Should().BeEmpty();
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } } 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>(); await act.Should().ThrowAsync<ValidationException>();
} }
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme", Name = "Acme",
Email = "acme@example.com", Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } } 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); 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() public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{ {
var data = new Mock<IVendorCompanyRosterDataService>(); 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>(); await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); 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 data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" }); 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians)); .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>(); var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }; 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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never); 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" } } 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion)); .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>(); var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" }); 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); .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.Should().NotBeNull();
captured!.CompanyId.Should().Be(7); captured!.CompanyId.Should().Be(7);
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
{ {
RowVersion = "AAAAAAAAD8I=", RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes } CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
}, "42", CancellationToken.None); }, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields!.Notes.Should().Be(notes); captured!.CompanyFields!.Notes.Should().Be(notes);
} }
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) } 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>()) (await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error => .Which.Errors.Should().ContainSingle(error =>
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } 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 // 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 // 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 = "" } 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>(); await act.Should().ThrowAsync<ValidationException>();
data.Verify( data.Verify(
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" } 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.Should().NotBeNull();
captured.CompanyFields!.City.Should().Be("Norfolk"); captured.CompanyFields!.City.Should().Be("Norfolk");
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
.ReturnsAsync(SampleReadModel(7, 1)); .ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource(); 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); 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);
}
} }

View file

@ -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<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<CancellationToken>()), Times.Never); 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>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token)) token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO> .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>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once); token), Times.Once);
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync( service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(), It.IsAny<int>(),

View file

@ -470,12 +470,14 @@ public class VendorDataServiceTests
search: "alice", search: "alice",
trades: new[] { "Plumbing" }, trades: new[] { "Plumbing" },
locations: new[] { "Chicago, IL" }, 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("GROUP BY");
sql.Should().Contain("LEFT JOIN"); sql.Should().Contain("LEFT JOIN");
sql.Should().Contain("TradeSpecialties"); sql.Should().Contain("TradeSpecialties");
sql.Should().Contain("TotalJobs"); sql.Should().Contain("TotalJobs");
sql.Should().Contain("AreaId");
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase) Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts"); .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("SELECT");
sql.Should().Contain("VendorCompanies"); 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");
}
} }

View file

@ -166,6 +166,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token)) token))
.ReturnsAsync((new[] .ReturnsAsync((new[]
{ {
@ -212,6 +213,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token), Times.Once); token), Times.Once);
} }
@ -228,6 +230,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>())) It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0)); .ReturnsAsync((new List<VendorCompanyGroup>(), 0));
@ -756,6 +759,8 @@ public class VendorServiceTests
}); });
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>())) data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>()); .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); var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
@ -794,6 +799,8 @@ public class VendorServiceTests
}); });
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>())) data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>()); .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); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -822,6 +829,8 @@ public class VendorServiceTests
}); });
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>())) data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>()); .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); var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -849,6 +858,8 @@ public class VendorServiceTests
SortOrder = index + 1 SortOrder = index + 1
}) })
.ToList()); .ToList());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, cts.Token); var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
@ -898,4 +909,96 @@ public class VendorServiceTests
TradeCatalog.CanonicalTrades, TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering()); 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);
}
} }

View 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!");
}
}
}

View file

@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests;
/// combination is registered more than once by different actions. /// 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 /// 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, /// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder)
/// GetWorkorderById) and the two shared controller-level base routes. /// and the two shared controller-level base routes.
/// </summary> /// </summary>
public class WorkOrderRouteContractTests public class WorkOrderRouteContractTests
{ {
@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes). /// come from 51 actions (Editworkorder binds two routes).
/// </summary> /// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{ {

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers namespace Api.SeaHavenIndustries.Controllers
@ -17,17 +18,159 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpGet("Stats")] [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 return Ok(new
{ {
total = stats.Total, total = stats.Total,
open = stats.Open, open = stats.Open,
notDispatched = stats.NotDispatched, 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
});
}
}
} }
} }

View file

@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
} }
[HttpGet("GetLocationList")] [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 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 var data = pagedResult.Items.Select(l => new
{ {
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = l.Email, ContactEmail = l.Email,
Status = l.Status, Status = l.Status,
AccountId = l.AccountId, AccountId = l.AccountId,
ClientName = l.AccountName,
AccountName = l.AccountName,
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList() Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
}); });

View 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 });
}
}
}

View 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);
}
}

View file

@ -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." })
};
}
}

View file

@ -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")] [HttpGet("can-approve")]
public IActionResult CanApprove([FromQuery] int tier) public IActionResult CanApprove([FromQuery] int tier)
{ {

View file

@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -70,7 +71,7 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken); var roster = await _rosterService.CreateRosterAsync(model, userId, User, cancellationToken);
return Ok(roster); return Ok(roster);
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -82,6 +83,12 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); 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) catch (VendorRosterDuplicateNameException dup)
{ {
return Conflict(new return Conflict(new
@ -109,7 +116,7 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken); var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster); return Ok(roster);
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -121,6 +128,12 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); 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) catch (KeyNotFoundException)
{ {
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
@ -164,7 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
try try
{ {
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken); var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, User, cancellationToken);
return Ok(roster); return Ok(roster);
} }
catch (ValidationException vex) catch (ValidationException vex)
@ -176,6 +189,12 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); 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) catch (KeyNotFoundException)
{ {
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" }); return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });

View file

@ -97,6 +97,7 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string[]? trades = null, [FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null, [FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null, [FromQuery] string[]? jobBuckets = null,
[FromQuery] string[]? areas = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync( var pagedResult = await _vendorService.GetVendorCompanyDirectoryPagedAsync(
@ -108,6 +109,7 @@ namespace Api.SeaHavenIndustries.Controllers
trades, trades,
locations, locations,
jobBuckets, jobBuckets,
areas,
cancellationToken); cancellationToken);
var data = pagedResult.Items.Select(v => new var data = pagedResult.Items.Select(v => new
@ -129,6 +131,8 @@ namespace Api.SeaHavenIndustries.Controllers
v.Notes, v.Notes,
v.IsActive, v.IsActive,
v.TotalJobs, v.TotalJobs,
v.AreaId,
v.AreaName,
Technicians = v.Technicians.Select(t => new Technicians = v.Technicians.Select(t => new
{ {
t.Id, t.Id,

View file

@ -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")] [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 try
{ {

View file

@ -222,6 +222,13 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
using (var ownerScope = app.Services.CreateScope())
{
await ownerScope.ServiceProvider
.GetRequiredService<IAccountOwnerDesignationService>()
.EnsureConfiguredOwnerAsync(CancellationToken.None);
}
//if (app.Environment.IsDevelopment()) //if (app.Environment.IsDevelopment())
//{ //{
// using var scope = app.Services.CreateScope(); // using var scope = app.Services.CreateScope();

View file

@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries
.HasIndex(w => w.InternalWONumber) .HasIndex(w => w.InternalWONumber)
.HasFilter("[istemplate] = 0"); .HasFilter("[istemplate] = 0");
builder.Entity<WorkOrder>()
.HasOne(w => w.ServiceDefinition)
.WithMany()
.HasForeignKey(w => w.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<WorkOrder>() builder.Entity<WorkOrder>()
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
.HasFilter("[istemplate] = 0"); .HasFilter("[istemplate] = 0");
@ -170,6 +176,12 @@ namespace Data.SeaHavenIndustries
builder.Entity<ApplicationUser>() builder.Entity<ApplicationUser>()
.HasIndex(u => u.AccountId); .HasIndex(u => u.AccountId);
builder.Entity<ApplicationUser>()
.HasIndex(u => u.IsAccountOwner)
.IsUnique()
.HasFilter("IsAccountOwner = 1")
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner");
builder.Entity<VendorCompany>() builder.Entity<VendorCompany>()
.HasIndex(c => c.NormalizedName) .HasIndex(c => c.NormalizedName)
.IsUnique(); .IsUnique();
@ -206,6 +218,118 @@ namespace Data.SeaHavenIndustries
builder.Entity<Trade>() builder.Entity<Trade>()
.HasIndex(t => t.NormalizedName) .HasIndex(t => t.NormalizedName)
.IsUnique(); .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<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; } public DbSet<Locations> Locations { get; set; }
@ -228,6 +352,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; } public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; } public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Trade> Trades { get; set; } public DbSet<Trade> Trades { get; set; }
public DbSet<Area> Areas { get; set; }
public DbSet<Vendor> Vendors { get; set; } public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; } public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; } public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
@ -241,6 +366,8 @@ namespace Data.SeaHavenIndustries
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; } public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
public DbSet<TaskListTemplate> TaskListTemplates { get; set; } public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
public DbSet<TaskListTemplateItem> TaskListTemplateItems { 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 // New DbSets for added entities
public DbSet<Accounts> Accounts { get; set; } public DbSet<Accounts> Accounts { get; set; }
@ -259,6 +386,8 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; } public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; } public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; } public DbSet<Region> Regions { get; set; }
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
public override int SaveChanges() public override int SaveChanges()
{ {
@ -338,12 +467,16 @@ namespace Data.SeaHavenIndustries
public string? Initials { get; set; } public string? Initials { get; set; }
public string? Color { get; set; } public string? Color { get; set; }
public int? Type { get; set; } // 1 for users 0 for admin public int? Type { get; set; } // 1 for users 0 for admin
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> /// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
public int? AccountId { get; set; } public int? AccountId { get; set; }
[ForeignKey(nameof(AccountId))] [ForeignKey(nameof(AccountId))]
public virtual Accounts? Account { get; set; } public virtual Accounts? Account { get; set; }
public ICollection<Template>? Templates { get; set; } public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; } public ICollection<WorkOrder>? WorkOrders { get; set; }
public ICollection<UserServiceArea> ServiceAreas { get; set; } = new List<UserServiceArea>();
} }
public class FullAuditEntity public class FullAuditEntity
{ {

View 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; }
}
}

View 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!;
}

View 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
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -220,6 +220,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Initials") b.Property<string>("Initials")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<bool>("IsAccountOwner")
.HasColumnType("bit");
b.Property<bool?>("IsDeleted") b.Property<bool?>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@ -246,6 +249,12 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<bool?>("PendingRegistration")
.HasColumnType("bit");
b.Property<DateTime?>("PendingRegistrationCreatedDate")
.HasColumnType("datetime2");
b.Property<string>("PhoneNumber") b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -275,6 +284,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AccountId"); b.HasIndex("AccountId");
b.HasIndex("IsAccountOwner")
.IsUnique()
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner")
.HasFilter("IsAccountOwner = 1");
b.HasIndex("NormalizedEmail") b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex"); .HasDatabaseName("EmailIndex");
@ -286,6 +300,35 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("AspNetUsers", (string)null); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1762,6 +1805,121 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Regions"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2043,6 +2201,47 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Trades"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2237,6 +2436,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address") b.Property<string>("Address")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("AreaId")
.HasColumnType("int");
b.Property<string>("City") b.Property<string>("City")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@ -2294,6 +2496,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AreaId");
b.HasIndex("NormalizedName") b.HasIndex("NormalizedName")
.IsUnique(); .IsUnique();
@ -2592,6 +2796,13 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<int?>("ServiceId")
.HasColumnType("int");
b.Property<string>("ServiceNameSnapshot")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ServiceNotes") b.Property<string>("ServiceNotes")
.HasMaxLength(4000) .HasMaxLength(4000)
.HasColumnType("nvarchar(4000)"); .HasColumnType("nvarchar(4000)");
@ -2676,6 +2887,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ScheduledDate"); b.HasIndex("ScheduledDate");
b.HasIndex("ServiceId");
b.HasIndex("SiteCode") b.HasIndex("SiteCode")
.HasFilter("[istemplate] = 0"); .HasFilter("[istemplate] = 0");
@ -3521,6 +3734,27 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.Locations", "Location") b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
@ -3582,6 +3816,28 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("POC"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
@ -3614,6 +3870,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor"); 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 => modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{ {
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
@ -3663,6 +3929,11 @@ namespace Data.SeaHavenIndustries.Migrations
.HasForeignKey("PrimaryDispatchId") .HasForeignKey("PrimaryDispatchId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account"); b.Navigation("Account");
b.Navigation("AssignToUser"); b.Navigation("AssignToUser");
@ -3670,6 +3941,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Locations"); b.Navigation("Locations");
b.Navigation("PrimaryDispatch"); b.Navigation("PrimaryDispatch");
b.Navigation("ServiceDefinition");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -3816,6 +4089,8 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
{ {
b.Navigation("ServiceAreas");
b.Navigation("Templates"); b.Navigation("Templates");
b.Navigation("WorkOrders"); b.Navigation("WorkOrders");
@ -3889,6 +4164,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Employees"); b.Navigation("Employees");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
{
b.Navigation("SupportedWorkOrderTypes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");

View 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;
}
}

View 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; }
}
}

View file

@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { 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] [Timestamp]
public byte[]? RowVersion { get; set; } public byte[]? RowVersion { get; set; }

View file

@ -81,6 +81,15 @@ namespace Data.SeaHavenIndustries
public string? PONumber { get; set; } public string? PONumber { get; set; }
public int? WorkOrderId { get; set; } public int? WorkOrderId { get; set; }
public string? VendorCompanyName { 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? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; } public decimal RequestedNTE { get; set; }
public string? VendorReason { get; set; } public string? VendorReason { get; set; }
@ -97,6 +106,8 @@ namespace Data.SeaHavenIndustries
public DateTime? ExpiresAt { get; set; } public DateTime? ExpiresAt { get; set; }
public string? NotificationStatus { get; set; } public string? NotificationStatus { get; set; }
public string? NotificationError { get; set; } public string? NotificationError { get; set; }
public int AttachmentCount { get; set; }
public bool WorkOrderClosed { get; set; }
} }
public class UpliftForWorkOrderData : UpliftForDispatchData public class UpliftForWorkOrderData : UpliftForDispatchData
@ -130,6 +141,16 @@ namespace Data.SeaHavenIndustries
public bool EvidenceScanPassed { get; set; } 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 // 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. // returned document is the one linked to this specific uplift request and dispatch.
public class UpliftEvidenceDownloadData public class UpliftEvidenceDownloadData

View file

@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary> /// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)] [MaxLength(128)]
public string? Service { get; set; } 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; } public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))] [ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; } public virtual Dispatch? PrimaryDispatch { get; set; }

View 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);
}

View file

@ -8,6 +8,10 @@ namespace SeaHaven.DataServices.Dto
public string? Name { get; init; } public string? Name { get; init; }
public DateTime? CreatedDate { get; init; } public DateTime? CreatedDate { get; init; }
public string? Status { 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 public sealed class UserProfileData

View file

@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers
w.OriginalDate, w.OriginalDate,
w.OriginalWeek, w.OriginalWeek,
w.WorkOrderType, w.WorkOrderType,
w.ServiceId,
w.ServiceNameSnapshot,
w.SiteCode, w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null, LocationName = w.Locations != null ? w.Locations.Name : null,
WoPocName = w.PocName, WoPocName = w.PocName,
@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers
w.CarriedOver, w.CarriedOver,
w.IsAddOn, w.IsAddOn,
w.WorkOrderType, w.WorkOrderType,
w.ServiceId,
w.ServiceNameSnapshot,
frozenSite?.Code ?? w.SiteCode, frozenSite?.Code ?? w.SiteCode,
frozenSite?.Label ?? w.LocationName, frozenSite?.Label ?? w.LocationName,
pocName, pocName,

View file

@ -121,6 +121,40 @@ namespace SeaHaven.DataServices.Helpers
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode)); 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( public static IQueryable<WorkOrder> ApplyStatusFilter(
IQueryable<WorkOrder> query, IQueryable<WorkOrder> query,
IReadOnlyList<LifecycleStatus>? statuses) IReadOnlyList<LifecycleStatus>? statuses)

View 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);
}
}

View file

@ -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;
}
}

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation namespace SeaHaven.DataServices.Implementation
@ -13,9 +15,15 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _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); 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 total = await query.CountAsync(cancellationToken);
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", 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 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();
}
} }
} }

View file

@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
int pageSize, int pageSize,
string? search, string? search,
IReadOnlyCollection<string>? states, 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)) if (!string.IsNullOrWhiteSpace(search))
{ {
query = query.Where(l => query = query.Where(l =>
l.Name!.Contains(search) || 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 }) 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 = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
} }
query = ApplyListSort(query, sortBy, sortDirection);
var totalCount = await query.CountAsync(cancellationToken); var totalCount = await query.CountAsync(cancellationToken);
var items = await query var items = await query
.OrderBy(l => l.Name)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount); 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) public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
{ {
return await _context.Locations return await _context.Locations

View file

@ -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));
}
}
}

View file

@ -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);
}
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
@ -24,8 +25,15 @@ namespace SeaHaven.DataServices.Implementation
from v in vendors.DefaultIfEmpty() from v in vendors.DefaultIfEmpty()
join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences join ev in _context.VendorCompletionDocuments on u.EvidenceDocumentId equals ev.Id into evidences
from ev in evidences.DefaultIfEmpty() 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) 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)) if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.u.Status == status); query = query.Where(x => x.u.Status == status);
@ -34,8 +42,18 @@ namespace SeaHaven.DataServices.Implementation
var total = await query.CountAsync(cancellationToken); 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 var items = await query
.OrderByDescending(x => x.u.CreatedDate)
.Skip((Math.Max(page, 1) - 1) * pageSize) .Skip((Math.Max(page, 1) - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => new UpliftListItemData .Select(x => new UpliftListItemData
@ -46,6 +64,17 @@ namespace SeaHaven.DataServices.Implementation
PONumber = x.d.PONumber, PONumber = x.d.PONumber,
WorkOrderId = x.d.WorkOrderId, WorkOrderId = x.d.WorkOrderId,
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, 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, CurrentNTE = x.u.CurrentNTE,
RequestedNTE = x.u.RequestedNTE, RequestedNTE = x.u.RequestedNTE,
VendorReason = x.u.VendorReason, VendorReason = x.u.VendorReason,
@ -60,13 +89,29 @@ namespace SeaHaven.DataServices.Implementation
EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null, EvidenceSizeBytes = x.ev != null ? x.ev.SizeBytes : null,
ExpiresAt = x.u.ExpiresAt, ExpiresAt = x.u.ExpiresAt,
NotificationStatus = x.u.NotificationStatus, 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); .ToListAsync(cancellationToken);
return (total, items); 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) public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
{ {
return await (from u in _context.DispatchUpliftRequests return await (from u in _context.DispatchUpliftRequests
@ -200,6 +245,27 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); .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) public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
{ {
return await _context.DispatchUpliftRequests return await _context.DispatchUpliftRequests
@ -252,6 +318,89 @@ namespace SeaHaven.DataServices.Implementation
.SumAsync(u => u.RequestedNTE, cancellationToken); .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( public Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(
int workOrderId, int workOrderId,
CancellationToken cancellationToken) CancellationToken cancellationToken)

View file

@ -42,11 +42,22 @@ namespace SeaHaven.DataServices.Implementation
.Select(user => new UserListRowData .Select(user => new UserListRowData
{ {
Id = user.Id, 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, Email = user.Email,
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(), Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
CreatedDate = user.CreatedDate, 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); .ToListAsync(cancellationToken);
} }
@ -93,6 +104,15 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync(cancellationToken); .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) public async Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken)
{ {
var normalizedEmail = email.Trim().ToUpperInvariant(); var normalizedEmail = email.Trim().ToUpperInvariant();
@ -135,6 +155,10 @@ namespace SeaHaven.DataServices.Implementation
.Where(role => role.UserId == id) .Where(role => role.UserId == id)
.ExecuteDeleteAsync(cancellationToken); .ExecuteDeleteAsync(cancellationToken);
await _context.UserPermissionOverrides
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.Users await _context.Users
.Where(existingUser => existingUser.Id == id) .Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken); .ExecuteDeleteAsync(cancellationToken);

View file

@ -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);
}
}

View file

@ -17,6 +17,11 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _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( public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
int? vendorId, int? vendorId,
int? companyId, int? companyId,
@ -57,6 +62,8 @@ namespace SeaHaven.DataServices.Implementation
Zip = c.Zip, Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl, GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes, Notes = c.Notes,
AreaId = c.AreaId,
AreaName = c.Area != null ? c.Area.Name : null,
RowVersion = c.RowVersion RowVersion = c.RowVersion
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@ -114,6 +121,8 @@ namespace SeaHaven.DataServices.Implementation
company.Zip = roster.Zip; company.Zip = roster.Zip;
company.GoogleMapsUrl = roster.GoogleMapsUrl; company.GoogleMapsUrl = roster.GoogleMapsUrl;
company.Notes = roster.Notes; company.Notes = roster.Notes;
if (roster.AreaIdProvided)
company.AreaId = roster.AreaId;
company.LastModificationTime = now; company.LastModificationTime = now;
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null; int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
if (actorId.HasValue) if (actorId.HasValue)
@ -245,6 +254,7 @@ namespace SeaHaven.DataServices.Implementation
Zip = roster.Zip, Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl, GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes, Notes = roster.Notes,
AreaId = roster.AreaId,
CreatedDate = now, CreatedDate = now,
createdby = roster.ActorUserId createdby = roster.ActorUserId
}; };
@ -367,6 +377,8 @@ namespace SeaHaven.DataServices.Implementation
company.GoogleMapsUrl = fields.GoogleMapsUrl; company.GoogleMapsUrl = fields.GoogleMapsUrl;
if (fields.Notes != null) if (fields.Notes != null)
company.Notes = fields.Notes; company.Notes = fields.Notes;
if (fields.AreaIdProvided)
company.AreaId = fields.AreaId;
// Keep the denormalized company columns on existing technicians aligned // Keep the denormalized company columns on existing technicians aligned
// with the company row (same invariant SaveRosterAsync maintains). This // with the company row (same invariant SaveRosterAsync maintains). This

View file

@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
return query.OrderBy(c => c.Id); 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) public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken); => await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? trades = null, IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null, IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null, IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Phase 1: select and paginate bounded company group keys in SQL. // Phase 1: select and paginate bounded company group keys in SQL.
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
companies, companies,
trades, trades,
locations, locations,
jobBuckets); jobBuckets,
areaFilter);
var totalCount = await keyQuery.CountAsync(cancellationToken); var totalCount = await keyQuery.CountAsync(cancellationToken);
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
? new Dictionary<int, VendorCompany>() ? new Dictionary<int, VendorCompany>()
: await _context.VendorCompanies : await _context.VendorCompanies
.AsNoTracking() .AsNoTracking()
.Include(c => c.Area)
.Where(c => missingCompanyIds.Contains(c.Id)) .Where(c => missingCompanyIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, cancellationToken); .ToDictionaryAsync(c => c.Id, cancellationToken);
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst), TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
GoogleMapsUrl = primary.GoogleMapsUrl, GoogleMapsUrl = primary.GoogleMapsUrl,
Notes = linked ? primary.CompanyNotes : null, Notes = linked ? primary.CompanyNotes : null,
AreaId = linked ? primary.CompanyAreaId : null,
AreaName = linked ? primary.CompanyAreaName : null,
IsActive = primary.IsActive, IsActive = primary.IsActive,
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs), TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null, TradeSpecialties = null,
GoogleMapsUrl = company.GoogleMapsUrl, GoogleMapsUrl = company.GoogleMapsUrl,
Notes = company.Notes, Notes = company.Notes,
AreaId = company.AreaId,
AreaName = company.Area?.Name,
IsActive = true, IsActive = true,
TotalJobs = 0, TotalJobs = 0,
Technicians = Array.Empty<VendorCompanyGroupTechnician>() Technicians = Array.Empty<VendorCompanyGroupTechnician>()
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? companies = null, IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null, IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null, IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null) IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null)
{ {
var sourceRows = BuildDirectoryUnionBaseQuery(isActive) var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations)); .Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
var grouped = sourceRows var grouped = sourceRows
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey }) .GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
SearchMatch = group.Max(row => row.SearchFlag), SearchMatch = group.Max(row => row.SearchFlag),
CompanyMatch = group.Max(row => row.CompanyFlag), CompanyMatch = group.Max(row => row.CompanyFlag),
TradeMatch = group.Max(row => row.TradeFlag), 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 // 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 }) if (selectedLocations is { Length: > 0 })
grouped = grouped.Where(key => key.LocationMatch == 1); grouped = grouped.Where(key => key.LocationMatch == 1);
if (areaFilter != null)
grouped = grouped.Where(key => key.AreaMatch == 1);
var selectedJobBuckets = jobBuckets? var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value)) .Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower()) .Select(value => value.Trim().ToLower())
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
string? search, string? search,
IReadOnlyCollection<string>? companies, IReadOnlyCollection<string>? companies,
IReadOnlyCollection<string>? trades, IReadOnlyCollection<string>? trades,
IReadOnlyCollection<string>? locations) IReadOnlyCollection<string>? locations,
VendorAreaFilter? areaFilter)
{ {
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row"); var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
var locationBody = selectedLocations is { Length: > 0 } var locationBody = selectedLocations is { Length: > 0 }
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations) ? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
: null; : null;
var areaBody = areaFilter == null
? null
: BuildAreaMatchBody(row, areaFilter);
var nameKey = Expression.Condition( var nameKey = Expression.Condition(
Expression.NotEqual( Expression.NotEqual(
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)), Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)), Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)), 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); row);
} }
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
CompanyCity = v.Company != null ? v.Company.City : null, CompanyCity = v.Company != null ? v.Company.City : null,
CompanyState = v.Company != null ? v.Company.State : null, CompanyState = v.Company != null ? v.Company.State : null,
CompanyNotes = v.Company != null ? v.Company.Notes : 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, ContactName = v.ContactName,
Email = v.Email, Email = v.Email,
Phone = v.Phone, Phone = v.Phone,
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = v.TradeSpecialties, TradeSpecialties = v.TradeSpecialties,
GoogleMapsUrl = v.GoogleMapsUrl, GoogleMapsUrl = v.GoogleMapsUrl,
Notes = v.Company != null ? v.Company.Notes : null, Notes = v.Company != null ? v.Company.Notes : null,
AreaId = v.Company != null ? v.Company.AreaId : null,
IsActive = v.IsActive, IsActive = v.IsActive,
TotalJobs = (int?)jobCount.TotalJobs ?? 0 TotalJobs = (int?)jobCount.TotalJobs ?? 0
}; };
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null, TradeSpecialties = null,
GoogleMapsUrl = c.GoogleMapsUrl, GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes, Notes = c.Notes,
AreaId = c.AreaId,
IsActive = true, IsActive = true,
TotalJobs = 0 TotalJobs = 0
}); });
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
return body!; 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( private static Expression BuildCompanyNameMatchBody(
ParameterExpression row, ParameterExpression row,
IReadOnlyCollection<string> selectedCompanies) IReadOnlyCollection<string> selectedCompanies)

View file

@ -24,6 +24,7 @@ namespace SeaHaven.DataServices.Implementation
else else
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter( baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId); baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);

View file

@ -0,0 +1,6 @@
namespace SeaHaven.DataServices.Interfaces;
public interface IAccountOwnerDataService
{
Task<bool> EnsureConfiguredOwnerAsync(string? configuredUserId, CancellationToken cancellationToken);
}

View file

@ -1,10 +1,57 @@
namespace SeaHaven.DataServices.Interfaces namespace SeaHaven.DataServices.Interfaces
{ {
using Data.SeaHavenIndustries.Enums;
public interface IDashboardDataService 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 class DashboardCounts
{ {
public int Total { get; set; } public int Total { get; set; }
@ -12,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
public int NotDispatched { get; set; } public int NotDispatched { get; set; }
public int Completed { get; set; } public int Completed { get; set; }
} }
public sealed record DashboardKpiCounts(
int ScheduledTomorrow,
int PendingUplifts,
int AvetaPending);
} }

View file

@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces
Task<bool> ExistsAsync(int id); Task<bool> ExistsAsync(int id);
Task<int> CountAsync(); 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?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -6,11 +6,13 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync( Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); 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<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task<IReadOnlyList<UpliftForWorkOrderData>> GetForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken); Task<DispatchUpliftRequest?> GetByIdAndWorkOrderAsync(int requestId, int workOrderId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, 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); Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
// SH-101: server-side join of an uplift request with its linked evidence document. // 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. // 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> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken); Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(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); Task<List<DispatchUpliftRequest>> GetPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// SH-101: active = Pending or ChangesRequested (the only states that block a new request). // SH-101: active = Pending or ChangesRequested (the only states that block a new request).
Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken); Task<bool> HasActiveAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -14,6 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken); Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken);
Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken); Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken);
Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken); Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken);
Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken);
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);

View file

@ -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);
}

View file

@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
{ {
public interface IVendorCompanyRosterDataService 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, // Loads the company roster from either a vendor row id and/or a company id,
// returning the company plus all non-deleted technician rows. // returning the company plus all non-deleted technician rows.
Task<VendorCompanyRosterReadModel?> GetRosterAsync( Task<VendorCompanyRosterReadModel?> GetRosterAsync(

View file

@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
// of technician Vendor.TradeSpecialties free text. // of technician Vendor.TradeSpecialties free text.
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken); 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, // SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all // with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
// companies; active includes companies with zero non-deleted technicians or at // 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>? trades = null,
IReadOnlyCollection<string>? locations = null, IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null, IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<Vendor> AddAsync(Vendor vendor); Task<Vendor> AddAsync(Vendor vendor);
Task UpdateAsync(Vendor vendor); Task UpdateAsync(Vendor vendor);

View file

@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces
DateOnly DateTo, DateOnly DateTo,
bool UnscheduledOnly, bool UnscheduledOnly,
IReadOnlyList<string>? Sites, IReadOnlyList<string>? Sites,
IReadOnlyList<string>? Regions,
IReadOnlyList<WorkOrderType>? Types, IReadOnlyList<WorkOrderType>? Types,
bool Overdue, bool Overdue,
IReadOnlyList<string>? Dispatchers, IReadOnlyList<string>? Dispatchers,

View file

@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces
int CarriedOver, int CarriedOver,
bool IsAddOn, bool IsAddOn,
WorkOrderType? WorkOrderType, WorkOrderType? WorkOrderType,
int? ServiceId,
string? ServiceNameSnapshot,
string? SiteCode, string? SiteCode,
string? LocationName, string? LocationName,
string? PocName, string? PocName,

View 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)
{
}
}
}

View file

@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public byte[]? RowVersion { get; set; } public byte[]? RowVersion { get; set; }
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new(); public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
} }
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { 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 string? ActorUserId { get; set; }
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new(); public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
} }
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { 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 // Raised by the roster data service when a technician removed from the snapshot

View file

@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; } public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? AreaId { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int TotalJobs { 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 // 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 // company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with // 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 CompanyFlag { get; set; }
public int TradeFlag { get; set; } public int TradeFlag { get; set; }
public int LocationFlag { 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. // 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 CompanyMatch { get; set; }
public int TradeMatch { get; set; } public int TradeMatch { get; set; }
public int LocationMatch { 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 // 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? CompanyCity { get; set; }
public string? CompanyState { get; set; } public string? CompanyState { get; set; }
public string? CompanyNotes { get; set; } public string? CompanyNotes { get; set; }
public int? CompanyAreaId { get; set; }
public string? CompanyAreaName { get; set; }
public string? ContactName { get; set; } public string? ContactName { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; } public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int TotalJobs { get; set; } public int TotalJobs { get; set; }
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>(); public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();

View 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();
}
}

View 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;
}
}
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Configuration;
public sealed class AccountOwnerOptions
{
public const string SectionName = "TeamMembers:AccountOwner";
public string? UserId { get; set; }
}

View 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));
}
}

View 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);
}
}

View file

@ -1,10 +1,131 @@
namespace SeaHaven.Services.DTOs 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 class DashboardStatsDTO
{ {
public int Total { get; set; } public int Total { get; set; }
public int Open { get; set; } public int Open { get; set; }
public int NotDispatched { get; set; } public int NotDispatched { get; set; }
public int Completed { 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; }
} }
} }

View file

@ -57,6 +57,10 @@ namespace SeaHaven.Services.DTOs
public string Date { get; set; } = string.Empty; public string Date { get; set; } = string.Empty;
public string? Status { get; set; } public string? Status { get; set; }
public string? Id { 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 public class UserProfileRowDTO

View file

@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs
public string? Email { get; set; } public string? Email { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public int? AccountId { get; set; } public int? AccountId { get; set; }
public string? AccountName { get; set; }
public DateTime? CreatedDate { get; set; } public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; } public string? CreatedBy { get; set; }

View 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; } = "";
}
}

View 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; }
}

View 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);
}

View file

@ -8,6 +8,12 @@ namespace SeaHaven.Services.DTOs
public string? PONumber { get; set; } public string? PONumber { get; set; }
public int? WorkOrderId { get; set; } public int? WorkOrderId { get; set; }
public string? VendorCompanyName { 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? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; } public decimal RequestedNTE { get; set; }
public decimal Delta { get; set; } public decimal Delta { get; set; }
@ -26,6 +32,14 @@ namespace SeaHaven.Services.DTOs
public DateTime? ExpiresAt { get; set; } public DateTime? ExpiresAt { get; set; }
public string? NotificationStatus { get; set; } public string? NotificationStatus { get; set; }
public string? NotificationError { 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 public class UpliftListResultDTO
@ -33,6 +47,7 @@ namespace SeaHaven.Services.DTOs
public int Total { get; set; } public int Total { get; set; }
public int Page { get; set; } public int Page { get; set; }
public int PageSize { get; set; } public int PageSize { get; set; }
public decimal PendingExposureTotal { get; set; }
public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>(); public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
} }

View file

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SeaHaven.Services.DTOs namespace SeaHaven.Services.DTOs
{ {
public class VendorRosterDTO public class VendorRosterDTO
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public string? RowVersion { get; set; } public string? RowVersion { get; set; }
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new(); public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
} }
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { 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(); public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
} }
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { 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(); public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
} }
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; } public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { 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.")
{
}
} }
} }

View file

@ -45,6 +45,8 @@ namespace SeaHaven.Services.DTOs
public string? TradeSpecialties { get; set; } public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; } public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public int TotalJobs { get; set; } public int TotalJobs { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new(); public List<VendorDirectoryTechnicianDTO> Technicians { get; set; } = new();

Some files were not shown because too many files have changed in this diff Show more