Compare commits

...

32 commits

Author SHA1 Message Date
Alexandre Brandizzi
3da227c06e Merge remote-tracking branch 'origin/dev' into feat/ab/sh-323-team-permissions
# Conflicts:
#	Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
2026-09-17 00:57:44 -03:00
Alexandre Brandizzi
6edcd2a463
Merge pull request #138 from Sea-Haven-Industries/feat/ab/sh-353-dashboard-kpis-stacked
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-353: Expose dashboard KPI counts
2026-09-16 22:02:17 -03:00
Alexandre Brandizzi
8acc580c5b
Merge branch 'dev' into feat/ab/sh-353-dashboard-kpis-stacked 2026-09-16 21:56:23 -03:00
Alexandre Brandizzi
a3a4ddde2a
Merge pull request #143 from Sea-Haven-Industries/feat/ab/sh-330-sites-list
SH-330: support site registry queries
2026-09-16 21:54:24 -03:00
Adam Moussa
f6bd779fe0
Merge branch 'dev' into feat/ab/sh-353-dashboard-kpis-stacked 2026-09-16 20:00:32 -04:00
Alexandre Brandizzi
9bf45d65ff
Merge branch 'dev' into feat/ab/sh-330-sites-list 2026-09-16 20:58:16 -03:00
Alexandre Brandizzi
db9a94f335
Merge pull request #132 from Sea-Haven-Industries/feat/ab/sh-328-permission-overrides
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-328: expose per-person permission overrides
2026-09-16 20:52:16 -03:00
Alexandre Brandizzi
660ebac628 feat(locations): support site registry queries (SH-330) 2026-09-16 20:45:15 -03:00
Alexandre Brandizzi
21b7083160
Merge branch 'dev' into feat/ab/sh-328-permission-overrides 2026-09-16 20:44:20 -03:00
Alexandre Brandizzi
1c0865ae15
Merge pull request #136 from Sea-Haven-Industries/feat/ab/sh-350-dashboard-trend
SH-350: Add dashboard trend metrics
2026-09-16 20:42:14 -03:00
Alexandre Brandizzi
63bda30595
Merge branch 'dev' into feat/ab/sh-328-permission-overrides 2026-09-16 20:36:19 -03:00
Alexandre Brandizzi
0bda64efc4
Merge branch 'dev' into feat/ab/sh-350-dashboard-trend 2026-09-16 20:36:14 -03:00
Alexandre Brandizzi
452d666547
Merge pull request #142 from Sea-Haven-Industries/fix/ab/sh-374-getworkorderbyid-binding
SH-374: return the work order from GetWorkorderById?id=
2026-09-16 20:34:16 -03:00
Alexandre Brandizzi
af441894d7 fix(permissions): clean up overrides on user delete and converge concurrent override writes (SH-328)
DeleteUserWithCascadeAsync never removed UserPermissionOverrides rows, and
the FK on UserId is Restrict. Once an override was saved for a member, the
admin hard-delete (DeleteUserAsync -> DeleteUserWithCascadeAsync) failed the
foreign key inside the transaction and the controller surfaced it as a 400,
so the user was never deleted. Add an explicit ExecuteDelete on
UserPermissionOverrides before the user is removed, matching how UserRoles is
already cleared in the same method (no schema change).

SetOverrideAsync was check-then-insert on the composite key with no
DbUpdateException handling, so two concurrent PUTs for the same
(UserId, PermissionKey) let the loser violate PK_UserPermissionOverrides and
return 500. Catch the conflict, detach the pending insert, and converge by
updating the persisted row to the caller's requested state.
2026-09-16 20:27:26 -03:00
Alexandre Brandizzi
fed45f423e
Merge branch 'dev' into feat/ab/sh-350-dashboard-trend 2026-09-16 20:26:23 -03:00
Alexandre Brandizzi
93ecbbd410 test(dashboard): guard Stats wire object for KPI counts (SH-353)
The added service test asserts DashboardStatsDTO, which already carried
ScheduledTomorrow, PendingUplifts and AvetaPending, so it does not guard
the regression that was actually shipped: the DashboardController Stats
anonymous response dropping those keys and leaving the tiles empty.

Add DashboardControllerTests.GetStats_SerialisesKpiCountsOnWireObject,
which drives the controller and asserts the three keys are present on the
serialised wire object, so re-dropping any of them fails a test.

Also reword the pending-uplifts comment in DashboardServiceTests: the
Pending-only-vs-ChangesRequested exclusion is the behaviour the code
ships today, an open product call, not a settled review decision.
2026-09-16 20:25:45 -03:00
Alexandre Brandizzi
7bdcc61651 fix(work-orders): bind GetWorkorderById id from the query string (SH-374) 2026-09-16 20:15:30 -03:00
Alexandre Brandizzi
104439c239 fix(dashboard): serialise KPI counts in Stats response (SH-353)
GetKpiCountsAsync computes ScheduledTomorrow, PendingUplifts and
AvetaPending and DashboardStatsDTO carries them, but the Stats
endpoint's anonymous wire object never serialised them, so the tiles
had nothing to read. Add scheduledTomorrow, pendingUplifts and
avetaPending to the response.

Add a DashboardServiceTests case covering GetKpiCountsAsync end to end:
it asserts all three counts and pins the pendingUplifts semantics to
Status "Pending" only, excluding "ChangesRequested" (which is back with
the vendor, not awaiting an approval decision).
2026-09-16 20:07:31 -03:00
Alexandre Brandizzi
5877f7817f fix(dashboard): bucket trend by calendar ScheduledDate (SH-350)
ScheduledDate is persisted as a midnight calendar value (board create writes
request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a
datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and
converted to America/New_York, so midnight became 19:00/20:00 the previous day
and every board-scheduled work order fell into the prior bucket: a job scheduled
today read as yesterday and overdue, and the Today bucket showed zero.

Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching
DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the
same rows. Rewrite the daily and yearly trend tests to assert calendar-date
classification (the removed conversion had encoded the shift into their
expectations) and add a regression test that a midnight-today work order stays
in the Today bucket and is not overdue.
2026-09-16 19:57:09 -03:00
Alexandre Brandizzi
7947e2e77d feat(dashboard): expose KPI counts (SH-353) 2026-09-16 19:23:53 -03:00
Alexandre Brandizzi
9d7efa34a6 feat(dashboard): add trend metrics (SH-350) 2026-09-16 18:42:26 -03:00
Alexandre Brandizzi
3358ea5058 feat(dashboard): add vendor insights (SH-349) 2026-09-16 18:10:42 -03:00
Alexandre Brandizzi
d4afcced87 feat(dashboard): add regional work order metrics (SH-348) 2026-09-16 18:03:38 -03:00
Alexandre Brandizzi
441735d39d feat(permissions): expose team member permission overrides (SH-328) 2026-09-16 18:02:02 -03:00
Alexandre Brandizzi
0194748e3b feat(dashboard): add dispatcher performance (SH-347) 2026-09-16 17:58:08 -03:00
Alexandre Brandizzi
d6c3cd2e24 feat(permissions): add team member permission policy foundation (SH-327) 2026-09-16 17:48:37 -03:00
Alexandre Brandizzi
9b39d0e5ec feat(dashboard): add dispatcher workload (SH-346) 2026-09-16 17:43:49 -03:00
Alexandre Brandizzi
a2e749c72d feat(dashboard): add core dashboard metrics (SH-343) 2026-09-16 17:28:21 -03:00
Alexandre Brandizzi
efd13b6f60 fix(dashboard): scope stats to authenticated accounts (SH-336) 2026-09-16 17:12:03 -03:00
Alexandre Brandizzi
d204536215
Merge pull request #121 from Sea-Haven-Industries/feat/ab/sh-278-vendor-area
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-278: add admin-assigned vendor company Area
2026-09-16 17:05:57 -03:00
Alexandre Brandizzi
3047c2ba59
Merge branch 'dev' into feat/ab/sh-278-vendor-area 2026-09-16 17:00:27 -03:00
Alexandre Brandizzi
8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00
62 changed files with 7929 additions and 115 deletions

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

View file

@ -1,7 +1,12 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
@ -17,33 +22,696 @@ public class DashboardServiceTests
return new ApplicationDbContext(options);
}
private static DashboardService NewService(ApplicationDbContext ctx) =>
new(new DashboardDataService(ctx));
private static DashboardService NewService(ApplicationDbContext ctx)
{
var resolver = new WorkOrderAccountResolver(
new AccountDataService(ctx),
new LocationDataService(ctx));
return new DashboardService(new DashboardDataService(ctx), resolver);
}
private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) =>
new() { Status = status, istemplate = isTemplate, AssignTo = assignTo };
private static ClaimsPrincipal AccountUser(
int accountId,
string userId = "dispatcher-1",
string role = "Admin")
{
var claims = new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static ClaimsPrincipal OrgWideUser()
{
var claims = new[]
{
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
new Claim(ClaimTypes.Role, "Admin")
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
[Fact]
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
Wo("Open", isTemplate: false),
Wo("Open", isTemplate: false, assignTo: "u1"),
Wo("In Progress"),
Wo("On Hold"),
Wo("Done"),
Wo("Done"),
Wo("Open", isTemplate: true),
Wo("Cancelled", isTemplate: false)
new WorkOrder { AccountId = 1, Status = "Open", istemplate = false },
new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" },
new WorkOrder { AccountId = 1, Status = "In Progress" },
new WorkOrder { AccountId = 1, Status = "On Hold" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Open", istemplate = true },
new WorkOrder { AccountId = 1, Status = "Cancelled" },
new WorkOrder { AccountId = 2, Status = "Open" },
new WorkOrder { Status = "Open" }
);
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None);
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
stats.Total.Should().Be(7);
stats.Open.Should().Be(4);
stats.NotDispatched.Should().Be(1);
stats.Completed.Should().Be(2);
}
[Fact]
public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open" },
new WorkOrder { AccountId = 2, Status = "Done" },
new WorkOrder { Status = "Open", istemplate = true });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None);
stats.Total.Should().Be(2);
stats.Open.Should().Be(1);
stats.Completed.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_ExposesKpiCounts_PendingUpliftsCountsPendingStatusOnly()
{
using var ctx = NewContext();
var today = DashboardBusinessTime.Today();
var tomorrow = today.AddDays(1);
// Scheduled-tomorrow tile: one account-1 order scheduled for tomorrow.
ctx.workOrders.Add(new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = tomorrow.ToDateTime(TimeOnly.MinValue)
});
// Aveta-pending tile: Aveta-required order in the today/tomorrow window
// with no Aveta attachment. Its Dispatch anchors the uplift requests below.
var avetaOrder = new WorkOrder
{
AccountId = 1,
AvetaRequired = true,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
};
ctx.workOrders.Add(avetaOrder);
ctx.SaveChanges();
var dispatch = new Dispatch { WorkOrderId = avetaOrder.Id, IsDeleted = false };
ctx.Set<Dispatch>().Add(dispatch);
ctx.SaveChanges();
// Pending-uplifts tile: only Status == "Pending" is an outstanding uplift
// awaiting an approval decision. This test pins the behaviour the code
// ships today: a "ChangesRequested" request has been sent back to the
// vendor, so it is NOT counted. Whether pendingUplifts should also include
// ChangesRequested is an open product call, not a settled review decision.
ctx.Set<DispatchUpliftRequest>().AddRange(
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "Pending", IsDeleted = false },
new DispatchUpliftRequest { DispatchId = dispatch.Id, Status = "ChangesRequested", IsDeleted = false });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
stats.ScheduledTomorrow.Should().Be(1);
stats.AvetaPending.Should().Be(1);
stats.PendingUplifts.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_MissingScopeFailsClosed()
{
using var ctx = NewContext();
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Role, "Dispatcher")
}, "test"));
var act = () => NewService(ctx).GetStatsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var exception = await act.Should().ThrowAsync<WorkOrderBoardValidationException>();
exception.Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange()
{
using var ctx = NewContext();
var today = DashboardBusinessTime.Today();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
OriginalDate = today.AddDays(-6)
},
new WorkOrder
{
AccountId = 1,
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress,
ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue)
},
new WorkOrder
{
AccountId = 1,
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
OriginalDate = today.AddDays(-15),
CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue)
},
new WorkOrder
{
AccountId = 1,
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete,
ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue)
});
ctx.SaveChanges();
var query = new DashboardStatsQueryDTO
{
DateFrom = today.AddDays(-10),
DateTo = today.AddDays(10)
};
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1), query, CancellationToken.None);
stats.Total.Should().Be(4);
stats.Breakdown.Overdue.Should().Be(1);
stats.Breakdown.Other.Should().Be(0);
stats.Breakdown.PM.Should().Be(2);
stats.Breakdown.Emergency.Should().Be(0);
stats.Breakdown.Reactive.Should().Be(1);
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);
stats.DueCount.Should().Be(3);
stats.CompletedDueCount.Should().Be(1);
stats.CompletionRate.Should().Be(33.33m);
stats.AverageResolutionDays.Should().Be(3m);
}
[Fact]
public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None);
stats.Total.Should().Be(1);
stats.Open.Should().Be(1);
}
[Fact]
public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole
{
Id = "dispatcher-role",
Name = "Dispatcher",
NormalizedName = "DISPATCHER"
});
ctx.Users.AddRange(
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
ctx.UserRoles.AddRange(
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed },
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" });
ctx.SaveChanges();
var result = await NewService(ctx).GetWorkloadAsync(
AccountUser(1, "dispatcher-1", "Dispatcher"),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
result.TotalDispatchers.Should().Be(1);
result.Items.Should().ContainSingle();
result.Items[0].DispatcherName.Should().Be("Ada One");
result.Items[0].TotalCount.Should().Be(2);
result.Items[0].OpenCount.Should().Be(1);
result.PageSize.Should().Be(10);
var adminResult = await NewService(ctx).GetWorkloadAsync(
AccountUser(1),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
adminResult.TotalDispatchers.Should().Be(2);
adminResult.Items.Select(item => item.DispatcherName)
.Should().ContainInOrder("Ada One", "Ben Two");
var performance = await NewService(ctx).GetPerformanceAsync(
AccountUser(1),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
performance.TotalDispatchers.Should().Be(2);
performance.Items[0].DispatcherName.Should().Be("Ada One");
}
[Fact]
public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole
{
Id = "dispatcher-role",
Name = "Dispatcher",
NormalizedName = "DISPATCHER"
});
ctx.Users.Add(new ApplicationUser
{
Id = "dispatcher-1",
FirstName = "Ada",
LastName = "One",
Color = "#123456"
});
ctx.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = "dispatcher-role"
});
var today = DashboardBusinessTime.Today();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
OriginalDate = today.AddDays(-5),
CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue)
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue)
});
ctx.SaveChanges();
var result = await NewService(ctx).GetPerformanceAsync(
AccountUser(1),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
result.Items.Should().ContainSingle();
result.Items[0].CompletionRate.Should().Be(100m);
result.Items[0].AverageResolutionDays.Should().Be(3m);
result.Items[0].Color.Should().Be("#123456");
}
[Fact]
public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther()
{
await using var ctx = NewContext();
ctx.Locations.AddRange(
new Locations { Id = 1, State = "NY" },
new Locations { Id = 2, State = "ca" },
new Locations { Id = 3, State = "XX" });
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, LocationId = 1 },
new WorkOrder { AccountId = 1, LocationId = 2 },
new WorkOrder { AccountId = 1, LocationId = 3 },
new WorkOrder { AccountId = 1, LocationId = null });
await ctx.SaveChangesAsync();
var result = await NewService(ctx).GetRegionsAsync(
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
result.Items.Select(row => row.Region).Should().Equal(
"East", "Central", "West", "California", "Unmapped/Other");
result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2);
}
[Fact]
public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics()
{
await using var ctx = NewContext();
ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false });
ctx.Vendors.Add(new Vendor
{
Id = 20,
CompanyId = 10,
IsActive = true,
CompanyName = "Acme Services"
});
ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 });
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
PrimaryDispatchId = 30,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
CompletedDate = DateTime.UtcNow.Date.AddDays(-1),
RescheduleCount = 1
},
new WorkOrder
{
AccountId = 1,
PrimaryDispatchId = 30,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = DateTime.UtcNow.Date.AddDays(-2),
CompletedDate = DateTime.UtcNow.Date.AddDays(-1)
});
await ctx.SaveChangesAsync();
var result = await NewService(ctx).GetVendorInsightsAsync(
AccountUser(1), CancellationToken.None);
result.TotalVendors.Should().Be(1);
result.Items.Should().ContainSingle();
result.Items[0].TotalJobs.Should().Be(2);
result.Items[0].CompletionRate.Should().Be(100);
result.Items[0].RescheduleRate.Should().Be(50);
}
[Fact]
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
{
using var ctx = NewContext();
// ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work
// order buckets on its own calendar day regardless of the time-of-day component. The
// UTC hours below were previously shifted a full day by an America/New_York conversion
// (03:00 UTC -> prior evening), so they double as a regression guard: each row must now
// stay on the calendar date it was scheduled for.
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled,
ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc),
CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc)
});
ctx.SaveChanges();
var query = new DashboardTrendQueryDTO
{
DateFrom = new DateOnly(2026, 1, 14),
DateTo = new DateOnly(2026, 1, 16)
};
var trend = await NewService(ctx).GetTrendAsync(
AccountUser(1), query, CancellationToken.None);
trend.Granularity.Should().Be("day");
trend.Buckets.Select(bucket => bucket.Date).Should().Equal(
new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16));
trend.Buckets.Select(bucket => bucket.Label).Should().Equal(
"2026-01-14", "2026-01-15", "2026-01-16");
// 01-14: only the canceled row scheduled that calendar day.
trend.Buckets[0].Total.Should().Be(1);
trend.Buckets[0].Open.Should().Be(0);
trend.Buckets[0].Canceled.Should().Be(1);
trend.Buckets[0].Completed.Should().Be(0);
trend.Buckets[0].Overdue.Should().Be(0);
// 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row.
trend.Buckets[1].Total.Should().Be(2);
trend.Buckets[1].Open.Should().Be(1);
trend.Buckets[1].Completed.Should().Be(1);
trend.Buckets[1].Canceled.Should().Be(0);
trend.Buckets[1].Overdue.Should().Be(1);
// 01-16: the 03:00 UTC scheduled row that stays on its own day.
trend.Buckets[2].Total.Should().Be(1);
trend.Buckets[2].Open.Should().Be(1);
trend.Buckets[2].Overdue.Should().Be(1);
trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent);
}
[Fact]
public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue()
{
using var ctx = NewContext();
var today = DashboardBusinessTime.Today();
// A board-scheduled work order for today is stored as midnight (`.Date`). It must land
// in the Today bucket and count as open, not roll back to yesterday and read as overdue.
ctx.workOrders.Add(new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
});
ctx.SaveChanges();
var query = new DashboardTrendQueryDTO
{
DateFrom = today.AddDays(-1),
DateTo = today.AddDays(1)
};
var trend = await NewService(ctx).GetTrendAsync(
AccountUser(1), query, CancellationToken.None);
var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today);
todayBucket.Total.Should().Be(1);
todayBucket.Open.Should().Be(1);
todayBucket.Overdue.Should().Be(0);
var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1));
yesterdayBucket.Total.Should().Be(0);
}
[Fact]
public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc),
CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc)
});
ctx.SaveChanges();
var query = new DashboardTrendQueryDTO
{
Range = "3m",
DateFrom = new DateOnly(2026, 6, 17),
DateTo = new DateOnly(2026, 9, 16)
};
var trend = await NewService(ctx).GetTrendAsync(
AccountUser(1), query, CancellationToken.None);
trend.Granularity.Should().Be("week");
trend.Buckets.Should().HaveCount(14);
trend.Buckets.Select(bucket => bucket.Date.DayOfWeek)
.Should().OnlyContain(day => day == DayOfWeek.Monday);
trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15));
trend.Buckets[0].Total.Should().Be(2);
trend.Buckets[0].Open.Should().Be(2);
trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14));
trend.Buckets[^1].Total.Should().Be(1);
trend.Buckets[^1].Completed.Should().Be(1);
}
[Fact]
public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc),
CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc)
},
// 2027-01-01 by calendar: outside the 2026 window. Previously an
// America/New_York conversion pulled it back to 2026-12-31 and into December.
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc)
});
ctx.SaveChanges();
var trend = await NewService(ctx).GetTrendAsync(
AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None);
trend.Granularity.Should().Be("month");
trend.Buckets.Should().HaveCount(12);
trend.Buckets[0].Label.Should().Be("2026-01-01");
trend.Buckets[0].Total.Should().Be(1);
trend.Buckets[0].Open.Should().Be(1);
trend.Buckets[0].Overdue.Should().Be(1);
trend.Buckets[2].Label.Should().Be("2026-03-01");
trend.Buckets[2].Total.Should().Be(1);
trend.Buckets[2].Completed.Should().Be(1);
// December stays empty: the 2027-01-01 row is no longer pulled inside the window.
trend.Buckets[11].Label.Should().Be("2026-12-01");
trend.Buckets[11].Total.Should().Be(0);
trend.Buckets[11].Open.Should().Be(0);
trend.Today.Should().Be(DashboardBusinessTime.Today());
}
[Fact]
public async Task GetTrendAsync_MarksTodayBucketAsCurrent()
{
using var ctx = NewContext();
var today = DashboardBusinessTime.Today();
ctx.workOrders.Add(new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0))
});
ctx.SaveChanges();
var query = new DashboardTrendQueryDTO
{
DateFrom = today.AddDays(-2),
DateTo = today.AddDays(2)
};
var trend = await NewService(ctx).GetTrendAsync(
AccountUser(1), query, CancellationToken.None);
trend.Today.Should().Be(today);
trend.Buckets.Should().HaveCount(5);
trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1);
trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today);
trend.Buckets[0].Total.Should().Be(1);
trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0);
}
[Fact]
public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-2",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
},
new WorkOrder
{
AccountId = 2,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
});
ctx.SaveChanges();
var query = new DashboardTrendQueryDTO
{
DateFrom = new DateOnly(2026, 2, 2),
DateTo = new DateOnly(2026, 2, 3)
};
var dispatcherTrend = await NewService(ctx).GetTrendAsync(
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
dispatcherTrend.Buckets.Should().HaveCount(2);
dispatcherTrend.Buckets[0].Total.Should().Be(1);
dispatcherTrend.Buckets[1].Total.Should().Be(0);
var adminTrend = await NewService(ctx).GetTrendAsync(
AccountUser(1), query, CancellationToken.None);
adminTrend.Buckets[0].Total.Should().Be(2);
var otherAccountTrend = await NewService(ctx).GetTrendAsync(
AccountUser(2), query, CancellationToken.None);
otherAccountTrend.Buckets[0].Total.Should().Be(1);
}
}

View file

@ -26,7 +26,7 @@ public class LocationControllerTests
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
@ -49,7 +49,7 @@ public class LocationControllerTests
public async Task GetLocationList_PassesStatesFilterToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>(),
@ -58,7 +58,7 @@ public class LocationControllerTests
PageSize = 10
});
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.VerifyAll();
@ -68,13 +68,13 @@ public class LocationControllerTests
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ThrowsAsync(new ValidationException(new[]
{
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
}));
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
@ -200,4 +200,90 @@ public class LocationControllerTests
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
}
[Fact]
public async Task GetLocationList_ForwardsSortParamsToService()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), TotalCount = 0, Page = 1, PageSize = 10 });
await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None);
service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"), Times.Once);
}
[Fact]
public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ThrowsAsync(new ValidationException(new[]
{
new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.")
}));
var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Validation Error");
response.Message.Should().Contain("sortBy");
}
[Fact]
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO>
{
new()
{
Id = 1,
Name = "STL8",
Address1 = "9 River Rd",
City = "St. Louis",
State = "MO",
Zip = "63101",
PhoneNumber = "555-0100",
Email = "poc@example.com",
AccountId = 3,
AccountName = "Acme Co",
Contacts = new List<SiteContactResponseDTO>
{
new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" },
new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" }
}
}
},
TotalCount = 1,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value);
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field");
row.GetProperty("Address").GetString().Should().Be("9 River Rd");
row.GetProperty("City").GetString().Should().Be("St. Louis");
row.GetProperty("State").GetString().Should().Be("MO");
row.GetProperty("ZipCode").GetString().Should().Be("63101");
row.GetProperty("Phone").GetString().Should().Be("555-0100");
row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com");
row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field");
row.GetProperty("AccountId").GetInt32().Should().Be(3);
row.GetProperty("ClientName").GetString().Should().Be("Acme Co");
row.GetProperty("AccountName").GetString().Should().Be("Acme Co");
var contacts = row.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
}
}

View file

@ -86,4 +86,129 @@ public class LocationDataServiceTests
totalCount.Should().Be(2);
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
}
private static async Task SeedRegistryAsync(ApplicationDbContext ctx)
{
ctx.Accounts.AddRange(
new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false },
new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false });
ctx.Locations.AddRange(
new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 },
new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 },
new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 },
new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 });
ctx.Contacts.AddRange(
new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" },
new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" },
new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" },
new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true },
new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" });
await ctx.SaveChangesAsync();
}
[Theory]
[InlineData("STL", new[] { 10 }, "site code")]
[InlineData("Borealis", new[] { 11, 13 }, "client name")]
[InlineData("Prairie", new[] { 11 }, "street address")]
[InlineData("Miami", new[] { 13 }, "city")]
public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField)
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, totalCount) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, term, null, CancellationToken.None);
totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}");
items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds);
}
[Theory]
[InlineData("code", 11, 12, 13, 10)]
[InlineData("client", 10, 12, 11, 13)]
[InlineData("address", 13, 11, 12, 10)]
[InlineData("city", 11, 12, 13, 10)]
[InlineData("state", 13, 12, 10, 11)]
[InlineData("poc", 13, 12, 10, 11)]
public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak(
string sortBy, params int[] expectedIds)
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc");
items.Select(l => l.Id).Should().ContainInOrder(expectedIds);
}
[Fact]
public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc");
// DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must
// sort last; including the deleted row ("Removed Poc") would instead place it second.
items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11);
}
[Fact]
public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc");
items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 });
}
[Fact]
public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways");
items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8");
}
[Fact]
public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages()
{
await using var ctx = NewContext();
ctx.Locations.AddRange(
new Locations { Id = 20, Name = "HOU1", City = "Dallas" },
new Locations { Id = 21, Name = "AUS3", City = "Dallas" },
new Locations { Id = 22, Name = "ELP4", City = "Dallas" });
await ctx.SaveChangesAsync();
var service = new LocationDataService(ctx);
var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc");
var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc");
totalCount.Should().Be(3);
page1.Select(l => l.Id).Should().ContainInOrder(20, 21);
page2.Select(l => l.Id).Should().ContainInOrder(22);
}
[Fact]
public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection()
{
await using var ctx = NewContext();
await SeedRegistryAsync(ctx);
var (items, _) = await new LocationDataService(ctx)
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co");
}
}

View file

@ -131,8 +131,8 @@ public class LocationServiceTests
{
var data = new Mock<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
.ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
@ -149,8 +149,8 @@ public class LocationServiceTests
{
var data = new Mock<ILocationDataService>();
IReadOnlyCollection<string>? captured = default;
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
.ReturnsAsync((new List<Locations>(), 0));
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
@ -173,14 +173,14 @@ public class LocationServiceTests
&& e.ErrorMessage.Contains("ZZ")
&& e.ErrorMessage.Contains("QQ")
&& !e.ErrorMessage.Contains("TX"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((new List<Locations>(), 0));
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
@ -533,4 +533,97 @@ public class LocationServiceTests
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
{
var data = new Mock<ILocationDataService>();
string? capturedSort = "sentinel";
string? capturedDirection = "sentinel";
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
{
capturedSort = sortBy;
capturedDirection = sortDirection;
})
.ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC ");
capturedSort.Should().Be("poc");
capturedDirection.Should().Be("desc");
}
[Theory]
[InlineData(null, null)]
[InlineData("", " ")]
public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults(
string? sortBy,
string? sortDirection)
{
var data = new Mock<ILocationDataService>();
string? capturedSort = "sentinel";
string? capturedDirection = "sentinel";
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
{
capturedSort = s;
capturedDirection = d;
})
.ReturnsAsync((new List<Locations>(), 0));
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection);
capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering");
capturedDirection.Should().Be("asc");
}
[Theory]
[InlineData("rating")]
[InlineData("password; drop table locations")]
public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy)
{
var data = new Mock<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e =>
e.PropertyName == "sortBy"
&& e.ErrorMessage.Contains("code")
&& e.ErrorMessage.Contains("poc"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Theory]
[InlineData("ascending")]
[InlineData("DESC; drop table locations")]
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
{
var data = new Mock<ILocationDataService>();
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e =>
e.PropertyName == "sortDirection"
&& e.ErrorMessage.Contains("asc")
&& e.ErrorMessage.Contains("desc"));
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows()
{
using var ctx = NewContext();
SeedAccount(ctx, 9, "Acme Co");
SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9;
await ctx.SaveChangesAsync();
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
var row = page.Items.Should().ContainSingle().Subject;
row.AccountId.Should().Be(9);
row.AccountName.Should().Be("Acme Co");
}
}

View file

@ -430,7 +430,7 @@ public class LocationSiteContactsTests
public async Task List_LoadsPageContactsInSingleBatchedRead()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((new List<Locations>
{
new() { Id = 1, Name = "One" },

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

@ -105,14 +105,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_Returns200()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -125,7 +125,7 @@ public class VendorCompanyRosterControllerTests
public async Task Create_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
@ -137,7 +137,7 @@ public class VendorCompanyRosterControllerTests
public async Task Create_DuplicateName_ReturnsStable409()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterDuplicateNameException(
"database detail",
new InvalidOperationException("IX_VendorCompanies_NormalizedName")));
@ -164,14 +164,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -183,7 +183,7 @@ public class VendorCompanyRosterControllerTests
public async Task Reconcile_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -199,7 +199,7 @@ public class VendorCompanyRosterControllerTests
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
};
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -212,7 +212,7 @@ public class VendorCompanyRosterControllerTests
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
@ -251,14 +251,14 @@ public class VendorCompanyRosterControllerTests
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -272,7 +272,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -285,7 +285,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
@ -297,7 +297,7 @@ public class VendorCompanyRosterControllerTests
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
@ -306,4 +306,29 @@ public class VendorCompanyRosterControllerTests
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
}
[Fact]
public async Task AreaAssignmentForbidden_Returns403OnEveryMutation()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new VendorAreaAssignmentForbiddenException());
var controller = NewController(service);
var results = new[]
{
await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }, CancellationToken.None),
await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", AreaId = 1 }, CancellationToken.None),
await controller.AddTechnicians(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }, CancellationToken.None)
};
foreach (var result in results)
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
service.Verify(x => x.ReconcileRosterAsync(7, It.IsAny<ReconcileVendorRosterDTO>(), "42", controller.User, It.IsAny<CancellationToken>()), Times.Once);
}
}

View file

@ -835,4 +835,43 @@ public class VendorCompanyRosterDataServiceTests
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task SaveRoster_AppliesAreaOnlyWhenProvidedAndReadsAreaName()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
context.Areas.AddRange(
new Area { Id = 1, Name = "East", NormalizedName = "east" },
new Area { Id = 2, Name = "Central", NormalizedName = "central" });
await context.SaveChangesAsync();
var (companyId, _) = await SeedCompanyWithTechnicianAsync(context, "Area Co", "Tech");
var service = new VendorCompanyRosterDataService(context);
VendorCompanyRosterWriteModel Snapshot(bool provided, int? areaId) => new()
{
CompanyId = companyId,
Name = "Area Co",
Email = "area@example.com",
AreaIdProvided = provided,
AreaId = areaId,
Technicians = context.Vendors.Where(v => v.CompanyId == companyId)
.Select(v => new RosterTechnicianWriteModel { Id = v.Id, ContactName = v.ContactName })
.ToList()
};
var assigned = await service.SaveRosterAsync(Snapshot(true, 2), CancellationToken.None);
assigned.AreaId.Should().Be(2);
assigned.AreaName.Should().Be("Central");
var untouched = await service.SaveRosterAsync(Snapshot(false, null), CancellationToken.None);
untouched.AreaId.Should().Be(2);
var cleared = await service.SaveRosterAsync(Snapshot(true, null), CancellationToken.None);
cleared.AreaId.Should().BeNull();
cleared.AreaName.Should().BeNull();
(await service.IsActiveAreaAsync(1, CancellationToken.None)).Should().BeTrue();
(await service.IsActiveAreaAsync(42, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -3,6 +3,7 @@ using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
@ -13,6 +14,13 @@ namespace Api.SeaHavenIndustries.Tests;
public class VendorCompanyRosterServiceTests
{
private static readonly ClaimsPrincipal Dispatcher = Principal("User");
private static readonly ClaimsPrincipal Admin = Principal("Admin");
private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) },
"Test"));
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
@ -48,7 +56,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -60,7 +68,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
@ -78,7 +86,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -98,7 +106,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO>()
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.Technicians.Should().BeEmpty();
@ -120,7 +128,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
@ -136,7 +144,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501)
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -163,7 +171,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -188,7 +196,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -205,7 +213,7 @@ public class VendorCompanyRosterServiceTests
GoogleMapsUrl = "http://maps.google.com/acme"
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
@ -228,7 +236,7 @@ public class VendorCompanyRosterServiceTests
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
}
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
}
@ -247,7 +255,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -270,7 +278,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme Co",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
@ -294,7 +302,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Notes = notes
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Notes.Should().Be(notes);
}
@ -311,7 +319,7 @@ public class VendorCompanyRosterServiceTests
Notes = new string('n', 501)
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -336,7 +344,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -354,7 +362,7 @@ public class VendorCompanyRosterServiceTests
Email = "acme@example.com"
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
@ -376,7 +384,7 @@ public class VendorCompanyRosterServiceTests
Technicians = null!
};
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().BeEmpty();
}
@ -397,7 +405,7 @@ public class VendorCompanyRosterServiceTests
}
};
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -420,7 +428,7 @@ public class VendorCompanyRosterServiceTests
{
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
}
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.Technicians.Should().ContainSingle();
captured.Technicians.Single().ContactName.Should().BeEmpty();
@ -442,7 +450,7 @@ public class VendorCompanyRosterServiceTests
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
};
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
}
@ -462,7 +470,7 @@ public class VendorCompanyRosterServiceTests
Name = "Acme",
Email = "acme@example.com",
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
}, "42", cts.Token);
}, "42", Dispatcher, cts.Token);
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
@ -477,7 +485,7 @@ public class VendorCompanyRosterServiceTests
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
@ -488,7 +496,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
@ -501,7 +509,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
@ -517,7 +525,7 @@ public class VendorCompanyRosterServiceTests
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
@ -530,7 +538,7 @@ public class VendorCompanyRosterServiceTests
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
@ -560,7 +568,7 @@ public class VendorCompanyRosterServiceTests
}
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
@ -590,7 +598,7 @@ public class VendorCompanyRosterServiceTests
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes }
}, "42", CancellationToken.None);
}, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields!.Notes.Should().Be(notes);
}
@ -605,7 +613,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
@ -632,7 +640,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
// non-null write model made the data layer bump RowVersion and rewrite every
@ -652,7 +660,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(
@ -676,7 +684,7 @@ public class VendorCompanyRosterServiceTests
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
captured!.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.City.Should().Be("Norfolk");
@ -690,8 +698,182 @@ public class VendorCompanyRosterServiceTests
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", Dispatcher, cts.Token);
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
}
private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId)
{
var model = SampleReadModel(7, 1);
model.AreaId = areaId;
return model;
}
[Fact]
public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync(false);
var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 };
var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId));
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_AdminAssigningActiveArea_PersistsAreaId()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true);
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(2));
var result = await NewService(data).CreateRosterAsync(
new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 },
"42",
Admin,
cts.Token);
captured!.AreaId.Should().Be(2);
result.AreaId.Should().Be(2);
data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once);
}
[Fact]
public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
foreach (int? requested in new int?[] { 3, null })
{
var dto = new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = requested
};
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
}
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
var captured = new List<VendorCompanyRosterWriteModel>();
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured.Add(model))
.ReturnsAsync(ReadModelWithArea(1));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com",
AreaId = 1
}, "42", Dispatcher, CancellationToken.None);
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme Renamed",
Email = "acme@example.com"
}, "42", Dispatcher, CancellationToken.None);
captured.Should().HaveCount(2);
captured[0].AreaIdProvided.Should().BeTrue();
captured[0].AreaId.Should().Be(1);
captured[1].AreaIdProvided.Should().BeFalse();
}
[Fact]
public async Task Reconcile_AdminClearsAreaToUnassigned()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(1));
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(ReadModelWithArea(null));
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
Name = "Acme",
Email = "acme@example.com",
AreaId = null
}, "42", Admin, CancellationToken.None);
captured!.AreaIdProvided.Should().BeTrue();
captured.AreaId.Should().BeNull();
data.Verify(x => x.IsActiveAreaAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NonAdminAreaChange_IsForbidden()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReadModelWithArea(null));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None);
await act.Should().ThrowAsync<VendorAreaAssignmentForbiddenException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull()
{
var omitted = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var cleared = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
var assigned = System.Text.Json.JsonSerializer.Deserialize<ReconcileVendorRosterDTO>(
"""{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""",
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
omitted.AreaIdProvided.Should().BeFalse();
cleared.AreaIdProvided.Should().BeTrue();
cleared.AreaId.Should().BeNull();
assigned.AreaIdProvided.Should().BeTrue();
assigned.AreaId.Should().Be(3);
}
}

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<CancellationToken>()), Times.Never);
}
@ -155,6 +156,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>
{
@ -203,6 +205,7 @@ public class VendorControllerTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
service.Verify(x => x.GetVendorTechnicianDirectoryPagedAsync(
It.IsAny<int>(),

View file

@ -470,12 +470,14 @@ public class VendorDataServiceTests
search: "alice",
trades: new[] { "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50" }).ToQueryString();
jobBuckets: new[] { "under-50" },
areaFilter: new VendorAreaFilter { AreaIds = new[] { 1, 2 }, IncludeUnassigned = true }).ToQueryString();
sql.Should().Contain("GROUP BY");
sql.Should().Contain("LEFT JOIN");
sql.Should().Contain("TradeSpecialties");
sql.Should().Contain("TotalJobs");
sql.Should().Contain("AreaId");
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
}
@ -1044,4 +1046,69 @@ public class VendorDataServiceTests
sql.Should().Contain("SELECT");
sql.Should().Contain("VendorCompanies");
}
[Fact]
public async Task GetCompanyDirectoryPagedAsync_AreaFilterOrsWithinFacetAndUnassignedIncludesLegacyRows()
{
await using var context = NewContext();
var east = new Area { Id = 1, Name = "East", NormalizedName = "east" };
var west = new Area { Id = 3, Name = "West", NormalizedName = "west" };
context.Areas.AddRange(east, west);
var eastCo = new VendorCompany { Name = "East Co", NormalizedName = "east co", Area = east };
var westCo = new VendorCompany { Name = "West Co", NormalizedName = "west co", Area = west };
var unassignedCo = new VendorCompany { Name = "Unassigned Co", NormalizedName = "unassigned co" };
var eastOnly = new VendorCompany { Name = "East Only", NormalizedName = "east only", Area = east };
context.VendorCompanies.AddRange(eastCo, westCo, unassignedCo, eastOnly);
context.Vendors.AddRange(
new Vendor { Company = eastCo, CompanyName = "East Co", ContactName = "E", City = "Boston", State = "MA", IsActive = true, IsDeleted = false },
new Vendor { Company = westCo, CompanyName = "West Co", ContactName = "W", IsActive = true, IsDeleted = false },
new Vendor { Company = unassignedCo, CompanyName = "Unassigned Co", ContactName = "U", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Legacy Co", ContactName = "L", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var eastResult = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
eastResult.Items.Select(item => item.CompanyName)
.Should().BeEquivalentTo("East Co", "East Only");
eastResult.TotalCount.Should().Be(2);
var eastRow = eastResult.Items.Single(item => item.CompanyName == "East Co");
eastRow.AreaId.Should().Be(east.Id);
eastRow.AreaName.Should().Be("East");
eastResult.Items.Single(item => item.CompanyName == "East Only").AreaName.Should().Be("East");
var eastOrUnassigned = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true,
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id }, IncludeUnassigned = true });
eastOrUnassigned.Items.Select(item => item.CompanyName)
.Should().BeEquivalentTo("East Co", "East Only", "Unassigned Co", "Legacy Co");
eastOrUnassigned.Items.Single(item => item.CompanyName == "Legacy Co").AreaId.Should().BeNull();
var eastAndBoston = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "Boston, MA" },
areaFilter: new VendorAreaFilter { AreaIds = new[] { east.Id } });
eastAndBoston.Items.Should().ContainSingle(item => item.CompanyName == "East Co");
var matchesNobody = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, areaFilter: new VendorAreaFilter());
matchesNobody.TotalCount.Should().Be(0);
matchesNobody.Items.Should().BeEmpty();
var unfiltered = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
unfiltered.TotalCount.Should().Be(5);
}
[Fact]
public async Task GetActiveAreasAsync_ExcludesArchivedAreas()
{
await using var context = NewContext();
context.Areas.AddRange(
new Area { Id = 1, Name = "East", NormalizedName = "east" },
new Area { Id = 4, Name = "California", NormalizedName = "california", IsActive = false });
await context.SaveChangesAsync();
var areas = await new VendorDataService(context).GetActiveAreasAsync(CancellationToken.None);
areas.Select(area => area.Name).Should().Equal("East");
}
}

View file

@ -166,6 +166,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token))
.ReturnsAsync((new[]
{
@ -212,6 +213,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token), Times.Once);
}
@ -228,6 +230,7 @@ public class VendorServiceTests
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
@ -756,6 +759,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
@ -794,6 +799,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -822,6 +829,8 @@ public class VendorServiceTests
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -849,6 +858,8 @@ public class VendorServiceTests
SortOrder = index + 1
})
.ToList());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
@ -898,4 +909,96 @@ public class VendorServiceTests
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
}
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 3, Name = "West", NormalizedName = "west" }
});
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
return data;
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().Equal(1);
captured.IncludeUnassigned.Should().BeTrue();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { "4", "West", "-3" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().BeEmpty();
captured.IncludeUnassigned.Should().BeFalse();
result.Items.Should().BeEmpty();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
{
VendorAreaFilter? captured = new();
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
captured.Should().BeNull();
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>());
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
.ReturnsAsync(new List<Area>
{
new() { Id = 3, Name = "West", NormalizedName = "west" },
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 4, Name = "california", NormalizedName = "california" }
});
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
(4, "california"), (1, "East"), (3, "West"));
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
}
}

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

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -17,17 +18,158 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("Stats")]
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
public async Task<IActionResult> GetStats(
[FromQuery] DashboardStatsQueryDTO query,
CancellationToken cancellationToken)
{
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken);
return Ok(new
{
total = stats.Total,
open = stats.Open,
notDispatched = stats.NotDispatched,
completed = stats.Completed
completed = stats.Completed,
scheduledTomorrow = stats.ScheduledTomorrow,
pendingUplifts = stats.PendingUplifts,
avetaPending = stats.AvetaPending,
breakdown = stats.Breakdown,
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,
completionRate = stats.CompletionRate,
averageResolutionDays = stats.AverageResolutionDays
});
}
[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")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
{
try
{
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
var data = pagedResult.Items.Select(l => new
{
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = l.Email,
Status = l.Status,
AccountId = l.AccountId,
ClientName = l.AccountName,
AccountName = l.AccountName,
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
});

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

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

View file

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

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

View file

@ -207,6 +207,25 @@ namespace Data.SeaHavenIndustries
.HasIndex(t => t.NormalizedName)
.IsUnique();
// 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.
@ -254,6 +273,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Trade> Trades { get; set; }
public DbSet<Area> Areas { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }

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

View file

@ -286,6 +286,35 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Area", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Areas");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{
b.Property<int>("Id")
@ -2259,6 +2288,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<int?>("AreaId")
.HasColumnType("int");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
@ -2316,6 +2348,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AreaId");
b.HasIndex("NormalizedName")
.IsUnique();
@ -3647,6 +3681,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.HasOne("Data.SeaHavenIndustries.Area", "Area")
.WithMany()
.HasForeignKey("AreaId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Area");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")

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

@ -16,6 +16,11 @@ namespace Data.SeaHavenIndustries
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: optional dispatcher territory (0 or 1 per company), admin-assigned.
public int? AreaId { get; set; }
[ForeignKey(nameof(AreaId))]
public Area? Area { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }

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

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -13,9 +15,15 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken)
public async Task<DashboardCounts> GetWorkOrderCountsAsync(
int? accountId,
CancellationToken cancellationToken)
{
var query = _context.workOrders.Where(w => w.istemplate != true);
if (accountId is int scopedAccountId)
{
query = query.Where(w => w.AccountId == scopedAccountId);
}
var total = await query.CountAsync(cancellationToken);
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);
@ -30,5 +38,194 @@ namespace SeaHaven.DataServices.Implementation
Completed = completed
};
}
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
if (dispatcherId != null)
query = query.Where(w => w.AssignTo == dispatcherId);
if (dateFrom is DateOnly from && dateTo is DateOnly to)
{
var fromDate = from.ToDateTime(TimeOnly.MinValue);
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
query = query.Where(w =>
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
}
return await query
.Select(w => new DashboardWorkOrder(
w.Id,
w.AssignTo,
w.WorkOrderType,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.ScheduledDate,
w.OriginalDate,
w.CompletedDate,
w.Locations == null ? null : w.Locations.State,
w.RescheduleCount,
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
? null
: w.PrimaryDispatch.Vendor.CompanyId,
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|| w.PrimaryDispatch.Vendor.Company == null
? null
: w.PrimaryDispatch.Vendor.Company.Name,
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
&& w.PrimaryDispatch.Vendor.IsActive,
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
&& w.PrimaryDispatch.Vendor.Company != null
&& w.PrimaryDispatch.Vendor.Company.IsDeleted == true))
.ToListAsync(cancellationToken);
}
public async Task<DashboardKpiCounts> GetKpiCountsAsync(
int? accountId,
string? dispatcherId,
DateOnly today,
CancellationToken cancellationToken)
{
var tomorrow = today.AddDays(1);
var dayAfterTomorrow = tomorrow.AddDays(1);
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
var scheduledTomorrow = await workOrders
.Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue)
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue))
.CountAsync(cancellationToken);
var avetaPending = await workOrders
.Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue)
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)
&& w.AvetaRequired
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& (w.LifecycleStatus != null
|| ((w.LegacyStatus ?? w.Status) != "Done"
&& (w.LegacyStatus ?? w.Status) != "Completed"
&& (w.LegacyStatus ?? w.Status) != "Complete"
&& (w.LegacyStatus ?? w.Status) != "Closed"
&& (w.LegacyStatus ?? w.Status) != "Canceled"
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
&& !w.workOrderAttachments!.Any(attachment =>
(attachment.IsDeleted == null || attachment.IsDeleted == false)
&& attachment.Category == WorkOrderMediaCategory.Aveta))
.CountAsync(cancellationToken);
var pendingUplifts = _context.DispatchUpliftRequests
.AsNoTracking()
.Where(request => request.Status == "Pending"
&& (request.IsDeleted == null || request.IsDeleted == false)
&& request.Dispatch != null
&& (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false));
if (accountId is int account)
{
pendingUplifts = pendingUplifts.Where(request =>
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account)
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
link.WorkOrder != null && link.WorkOrder.AccountId == account));
}
if (dispatcherId != null)
{
pendingUplifts = pendingUplifts.Where(request =>
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId)
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId));
}
return new DashboardKpiCounts(
scheduledTomorrow,
await pendingUplifts.CountAsync(cancellationToken),
avetaPending);
}
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken)
{
var dispatcherUsers = from user in _context.Users.AsNoTracking()
join userRole in _context.UserRoles
on user.Id equals userRole.UserId
join role in _context.Roles
on userRole.RoleId equals role.Id
where user.IsDeleted != true
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
&& (dispatcherId == null || user.Id == dispatcherId)
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 (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,
string? search,
IReadOnlyCollection<string>? states,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? sortBy = null,
string? sortDirection = null)
{
var query = _context.Locations.AsNoTracking();
IQueryable<Locations> query = _context.Locations
.AsNoTracking()
.Include(l => l.Account);
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(l =>
l.Name!.Contains(search) ||
l.City!.Contains(search));
l.Address1!.Contains(search) ||
l.City!.Contains(search) ||
l.Account != null && l.Account.Name!.Contains(search));
}
if (states is { Count: > 0 })
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
}
query = ApplyListSort(query, sortBy, sortDirection);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(l => l.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
{
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
{
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
"poc" => descending
? query.OrderByDescending(FirstActiveContactName)
: query.OrderBy(FirstActiveContactName),
_ => query.OrderBy(l => l.Name)
};
return ordered.ThenBy(l => l.Id);
}
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
l => l.Contacts
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => c.FirstName)
.FirstOrDefault();
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations

View file

@ -0,0 +1,101 @@
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)
{
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 PUT inserted the same (UserId, PermissionKey) between our
// existence check and this save, violating PK_UserPermissionOverrides.
// Converge on the caller's intent by updating the persisted row.
_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

@ -135,6 +135,10 @@ namespace SeaHaven.DataServices.Implementation
.Where(role => role.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.UserPermissionOverrides
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
await _context.Users
.Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken);

View file

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

View file

@ -106,6 +106,15 @@ namespace SeaHaven.DataServices.Implementation
return query.OrderBy(c => c.Id);
}
public async Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken)
{
return await _context.Areas
.AsNoTracking()
.Where(a => a.IsActive)
.OrderBy(a => a.Id)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
@ -177,6 +186,7 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default)
{
// Phase 1: select and paginate bounded company group keys in SQL.
@ -186,7 +196,8 @@ namespace SeaHaven.DataServices.Implementation
companies,
trades,
locations,
jobBuckets);
jobBuckets,
areaFilter);
var totalCount = await keyQuery.CountAsync(cancellationToken);
@ -222,6 +233,7 @@ namespace SeaHaven.DataServices.Implementation
? new Dictionary<int, VendorCompany>()
: await _context.VendorCompanies
.AsNoTracking()
.Include(c => c.Area)
.Where(c => missingCompanyIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, cancellationToken);
@ -291,6 +303,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = AggregateTradeTokens(techniciansNewestFirst),
GoogleMapsUrl = primary.GoogleMapsUrl,
Notes = linked ? primary.CompanyNotes : null,
AreaId = linked ? primary.CompanyAreaId : null,
AreaName = linked ? primary.CompanyAreaName : null,
IsActive = primary.IsActive,
TotalJobs = techniciansNewestFirst.Sum(row => row.TotalJobs),
Technicians = techniciansNewestFirst.Select(row => new VendorCompanyGroupTechnician
@ -344,6 +358,8 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null,
GoogleMapsUrl = company.GoogleMapsUrl,
Notes = company.Notes,
AreaId = company.AreaId,
AreaName = company.Area?.Name,
IsActive = true,
TotalJobs = 0,
Technicians = Array.Empty<VendorCompanyGroupTechnician>()
@ -355,10 +371,11 @@ namespace SeaHaven.DataServices.Implementation
IReadOnlyCollection<string>? companies = null,
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null)
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null)
{
var sourceRows = BuildDirectoryUnionBaseQuery(isActive)
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations));
.Select(BuildGroupSourceRowSelector(search, companies, trades, locations, areaFilter));
var grouped = sourceRows
.GroupBy(row => new { row.CompanyId, row.NameKey, row.VendorKey })
@ -372,7 +389,8 @@ namespace SeaHaven.DataServices.Implementation
SearchMatch = group.Max(row => row.SearchFlag),
CompanyMatch = group.Max(row => row.CompanyFlag),
TradeMatch = group.Max(row => row.TradeFlag),
LocationMatch = group.Max(row => row.LocationFlag)
LocationMatch = group.Max(row => row.LocationFlag),
AreaMatch = group.Max(row => row.AreaFlag)
});
// Each facet selects the group when any status-eligible row matches it
@ -405,6 +423,9 @@ namespace SeaHaven.DataServices.Implementation
if (selectedLocations is { Length: > 0 })
grouped = grouped.Where(key => key.LocationMatch == 1);
if (areaFilter != null)
grouped = grouped.Where(key => key.AreaMatch == 1);
var selectedJobBuckets = jobBuckets?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLower())
@ -429,7 +450,8 @@ namespace SeaHaven.DataServices.Implementation
string? search,
IReadOnlyCollection<string>? companies,
IReadOnlyCollection<string>? trades,
IReadOnlyCollection<string>? locations)
IReadOnlyCollection<string>? locations,
VendorAreaFilter? areaFilter)
{
var row = Expression.Parameter(typeof(VendorDirectoryRow), "row");
@ -466,6 +488,9 @@ namespace SeaHaven.DataServices.Implementation
var locationBody = selectedLocations is { Length: > 0 }
? BuildLocationMatchBody<VendorDirectoryRow>(row, selectedLocations)
: null;
var areaBody = areaFilter == null
? null
: BuildAreaMatchBody(row, areaFilter);
var nameKey = Expression.Condition(
Expression.NotEqual(
@ -508,7 +533,8 @@ namespace SeaHaven.DataServices.Implementation
Bind(nameof(VendorDirectoryGroupSourceRow.SearchFlag), Flag(searchBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.CompanyFlag), Flag(companyBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.TradeFlag), Flag(tradeBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody))),
Bind(nameof(VendorDirectoryGroupSourceRow.LocationFlag), Flag(locationBody)),
Bind(nameof(VendorDirectoryGroupSourceRow.AreaFlag), Flag(areaBody))),
row);
}
@ -535,6 +561,8 @@ namespace SeaHaven.DataServices.Implementation
CompanyCity = v.Company != null ? v.Company.City : null,
CompanyState = v.Company != null ? v.Company.State : null,
CompanyNotes = v.Company != null ? v.Company.Notes : null,
CompanyAreaId = v.Company != null ? v.Company.AreaId : null,
CompanyAreaName = v.Company != null && v.Company.Area != null ? v.Company.Area.Name : null,
ContactName = v.ContactName,
Email = v.Email,
Phone = v.Phone,
@ -702,6 +730,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = v.TradeSpecialties,
GoogleMapsUrl = v.GoogleMapsUrl,
Notes = v.Company != null ? v.Company.Notes : null,
AreaId = v.Company != null ? v.Company.AreaId : null,
IsActive = v.IsActive,
TotalJobs = (int?)jobCount.TotalJobs ?? 0
};
@ -736,6 +765,7 @@ namespace SeaHaven.DataServices.Implementation
TradeSpecialties = null,
GoogleMapsUrl = c.GoogleMapsUrl,
Notes = c.Notes,
AreaId = c.AreaId,
IsActive = true,
TotalJobs = 0
});
@ -777,6 +807,32 @@ namespace SeaHaven.DataServices.Implementation
return body!;
}
// SH-278: OR within the Area facet. Selected ids match the company's stable
// AreaId; the Unassigned sentinel matches rows without an Area, which includes
// unlinked legacy vendors that have no VendorCompany at all. A filter with no
// resolvable ids and no Unassigned matches nobody.
private static Expression BuildAreaMatchBody(ParameterExpression row, VendorAreaFilter areaFilter)
{
var areaId = Expression.Property(row, nameof(VendorDirectoryRow.AreaId));
Expression? body = null;
if (areaFilter.AreaIds.Count > 0)
{
var ids = areaFilter.AreaIds.Distinct().Select(id => (int?)id).ToArray();
body = OrElse(body, Expression.Call(
typeof(Enumerable),
nameof(Enumerable.Contains),
new[] { typeof(int?) },
Expression.Constant(ids),
areaId));
}
if (areaFilter.IncludeUnassigned)
body = OrElse(body, Expression.Equal(areaId, Expression.Constant(null, typeof(int?))));
return body ?? Expression.Constant(false);
}
private static Expression BuildCompanyNameMatchBody(
ParameterExpression row,
IReadOnlyCollection<string> selectedCompanies)

View file

@ -1,10 +1,57 @@
namespace SeaHaven.DataServices.Interfaces
{
using Data.SeaHavenIndustries.Enums;
public interface IDashboardDataService
{
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
Task<DashboardCounts> GetWorkOrderCountsAsync(
int? accountId,
CancellationToken cancellationToken);
Task<DashboardKpiCounts> GetKpiCountsAsync(
int? accountId,
string? dispatcherId,
DateOnly today,
CancellationToken cancellationToken);
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken);
Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken);
}
public sealed record DashboardWorkOrder(
int Id,
string? AssignTo,
WorkOrderType? WorkOrderType,
LifecycleStatus? LifecycleStatus,
string? LegacyStatus,
DateTime? ScheduledDate,
DateOnly? OriginalDate,
DateTime? CompletedDate,
string? LocationState = null,
int RescheduleCount = 0,
int? VendorCompanyId = null,
string? VendorCompanyName = null,
bool VendorIsActive = false,
bool VendorCompanyIsDeleted = false);
public sealed record DashboardDispatcherWorkload(
string DispatcherId,
string DispatcherName,
string? Color,
int TotalCount,
int OpenCount);
public class DashboardCounts
{
public int Total { get; set; }
@ -12,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
public int NotDispatched { get; set; }
public int Completed { get; set; }
}
public sealed record DashboardKpiCounts(
int ScheduledTomorrow,
int PendingUplifts,
int AvetaPending);
}

View file

@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces
Task<bool> ExistsAsync(int id);
Task<int> CountAsync();
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
/// <summary>
/// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order.
/// Sort options trail the token so existing call sites stay source-compatible.
/// </summary>
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null);
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);

View file

@ -0,0 +1,14 @@
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);
}

View file

@ -4,6 +4,9 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IVendorCompanyRosterDataService
{
// SH-278: true only for an existing, non-archived Area in the catalogue.
Task<bool> IsActiveAreaAsync(int areaId, CancellationToken cancellationToken);
// Loads the company roster from either a vendor row id and/or a company id,
// returning the company plus all non-deleted technician rows.
Task<VendorCompanyRosterReadModel?> GetRosterAsync(

View file

@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
// of technician Vendor.TradeSpecialties free text.
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
// SH-278: active (non-archived) Area catalogue rows for vendor facets and for
// resolving areas[n] filter ids. Archived Areas are excluded.
Task<IReadOnlyList<Area>> GetActiveAreasAsync(CancellationToken cancellationToken);
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
// companies; active includes companies with zero non-deleted technicians or at
@ -66,6 +70,7 @@ namespace SeaHaven.DataServices.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
VendorAreaFilter? areaFilter = null,
CancellationToken cancellationToken = default);
Task<Vendor> AddAsync(Vendor vendor);
Task UpdateAsync(Vendor vendor);

View file

@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public byte[]? RowVersion { get; set; }
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
}
@ -43,6 +45,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
public bool AreaIdProvided { get; set; }
public int? AreaId { get; set; }
public string? ActorUserId { get; set; }
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
}
@ -81,6 +87,10 @@ namespace SeaHaven.DataServices.Models
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: applied only when AreaIdProvided; null AreaId means Unassigned.
public bool AreaIdProvided { get; set; }
public int? AreaId { get; set; }
}
// Raised by the roster data service when a technician removed from the snapshot

View file

@ -17,10 +17,20 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
// SH-278: Area facet filter for the company directory. AreaIds are already
// resolved against the active catalogue; IncludeUnassigned selects groups with no
// Area (including unlinked legacy vendors). An empty filter matches nobody.
public sealed class VendorAreaFilter
{
public IReadOnlyCollection<int> AreaIds { get; init; } = Array.Empty<int>();
public bool IncludeUnassigned { get; init; }
}
// SH-281 company directory: per-row source for the grouped key query. Carries the
// company group key parts (CompanyId 0 = unlinked; NameKey groups unlinked legacy
// vendors by trimmed case-insensitive CompanyName; VendorKey isolates vendors with
@ -37,6 +47,7 @@ namespace SeaHaven.DataServices.Models
public int CompanyFlag { get; set; }
public int TradeFlag { get; set; }
public int LocationFlag { get; set; }
public int AreaFlag { get; set; }
}
// SH-281: one row per company group after SQL grouping — the bounded key page.
@ -51,6 +62,7 @@ namespace SeaHaven.DataServices.Models
public int CompanyMatch { get; set; }
public int TradeMatch { get; set; }
public int LocationMatch { get; set; }
public int AreaMatch { get; set; }
}
// SH-281: full status-eligible roster row (phase 2) carrying both the vendor's own
@ -69,6 +81,8 @@ namespace SeaHaven.DataServices.Models
public string? CompanyCity { get; set; }
public string? CompanyState { get; set; }
public string? CompanyNotes { get; set; }
public int? CompanyAreaId { get; set; }
public string? CompanyAreaName { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
@ -124,6 +138,8 @@ namespace SeaHaven.DataServices.Models
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
public IReadOnlyList<VendorCompanyGroupTechnician> Technicians { get; set; } = Array.Empty<VendorCompanyGroupTechnician>();

View file

@ -0,0 +1,130 @@
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;
}
}
}

View file

@ -1,10 +1,121 @@
namespace SeaHaven.Services.DTOs
{
public class DashboardStatsQueryDTO
{
public DateOnly? DateFrom { get; set; }
public DateOnly? DateTo { 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 sealed class DashboardRegionResponseDTO
{
public IReadOnlyList<DashboardRegionRowDTO> Items { get; init; } = [];
}
public sealed class DashboardRegionRowDTO
{
public string Region { get; init; } = string.Empty;
public int WorkOrderCount { get; init; }
}
public sealed class DashboardVendorInsightsResponseDTO
{
public IReadOnlyList<DashboardVendorInsightsRowDTO> Items { get; init; } = [];
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalVendors { get; init; }
}
public sealed class DashboardVendorInsightsRowDTO
{
public int VendorCompanyId { get; init; }
public string VendorCompanyName { get; init; } = string.Empty;
public decimal CompletionRate { get; init; }
public decimal RescheduleRate { get; init; }
public decimal? AverageResolutionDays { get; init; }
public int TotalJobs { get; init; }
}
public class DashboardStatsDTO
{
public int Total { get; set; }
public int Open { get; set; }
public int NotDispatched { get; set; }
public int Completed { get; set; }
public int ScheduledTomorrow { get; set; }
public int PendingUplifts { get; set; }
public int AvetaPending { get; set; }
public DashboardBreakdownDTO Breakdown { get; set; } = new();
public int DueCount { get; set; }
public int CompletedDueCount { get; set; }
public decimal CompletionRate { get; set; }
public decimal? AverageResolutionDays { get; set; }
}
public 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

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

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

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SeaHaven.Services.DTOs
{
public class VendorRosterDTO
@ -12,6 +14,8 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public int? AreaId { get; set; }
public string? AreaName { get; set; }
public string? RowVersion { get; set; }
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
}
@ -50,6 +54,10 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: optional Area id; null leaves the new company Unassigned. Assigning
// an Area requires the Admin role.
public int? AreaId { get; set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
@ -65,6 +73,22 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: an omitted areaId leaves the stored Area unchanged, so clients that
// predate Area never clear it. An explicit null clears it to Unassigned.
private int? _areaId;
public int? AreaId
{
get => _areaId;
set
{
_areaId = value;
AreaIdProvided = true;
}
}
[JsonIgnore]
public bool AreaIdProvided { get; private set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
@ -89,5 +113,30 @@ namespace SeaHaven.Services.DTOs
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
// SH-278: same presence semantics as ReconcileVendorRosterDTO.AreaId.
private int? _areaId;
public int? AreaId
{
get => _areaId;
set
{
_areaId = value;
AreaIdProvided = true;
}
}
[JsonIgnore]
public bool AreaIdProvided { get; private set; }
}
// SH-278: raised when a caller without the Admin role tries to change a vendor
// company's Area. Mapped to a stable 403 by the API.
public sealed class VendorAreaAssignmentForbiddenException : Exception
{
public VendorAreaAssignmentForbiddenException()
: base("Only administrators can assign a vendor company Area.")
{
}
}
}

View file

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

View file

@ -59,7 +59,21 @@ namespace SeaHaven.Services.DTOs
{
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
public IEnumerable<VendorFacetAreaDTO> Areas { get; set; } = Enumerable.Empty<VendorFacetAreaDTO>();
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
}
// SH-278: one active Area option. Id is the stable areas[n] filter value.
public class VendorFacetAreaDTO
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
// SH-278: wire values accepted by the vendor directory areas[n] filter besides ids.
public static class VendorAreaFilterValues
{
public const string Unassigned = "__unassigned__";
}
}

View file

@ -0,0 +1,13 @@
namespace SeaHaven.Services.Helpers;
public static class DashboardBusinessTime
{
public const string TimeZoneId = "America/New_York";
public static DateOnly Today()
{
var timezone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId);
var businessNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezone);
return DateOnly.FromDateTime(businessNow);
}
}

View file

@ -0,0 +1,113 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Helpers;
public static class DashboardMetrics
{
public static DashboardMetricResult Calculate(
IReadOnlyList<DashboardWorkOrder> workOrders,
DateOnly today)
{
var breakdown = new DashboardBreakdownDTO();
var dueCount = 0;
var completedDueCount = 0;
var resolutionDays = new List<decimal>();
foreach (var workOrder in workOrders)
{
var status = workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
var completed = status == LifecycleStatus.Completed;
var canceled = status == LifecycleStatus.Canceled;
var pastDue = IsPastDue(workOrder, status, today);
AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue);
var due = completed || (workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) <= today);
if (due)
{
dueCount++;
if (completed)
completedDueCount++;
}
if (completed && workOrder.CompletedDate is DateTime completedDate)
{
var anchor = workOrder.OriginalDate
?? (workOrder.ScheduledDate is DateTime scheduledDate
? DateOnly.FromDateTime(scheduledDate.Date)
: null);
if (anchor is DateOnly originalDate)
resolutionDays.Add((decimal)(completedDate.Date - originalDate.ToDateTime(TimeOnly.MinValue)).TotalDays);
}
}
return new DashboardMetricResult(
workOrders.Count,
workOrders.Count(workOrder =>
{
var status = workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
return status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
}),
workOrders.Count(workOrder => string.IsNullOrWhiteSpace(workOrder.AssignTo)),
workOrders.Count(workOrder =>
(workOrder.LifecycleStatus ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
== LifecycleStatus.Completed),
breakdown,
dueCount,
completedDueCount,
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2));
}
private static bool IsPastDue(
DashboardWorkOrder workOrder,
LifecycleStatus? status,
DateOnly today)
=> workOrder.ScheduledDate is DateTime scheduled
&& DateOnly.FromDateTime(scheduled.Date) < today
&& status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
private static void AddBreakdownCount(
DashboardBreakdownDTO breakdown,
WorkOrderType? type,
bool pastDue)
{
if (pastDue)
{
breakdown.Overdue++;
return;
}
switch (type)
{
case WorkOrderType.PM:
breakdown.PM++;
break;
case WorkOrderType.Emergency:
breakdown.Emergency++;
break;
case WorkOrderType.Reactive:
breakdown.Reactive++;
break;
default:
breakdown.Other++;
break;
}
}
}
public sealed record DashboardMetricResult(
int Total,
int Open,
int NotDispatched,
int Completed,
DashboardBreakdownDTO Breakdown,
int DueCount,
int CompletedDueCount,
decimal CompletionRate,
decimal? AverageResolutionDays);

View file

@ -0,0 +1,84 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.Services.Helpers;
public static class DashboardRegions
{
public static readonly IReadOnlyList<string> Names =
["East", "Central", "West", "California", "Unmapped/Other"];
public static string Resolve(string? state)
{
if (string.IsNullOrWhiteSpace(state))
return "Unmapped/Other";
return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region)
? region
: "Unmapped/Other";
}
public static IReadOnlyDictionary<string, int> Count(
IReadOnlyList<DashboardWorkOrder> workOrders)
{
var counts = Names.ToDictionary(name => name, _ => 0, StringComparer.Ordinal);
foreach (var workOrder in workOrders)
counts[Resolve(workOrder.LocationState)]++;
return counts;
}
private static readonly IReadOnlyDictionary<string, string> StateToRegion =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["CT"] = "East",
["DE"] = "East",
["GA"] = "East",
["ME"] = "East",
["MD"] = "East",
["MA"] = "East",
["NH"] = "East",
["NJ"] = "East",
["NY"] = "East",
["NC"] = "East",
["PA"] = "East",
["RI"] = "East",
["SC"] = "East",
["VT"] = "East",
["VA"] = "East",
["WV"] = "East",
["AL"] = "Central",
["AR"] = "Central",
["FL"] = "Central",
["IL"] = "Central",
["IN"] = "Central",
["IA"] = "Central",
["KS"] = "Central",
["KY"] = "Central",
["LA"] = "Central",
["MI"] = "Central",
["MN"] = "Central",
["MS"] = "Central",
["MO"] = "Central",
["NE"] = "Central",
["ND"] = "Central",
["OH"] = "Central",
["OK"] = "Central",
["SD"] = "Central",
["TN"] = "Central",
["WI"] = "Central",
["AK"] = "West",
["AZ"] = "West",
["CO"] = "West",
["HI"] = "West",
["ID"] = "West",
["MT"] = "West",
["NV"] = "West",
["NM"] = "West",
["OR"] = "West",
["TX"] = "West",
["UT"] = "West",
["WA"] = "West",
["WY"] = "West",
["CA"] = "California"
};
}

View file

@ -1,5 +1,10 @@
using System.Globalization;
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
@ -7,22 +12,396 @@ namespace SeaHaven.Services.Implementation
public class DashboardService : IDashboardService
{
private readonly IDashboardDataService _dataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public DashboardService(IDashboardDataService dataService)
public DashboardService(
IDashboardDataService dataService,
IWorkOrderAccountResolver accountResolver)
{
_dataService = dataService;
_accountResolver = accountResolver;
}
public async Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken)
public async Task<DashboardStatsDTO> GetStatsAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
CancellationToken cancellationToken)
{
var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken);
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
var kpis = await _dataService.GetKpiCountsAsync(
accountId,
dispatcherId,
DashboardBusinessTime.Today(),
cancellationToken);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId,
dispatcherId,
query.DateFrom,
query.DateTo,
cancellationToken);
var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today());
var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null;
return new DashboardStatsDTO
{
Total = counts.Total,
Open = counts.Open,
NotDispatched = counts.NotDispatched,
Completed = counts.Completed
Total = useMetrics ? metrics.Total : counts.Total,
Open = useMetrics ? metrics.Open : counts.Open,
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
Completed = useMetrics ? metrics.Completed : counts.Completed,
ScheduledTomorrow = kpis.ScheduledTomorrow,
PendingUplifts = kpis.PendingUplifts,
AvetaPending = kpis.AvetaPending,
Breakdown = metrics.Breakdown,
DueCount = metrics.DueCount,
CompletedDueCount = metrics.CompletedDueCount,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
};
}
public async Task<DashboardWorkloadResponseDTO> GetWorkloadAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
int page,
CancellationToken cancellationToken)
{
if (page < 1)
throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var rows = await _dataService.GetDispatcherWorkloadAsync(
accountId,
dispatcherId,
query.DateFrom,
query.DateTo,
cancellationToken);
var orderedRows = rows
.OrderByDescending(row => row.OpenCount)
.ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.DispatcherId, StringComparer.Ordinal)
.ToList();
const int pageSize = 10;
return new DashboardWorkloadResponseDTO
{
Items = orderedRows.Skip((page - 1) * pageSize).Take(pageSize)
.Select(row => new DashboardWorkloadRowDTO
{
DispatcherId = row.DispatcherId,
DispatcherName = row.DispatcherName,
Color = row.Color,
TotalCount = row.TotalCount,
OpenCount = row.OpenCount
}).ToList(),
Page = page,
PageSize = pageSize,
TotalDispatchers = orderedRows.Count
};
}
public async Task<DashboardPerformanceResponseDTO> GetPerformanceAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
int page,
CancellationToken cancellationToken)
{
if (page < 1)
throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var directory = await _dataService.GetDispatcherWorkloadAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
var today = DashboardBusinessTime.Today();
var rows = directory.Select(dispatcher =>
{
var metrics = DashboardMetrics.Calculate(
workOrders.Where(workOrder => workOrder.AssignTo == dispatcher.DispatcherId).ToList(),
today);
return new DashboardPerformanceRowDTO
{
DispatcherId = dispatcher.DispatcherId,
DispatcherName = dispatcher.DispatcherName,
Color = dispatcher.Color,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
};
})
.OrderByDescending(row => row.CompletionRate)
.ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.DispatcherId, StringComparer.Ordinal)
.ToList();
const int pageSize = 10;
return new DashboardPerformanceResponseDTO
{
Items = rows.Skip((page - 1) * pageSize).Take(pageSize).ToList(),
Page = page,
PageSize = pageSize,
TotalDispatchers = rows.Count
};
}
public async Task<DashboardRegionResponseDTO> GetRegionsAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
var counts = DashboardRegions.Count(workOrders);
return new DashboardRegionResponseDTO
{
Items = DashboardRegions.Names
.Select(region => new DashboardRegionRowDTO
{
Region = region,
WorkOrderCount = counts[region]
})
.ToList()
};
}
public async Task<DashboardTrendResponseDTO> GetTrendAsync(
ClaimsPrincipal user,
DashboardTrendQueryDTO query,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var today = DashboardBusinessTime.Today();
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
var firstBucketStart = BucketStart(windowFrom, granularity);
var lastBucketStart = BucketStart(windowTo, granularity);
var lastBucketEnd = BucketEnd(lastBucketStart, granularity);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId,
dispatcherId,
firstBucketStart.AddDays(-1),
lastBucketEnd.AddDays(1),
cancellationToken);
var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity)
.Select(start => new TrendBucket(start))
.ToList();
var bucketStarts = buckets.Select(bucket => bucket.Start).ToList();
foreach (var workOrder in workOrders)
{
if (workOrder.ScheduledDate is not DateTime scheduled)
continue;
var businessDate = DateOnly.FromDateTime(scheduled.Date);
if (businessDate < firstBucketStart || businessDate > lastBucketEnd)
continue;
var bucket = buckets[LocateBucket(bucketStarts, businessDate)];
var status = workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
bucket.Total++;
if (status == LifecycleStatus.Completed)
bucket.Completed++;
else if (status == LifecycleStatus.Canceled)
bucket.Canceled++;
else
{
bucket.Open++;
if (businessDate < today)
bucket.Overdue++;
}
}
DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd
? null
: buckets[LocateBucket(bucketStarts, today)].Start;
return new DashboardTrendResponseDTO
{
Granularity = granularity,
Today = today,
Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO
{
Date = bucket.Start,
Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Total = bucket.Total,
Open = bucket.Open,
Completed = bucket.Completed,
Canceled = bucket.Canceled,
Overdue = bucket.Overdue,
IsCurrent = bucket.Start == currentStart
}).ToList()
};
}
private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow(
DashboardTrendQueryDTO query,
DateOnly today)
{
if (query.Year is int year)
{
if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year)
throw new ArgumentOutOfRangeException(nameof(query));
return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month");
}
if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase))
{
var weeklyTo = query.DateTo ?? today;
var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1);
if (weeklyFrom > weeklyTo)
throw new ArgumentOutOfRangeException(nameof(query));
return (weeklyFrom, weeklyTo, "week");
}
if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo)
{
if (dailyFrom > dailyTo)
throw new ArgumentOutOfRangeException(nameof(query));
return (dailyFrom, dailyTo, "day");
}
if (query.DateFrom.HasValue != query.DateTo.HasValue)
throw new ArgumentOutOfRangeException(nameof(query));
return (today, today, "day");
}
private static List<DateOnly> BuildBucketStarts(DateOnly from, DateOnly to, string granularity)
{
var starts = new List<DateOnly>();
var current = BucketStart(from, granularity);
while (current <= to)
{
starts.Add(current);
current = granularity switch
{
"week" => current.AddDays(7),
"month" => current.AddMonths(1),
_ => current.AddDays(1)
};
}
return starts;
}
private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch
{
"week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)),
"month" => new DateOnly(date.Year, date.Month, 1),
_ => date
};
private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch
{
"week" => start.AddDays(6),
"month" => start.AddMonths(1).AddDays(-1),
_ => start
};
private static int LocateBucket(List<DateOnly> starts, DateOnly date)
{
var low = 0;
var high = starts.Count - 1;
while (low < high)
{
var middle = (low + high + 1) / 2;
if (starts[middle] <= date)
low = middle;
else
high = middle - 1;
}
return low;
}
private const string ThreeMonthRange = "3m";
private sealed class TrendBucket(DateOnly start)
{
public DateOnly Start { get; } = start;
public int Total { get; set; }
public int Open { get; set; }
public int Completed { get; set; }
public int Canceled { get; set; }
public int Overdue { get; set; }
}
public async Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, null, null, null, cancellationToken);
var activeVendorOrders = workOrders
.Where(workOrder => workOrder.VendorCompanyId.HasValue
&& workOrder.VendorIsActive
&& !workOrder.VendorCompanyIsDeleted)
.ToList();
var today = DashboardBusinessTime.Today();
var rows = activeVendorOrders
.GroupBy(workOrder => new
{
Id = workOrder.VendorCompanyId!.Value,
Name = workOrder.VendorCompanyName ?? string.Empty
})
.Select(group =>
{
var orders = group.ToList();
var metrics = DashboardMetrics.Calculate(orders, today);
var rescheduled = orders.Count(order => order.RescheduleCount >= 1);
return new DashboardVendorInsightsRowDTO
{
VendorCompanyId = group.Key.Id,
VendorCompanyName = group.Key.Name,
CompletionRate = metrics.CompletionRate,
RescheduleRate = Math.Round((decimal)rescheduled / orders.Count * 100, 2),
AverageResolutionDays = metrics.AverageResolutionDays,
TotalJobs = orders.Count
};
})
.OrderByDescending(row => row.CompletionRate)
.ThenBy(row => row.VendorCompanyName, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.VendorCompanyId)
.ToList();
const int pageSize = 10;
return new DashboardVendorInsightsResponseDTO
{
Items = rows.Take(pageSize).ToList(),
Page = 1,
PageSize = pageSize,
TotalVendors = rows.Count
};
}
}

View file

@ -166,11 +166,15 @@ namespace SeaHaven.Services.Implementation
int pageSize,
string? search,
string? states = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
string? sortBy = null,
string? sortDirection = null)
{
var stateFilter = NormalizeStateFilter(states);
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
var sort = NormalizeSortColumn(sortBy);
var direction = NormalizeSortDirection(sortDirection);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken, sort, direction);
// SH-138: one bounded contact read for the whole page, never per row.
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
@ -184,6 +188,42 @@ namespace SeaHaven.Services.Implementation
};
}
internal static readonly IReadOnlyCollection<string> SortableColumns = new[] { "code", "client", "address", "city", "state", "poc" };
internal static string? NormalizeSortColumn(string? sortBy)
{
if (string.IsNullOrWhiteSpace(sortBy))
return null;
var normalized = sortBy.Trim().ToLowerInvariant();
if (!SortableColumns.Contains(normalized))
{
throw new ValidationException(new[]
{
new ValidationFailure("sortBy", $"sortBy must be one of: {string.Join(", ", SortableColumns)}.")
});
}
return normalized;
}
internal static string NormalizeSortDirection(string? sortDirection)
{
if (string.IsNullOrWhiteSpace(sortDirection))
return "asc";
var normalized = sortDirection.Trim().ToLowerInvariant();
if (normalized != "asc" && normalized != "desc")
{
throw new ValidationException(new[]
{
new ValidationFailure("sortDirection", "sortDirection must be asc or desc.")
});
}
return normalized;
}
internal static IReadOnlyCollection<string>? NormalizeStateFilter(string? states)
{
if (string.IsNullOrWhiteSpace(states))
@ -503,6 +543,7 @@ namespace SeaHaven.Services.Implementation
Email = location.Email,
Status = location.Status,
AccountId = location.AccountId,
AccountName = location.Account?.Name,
CreatedDate = location.CreatedDate,
CreatedBy = location.createdby,
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)

View file

@ -0,0 +1,96 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace SeaHaven.Services.Implementation;
public sealed class TeamPermissionService : ITeamPermissionService
{
private readonly ITeamPermissionOverrideDataService _dataService;
private readonly ITeamPermissionPolicy _policy;
public TeamPermissionService(
ITeamPermissionOverrideDataService dataService,
ITeamPermissionPolicy policy)
{
_dataService = dataService;
_policy = policy;
}
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> GetProfileAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
if (!IsAdmin(caller))
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.Forbidden);
var user = await _dataService.GetUserAsync(userId, cancellationToken);
return user is null
? TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.NotFound)
: TeamPermissionResult<TeamPermissionProfileDTO>.Success(BuildProfile(user));
}
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
string userId,
string permissionKey,
UserPermissionState state,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
if (!IsAdmin(caller))
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.Forbidden);
var canonicalKey = TeamPermissionKeys.All.FirstOrDefault(
key => string.Equals(key, permissionKey, StringComparison.OrdinalIgnoreCase));
if (canonicalKey is null)
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(
TeamPermissionResultStatus.InvalidPermissionKey);
var user = await _dataService.GetUserAsync(userId, cancellationToken);
if (user is null)
return TeamPermissionResult<TeamPermissionProfileDTO>.Failure(TeamPermissionResultStatus.NotFound);
await _dataService.SetOverrideAsync(userId, canonicalKey, state, cancellationToken);
var updatedOverrides = user.Overrides.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase);
updatedOverrides[canonicalKey] = state;
return TeamPermissionResult<TeamPermissionProfileDTO>.Success(
BuildProfile(new TeamPermissionUserData
{
UserId = user.UserId,
RoleName = user.RoleName,
Overrides = updatedOverrides
}));
}
private TeamPermissionProfileDTO BuildProfile(TeamPermissionUserData user)
{
return new TeamPermissionProfileDTO
{
UserId = user.UserId,
RoleName = user.RoleName,
Permissions = TeamPermissionKeys.All
.Select(key => new TeamPermissionValueDTO
{
PermissionKey = key,
OverrideState = user.Overrides.TryGetValue(key, out var state)
? state
: UserPermissionState.Unset,
IsGranted = _policy.IsAllowed(user.RoleName, key, user.Overrides)
})
.ToList()
};
}
private static bool IsAdmin(ClaimsPrincipal? caller) =>
caller?.IsInRole("Admin") == true;
}

View file

@ -6,6 +6,7 @@ using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using System.Security.Claims;
namespace SeaHaven.Services.Implementation
{
@ -13,6 +14,8 @@ namespace SeaHaven.Services.Implementation
{
private const int MaxNotesLength = 500;
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
private const string AdminRole = "Admin";
private const string AreaNotFoundMessage = "Area was not found.";
private readonly IVendorCompanyRosterDataService _rosterDataService;
@ -39,6 +42,7 @@ namespace SeaHaven.Services.Implementation
public async Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -60,6 +64,13 @@ namespace SeaHaven.Services.Implementation
"Technician ids are not allowed when creating a vendor company.")
});
await EnsureAreaAssignmentAllowedAsync(
caller,
dto.AreaId,
currentAreaId: null,
nameof(CreateVendorRosterDTO.AreaId),
cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
Name = dto.Name,
@ -71,6 +82,8 @@ namespace SeaHaven.Services.Implementation
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
AreaIdProvided = true,
AreaId = dto.AreaId,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
@ -83,6 +96,7 @@ namespace SeaHaven.Services.Implementation
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -101,6 +115,14 @@ namespace SeaHaven.Services.Implementation
// Mismatched ids: every non-null technician id must belong to this company.
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
if (dto.AreaIdProvided)
await EnsureAreaChangeAllowedAsync(
companyId,
caller,
dto.AreaId,
nameof(ReconcileVendorRosterDTO.AreaId),
cancellationToken);
var writeModel = new VendorCompanyRosterWriteModel
{
CompanyId = companyId,
@ -114,6 +136,8 @@ namespace SeaHaven.Services.Implementation
Zip = dto.Zip,
GoogleMapsUrl = dto.GoogleMapsUrl,
Notes = dto.Notes,
AreaIdProvided = dto.AreaIdProvided,
AreaId = dto.AreaId,
ActorUserId = userId,
Technicians = MapTechnicians(dto.Technicians)
};
@ -126,6 +150,7 @@ namespace SeaHaven.Services.Implementation
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
@ -167,6 +192,14 @@ namespace SeaHaven.Services.Implementation
if (failures.Count > 0)
throw new ValidationException(failures);
if (companyFields is { AreaIdProvided: true })
await EnsureAreaChangeAllowedAsync(
companyId,
caller,
companyFields.AreaId,
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}",
cancellationToken);
// The contact-group invariant ("at least one company phone or email") is
// preserved by construction: blank company fields map to null ("unchanged"),
// so this endpoint can only set valid phone/email values, never clear them.
@ -184,6 +217,50 @@ namespace SeaHaven.Services.Implementation
return MapToDTO(saved);
}
// SH-278: Area assignment is Admin-only. A payload that leaves the stored Area
// unchanged is allowed for any authenticated caller, so non-admin users can
// still save other company fields while round-tripping the current Area.
private async Task EnsureAreaChangeAllowedAsync(
int companyId,
ClaimsPrincipal caller,
int? requestedAreaId,
string propertyName,
CancellationToken cancellationToken)
{
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken)
?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found.");
await EnsureAreaAssignmentAllowedAsync(
caller,
requestedAreaId,
roster.AreaId,
propertyName,
cancellationToken);
}
private async Task EnsureAreaAssignmentAllowedAsync(
ClaimsPrincipal caller,
int? requestedAreaId,
int? currentAreaId,
string propertyName,
CancellationToken cancellationToken)
{
if (requestedAreaId == currentAreaId)
return;
if (caller?.IsInRole(AdminRole) != true)
throw new VendorAreaAssignmentForbiddenException();
if (requestedAreaId is int areaId
&& (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken)))
{
throw new ValidationException(new[]
{
new ValidationFailure(propertyName, AreaNotFoundMessage)
});
}
}
private static void EnsureAuthenticated(string userId)
{
if (string.IsNullOrWhiteSpace(userId))
@ -290,7 +367,8 @@ namespace SeaHaven.Services.Implementation
|| !string.IsNullOrWhiteSpace(fields.State)
|| !string.IsNullOrWhiteSpace(fields.Zip)
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|| !string.IsNullOrWhiteSpace(fields.Notes);
|| !string.IsNullOrWhiteSpace(fields.Notes)
|| fields.AreaIdProvided;
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
model.Name == null
@ -301,7 +379,8 @@ namespace SeaHaven.Services.Implementation
&& model.State == null
&& model.Zip == null
&& model.GoogleMapsUrl == null
&& model.Notes == null;
&& model.Notes == null
&& !model.AreaIdProvided;
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
VendorRosterCompanyFieldsDTO fields,
@ -344,7 +423,9 @@ namespace SeaHaven.Services.Implementation
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes,
AreaIdProvided = fields.AreaIdProvided,
AreaId = fields.AreaId
};
}
@ -443,6 +524,8 @@ namespace SeaHaven.Services.Implementation
Zip = roster.Zip,
GoogleMapsUrl = roster.GoogleMapsUrl,
Notes = roster.Notes,
AreaId = roster.AreaId,
AreaName = roster.AreaName,
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
{

View file

@ -4,6 +4,7 @@ using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Globalization;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
@ -160,10 +161,12 @@ namespace SeaHaven.Services.Implementation
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, MaxDirectoryPageSize);
var areaFilter = await ResolveAreaFilterAsync(areas, cancellationToken);
var (items, totalCount) = await _vendorDataService.GetCompanyDirectoryPagedAsync(
normalizedPage,
normalizedPageSize,
@ -173,6 +176,7 @@ namespace SeaHaven.Services.Implementation
trades,
locations,
jobBuckets,
areaFilter,
cancellationToken);
return new PagedResult<VendorDirectoryItemDTO>
@ -184,6 +188,47 @@ namespace SeaHaven.Services.Implementation
};
}
// SH-278 areas[n] contract: blank entries are ignored; "__unassigned__" selects
// companies without an Area; other values must be stable positive integer ids
// of active catalogue rows. Display names, unknown ids and archived ids never
// error — they simply match nobody. Null means no Area filter was requested.
private async Task<VendorAreaFilter?> ResolveAreaFilterAsync(
IReadOnlyCollection<string>? areas,
CancellationToken cancellationToken)
{
var values = areas?
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.ToList();
if (values is not { Count: > 0 })
return null;
var includeUnassigned = values.Contains(VendorAreaFilterValues.Unassigned, StringComparer.Ordinal);
var requestedIds = new HashSet<int>();
foreach (var value in values)
{
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
requestedIds.Add(id);
}
IReadOnlyCollection<int> activeIds = Array.Empty<int>();
if (requestedIds.Count > 0)
{
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
activeIds = activeAreas
.Select(area => area.Id)
.Where(requestedIds.Contains)
.ToArray();
}
return new VendorAreaFilter
{
AreaIds = activeIds,
IncludeUnassigned = includeUnassigned
};
}
private static VendorDirectoryItemDTO MapToCompanyGroup(VendorCompanyGroup group) => new()
{
Id = group.Id,
@ -201,6 +246,8 @@ namespace SeaHaven.Services.Implementation
TradeSpecialties = group.TradeSpecialties,
GoogleMapsUrl = group.GoogleMapsUrl,
Notes = group.Notes,
AreaId = group.AreaId,
AreaName = group.AreaName,
IsActive = group.IsActive,
TotalJobs = group.TotalJobs,
Technicians = group.Technicians.Select(MapToTechnician).ToList()
@ -731,6 +778,7 @@ namespace SeaHaven.Services.Implementation
// populated even on an empty database and independent of the status filter.
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
var activeAreas = await _vendorDataService.GetActiveAreasAsync(cancellationToken);
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
@ -772,10 +820,19 @@ namespace SeaHaven.Services.Implementation
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
// SH-278: the active Area catalogue, A–Z case-insensitive, independent of the
// status filter and of whether any company currently uses an Area. Clients
// prepend the "__unassigned__" option themselves.
var areas = activeAreas
.OrderBy(area => area.Name, StringComparer.OrdinalIgnoreCase)
.Select(area => new VendorFacetAreaDTO { Id = area.Id, Name = area.Name })
.ToList();
return new VendorFacetsDTO
{
Companies = companyFacets,
Trades = trades,
Areas = areas,
Locations = locations,
JobBuckets = new[]
{

View file

@ -1,9 +1,39 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IDashboardService
{
Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken);
Task<DashboardStatsDTO> GetStatsAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
CancellationToken cancellationToken);
Task<DashboardWorkloadResponseDTO> GetWorkloadAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
int page,
CancellationToken cancellationToken);
Task<DashboardPerformanceResponseDTO> GetPerformanceAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
int page,
CancellationToken cancellationToken);
Task<DashboardRegionResponseDTO> GetRegionsAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
CancellationToken cancellationToken);
Task<DashboardTrendResponseDTO> GetTrendAsync(
ClaimsPrincipal user,
DashboardTrendQueryDTO query,
CancellationToken cancellationToken);
Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken);
}
}

View file

@ -19,7 +19,7 @@ namespace SeaHaven.Services.Interfaces
Task<bool> LocationExistsAsync(int id);
Task<int> GetTotalLocationCountAsync();
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default);
Task<PagedResult<LocationDTO>> GetLocationListPagedAsync(int page, int pageSize, string? search, string? states = null, CancellationToken cancellationToken = default, string? sortBy = null, string? sortDirection = null);
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);

View file

@ -0,0 +1,20 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
using System.Security.Claims;
namespace SeaHaven.Services.Interfaces;
public interface ITeamPermissionService
{
Task<TeamPermissionResult<TeamPermissionProfileDTO>> GetProfileAsync(
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
string userId,
string permissionKey,
UserPermissionState state,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
@ -13,18 +14,21 @@ namespace SeaHaven.Services.Interfaces
Task<VendorRosterDTO> CreateRosterAsync(
CreateVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<VendorRosterDTO> ReconcileRosterAsync(
int companyId,
ReconcileVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<VendorRosterDTO> AddTechniciansAsync(
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
ClaimsPrincipal caller,
CancellationToken cancellationToken);
}
}

View file

@ -41,6 +41,7 @@ namespace SeaHaven.Services.Interfaces
IReadOnlyCollection<string>? trades = null,
IReadOnlyCollection<string>? locations = null,
IReadOnlyCollection<string>? jobBuckets = null,
IReadOnlyCollection<string>? areas = null,
CancellationToken cancellationToken = default);
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);

View file

@ -835,8 +835,8 @@ public class WorkOrderAccountScopeTests
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
public Task<int> CountAsync() => _inner.CountAsync();
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken)
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken);
int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null)
=> _inner.GetListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
=> _inner.GetDetailByIdAsync(id, cancellationToken);
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)