mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
Merge branch 'dev' into feat/ab/sh-210-uplift-decisions
This commit is contained in:
commit
1eadb82f28
72 changed files with 17973 additions and 59 deletions
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
54
Api.SeaHavenIndustries.Tests/DashboardControllerTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class DashboardControllerTests
|
||||
{
|
||||
private static object Prop(object source, string name) =>
|
||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||
|
||||
private static DashboardController NewController(Mock<IDashboardService> service) =>
|
||||
new(service.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
// Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and
|
||||
// DashboardStatsDTO already carried these three counts; the regression this
|
||||
// pins is the controller's anonymous response silently dropping them, which
|
||||
// is what left the tiles with nothing to read.
|
||||
[Fact]
|
||||
public async Task GetStats_SerialisesKpiCountsOnWireObject()
|
||||
{
|
||||
var service = new Mock<IDashboardService>();
|
||||
service.Setup(s => s.GetStatsAsync(
|
||||
It.IsAny<ClaimsPrincipal>(),
|
||||
It.IsAny<DashboardStatsQueryDTO>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DashboardStatsDTO
|
||||
{
|
||||
ScheduledTomorrow = 3,
|
||||
PendingUplifts = 5,
|
||||
AvetaPending = 7
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetStats(
|
||||
new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
|
||||
var body = result.Should().BeOfType<OkObjectResult>().Subject.Value!;
|
||||
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
|
||||
((int)Prop(body, "pendingUplifts")).Should().Be(5);
|
||||
((int)Prop(body, "avetaPending")).Should().Be(7);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,723 @@ 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" },
|
||||
new Locations { Id = 4, State = "indiana" },
|
||||
new Locations { Id = 5, State = "NEW YORK" },
|
||||
new Locations { Id = 6, State = "California" });
|
||||
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 = 4 },
|
||||
new WorkOrder { AccountId = 1, LocationId = 5 },
|
||||
new WorkOrder { AccountId = 1, LocationId = 6 },
|
||||
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");
|
||||
// NY + "NEW YORK" -> East; "indiana" -> Central; "ca" + "California" -> California;
|
||||
// "XX" + unassigned location -> Unmapped/Other. Full-name storage forms must not
|
||||
// fall through to Unmapped/Other (SH-348 review).
|
||||
result.Items.Select(row => row.WorkOrderCount).Should().Equal(2, 1, 0, 2, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MapsEveryUsStateAndItsFullNameToACanonicalBucket()
|
||||
{
|
||||
foreach (var code in UsStateCodes.All)
|
||||
{
|
||||
var byCode = DashboardRegions.Resolve(code);
|
||||
byCode.Should().NotBe(
|
||||
"Unmapped/Other",
|
||||
$"state code {code} must belong to a canonical region");
|
||||
|
||||
var fullName = UsStateCodes.ExpandStorageValues(new[] { code })
|
||||
.First(value => value != code);
|
||||
DashboardRegions.Resolve(fullName).Should().Be(
|
||||
byCode,
|
||||
$"the full-name storage form of {code} must resolve to the same region as the code");
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", null, It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||
|
|
@ -49,7 +49,7 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_PassesStatesFilterToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, null, "tx, mo", It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>(),
|
||||
|
|
@ -58,7 +58,7 @@ public class LocationControllerTests
|
|||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", CancellationToken.None);
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "tx, mo", cancellationToken: CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.VerifyAll();
|
||||
|
|
@ -68,13 +68,13 @@ public class LocationControllerTests
|
|||
public async Task GetLocationList_WhenStatesInvalid_ReturnsExistingValidationErrorShape()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("states", "states must be valid US postal abbreviations: ZZ.")
|
||||
}));
|
||||
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", CancellationToken.None);
|
||||
var result = await NewController(service).GetLocationList(null, 1, 10, "ZZ", cancellationToken: CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
|
|
@ -200,4 +200,90 @@ public class LocationControllerTests
|
|||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_ForwardsSortParamsToService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO> { Items = new List<LocationDTO>(), TotalCount = 0, Page = 1, PageSize = 10 });
|
||||
|
||||
await NewController(service).GetLocationList(sortBy: "city", sortDirection: "desc", cancellationToken: CancellationToken.None);
|
||||
|
||||
service.Verify(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), "city", "desc"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_WhenSortInvalid_ReturnsExistingValidationErrorShape()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure("sortBy", "sortBy must be one of: code, client, address, city, state, poc.")
|
||||
}));
|
||||
|
||||
var result = await NewController(service).GetLocationList(sortBy: "nope", cancellationToken: CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
response.Status.Should().Be("Validation Error");
|
||||
response.Message.Should().Contain("sortBy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationList_ProjectsClientAccountNameAndSiteFields()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "", null, It.IsAny<CancellationToken>(), null, null))
|
||||
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||
{
|
||||
Items = new List<LocationDTO>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Name = "STL8",
|
||||
Address1 = "9 River Rd",
|
||||
City = "St. Louis",
|
||||
State = "MO",
|
||||
Zip = "63101",
|
||||
PhoneNumber = "555-0100",
|
||||
Email = "poc@example.com",
|
||||
AccountId = 3,
|
||||
AccountName = "Acme Co",
|
||||
Contacts = new List<SiteContactResponseDTO>
|
||||
{
|
||||
new() { Id = 10, Name = "Cara Lane", Phone = "555-0100" },
|
||||
new() { Id = 11, Name = "Alan Ford", Phone = "555-0101" }
|
||||
}
|
||||
}
|
||||
},
|
||||
TotalCount = 1,
|
||||
Page = 1,
|
||||
PageSize = 10
|
||||
});
|
||||
|
||||
var result = await NewController(service).GetLocationList(cancellationToken: CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
using var json = System.Text.Json.JsonSerializer.SerializeToDocument(ok.Value);
|
||||
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
|
||||
|
||||
row.GetProperty("Name").GetString().Should().Be("STL8", "the site code is the legacy Name field");
|
||||
row.GetProperty("Address").GetString().Should().Be("9 River Rd");
|
||||
row.GetProperty("City").GetString().Should().Be("St. Louis");
|
||||
row.GetProperty("State").GetString().Should().Be("MO");
|
||||
row.GetProperty("ZipCode").GetString().Should().Be("63101");
|
||||
row.GetProperty("Phone").GetString().Should().Be("555-0100");
|
||||
row.GetProperty("ContactEmail").GetString().Should().Be("poc@example.com");
|
||||
row.GetProperty("Contact").GetString().Should().Be("Cara Lane", "the first ordered contact is the legacy Contact field");
|
||||
row.GetProperty("AccountId").GetInt32().Should().Be(3);
|
||||
row.GetProperty("ClientName").GetString().Should().Be("Acme Co");
|
||||
row.GetProperty("AccountName").GetString().Should().Be("Acme Co");
|
||||
var contacts = row.GetProperty("Contacts");
|
||||
contacts.GetArrayLength().Should().Be(2);
|
||||
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
|
||||
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,4 +86,129 @@ public class LocationDataServiceTests
|
|||
totalCount.Should().Be(2);
|
||||
items.Select(l => l.State).Should().BeEquivalentTo("indiana", "MO");
|
||||
}
|
||||
|
||||
private static async Task SeedRegistryAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
ctx.Accounts.AddRange(
|
||||
new Accounts { Id = 1, Name = "Acme Co", IsDeleted = false },
|
||||
new Accounts { Id = 2, Name = "Borealis LLC", IsDeleted = false });
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 10, Name = "STL8", Address1 = "9 River Rd", City = "St. Louis", State = "MO", AccountId = 1 },
|
||||
new Locations { Id = 11, Name = "DFW8", Address1 = "2 Prairie Ave", City = "Dallas", State = "TX", AccountId = 2 },
|
||||
new Locations { Id = 12, Name = "IND9", Address1 = "5 Circle Blvd", City = "Indianapolis", State = "IN", AccountId = 1 },
|
||||
new Locations { Id = 13, Name = "MIA2", Address1 = "1 Bay Dr", City = "Miami", State = "FL", AccountId = 2 });
|
||||
ctx.Contacts.AddRange(
|
||||
new Contacts { Id = 100, LocationId = 10, SiteContactOrder = 0, FirstName = "Cara Lane", PhoneNumber = "555-0001" },
|
||||
new Contacts { Id = 101, LocationId = 10, SiteContactOrder = 1, FirstName = "Alan Ford", PhoneNumber = "555-0002" },
|
||||
new Contacts { Id = 102, LocationId = 11, SiteContactOrder = 1, FirstName = "Zoe Park", PhoneNumber = "555-0003" },
|
||||
new Contacts { Id = 103, LocationId = 11, SiteContactOrder = 0, FirstName = "Removed Poc", PhoneNumber = "555-0004", IsDeleted = true },
|
||||
new Contacts { Id = 104, LocationId = 12, SiteContactOrder = 0, FirstName = "Bob Quinn", PhoneNumber = "555-0005" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("STL", new[] { 10 }, "site code")]
|
||||
[InlineData("Borealis", new[] { 11, 13 }, "client name")]
|
||||
[InlineData("Prairie", new[] { 11 }, "street address")]
|
||||
[InlineData("Miami", new[] { 13 }, "city")]
|
||||
public async Task GetListPagedAsync_SearchMatchesCodeClientAddressAndCity(string term, int[] expectedIds, string becauseField)
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, totalCount) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, term, null, CancellationToken.None);
|
||||
|
||||
totalCount.Should().Be(expectedIds.Length, $"search must match the {becauseField}");
|
||||
items.Select(l => l.Id).Should().BeEquivalentTo(expectedIds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("code", 11, 12, 13, 10)]
|
||||
[InlineData("client", 10, 12, 11, 13)]
|
||||
[InlineData("address", 13, 11, 12, 10)]
|
||||
[InlineData("city", 11, 12, 13, 10)]
|
||||
[InlineData("state", 13, 12, 10, 11)]
|
||||
[InlineData("poc", 13, 12, 10, 11)]
|
||||
public async Task GetListPagedAsync_SortByAllowlistedColumn_OrdersAscending_WithIdTiebreak(
|
||||
string sortBy, params int[] expectedIds)
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, sortBy, "asc");
|
||||
|
||||
items.Select(l => l.Id).Should().ContainInOrder(expectedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_SortByPoc_IgnoresDeletedContacts_AndSortsByFirstActiveContact()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "poc", "asc");
|
||||
|
||||
// DFW8's order-0 row is soft deleted, so its POC is "Zoe Park" (order 1) and it must
|
||||
// sort last; including the deleted row ("Removed Poc") would instead place it second.
|
||||
items.Select(l => l.Id).Should().ContainInOrder(13, 12, 10, 11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_SortByClient_Desc_ReversesAccountOrder()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "client", "desc");
|
||||
|
||||
items.Select(l => l.Id).Should().ContainInOrder(new[] { 11, 13, 10, 12 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_UnknownSortColumn_FallsBackToLegacyCodeOrder()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None, "not-a-column", "sideways");
|
||||
|
||||
items.Select(l => l.Name).Should().ContainInOrder("DFW8", "IND9", "MIA2", "STL8");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_EqualSortValues_BreakTiesById_AcrossPages()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 20, Name = "HOU1", City = "Dallas" },
|
||||
new Locations { Id = 21, Name = "AUS3", City = "Dallas" },
|
||||
new Locations { Id = 22, Name = "ELP4", City = "Dallas" });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var service = new LocationDataService(ctx);
|
||||
|
||||
var (page1, totalCount) = await service.GetListPagedAsync(1, 2, null, null, CancellationToken.None, "city", "asc");
|
||||
var (page2, _) = await service.GetListPagedAsync(2, 2, null, null, CancellationToken.None, "city", "asc");
|
||||
|
||||
totalCount.Should().Be(3);
|
||||
page1.Select(l => l.Id).Should().ContainInOrder(20, 21);
|
||||
page2.Select(l => l.Id).Should().ContainInOrder(22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetListPagedAsync_ListLoadIncludesAccountNameForProjection()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedRegistryAsync(ctx);
|
||||
|
||||
var (items, _) = await new LocationDataService(ctx)
|
||||
.GetListPagedAsync(1, 10, null, null, CancellationToken.None);
|
||||
|
||||
items.Single(l => l.Id == 10).Account!.Name.Should().Be("Acme Co");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ public class LocationServiceTests
|
|||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
IReadOnlyCollection<string>? captured = default;
|
||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, states, _) => captured = states)
|
||||
data.Setup(d => d.GetListPagedAsync(1, 10, null, It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, states, _, _, _) => captured = states)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, " tx , Mo ,,TX, ", CancellationToken.None);
|
||||
|
|
@ -149,8 +149,8 @@ public class LocationServiceTests
|
|||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
IReadOnlyCollection<string>? captured = default;
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken>((_, _, _, stateFilter, _) => captured = stateFilter)
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, stateFilter, _, _, _) => captured = stateFilter)
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var page = await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, "search", states, CancellationToken.None);
|
||||
|
|
@ -173,14 +173,14 @@ public class LocationServiceTests
|
|||
&& e.ErrorMessage.Contains("ZZ")
|
||||
&& e.ErrorMessage.Contains("QQ")
|
||||
&& !e.ErrorMessage.Contains("TX"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_AcceptsAllFiftyStateCodes()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
var allStates = string.Join(",", SeaHaven.Services.Helpers.UsStateCodes.All);
|
||||
|
|
@ -533,4 +533,97 @@ public class LocationServiceTests
|
|||
again.Should().BeFalse();
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
string? capturedSort = "sentinel";
|
||||
string? capturedDirection = "sentinel";
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, sortBy, sortDirection) =>
|
||||
{
|
||||
capturedSort = sortBy;
|
||||
capturedDirection = sortDirection;
|
||||
})
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: " POC ", sortDirection: " DESC ");
|
||||
|
||||
capturedSort.Should().Be("poc");
|
||||
capturedDirection.Should().Be("desc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", " ")]
|
||||
public async Task GetLocationListPagedAsync_BlankOrDefaultSort_PassesNormalizedDefaults(
|
||||
string? sortBy,
|
||||
string? sortDirection)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
string? capturedSort = "sentinel";
|
||||
string? capturedDirection = "sentinel";
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<int, int, string?, IReadOnlyCollection<string>?, CancellationToken, string?, string?>((_, _, _, _, _, s, d) =>
|
||||
{
|
||||
capturedSort = s;
|
||||
capturedDirection = d;
|
||||
})
|
||||
.ReturnsAsync((new List<Locations>(), 0));
|
||||
|
||||
await NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy, sortDirection: sortDirection);
|
||||
|
||||
capturedSort.Should().BeNull("a blank sort column must keep the legacy ordering");
|
||||
capturedDirection.Should().Be("asc");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("rating")]
|
||||
[InlineData("password; drop table locations")]
|
||||
public async Task GetLocationListPagedAsync_InvalidSortBy_ThrowsValidationAndNeverQueries(string sortBy)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: sortBy);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "sortBy"
|
||||
&& e.ErrorMessage.Contains("code")
|
||||
&& e.ErrorMessage.Contains("poc"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ascending")]
|
||||
[InlineData("DESC; drop table locations")]
|
||||
public async Task GetLocationListPagedAsync_InvalidSortDirection_ThrowsValidationAndNeverQueries(string sortDirection)
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
|
||||
var act = () => NewServiceWithSpy(data.Object).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None, sortBy: "city", sortDirection: sortDirection);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e =>
|
||||
e.PropertyName == "sortDirection"
|
||||
&& e.ErrorMessage.Contains("asc")
|
||||
&& e.ErrorMessage.Contains("desc"));
|
||||
data.Verify(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_MapsClientAccountNameIntoRows()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccount(ctx, 9, "Acme Co");
|
||||
SeedLocation(ctx, "Alpha Site", "Austin").AccountId = 9;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
var row = page.Items.Should().ContainSingle().Subject;
|
||||
row.AccountId.Should().Be(9);
|
||||
row.AccountName.Should().Be("Acme Co");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ public class LocationSiteContactsTests
|
|||
public async Task List_LoadsPageContactsInSingleBatchedRead()
|
||||
{
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
|
||||
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((new List<Locations>
|
||||
{
|
||||
new() { Id = 1, Name = "One" },
|
||||
|
|
|
|||
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal file
185
Api.SeaHavenIndustries.Tests/ServicesRegistryControllerTests.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class ServicesRegistryControllerTests
|
||||
{
|
||||
private static ServicesRegistryController NewController(Mock<IServicesRegistryService> service, params string[] roles)
|
||||
{
|
||||
var controller = new ServicesRegistryController(service.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static ServicesRegistryValidationErrorDto? ErrorValue(IActionResult result) =>
|
||||
(result as ObjectResult)?.Value as ServicesRegistryValidationErrorDto;
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsOkWithServices()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.GetAllAsync(true, WorkOrderType.PM, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ServiceDto> { new() { Id = 1, Name = "Leak", IsActive = true } });
|
||||
|
||||
var result = await NewController(service, "Admin").GetAll(true, WorkOrderType.PM, CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
(ok.Value as IEnumerable<ServiceDto>).Should().ContainSingle(s => s.Name == "Leak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_WhenValidationCode_ReturnsBadRequestWithStableCodeAndMessage()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.GetAllAsync(null, WorkOrderType.Project, It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException(
|
||||
"WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency."));
|
||||
|
||||
var result = await NewController(service, "Admin").GetAll(null, WorkOrderType.Project, CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
ErrorValue(bad)!.Code.Should().Be("WorkOrderTypeInvalid");
|
||||
ErrorValue(bad)!.Message.Should().Be("Supported work order types may only include PM, Reactive, or Emergency.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WhenMissing_ReturnsNotFoundWithCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.GetByIdAsync(9, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ServiceDto?)null);
|
||||
|
||||
var result = await NewController(service, "Admin").GetById(9, CancellationToken.None);
|
||||
|
||||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
ErrorValue(notFound)!.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PassesCallerAndReturnsCreated()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ServiceDto { Id = 42, Name = "Leak", IsActive = true });
|
||||
|
||||
var result = await NewController(service, "Scheduler").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
|
||||
|
||||
var created = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
created.StatusCode.Should().Be(StatusCodes.Status201Created);
|
||||
(created.Value as ServiceDto)!.Id.Should().Be(42);
|
||||
service.Verify(s => s.CreateAsync(
|
||||
It.Is<ClaimsPrincipal>(p => p.IsInRole("Scheduler")),
|
||||
It.Is<ServiceInput>(i => i.Name == "Leak"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenForbidden_Returns403WithStableCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."));
|
||||
|
||||
var result = await NewController(service, "User").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenDuplicateName_Returns400WithStableCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.CreateAsync(It.IsAny<ClaimsPrincipal>(), It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."));
|
||||
|
||||
var result = await NewController(service, "Admin").Create(new ServiceInput { Name = "Leak" }, CancellationToken.None);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
ErrorValue(bad)!.Code.Should().Be("DuplicateName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ReturnsOkWithUpdatedService()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 7, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ServiceDto { Id = 7, Name = "Renamed" });
|
||||
|
||||
var result = await NewController(service, "Admin").Update(7, new ServiceInput { Name = "Renamed" }, CancellationToken.None);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
(ok.Value as ServiceDto)!.Name.Should().Be("Renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenNotFound_Returns404WithCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.UpdateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<ServiceInput>(), It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
|
||||
|
||||
var result = await NewController(service, "Admin").Update(404, new ServiceInput(), CancellationToken.None);
|
||||
|
||||
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
ErrorValue(notFound)!.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_ReturnsOk()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
|
||||
var result = await NewController(service, "Admin").Deactivate(3, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.Verify(s => s.DeactivateAsync(
|
||||
It.Is<ClaimsPrincipal>(p => p.IsInRole("Admin")), 3, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenForbidden_Returns403WithStableCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 3, It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."));
|
||||
|
||||
var result = await NewController(service, "Scheduler").Deactivate(3, CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||
ErrorValue(forbidden)!.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenNotFound_Returns404WithCode()
|
||||
{
|
||||
var service = new Mock<IServicesRegistryService>();
|
||||
service.Setup(s => s.DeactivateAsync(It.IsAny<ClaimsPrincipal>(), 404, It.IsAny<CancellationToken>()))
|
||||
.Throws(new ServicesRegistryValidationException("NotFound", "Service not found."));
|
||||
|
||||
var result = await NewController(service, "Admin").Deactivate(404, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
}
|
||||
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal file
396
Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
// Global Services registry behavior. The registry has no tenant scope
|
||||
// (no AccountId), authorization is enforced at the service boundary, and the
|
||||
// only allowed supported work-order types are PM, Reactive, and Emergency.
|
||||
public class ServicesRegistryServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ServicesRegistryService NewService(ApplicationDbContext ctx) =>
|
||||
new(new ServicesRegistryDataService(ctx));
|
||||
|
||||
private static ClaimsPrincipal Principal(params string[] roles) =>
|
||||
new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"));
|
||||
|
||||
private static ClaimsPrincipal Admin() => Principal("Admin");
|
||||
private static ClaimsPrincipal Scheduler() => Principal("Scheduler");
|
||||
|
||||
private static ServiceInput Input(
|
||||
string name = "Leak",
|
||||
string? trade = "Plumbing & Water Systems",
|
||||
string? iconKey = "plumbing-water-systems",
|
||||
bool requiresDoc = false,
|
||||
int? templateId = null,
|
||||
List<WorkOrderType>? types = null) => new()
|
||||
{
|
||||
Name = name,
|
||||
Trade = trade,
|
||||
IconKey = iconKey,
|
||||
RequiresCompletionDocument = requiresDoc,
|
||||
CompletionDocTemplateId = templateId,
|
||||
SupportedWorkOrderTypes = types ?? new List<WorkOrderType>
|
||||
{
|
||||
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
|
||||
}
|
||||
};
|
||||
|
||||
private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) =>
|
||||
new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted };
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None);
|
||||
|
||||
dto.Id.Should().BeGreaterThan(0);
|
||||
dto.Name.Should().Be("Leak Detection");
|
||||
dto.Trade.Should().Be("Plumbing & Water Systems");
|
||||
dto.IconKey.Should().Be("plumbing-water-systems");
|
||||
dto.IsActive.Should().BeTrue("new services are always active");
|
||||
dto.CompletionDocTemplate.Should().BeNull();
|
||||
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency });
|
||||
|
||||
var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single();
|
||||
row.NormalizedName.Should().Be("leak detection");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var input = Input(types: null);
|
||||
input.SupportedWorkOrderTypes = null;
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
|
||||
|
||||
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None);
|
||||
var ex = (await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Subject.Single();
|
||||
ex.Code.Should().Be("NameRequired");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TradeInvalid");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" hvac ")]
|
||||
[InlineData("HVAC")]
|
||||
[InlineData("hvac")]
|
||||
public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None);
|
||||
dto.Trade.Should().Be("HVAC");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("IconKeyInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
// Distinct actors with different role sets still share one global registry.
|
||||
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
|
||||
|
||||
var all = await service.GetAllAsync(null, null, CancellationToken.None);
|
||||
all.Should().ContainSingle(s => s.Id == first.Id);
|
||||
typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None))
|
||||
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
|
||||
|
||||
ctx.CompletionDocTemplates.Add(Template("Inactive", active: false));
|
||||
ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true));
|
||||
ctx.SaveChanges();
|
||||
|
||||
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None))
|
||||
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
|
||||
|
||||
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None))
|
||||
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var template = Template("Completion Packet");
|
||||
ctx.CompletionDocTemplates.Add(template);
|
||||
ctx.SaveChanges();
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None);
|
||||
|
||||
dto.RequiresCompletionDocument.Should().BeTrue();
|
||||
dto.CompletionDocTemplate.Should().NotBeNull();
|
||||
dto.CompletionDocTemplate!.Id.Should().Be(template.Id);
|
||||
dto.CompletionDocTemplate.Name.Should().Be("Completion Packet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var template = Template("Completion Packet");
|
||||
ctx.CompletionDocTemplates.Add(template);
|
||||
ctx.SaveChanges();
|
||||
|
||||
var dto = await NewService(ctx).CreateAsync(
|
||||
Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None);
|
||||
|
||||
dto.RequiresCompletionDocument.Should().BeFalse();
|
||||
dto.CompletionDocTemplate.Should().BeNull();
|
||||
ctx.Services.Single().CompletionDocTemplateId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WorkOrderType.PO)]
|
||||
[InlineData(WorkOrderType.Project)]
|
||||
[InlineData(WorkOrderType.Inspection)]
|
||||
[InlineData(WorkOrderType.AddOn)]
|
||||
[InlineData(WorkOrderType.Other)]
|
||||
public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var input = Input(types: new List<WorkOrderType> { WorkOrderType.PM, unsupported });
|
||||
var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_RewritesFieldsButPreservesActiveState()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
var updated = await service.UpdateAsync(
|
||||
Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None);
|
||||
|
||||
updated.Name.Should().Be("Leak Repair");
|
||||
updated.Trade.Should().Be("HVAC");
|
||||
updated.IconKey.Should().Be("hvac");
|
||||
updated.IsActive.Should().BeFalse("update preserves the persisted active state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenAdminRequestsActiveState_ReactivatesService()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
var input = Input("Inactive service");
|
||||
input.IsActive = true;
|
||||
var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None);
|
||||
|
||||
updated.IsActive.Should().BeTrue();
|
||||
ctx.Services.Single().IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
var input = Input("Inactive service");
|
||||
input.IsActive = true;
|
||||
var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
ctx.Services.Single().IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenNotFound_ThrowsNotFound()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None);
|
||||
|
||||
var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
ctx.Services.Single().Name.Should().Be("Leak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_SoftStateChangeOnly_PreservesRow()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
|
||||
|
||||
ctx.Services.Should().HaveCount(1, "deactivation never hard deletes");
|
||||
var byId = await service.GetByIdAsync(created.Id, CancellationToken.None);
|
||||
byId.Should().NotBeNull();
|
||||
byId!.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenScheduler_ThrowsForbidden()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
|
||||
|
||||
var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
|
||||
ctx.Services.Single().IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WhenNotFound_ThrowsNotFound()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_FiltersByActiveStateAndWorkOrderType()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List<WorkOrderType> { WorkOrderType.PM }), CancellationToken.None);
|
||||
var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List<WorkOrderType> { WorkOrderType.Reactive }), CancellationToken.None);
|
||||
await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None);
|
||||
|
||||
(await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id)
|
||||
.Should().BeEquivalentTo(new[] { pm.Id });
|
||||
(await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id)
|
||||
.Should().BeEquivalentTo(new[] { reactive.Id });
|
||||
|
||||
(await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id)
|
||||
.Should().BeEquivalentTo(new[] { pm.Id });
|
||||
(await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None))
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None);
|
||||
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WhenMissing_ReturnsNull()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
(await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while
|
||||
/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order,
|
||||
/// and an unknown id keeps the existing "ID not found!" response.
|
||||
/// </summary>
|
||||
public class WorkOrderGetByIdBindingTests
|
||||
{
|
||||
private const int ExistingId = 374;
|
||||
private const int UnknownId = 999_999;
|
||||
|
||||
private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController))
|
||||
.Where(cad => cad.ActionConstraints!
|
||||
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
|
||||
.Any(c => c.HttpMethods.Contains("GET")))
|
||||
.Single(cad => string.Equals(
|
||||
cad.AttributeRouteInfo?.Template?.Trim('/'),
|
||||
$"api/WorkOrder/{relativeTemplate}",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GetWorkorderById", "Query")]
|
||||
[InlineData("{id:int}", "Path")]
|
||||
public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource)
|
||||
{
|
||||
var action = ActionForTemplate(relativeTemplate);
|
||||
|
||||
var id = action.Parameters.Single(p => p.Name == "id");
|
||||
id.BindingInfo.Should().NotBeNull();
|
||||
id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource);
|
||||
}
|
||||
|
||||
private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController()
|
||||
{
|
||||
var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" };
|
||||
var service = new Mock<IWorkOrderService>();
|
||||
service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny<int?>())).ReturnsAsync(existing);
|
||||
service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny<int?>()))
|
||||
.ReturnsAsync((WorkOrderDetailReadModel?)null);
|
||||
|
||||
var controller = new WorkOrderController(
|
||||
service.Object,
|
||||
Mock.Of<IWorkOrderAccountResolver>(),
|
||||
Mock.Of<ILogger<WorkOrderController>>())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
return (controller, existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_id_returns_the_same_work_order_through_both_routes()
|
||||
{
|
||||
var (controller, existing) = NewController();
|
||||
|
||||
var byQuery = await controller.GetWorkorderById(ExistingId);
|
||||
var byRoute = await controller.GetWorkorderByRouteId(ExistingId);
|
||||
|
||||
byQuery.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||
byRoute.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_id_keeps_the_not_found_response_through_both_routes()
|
||||
{
|
||||
var (controller, _) = NewController();
|
||||
|
||||
foreach (var result in new[]
|
||||
{
|
||||
await controller.GetWorkorderById(UnknownId),
|
||||
await controller.GetWorkorderByRouteId(UnknownId),
|
||||
})
|
||||
{
|
||||
var body = result.Should().BeOfType<BadRequestObjectResult>().Which.Value
|
||||
.Should().BeOfType<Response>().Subject;
|
||||
body.Status.Should().Be("Error");
|
||||
body.Message.Should().Be("ID not found!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
/// combination is registered more than once by different actions.
|
||||
///
|
||||
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
|
||||
/// GetWorkorderById) and the two shared controller-level base routes.
|
||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder)
|
||||
/// and the two shared controller-level base routes.
|
||||
/// </summary>
|
||||
public class WorkOrderRouteContractTests
|
||||
{
|
||||
|
|
@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests
|
|||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
||||
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes).
|
||||
/// come from 51 actions (Editworkorder binds two routes).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -17,17 +18,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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("GetLocationList")]
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
|
||||
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
|
||||
|
||||
var data = pagedResult.Items.Select(l => new
|
||||
{
|
||||
|
|
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = l.Email,
|
||||
Status = l.Status,
|
||||
AccountId = l.AccountId,
|
||||
ClientName = l.AccountName,
|
||||
AccountName = l.AccountName,
|
||||
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
|
||||
});
|
||||
|
||||
|
|
|
|||
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal file
110
Api.SeaHavenIndustries/Controllers/ServicesRegistryController.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/services")]
|
||||
public class ServicesRegistryController : Controller
|
||||
{
|
||||
private readonly IServicesRegistryService _servicesRegistryService;
|
||||
|
||||
public ServicesRegistryController(IServicesRegistryService servicesRegistryService)
|
||||
{
|
||||
_servicesRegistryService = servicesRegistryService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool? isActive, [FromQuery] WorkOrderType? workOrderType, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var services = await _servicesRegistryService.GetAllAsync(isActive, workOrderType, cancellationToken);
|
||||
return Ok(services);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex)
|
||||
{
|
||||
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var service = await _servicesRegistryService.GetByIdAsync(id, cancellationToken);
|
||||
if (service == null)
|
||||
return NotFound(new ServicesRegistryValidationErrorDto { Code = "NotFound", Message = "Service not found." });
|
||||
|
||||
return Ok(service);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _servicesRegistryService.CreateAsync(User, input, cancellationToken);
|
||||
return StatusCode(StatusCodes.Status201Created, created);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return MapForbidden(ex);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex)
|
||||
{
|
||||
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await _servicesRegistryService.UpdateAsync(User, id, input, cancellationToken);
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return MapForbidden(ex);
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex)
|
||||
{
|
||||
return BadRequest(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/deactivate")]
|
||||
public async Task<IActionResult> Deactivate(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _servicesRegistryService.DeactivateAsync(User, id, cancellationToken);
|
||||
return Ok(new { message = "Service deactivated" });
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (ServicesRegistryValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return MapForbidden(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult MapForbidden(ServicesRegistryValidationException ex)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ServicesRegistryValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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." })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(w => w.InternalWONumber)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasOne(w => w.ServiceDefinition)
|
||||
.WithMany()
|
||||
.HasForeignKey(w => w.ServiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
|
@ -207,6 +213,53 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(t => t.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
// Bounded lengths keep the unique NormalizedName key SQL
|
||||
// Server-indexable (nvarchar(max) is not).
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.Name)
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.NormalizedName)
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.Trade)
|
||||
.HasMaxLength(64);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.Trade);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.Property(s => s.IconKey)
|
||||
.HasMaxLength(64);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.IsActive);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasOne(s => s.CompletionDocTemplate)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.CompletionDocTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<Service>()
|
||||
.HasIndex(s => s.CompletionDocTemplateId);
|
||||
|
||||
builder.Entity<ServiceWorkOrderType>()
|
||||
.HasIndex(t => new { t.ServiceId, t.WorkOrderType })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<ServiceWorkOrderType>()
|
||||
.HasOne(t => t.Service)
|
||||
.WithMany(s => s.SupportedWorkOrderTypes)
|
||||
.HasForeignKey(t => t.ServiceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// SH-278 Area catalogue: bounded names so the unique key is indexable.
|
||||
builder.Entity<Area>()
|
||||
.Property(a => a.Name)
|
||||
|
|
@ -225,6 +278,32 @@ namespace Data.SeaHavenIndustries
|
|||
.WithMany()
|
||||
.HasForeignKey(c => c.AreaId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Per-person team permission overrides. Composite primary key
|
||||
// (UserId + PermissionKey) plus an explicitly named unique composite
|
||||
// index; missing rows behave as Unset.
|
||||
builder.Entity<UserPermissionOverride>(entity =>
|
||||
{
|
||||
entity.HasKey(o => new { o.UserId, o.PermissionKey });
|
||||
|
||||
entity.Property(o => o.UserId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
entity.Property(o => o.PermissionKey)
|
||||
.HasMaxLength(64);
|
||||
|
||||
entity.Property(o => o.State)
|
||||
.IsRequired();
|
||||
|
||||
entity.HasOne(o => o.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(o => o.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasIndex(o => new { o.UserId, o.PermissionKey })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
});
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -261,6 +340,8 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<DispatchUpliftRequest> DispatchUpliftRequests { get; set; }
|
||||
public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
|
||||
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }
|
||||
public DbSet<Service> Services { get; set; }
|
||||
public DbSet<ServiceWorkOrderType> ServiceWorkOrderTypes { get; set; }
|
||||
|
||||
// New DbSets for added entities
|
||||
public DbSet<Accounts> Accounts { get; set; }
|
||||
|
|
@ -279,6 +360,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<Department> Departments { get; set; }
|
||||
public DbSet<JobTitle> JobTitles { get; set; }
|
||||
public DbSet<Region> Regions { get; set; }
|
||||
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
|
|
|
|||
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
18
Data.SeaHavenIndustries/Auth/UserPermissionOverride.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-person team permission override, keyed by user and canonical
|
||||
/// permission key. Explicit Allow/Deny rows take precedence over role defaults;
|
||||
/// <see cref="UserPermissionState.Unset"/> (and a missing row) falls back to
|
||||
/// the role default.
|
||||
/// </summary>
|
||||
public class UserPermissionOverride
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public string PermissionKey { get; set; } = null!;
|
||||
public UserPermissionState State { get; set; } = UserPermissionState.Unset;
|
||||
public virtual ApplicationUser? User { get; set; }
|
||||
}
|
||||
}
|
||||
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
13
Data.SeaHavenIndustries/Enums/PermissionEnums.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// Tri-state state of a per-person team permission override.
|
||||
/// A missing row behaves the same as <see cref="Unset"/>.
|
||||
/// </summary>
|
||||
public enum UserPermissionState
|
||||
{
|
||||
Unset = 0,
|
||||
Allow = 1,
|
||||
Deny = 2
|
||||
}
|
||||
}
|
||||
4074
Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs
generated
Normal file
4074
Data.SeaHavenIndustries/Migrations/20260916185443_SH303_ServicesRegistry.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,162 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH303_ServicesRegistry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Services",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Trade = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
IconKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
RequiresCompletionDocument = table.Column<bool>(type: "bit", nullable: false),
|
||||
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Services", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Services_CompletionDocTemplates_CompletionDocTemplateId",
|
||||
column: x => x.CompletionDocTemplateId,
|
||||
principalTable: "CompletionDocTemplates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServiceWorkOrderTypes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ServiceId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderType = table.Column<int>(type: "int", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServiceWorkOrderTypes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ServiceWorkOrderTypes_Services_ServiceId",
|
||||
column: x => x.ServiceId,
|
||||
principalTable: "Services",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_CompletionDocTemplateId",
|
||||
table: "Services",
|
||||
column: "CompletionDocTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_IsActive",
|
||||
table: "Services",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_NormalizedName",
|
||||
table: "Services",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Services_Trade",
|
||||
table: "Services",
|
||||
column: "Trade");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServiceWorkOrderTypes_ServiceId_WorkOrderType",
|
||||
table: "ServiceWorkOrderTypes",
|
||||
columns: new[] { "ServiceId", "WorkOrderType" },
|
||||
unique: true);
|
||||
|
||||
// Additive seed: one active service per active Problem dropdown
|
||||
// value already stored in the database (never the prototype's demo
|
||||
// leaves). Idempotent via NormalizedName NOT EXISTS; dropdown rows are
|
||||
// only read, never altered. Repeated source values collapse onto a
|
||||
// deterministic MIN(Value) representative per normalized key.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO [Services] ([Name], [NormalizedName], [Trade], [IconKey], [RequiresCompletionDocument], [CompletionDocTemplateId], [IsActive], [IsDeleted], [createdby])
|
||||
SELECT seeded.[Name], seeded.[NormalizedName], seeded.[Trade],
|
||||
CASE seeded.[Trade]
|
||||
WHEN N'Plumbing & Water Systems' THEN N'plumbing-water-systems'
|
||||
WHEN N'Electrical' THEN N'electrical'
|
||||
WHEN N'HVAC' THEN N'hvac'
|
||||
WHEN N'Fire & Life Safety' THEN N'fire-life-safety'
|
||||
ELSE N'general-building-handyman'
|
||||
END,
|
||||
0, NULL, 1, 0, N'sh303-migration'
|
||||
FROM (
|
||||
SELECT agg.[Name], agg.[NormalizedName],
|
||||
CASE
|
||||
WHEN agg.[Name] IN (N'Leak', N'Clogged') THEN N'Plumbing & Water Systems'
|
||||
WHEN agg.[Name] = N'No Power' THEN N'Electrical'
|
||||
WHEN agg.[Name] IN (N'Not Cooling', N'Not Heating') THEN N'HVAC'
|
||||
WHEN agg.[Name] = N'Alarm Fault' THEN N'Fire & Life Safety'
|
||||
ELSE N'General Building & Handyman'
|
||||
END AS [Trade]
|
||||
FROM (
|
||||
SELECT MIN(src.[Value]) AS [Name],
|
||||
LOWER(LTRIM(RTRIM(src.[Value]))) AS [NormalizedName]
|
||||
FROM [DropdownOptions] src
|
||||
WHERE src.[Category] = N'Problem'
|
||||
AND src.[IsActive] = 1
|
||||
GROUP BY LOWER(LTRIM(RTRIM(src.[Value])))
|
||||
) agg
|
||||
) seeded
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM [Services] s WHERE s.[NormalizedName] = seeded.[NormalizedName]
|
||||
);
|
||||
|
||||
INSERT INTO [ServiceWorkOrderTypes] ([ServiceId], [WorkOrderType], [IsDeleted])
|
||||
SELECT s.[Id], types.[WorkOrderType], 0
|
||||
FROM [Services] s
|
||||
CROSS JOIN (VALUES (2), (6), (3)) AS types([WorkOrderType])
|
||||
WHERE s.[createdby] = N'sh303-migration'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM [ServiceWorkOrderTypes] x
|
||||
WHERE x.[ServiceId] = s.[Id]
|
||||
AND x.[WorkOrderType] = types.[WorkOrderType]
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServiceWorkOrderTypes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Services");
|
||||
}
|
||||
}
|
||||
}
|
||||
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
3966
Data.SeaHavenIndustries/Migrations/20260916201532_SH327_UserPermissionOverrides.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,46 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH327_UserPermissionOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserPermissionOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
PermissionKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
State = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserPermissionOverrides", x => new { x.UserId, x.PermissionKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserPermissionOverrides_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserPermissionOverrides_UserId_PermissionKey",
|
||||
table: "UserPermissionOverrides",
|
||||
columns: new[] { "UserId", "PermissionKey" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserPermissionOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
4134
Data.SeaHavenIndustries/Migrations/20260916204606_SH187_WorkOrderServiceLink.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,73 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH187_WorkOrderServiceLink : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ServiceId",
|
||||
table: "workOrders",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ServiceNameSnapshot",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE w
|
||||
SET
|
||||
w.[ServiceNameSnapshot] = LEFT(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')), 200),
|
||||
w.[ServiceId] = s.[Id]
|
||||
FROM [workOrders] w
|
||||
LEFT JOIN [Services] s
|
||||
ON (s.[IsDeleted] <> 1 OR s.[IsDeleted] IS NULL)
|
||||
AND s.[NormalizedName] = LOWER(LTRIM(RTRIM(COALESCE(NULLIF(LTRIM(RTRIM(w.[Trade])), N''), NULLIF(LTRIM(RTRIM(w.[Problem])), N'')))))
|
||||
WHERE w.[ServiceNameSnapshot] IS NULL;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_ServiceId",
|
||||
table: "workOrders",
|
||||
column: "ServiceId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_workOrders_Services_ServiceId",
|
||||
table: "workOrders",
|
||||
column: "ServiceId",
|
||||
principalTable: "Services",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_workOrders_Services_ServiceId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_ServiceId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ServiceId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ServiceNameSnapshot",
|
||||
table: "workOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1791,6 +1791,121 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Regions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("CompletionDocTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("IconKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<bool>("RequiresCompletionDocument")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletionDocTemplateId");
|
||||
|
||||
b.HasIndex("IsActive");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Trade");
|
||||
|
||||
b.ToTable("Services");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("ServiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "WorkOrderType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ServiceWorkOrderTypes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -2072,6 +2187,28 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Trades");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("PermissionKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("UserId", "PermissionKey");
|
||||
|
||||
b.HasIndex("UserId", "PermissionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
|
||||
b.ToTable("UserPermissionOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -2626,6 +2763,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<int?>("ServiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ServiceNameSnapshot")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("ServiceNotes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
|
@ -2710,6 +2854,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("ServiceId");
|
||||
|
||||
b.HasIndex("SiteCode")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
|
|
@ -3555,6 +3701,27 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CompletionDocTemplateId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("CompletionDocTemplate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Service", "Service")
|
||||
.WithMany("SupportedWorkOrderTypes")
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
|
||||
|
|
@ -3616,6 +3783,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("POC");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||
|
|
@ -3707,6 +3885,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasForeignKey("PrimaryDispatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("AssignToUser");
|
||||
|
|
@ -3714,6 +3897,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Locations");
|
||||
|
||||
b.Navigation("PrimaryDispatch");
|
||||
|
||||
b.Navigation("ServiceDefinition");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -3933,6 +4118,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Employees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Service", b =>
|
||||
{
|
||||
b.Navigation("SupportedWorkOrderTypes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
|
|
|
|||
29
Data.SeaHavenIndustries/Models/Service.cs
Normal file
29
Data.SeaHavenIndustries/Models/Service.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
// Global (tenant-scope free) Services registry. Services are never
|
||||
// hard deleted; deactivation is a soft IsActive state change so historical
|
||||
// work-order references remain resolvable.
|
||||
public class Service : FullAuditEntity
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string NormalizedName { get; set; } = "";
|
||||
public string Trade { get; set; } = "";
|
||||
public string IconKey { get; set; } = "";
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public int? CompletionDocTemplateId { get; set; }
|
||||
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public virtual ICollection<ServiceWorkOrderType> SupportedWorkOrderTypes { get; set; } = new List<ServiceWorkOrderType>();
|
||||
}
|
||||
|
||||
// Supported work-order types (PM, Reactive, Emergency) stored as an
|
||||
// explicitly queryable relation so work-order features can filter by type.
|
||||
public class ServiceWorkOrderType : FullAuditEntity
|
||||
{
|
||||
public int ServiceId { get; set; }
|
||||
public virtual Service? Service { get; set; }
|
||||
public WorkOrderType WorkOrderType { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,11 @@ namespace Data.SeaHavenIndustries
|
|||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? Service { get; set; }
|
||||
public int? ServiceId { get; set; }
|
||||
[MaxLength(200)]
|
||||
public string? ServiceNameSnapshot { get; set; }
|
||||
[ForeignKey(nameof(ServiceId))]
|
||||
public virtual Service? ServiceDefinition { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
|
|
|
|||
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
11
SeaHaven.DataServices/Dto/TeamPermissionReadModels.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Dto;
|
||||
|
||||
public sealed class TeamPermissionUserData
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
public IReadOnlyDictionary<string, UserPermissionState> Overrides { get; init; }
|
||||
= new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.OriginalDate,
|
||||
w.OriginalWeek,
|
||||
w.WorkOrderType,
|
||||
w.ServiceId,
|
||||
w.ServiceNameSnapshot,
|
||||
w.SiteCode,
|
||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||
WoPocName = w.PocName,
|
||||
|
|
@ -136,6 +138,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.CarriedOver,
|
||||
w.IsAddOn,
|
||||
w.WorkOrderType,
|
||||
w.ServiceId,
|
||||
w.ServiceNameSnapshot,
|
||||
frozenSite?.Code ?? w.SiteCode,
|
||||
frozenSite?.Label ?? w.LocationName,
|
||||
pocName,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
|
|||
int pageSize,
|
||||
string? search,
|
||||
IReadOnlyCollection<string>? states,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
string? sortBy = null,
|
||||
string? sortDirection = null)
|
||||
{
|
||||
var query = _context.Locations.AsNoTracking();
|
||||
IQueryable<Locations> query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Account);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(l =>
|
||||
l.Name!.Contains(search) ||
|
||||
l.City!.Contains(search));
|
||||
l.Address1!.Contains(search) ||
|
||||
l.City!.Contains(search) ||
|
||||
l.Account != null && l.Account.Name!.Contains(search));
|
||||
}
|
||||
|
||||
if (states is { Count: > 0 })
|
||||
|
|
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
|
||||
}
|
||||
|
||||
query = ApplyListSort(query, sortBy, sortDirection);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderBy(l => l.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
|
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
|
||||
{
|
||||
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
|
||||
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
|
||||
{
|
||||
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
|
||||
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
|
||||
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
|
||||
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
|
||||
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
|
||||
"poc" => descending
|
||||
? query.OrderByDescending(FirstActiveContactName)
|
||||
: query.OrderBy(FirstActiveContactName),
|
||||
_ => query.OrderBy(l => l.Name)
|
||||
};
|
||||
|
||||
return ordered.ThenBy(l => l.Id);
|
||||
}
|
||||
|
||||
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
|
||||
l => l.Contacts
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => c.FirstName)
|
||||
.FirstOrDefault();
|
||||
|
||||
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Locations
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class ServicesRegistryDataService : IServicesRegistryDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public ServicesRegistryDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Services
|
||||
.AsNoTracking()
|
||||
.Where(s => s.IsDeleted != true);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(s => s.IsActive == isActive.Value);
|
||||
|
||||
if (workOrderType.HasValue)
|
||||
query = query.Where(s => s.SupportedWorkOrderTypes.Any(t => t.WorkOrderType == workOrderType.Value));
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(s => s.Name)
|
||||
.Select(s => new ServiceRegistryRow
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = s.Name,
|
||||
Trade = s.Trade,
|
||||
IconKey = s.IconKey,
|
||||
RequiresCompletionDocument = s.RequiresCompletionDocument,
|
||||
IsActive = s.IsActive,
|
||||
CompletionDocTemplateId = s.CompletionDocTemplateId,
|
||||
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
|
||||
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Services.AsNoTracking()
|
||||
.Where(s => s.Id == id && s.IsDeleted != true)
|
||||
.Select(s => new ServiceRegistryRow
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = s.Name,
|
||||
Trade = s.Trade,
|
||||
IconKey = s.IconKey,
|
||||
RequiresCompletionDocument = s.RequiresCompletionDocument,
|
||||
IsActive = s.IsActive,
|
||||
CompletionDocTemplateId = s.CompletionDocTemplateId,
|
||||
CompletionDocTemplateName = s.CompletionDocTemplate != null ? s.CompletionDocTemplate.Name : null,
|
||||
SupportedWorkOrderTypes = s.SupportedWorkOrderTypes.Select(t => t.WorkOrderType).ToList()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Services
|
||||
.Include(s => s.SupportedWorkOrderTypes)
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Services
|
||||
.AnyAsync(s => s.IsDeleted != true
|
||||
&& s.NormalizedName == normalizedName
|
||||
&& (excludeServiceId == null || s.Id != excludeServiceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.CompletionDocTemplates
|
||||
.Where(t => t.Id == templateId && t.IsActive && t.IsDeleted != true)
|
||||
.Select(t => new LinkedTemplateSummary { Id = t.Id, Name = t.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.Services.AddAsync(service, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueNameViolation(ex))
|
||||
{
|
||||
throw new ServicesRegistryDuplicateNameException(ex);
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
public async Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken)
|
||||
{
|
||||
var desired = supportedWorkOrderTypes.Distinct().ToList();
|
||||
var current = service.SupportedWorkOrderTypes ?? new List<ServiceWorkOrderType>();
|
||||
|
||||
var stale = current.Where(t => !desired.Contains(t.WorkOrderType)).ToList();
|
||||
_context.ServiceWorkOrderTypes.RemoveRange(stale);
|
||||
|
||||
var existingTypes = current.Select(t => t.WorkOrderType).ToHashSet();
|
||||
foreach (var type in desired.Where(t => !existingTypes.Contains(t)))
|
||||
{
|
||||
_context.ServiceWorkOrderTypes.Add(new ServiceWorkOrderType
|
||||
{
|
||||
ServiceId = service.Id,
|
||||
WorkOrderType = type
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueNameViolation(ex))
|
||||
{
|
||||
throw new ServicesRegistryDuplicateNameException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var service = await _context.Services
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.IsDeleted != true, cancellationToken);
|
||||
if (service == null)
|
||||
return false;
|
||||
|
||||
service.IsActive = false;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsUniqueNameViolation(DbUpdateException exception)
|
||||
{
|
||||
var message = exception.InnerException?.Message ?? exception.Message;
|
||||
return message.Contains("IX_Services_NormalizedName", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("NormalizedName", StringComparison.OrdinalIgnoreCase)
|
||||
&& (message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("unique", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,57 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
public interface IDashboardDataService
|
||||
{
|
||||
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
|
||||
Task<DashboardCounts> GetWorkOrderCountsAsync(
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardKpiCounts> GetKpiCountsAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly today,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||
int? accountId,
|
||||
string? dispatcherId,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record DashboardWorkOrder(
|
||||
int Id,
|
||||
string? AssignTo,
|
||||
WorkOrderType? WorkOrderType,
|
||||
LifecycleStatus? LifecycleStatus,
|
||||
string? LegacyStatus,
|
||||
DateTime? ScheduledDate,
|
||||
DateOnly? OriginalDate,
|
||||
DateTime? CompletedDate,
|
||||
string? LocationState = null,
|
||||
int RescheduleCount = 0,
|
||||
int? VendorCompanyId = null,
|
||||
string? VendorCompanyName = null,
|
||||
bool VendorIsActive = false,
|
||||
bool VendorCompanyIsDeleted = false);
|
||||
|
||||
public sealed record DashboardDispatcherWorkload(
|
||||
string DispatcherId,
|
||||
string DispatcherName,
|
||||
string? Color,
|
||||
int TotalCount,
|
||||
int OpenCount);
|
||||
|
||||
public class DashboardCounts
|
||||
{
|
||||
public int Total { get; set; }
|
||||
|
|
@ -12,4 +59,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
public int NotDispatched { get; set; }
|
||||
public int Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed record DashboardKpiCounts(
|
||||
int ScheduledTomorrow,
|
||||
int PendingUplifts,
|
||||
int AvetaPending);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<bool> ExistsAsync(int id);
|
||||
Task<int> CountAsync();
|
||||
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Sort keys are validated upstream; unknown or null keys fall back to the legacy code order.
|
||||
/// Sort options trail the token so existing call sites stay source-compatible.
|
||||
/// </summary>
|
||||
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken, string? sortBy = null, string? sortDirection = null);
|
||||
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
// Read-side projection for the global Services registry. The linked
|
||||
// completion template is projected to id/name only — full template
|
||||
// entities are never materialized.
|
||||
public class ServiceRegistryRow
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Trade { get; set; } = "";
|
||||
public string IconKey { get; set; } = "";
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int? CompletionDocTemplateId { get; set; }
|
||||
public string? CompletionDocTemplateName { get; set; }
|
||||
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LinkedTemplateSummary
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public interface IServicesRegistryDataService
|
||||
{
|
||||
Task<IReadOnlyList<ServiceRegistryRow>> GetServiceRowsAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
|
||||
Task<ServiceRegistryRow?> GetServiceRowByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<Service?> GetServiceForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||
Task<bool> NormalizedNameExistsAsync(string normalizedName, int? excludeServiceId, CancellationToken cancellationToken);
|
||||
Task<LinkedTemplateSummary?> GetActiveTemplateSummaryAsync(int templateId, CancellationToken cancellationToken);
|
||||
Task<Service> AddServiceAsync(Service service, CancellationToken cancellationToken);
|
||||
Task SaveServiceAsync(Service service, IReadOnlyCollection<WorkOrderType> supportedWorkOrderTypes, CancellationToken cancellationToken);
|
||||
Task<bool> DeactivateServiceAsync(int id, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int CarriedOver,
|
||||
bool IsAddOn,
|
||||
WorkOrderType? WorkOrderType,
|
||||
int? ServiceId,
|
||||
string? ServiceNameSnapshot,
|
||||
string? SiteCode,
|
||||
string? LocationName,
|
||||
string? PocName,
|
||||
|
|
|
|||
14
SeaHaven.DataServices/Models/ServicesRegistryModels.cs
Normal file
14
SeaHaven.DataServices/Models/ServicesRegistryModels.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace SeaHaven.DataServices.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised by the registry data service when the database unique name index
|
||||
/// wins a concurrent create or update race.
|
||||
/// </summary>
|
||||
public sealed class ServicesRegistryDuplicateNameException : Exception
|
||||
{
|
||||
public ServicesRegistryDuplicateNameException(Exception innerException)
|
||||
: base("A service name is already in use.", innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
248
SeaHaven.Services.Tests/TeamPermissionPolicyTests.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHaven.Services.Tests;
|
||||
|
||||
public class TeamPermissionPolicyTests
|
||||
{
|
||||
private static readonly string[] DispatcherKeys =
|
||||
{
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
TeamPermissionKeys.EditVendors,
|
||||
TeamPermissionKeys.DeactivateVendors,
|
||||
TeamPermissionKeys.CreateSites,
|
||||
TeamPermissionKeys.EditSites,
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
TeamPermissionKeys.EditOthersWorkOrders,
|
||||
TeamPermissionKeys.CancelWorkOrders,
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
};
|
||||
|
||||
private static readonly string[] SchedulerOnlyKeys =
|
||||
{
|
||||
TeamPermissionKeys.DeleteSites,
|
||||
TeamPermissionKeys.CreateServices,
|
||||
TeamPermissionKeys.EditServices,
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||
};
|
||||
|
||||
private static readonly string[] AdminOnlyKeys =
|
||||
{
|
||||
TeamPermissionKeys.ManageTeamMembers,
|
||||
TeamPermissionKeys.ChangeTeamMemberRole,
|
||||
TeamPermissionKeys.DeactivateServices,
|
||||
TeamPermissionKeys.DeleteCompletionDocTemplates,
|
||||
TeamPermissionKeys.DeleteWorkOrders,
|
||||
TeamPermissionKeys.ReviewUplifts
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, UserPermissionState> Overrides(
|
||||
string key, UserPermissionState state) =>
|
||||
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[key] = state
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Registry_Exposes_Exactly_The_22_Prototype_Keys()
|
||||
{
|
||||
TeamPermissionKeys.All.Should().HaveCount(22);
|
||||
TeamPermissionKeys.KnownKeys.Should().HaveCount(22);
|
||||
TeamPermissionKeys.All.Should().OnlyHaveUniqueItems();
|
||||
TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key));
|
||||
TeamPermissionKeys.All.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
"manageTeamMembers",
|
||||
"changeTeamMemberRole",
|
||||
"createVendors",
|
||||
"editVendors",
|
||||
"deactivateVendors",
|
||||
"createSites",
|
||||
"editSites",
|
||||
"deleteSites",
|
||||
"createServices",
|
||||
"editServices",
|
||||
"deactivateServices",
|
||||
"createCompletionDocTemplates",
|
||||
"editCompletionDocTemplates",
|
||||
"deleteCompletionDocTemplates",
|
||||
"createWorkOrders",
|
||||
"editOthersWorkOrders",
|
||||
"cancelWorkOrders",
|
||||
"deleteWorkOrders",
|
||||
"requestUplifts",
|
||||
"autoApproveUplifts",
|
||||
"reviewUplifts",
|
||||
"viewAllDispatchersOnDashboard"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys()
|
||||
{
|
||||
TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue();
|
||||
TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue();
|
||||
TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse();
|
||||
TeamPermissionKeys.IsKnown("").Should().BeFalse();
|
||||
TeamPermissionKeys.IsKnown(null).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatcher_Gets_Exactly_Prototype_Defaults()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal);
|
||||
policy.IsAllowed("Dispatcher", key).Should().Be(expected,
|
||||
$"Dispatcher default for '{key}' should be {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_Gets_Exactly_Prototype_Defaults()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal)
|
||||
|| DispatcherKeys.Contains(key, StringComparer.Ordinal)
|
||||
&& key is not TeamPermissionKeys.RequestUplifts
|
||||
&& key is not TeamPermissionKeys.AutoApproveUplifts;
|
||||
|
||||
policy.IsAllowed("Scheduler", key).Should().Be(expected,
|
||||
$"Scheduler default for '{key}' should be {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_Has_Effective_Access_To_Every_Key()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_Recognition_Is_Case_Insensitive()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue();
|
||||
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue();
|
||||
policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue();
|
||||
|
||||
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse();
|
||||
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Manager")]
|
||||
[InlineData("OfficeAdmin")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role)
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed(role, key).Should().BeFalse(
|
||||
$"unknown/legacy role '{role}' must not hold '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow))
|
||||
.Should().BeFalse($"an unknown role must not be elevated via '{key}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allow_Override_Grants_Key_Outside_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts)
|
||||
.Should().BeFalse();
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deny_Override_Removes_Key_Inside_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors)
|
||||
.Should().BeTrue();
|
||||
policy.IsAllowed(
|
||||
"Dispatcher",
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Dispatcher",
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset))
|
||||
.Should().BeTrue();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset))
|
||||
.Should().BeFalse();
|
||||
|
||||
policy.IsAllowed(
|
||||
"Scheduler",
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
new Dictionary<string, UserPermissionState>())
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_Is_Immune_To_Overrides()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
foreach (var key in TeamPermissionKeys.All)
|
||||
{
|
||||
policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny))
|
||||
.Should().BeTrue($"Admin must keep '{key}' despite a Deny override");
|
||||
policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset))
|
||||
.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Permission_Key_Is_Denied_For_Every_Role()
|
||||
{
|
||||
var policy = new TeamPermissionPolicy();
|
||||
|
||||
policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse();
|
||||
policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse();
|
||||
policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow))
|
||||
.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
130
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal file
130
SeaHaven.Services.Tests/TeamPermissionServiceTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
67
SeaHaven.Services/Constants/TeamPermissionKeys.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
namespace SeaHaven.Services.Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical, immutable registry of team-members permission keys.
|
||||
/// Exactly the 22 approved prototype keys; stored overrides must reference
|
||||
/// these keys. Keys are stable identifiers and must never be renamed.
|
||||
/// </summary>
|
||||
public static class TeamPermissionKeys
|
||||
{
|
||||
public const string ManageTeamMembers = "manageTeamMembers";
|
||||
public const string ChangeTeamMemberRole = "changeTeamMemberRole";
|
||||
public const string CreateVendors = "createVendors";
|
||||
public const string EditVendors = "editVendors";
|
||||
public const string DeactivateVendors = "deactivateVendors";
|
||||
public const string CreateSites = "createSites";
|
||||
public const string EditSites = "editSites";
|
||||
public const string DeleteSites = "deleteSites";
|
||||
public const string CreateServices = "createServices";
|
||||
public const string EditServices = "editServices";
|
||||
public const string DeactivateServices = "deactivateServices";
|
||||
public const string CreateCompletionDocTemplates = "createCompletionDocTemplates";
|
||||
public const string EditCompletionDocTemplates = "editCompletionDocTemplates";
|
||||
public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates";
|
||||
public const string CreateWorkOrders = "createWorkOrders";
|
||||
public const string EditOthersWorkOrders = "editOthersWorkOrders";
|
||||
public const string CancelWorkOrders = "cancelWorkOrders";
|
||||
public const string DeleteWorkOrders = "deleteWorkOrders";
|
||||
public const string RequestUplifts = "requestUplifts";
|
||||
public const string AutoApproveUplifts = "autoApproveUplifts";
|
||||
public const string ReviewUplifts = "reviewUplifts";
|
||||
public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllKeys = Array.AsReadOnly(new[]
|
||||
{
|
||||
ManageTeamMembers,
|
||||
ChangeTeamMemberRole,
|
||||
CreateVendors,
|
||||
EditVendors,
|
||||
DeactivateVendors,
|
||||
CreateSites,
|
||||
EditSites,
|
||||
DeleteSites,
|
||||
CreateServices,
|
||||
EditServices,
|
||||
DeactivateServices,
|
||||
CreateCompletionDocTemplates,
|
||||
EditCompletionDocTemplates,
|
||||
DeleteCompletionDocTemplates,
|
||||
CreateWorkOrders,
|
||||
EditOthersWorkOrders,
|
||||
CancelWorkOrders,
|
||||
DeleteWorkOrders,
|
||||
RequestUplifts,
|
||||
AutoApproveUplifts,
|
||||
ReviewUplifts,
|
||||
ViewAllDispatchersOnDashboard
|
||||
});
|
||||
|
||||
public static IReadOnlyList<string> All => AllKeys;
|
||||
|
||||
public static IReadOnlySet<string> KnownKeys { get; } =
|
||||
new HashSet<string>(AllKeys, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsKnown(string? permissionKey) =>
|
||||
!string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Email { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
public string? AccountName { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
|
|
|
|||
39
SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs
Normal file
39
SeaHaven.Services/DTOs/ServicesRegistryDTOs.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class ServiceDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Trade { get; set; } = "";
|
||||
public string IconKey { get; set; } = "";
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public LinkedTemplateSummaryDto? CompletionDocTemplate { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<WorkOrderType> SupportedWorkOrderTypes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LinkedTemplateSummaryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ServiceInput
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public string? IconKey { get; set; }
|
||||
public bool RequiresCompletionDocument { get; set; }
|
||||
public int? CompletionDocTemplateId { get; set; }
|
||||
public List<WorkOrderType>? SupportedWorkOrderTypes { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class ServicesRegistryValidationErrorDto
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
}
|
||||
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
49
SeaHaven.Services/DTOs/TeamPermissionDTOs.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs;
|
||||
|
||||
public sealed class TeamPermissionProfileDTO
|
||||
{
|
||||
public required string UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
public required IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TeamPermissionValueDTO
|
||||
{
|
||||
public required string PermissionKey { get; init; }
|
||||
public UserPermissionState OverrideState { get; init; }
|
||||
public bool IsGranted { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SetTeamPermissionOverrideDTO
|
||||
{
|
||||
public UserPermissionState State { get; init; }
|
||||
}
|
||||
|
||||
public enum TeamPermissionResultStatus
|
||||
{
|
||||
Success,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
InvalidPermissionKey
|
||||
}
|
||||
|
||||
public sealed class TeamPermissionResult<T>
|
||||
{
|
||||
private TeamPermissionResult(TeamPermissionResultStatus status, T? value)
|
||||
{
|
||||
Status = status;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public TeamPermissionResultStatus Status { get; }
|
||||
public T? Value { get; }
|
||||
public bool IsSuccess => Status == TeamPermissionResultStatus.Success;
|
||||
|
||||
public static TeamPermissionResult<T> Success(T value) =>
|
||||
new(TeamPermissionResultStatus.Success, value);
|
||||
|
||||
public static TeamPermissionResult<T> Failure(TeamPermissionResultStatus status) =>
|
||||
new(status, default);
|
||||
}
|
||||
|
|
@ -84,6 +84,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateOnly? OriginalDate { get; set; }
|
||||
public DateOnly? OriginalWeek { get; set; }
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
public int? ServiceId { get; set; }
|
||||
public string? ServiceNameSnapshot { get; set; }
|
||||
public bool IsPastDue { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Trade { get; set; }
|
||||
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
||||
public string? PrimaryService { get; set; }
|
||||
public int? ServiceId { get; set; }
|
||||
public List<string>? ExtraServices { get; set; }
|
||||
public string? ServiceNotes { get; set; }
|
||||
/// <summary>Per-WO Aveta compliance. Omit or false = not required.</summary>
|
||||
|
|
|
|||
12
SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs
Normal file
12
SeaHaven.Services/Exceptions/ServicesRegistryExceptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace SeaHaven.Services.Exceptions
|
||||
{
|
||||
public class ServicesRegistryValidationException : Exception
|
||||
{
|
||||
public ServicesRegistryValidationException(string code, string message) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
}
|
||||
}
|
||||
13
SeaHaven.Services/Helpers/DashboardBusinessTime.cs
Normal file
13
SeaHaven.Services/Helpers/DashboardBusinessTime.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
113
SeaHaven.Services/Helpers/DashboardMetrics.cs
Normal file
113
SeaHaven.Services/Helpers/DashboardMetrics.cs
Normal 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);
|
||||
85
SeaHaven.Services/Helpers/DashboardRegions.cs
Normal file
85
SeaHaven.Services/Helpers/DashboardRegions.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
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)
|
||||
{
|
||||
var code = UsStateCodes.ToCode(state);
|
||||
if (code is null)
|
||||
return "Unmapped/Other";
|
||||
|
||||
return StateToRegion.TryGetValue(code, 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"
|
||||
};
|
||||
}
|
||||
|
|
@ -56,8 +56,29 @@ namespace SeaHaven.Services.Helpers
|
|||
["WY"] = "WYOMING"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> CodesByName =
|
||||
Names.ToDictionary(pair => pair.Value, pair => pair.Key, StringComparer.Ordinal);
|
||||
|
||||
public static IReadOnlySet<string> All { get; } = new HashSet<string>(Names.Keys, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a stored state value to its two-letter postal code. Accepts either a
|
||||
/// postal code ("IN") or a full state name ("Indiana"), case-insensitively, matching
|
||||
/// the storage forms produced by <see cref="ExpandStorageValues"/>. Returns null when
|
||||
/// the value is blank or is not a recognised US state.
|
||||
/// </summary>
|
||||
public static string? ToCode(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var normalized = value.Trim().ToUpperInvariant();
|
||||
if (All.Contains(normalized))
|
||||
return normalized;
|
||||
|
||||
return CodesByName.TryGetValue(normalized, out var code) ? code : null;
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<string> ExpandStorageValues(IEnumerable<string> codes)
|
||||
{
|
||||
return codes
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ namespace SeaHaven.Services.Helpers
|
|||
public const string DocStatus = "docStatus";
|
||||
public const string CompletedDate = "completedDate";
|
||||
public const string Pm = "pm";
|
||||
public const string ServiceId = "serviceId";
|
||||
public const string ServiceNotes = "serviceNotes";
|
||||
public const string ExtraServices = "extraServices";
|
||||
public const string AdditionalContacts = "additionalContacts";
|
||||
|
|
@ -49,6 +50,7 @@ namespace SeaHaven.Services.Helpers
|
|||
DocStatus,
|
||||
CompletedDate,
|
||||
Pm,
|
||||
ServiceId,
|
||||
ServiceNotes,
|
||||
ExtraServices,
|
||||
AdditionalContacts,
|
||||
|
|
@ -93,6 +95,7 @@ namespace SeaHaven.Services.Helpers
|
|||
DocStatus => "DocStatus",
|
||||
CompletedDate => "CompletedDate",
|
||||
Pm => "Trade",
|
||||
ServiceId => "ServiceId",
|
||||
ServiceNotes => "ServiceNotes",
|
||||
ExtraServices => "ExtraServices",
|
||||
AdditionalContacts => "AdditionalContacts",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
255
SeaHaven.Services/Implementation/ServicesRegistryService.cs
Normal file
255
SeaHaven.Services/Implementation/ServicesRegistryService.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class ServicesRegistryService : IServicesRegistryService
|
||||
{
|
||||
// Product contract: the registry only accepts the three work-order types
|
||||
// the product currently supports. Any other WorkOrderType is rejected.
|
||||
public static readonly IReadOnlySet<WorkOrderType> AllowedWorkOrderTypes =
|
||||
new HashSet<WorkOrderType> { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
|
||||
|
||||
private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes =
|
||||
{ WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency };
|
||||
|
||||
public const string SchedulerRole = "Scheduler";
|
||||
public const string AdminRole = "Admin";
|
||||
|
||||
private const int NameMaxLength = 200;
|
||||
private const int IconKeyMaxLength = 64;
|
||||
|
||||
private readonly IServicesRegistryDataService _dataService;
|
||||
|
||||
public ServicesRegistryService(IServicesRegistryDataService dataService)
|
||||
{
|
||||
_dataService = dataService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateWorkOrderTypeFilter(workOrderType);
|
||||
var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken);
|
||||
return rows.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
|
||||
return row == null ? null : MapToDto(row);
|
||||
}
|
||||
|
||||
public async Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureCanMutate(user);
|
||||
|
||||
var name = RequireValidName(input.Name);
|
||||
var trade = RequireCanonicalTrade(input.Trade);
|
||||
var iconKey = RequireValidIconKey(input.IconKey);
|
||||
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
|
||||
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
|
||||
|
||||
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken))
|
||||
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
|
||||
|
||||
var service = new Service
|
||||
{
|
||||
Name = name,
|
||||
NormalizedName = TradeCatalog.Normalize(name),
|
||||
Trade = trade,
|
||||
IconKey = iconKey,
|
||||
RequiresCompletionDocument = input.RequiresCompletionDocument,
|
||||
CompletionDocTemplateId = template?.Id,
|
||||
IsActive = true,
|
||||
SupportedWorkOrderTypes = supportedTypes
|
||||
.Select(t => new ServiceWorkOrderType { WorkOrderType = t })
|
||||
.ToList()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _dataService.AddServiceAsync(service, cancellationToken);
|
||||
}
|
||||
catch (ServicesRegistryDuplicateNameException)
|
||||
{
|
||||
throw DuplicateName();
|
||||
}
|
||||
|
||||
var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken);
|
||||
return MapToDto(row!);
|
||||
}
|
||||
|
||||
public async Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureCanMutate(user);
|
||||
|
||||
var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken);
|
||||
if (service == null || service.IsDeleted == true)
|
||||
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
|
||||
|
||||
var name = RequireValidName(input.Name);
|
||||
var trade = RequireCanonicalTrade(input.Trade);
|
||||
var iconKey = RequireValidIconKey(input.IconKey);
|
||||
var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes);
|
||||
var template = await ResolveTemplateLinkAsync(input, cancellationToken);
|
||||
|
||||
if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken))
|
||||
throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists.");
|
||||
|
||||
service.Name = name;
|
||||
service.NormalizedName = TradeCatalog.Normalize(name);
|
||||
service.Trade = trade;
|
||||
service.IconKey = iconKey;
|
||||
service.RequiresCompletionDocument = input.RequiresCompletionDocument;
|
||||
service.CompletionDocTemplateId = template?.Id;
|
||||
if (input.IsActive.HasValue)
|
||||
{
|
||||
EnsureCanChangeActiveState(user);
|
||||
service.IsActive = input.IsActive.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken);
|
||||
}
|
||||
catch (ServicesRegistryDuplicateNameException)
|
||||
{
|
||||
throw DuplicateName();
|
||||
}
|
||||
|
||||
var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken);
|
||||
return MapToDto(row!);
|
||||
}
|
||||
|
||||
public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (user is null || !user.IsInRole(AdminRole))
|
||||
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services.");
|
||||
|
||||
var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken);
|
||||
if (!deactivated)
|
||||
throw new ServicesRegistryValidationException("NotFound", "Service not found.");
|
||||
}
|
||||
|
||||
private static void EnsureCanMutate(ClaimsPrincipal user)
|
||||
{
|
||||
if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole)))
|
||||
throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required.");
|
||||
}
|
||||
|
||||
private static void EnsureCanChangeActiveState(ClaimsPrincipal user)
|
||||
{
|
||||
if (user is null || !user.IsInRole(AdminRole))
|
||||
throw new ServicesRegistryValidationException("Forbidden", "Only administrators can change service active state.");
|
||||
}
|
||||
|
||||
private static ServicesRegistryValidationException DuplicateName() =>
|
||||
new("DuplicateName", "A service with this name already exists.");
|
||||
|
||||
private static string RequireValidName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ServicesRegistryValidationException("NameRequired", "Service name is required.");
|
||||
|
||||
var trimmed = name.Trim();
|
||||
if (trimmed.Length > NameMaxLength)
|
||||
throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters.");
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string RequireCanonicalTrade(string? trade)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trade))
|
||||
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
|
||||
|
||||
var normalized = TradeCatalog.Normalize(trade);
|
||||
var canonical = TradeCatalog.CanonicalTrades
|
||||
.FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized);
|
||||
|
||||
if (canonical == null)
|
||||
throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values.");
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
private static string RequireValidIconKey(string? iconKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconKey))
|
||||
throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required.");
|
||||
|
||||
var trimmed = iconKey.Trim();
|
||||
if (trimmed.Length > IconKeyMaxLength)
|
||||
throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters.");
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static List<WorkOrderType> RequireValidSupportedTypes(IEnumerable<WorkOrderType>? supportedTypes)
|
||||
{
|
||||
// The registry form does not expose type categories yet. Until it
|
||||
// does, an omitted or empty category list keeps a service available
|
||||
// to the three supported work-order types.
|
||||
var types = supportedTypes?.Distinct().ToList();
|
||||
if (types == null || types.Count == 0)
|
||||
return DefaultSupportedWorkOrderTypes.ToList();
|
||||
|
||||
if (types.Any(t => !AllowedWorkOrderTypes.Contains(t)))
|
||||
throw new ServicesRegistryValidationException(
|
||||
"WorkOrderTypeInvalid",
|
||||
"Supported work order types may only include PM, Reactive, or Emergency.");
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType)
|
||||
{
|
||||
if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value))
|
||||
throw new ServicesRegistryValidationException(
|
||||
"WorkOrderTypeInvalid",
|
||||
"Supported work order types may only include PM, Reactive, or Emergency.");
|
||||
}
|
||||
|
||||
private async Task<LinkedTemplateSummary?> ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken)
|
||||
{
|
||||
// Toggle off clears/ignores any template link.
|
||||
if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId)
|
||||
return null;
|
||||
|
||||
var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
throw new ServicesRegistryValidationException(
|
||||
"TemplateInvalid",
|
||||
"Completion document template must reference an active, non-deleted template.");
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private static ServiceDto MapToDto(ServiceRegistryRow row)
|
||||
{
|
||||
return new ServiceDto
|
||||
{
|
||||
Id = row.Id,
|
||||
Name = row.Name,
|
||||
Trade = row.Trade,
|
||||
IconKey = row.IconKey,
|
||||
RequiresCompletionDocument = row.RequiresCompletionDocument,
|
||||
CompletionDocTemplate = row.CompletionDocTemplateId == null
|
||||
? null
|
||||
: new LinkedTemplateSummaryDto
|
||||
{
|
||||
Id = row.CompletionDocTemplateId.Value,
|
||||
Name = row.CompletionDocTemplateName ?? ""
|
||||
},
|
||||
IsActive = row.IsActive,
|
||||
SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
88
SeaHaven.Services/Implementation/TeamPermissionPolicy.cs
Normal file
88
SeaHaven.Services/Implementation/TeamPermissionPolicy.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public sealed class TeamPermissionPolicy : ITeamPermissionPolicy
|
||||
{
|
||||
private static readonly StringComparer KeyComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private static readonly HashSet<string> DispatcherDefaults = new(KeyComparer)
|
||||
{
|
||||
TeamPermissionKeys.CreateVendors,
|
||||
TeamPermissionKeys.EditVendors,
|
||||
TeamPermissionKeys.DeactivateVendors,
|
||||
TeamPermissionKeys.CreateSites,
|
||||
TeamPermissionKeys.EditSites,
|
||||
TeamPermissionKeys.CreateWorkOrders,
|
||||
TeamPermissionKeys.EditOthersWorkOrders,
|
||||
TeamPermissionKeys.CancelWorkOrders,
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SchedulerDefaults = new(
|
||||
DispatcherDefaults
|
||||
.Except(new[]
|
||||
{
|
||||
TeamPermissionKeys.RequestUplifts,
|
||||
TeamPermissionKeys.AutoApproveUplifts
|
||||
}, KeyComparer)
|
||||
.Concat(new[]
|
||||
{
|
||||
TeamPermissionKeys.DeleteSites,
|
||||
TeamPermissionKeys.CreateServices,
|
||||
TeamPermissionKeys.EditServices,
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates,
|
||||
TeamPermissionKeys.ViewAllDispatchersOnDashboard
|
||||
}), KeyComparer);
|
||||
|
||||
public bool IsAllowed(
|
||||
string? roleName,
|
||||
string permissionKey,
|
||||
IReadOnlyDictionary<string, UserPermissionState>? overrides = null)
|
||||
{
|
||||
if (!TeamPermissionKeys.IsKnown(permissionKey))
|
||||
return false;
|
||||
|
||||
if (IsAdmin(roleName))
|
||||
return true;
|
||||
|
||||
var defaults = ResolveRoleDefaults(roleName);
|
||||
if (defaults is null)
|
||||
return false;
|
||||
|
||||
if (overrides is not null
|
||||
&& overrides.TryGetValue(permissionKey, out var state))
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
UserPermissionState.Allow => true,
|
||||
UserPermissionState.Deny => false,
|
||||
_ => defaults.Contains(permissionKey)
|
||||
};
|
||||
}
|
||||
|
||||
return defaults.Contains(permissionKey);
|
||||
}
|
||||
|
||||
private static bool IsAdmin(string? roleName) =>
|
||||
string.Equals(roleName, "Admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IReadOnlySet<string>? ResolveRoleDefaults(string? roleName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleName))
|
||||
return null;
|
||||
|
||||
if (roleName.Equals("Dispatcher", StringComparison.OrdinalIgnoreCase))
|
||||
return DispatcherDefaults;
|
||||
|
||||
if (roleName.Equals("Scheduler", StringComparison.OrdinalIgnoreCase))
|
||||
return SchedulerDefaults;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
SeaHaven.Services/Implementation/TeamPermissionService.cs
Normal file
96
SeaHaven.Services/Implementation/TeamPermissionService.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||
|
||||
public WorkOrderBoardCreateService(
|
||||
IWorkOrderBoardDataService boardDataService,
|
||||
|
|
@ -27,7 +28,8 @@ namespace SeaHaven.Services.Implementation
|
|||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderBoardCreateValidation validator,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
IServicesRegistryService? servicesRegistryService = null)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
_mutationData = mutationData;
|
||||
|
|
@ -35,6 +37,7 @@ namespace SeaHaven.Services.Implementation
|
|||
_auditService = auditService;
|
||||
_validator = validator;
|
||||
_accountResolver = accountResolver;
|
||||
_servicesRegistryService = servicesRegistryService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||
|
|
@ -54,7 +57,8 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
||||
var siteCode = request.SiteCode!.Trim();
|
||||
var primaryService = ResolvePrimaryService(request);
|
||||
var selectedService = await ResolveServiceAsync(request.ServiceId, request.WorkOrderType, request.PrimaryService ?? request.Trade, cancellationToken);
|
||||
var primaryService = selectedService?.Name ?? ResolvePrimaryService(request);
|
||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
||||
var pocName = TrimOrNull(request.PocName);
|
||||
|
|
@ -87,6 +91,8 @@ namespace SeaHaven.Services.Implementation
|
|||
AccountId = accountId,
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Trade = primaryService,
|
||||
ServiceId = selectedService?.Id,
|
||||
ServiceNameSnapshot = selectedService?.Name,
|
||||
ExtraServices = extraServicesJson,
|
||||
ServiceNotes = serviceNotes,
|
||||
AvetaRequired = request.AvetaRequired == true,
|
||||
|
|
@ -138,6 +144,8 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||
if (workOrder.Trade != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
||||
if (workOrder.ServiceId.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceId", "", workOrder.ServiceId.Value.ToString()));
|
||||
if (workOrder.ExtraServices != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
||||
if (workOrder.ServiceNotes != null)
|
||||
|
|
@ -251,6 +259,46 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
}
|
||||
|
||||
private async Task<ServiceDto?> ResolveServiceAsync(
|
||||
int? serviceId,
|
||||
WorkOrderType workOrderType,
|
||||
string? legacyLabel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_servicesRegistryService == null)
|
||||
{
|
||||
if (serviceId.HasValue)
|
||||
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId is not available.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType))
|
||||
{
|
||||
if (serviceId.HasValue)
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"ServiceInvalid",
|
||||
"serviceId is not supported for this work order type.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken);
|
||||
if (serviceId.HasValue)
|
||||
{
|
||||
var selected = services.FirstOrDefault(service => service.Id == serviceId.Value);
|
||||
if (selected == null)
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"ServiceInvalid",
|
||||
"serviceId must reference an active service that supports this work order type.");
|
||||
return selected;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(legacyLabel))
|
||||
return null;
|
||||
|
||||
return services.FirstOrDefault(service =>
|
||||
string.Equals(service.Name.Trim(), legacyLabel.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string? SerializeExtraServices(List<string>? extras)
|
||||
{
|
||||
if (extras == null || extras.Count == 0)
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ namespace SeaHaven.Services.Implementation
|
|||
OriginalDate = row.OriginalDate,
|
||||
OriginalWeek = row.OriginalWeek,
|
||||
WorkOrderType = row.WorkOrderType,
|
||||
ServiceId = row.ServiceId,
|
||||
ServiceNameSnapshot = row.ServiceNameSnapshot,
|
||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||
SiteCode = row.SiteCode,
|
||||
LocationName = row.LocationName,
|
||||
|
|
@ -136,7 +138,7 @@ namespace SeaHaven.Services.Implementation
|
|||
TechPhone = row.TechPhone,
|
||||
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||
Pm = row.Trade ?? row.Problem,
|
||||
Pm = row.Trade ?? row.ServiceNameSnapshot ?? row.Problem,
|
||||
ServiceNotes = row.ServiceNotes,
|
||||
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||
AdditionalContacts = MapAdditionalContacts(row),
|
||||
|
|
|
|||
|
|
@ -16,15 +16,18 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||
|
||||
public WorkOrderBoardUpdateService(
|
||||
IWorkOrderBoardDataService boardDataService,
|
||||
IWorkOrderBoardMutationDataService mutationData,
|
||||
IWorkOrderAuditService auditService)
|
||||
IWorkOrderAuditService auditService,
|
||||
IServicesRegistryService? servicesRegistryService = null)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
_mutationData = mutationData;
|
||||
_auditService = auditService;
|
||||
_servicesRegistryService = servicesRegistryService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
||||
|
|
@ -73,6 +76,13 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
||||
|
||||
if (canonicalField.Equals(WorkOrderBoardFieldNames.WorkOrderType, StringComparison.OrdinalIgnoreCase)
|
||||
&& workOrder.ServiceId.HasValue)
|
||||
{
|
||||
var nextType = ParseWorkOrderType(value: request.Value);
|
||||
await EnsureServiceSupportsTypeAsync(workOrder.ServiceId.Value, nextType, ct);
|
||||
}
|
||||
|
||||
Dispatch? dispatch = null;
|
||||
ResolvedDispatch? resolved = null;
|
||||
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
|
||||
|
|
@ -181,6 +191,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
||||
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.ServiceId => await ApplyServiceIdAsync(workOrder, value, auditField, cancellationToken),
|
||||
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||
WorkOrderBoardFieldNames.LifecycleStatus => ApplyLifecycleStatus(workOrder, value, auditField),
|
||||
|
|
@ -388,6 +399,83 @@ namespace SeaHaven.Services.Implementation
|
|||
return changes;
|
||||
}
|
||||
|
||||
private async Task<List<FieldChange>> ApplyServiceIdAsync(
|
||||
WorkOrder workOrder,
|
||||
string? value,
|
||||
string auditField,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_servicesRegistryService == null)
|
||||
throw new WorkOrderBoardValidationException("ServiceInvalid", "Service registry is unavailable.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
var previousServiceId = workOrder.ServiceId;
|
||||
var previousServiceLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade;
|
||||
workOrder.ServiceId = null;
|
||||
workOrder.ServiceNameSnapshot = null;
|
||||
workOrder.Trade = null;
|
||||
return new List<FieldChange>
|
||||
{
|
||||
FieldChange.ForField(auditField, previousServiceId?.ToString(), null),
|
||||
FieldChange.ForField("Trade", previousServiceLabel, null)
|
||||
};
|
||||
}
|
||||
|
||||
if (!int.TryParse(value.Trim(), out var serviceId) || serviceId <= 0)
|
||||
throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer.");
|
||||
|
||||
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
|
||||
if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)
|
||||
|| service == null
|
||||
|| !service.IsActive
|
||||
|| !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"ServiceInvalid",
|
||||
"serviceId must reference an active service that supports this work order type.");
|
||||
}
|
||||
|
||||
var oldId = workOrder.ServiceId;
|
||||
var oldLabel = workOrder.ServiceNameSnapshot ?? workOrder.Trade;
|
||||
workOrder.ServiceId = service.Id;
|
||||
workOrder.ServiceNameSnapshot = service.Name;
|
||||
workOrder.Trade = service.Name;
|
||||
return new List<FieldChange>
|
||||
{
|
||||
oldId == service.Id
|
||||
? FieldChange.Unchanged(auditField)
|
||||
: FieldChange.ForField(auditField, oldId?.ToString(), service.Id.ToString()),
|
||||
oldLabel == service.Name
|
||||
? FieldChange.Unchanged("Trade")
|
||||
: FieldChange.ForField("Trade", oldLabel, service.Name)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnsureServiceSupportsTypeAsync(int serviceId, WorkOrderType workOrderType, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_servicesRegistryService == null)
|
||||
throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable.");
|
||||
|
||||
var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken);
|
||||
if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType))
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"ServiceTypeInvalid",
|
||||
"The selected service does not support this work order type.");
|
||||
}
|
||||
|
||||
private static WorkOrderType ParseWorkOrderType(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)
|
||||
|| string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)
|
||||
|| !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
19
SeaHaven.Services/Interfaces/IServicesRegistryService.cs
Normal file
19
SeaHaven.Services/Interfaces/IServicesRegistryService.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
// Global Services registry. Tenant scope is global (no AccountId),
|
||||
// matching DropdownOptions. Reads are authenticated; create/update require
|
||||
// the Scheduler or Admin role and deactivation is Admin-only, enforced at
|
||||
// this service entry — never in the controller alone.
|
||||
public interface IServicesRegistryService
|
||||
{
|
||||
Task<IReadOnlyList<ServiceDto>> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken);
|
||||
Task<ServiceDto?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<ServiceDto> CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken);
|
||||
Task<ServiceDto> UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken);
|
||||
Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
23
SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs
Normal file
23
SeaHaven.Services/Interfaces/ITeamPermissionPolicy.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure evaluator for team-members permissions. Applies explicit
|
||||
/// per-person overrides on top of role defaults; Admin has effective access
|
||||
/// to every canonical key regardless of stored values. Unknown or legacy
|
||||
/// roles receive no permissions.
|
||||
/// </summary>
|
||||
public interface ITeamPermissionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves whether a user in <paramref name="roleName"/> holds
|
||||
/// <paramref name="permissionKey"/>. <paramref name="overrides"/> is the
|
||||
/// person's stored override set (missing key behaves as Unset).
|
||||
/// </summary>
|
||||
bool IsAllowed(
|
||||
string? roleName,
|
||||
string permissionKey,
|
||||
IReadOnlyDictionary<string, UserPermissionState>? overrides = null);
|
||||
}
|
||||
}
|
||||
20
SeaHaven.Services/Interfaces/ITeamPermissionService.cs
Normal file
20
SeaHaven.Services/Interfaces/ITeamPermissionService.cs
Normal 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);
|
||||
}
|
||||
342
SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs
Normal file
342
SeaHavenIndustries.Tests/SH303ServicesRegistryMigrationTests.cs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
using System.Reflection;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Migrations;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations.Operations;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
// Shape evidence for the global Services registry migration (table,
|
||||
// indexes, FK, idempotent additive seed) plus a SQL Server execution test that
|
||||
// is silently skipped when no provider is reachable, mirroring existing
|
||||
// provider-gated migration tests.
|
||||
public class SH303ServicesRegistryMigrationTests
|
||||
{
|
||||
private const string LocalDbMaster =
|
||||
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||
|
||||
[Fact]
|
||||
public void Up_CreatesRegistryTablesUniqueIndexesFkAndIdempotentProblemSeed()
|
||||
{
|
||||
var operations = BuildOperations("Up");
|
||||
|
||||
var createTables = operations.OfType<CreateTableOperation>().ToList();
|
||||
Assert.Equal(
|
||||
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
|
||||
createTables.Select(t => t.Name).ToHashSet());
|
||||
|
||||
var services = createTables.Single(t => t.Name == "Services");
|
||||
Assert.Contains(services.Columns, c => c.Name == "NormalizedName" && (string)c.ColumnType! == "nvarchar(200)");
|
||||
Assert.Contains(services.Columns, c => c.Name == "CompletionDocTemplateId" && c.IsNullable);
|
||||
Assert.Contains(services.Columns, c => c.Name == "RequiresCompletionDocument" && !c.IsNullable);
|
||||
Assert.Contains(services.Columns, c => c.Name == "IsActive" && !c.IsNullable);
|
||||
|
||||
var foreignKeys = services.ForeignKeys.Concat(
|
||||
createTables.Single(t => t.Name == "ServiceWorkOrderTypes").ForeignKeys)
|
||||
.ToList();
|
||||
Assert.Contains(foreignKeys, fk =>
|
||||
fk.Table == "Services"
|
||||
&& fk.PrincipalTable == "CompletionDocTemplates"
|
||||
&& fk.OnDelete == ReferentialAction.Restrict);
|
||||
Assert.Contains(foreignKeys, fk =>
|
||||
fk.Table == "ServiceWorkOrderTypes" && fk.PrincipalTable == "Services");
|
||||
|
||||
var indexes = operations.OfType<CreateIndexOperation>().ToList();
|
||||
Assert.Contains(indexes, i =>
|
||||
i.Table == "Services" && i.Columns.SequenceEqual(new[] { "NormalizedName" }) && i.IsUnique);
|
||||
Assert.Contains(indexes, i =>
|
||||
i.Table == "ServiceWorkOrderTypes"
|
||||
&& i.Columns.SequenceEqual(new[] { "ServiceId", "WorkOrderType" })
|
||||
&& i.IsUnique);
|
||||
|
||||
// The seed is raw additive SQL: read-only over DropdownOptions, idempotent
|
||||
// via NormalizedName NOT EXISTS, and never mutates dropdown rows.
|
||||
var seedSql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
|
||||
Assert.Contains("[Category] = N'Problem'", seedSql);
|
||||
Assert.Contains("[IsActive] = 1", seedSql);
|
||||
Assert.Contains("NOT EXISTS", seedSql);
|
||||
Assert.Contains("INSERT INTO [Services]", seedSql);
|
||||
Assert.Contains("INSERT INTO [ServiceWorkOrderTypes]", seedSql);
|
||||
Assert.DoesNotContain("UPDATE [DropdownOptions]", seedSql);
|
||||
Assert.DoesNotContain("DELETE FROM [DropdownOptions]", seedSql);
|
||||
Assert.DoesNotContain("DELETE [DropdownOptions]", seedSql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Down_DropsRegistryTables()
|
||||
{
|
||||
var operations = BuildOperations("Down");
|
||||
|
||||
var drops = operations.OfType<DropTableOperation>().Select(d => d.Name).ToList();
|
||||
Assert.Equal(
|
||||
new HashSet<string> { "Services", "ServiceWorkOrderTypes" },
|
||||
drops.ToHashSet());
|
||||
Assert.Empty(operations.OfType<SqlOperation>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SH187_Up_AddsNullableServiceLinkAndBackfillsLegacyLabels()
|
||||
{
|
||||
var operations = BuildOperations<SH187_WorkOrderServiceLink>("Up");
|
||||
|
||||
var columns = operations.OfType<AddColumnOperation>().ToList();
|
||||
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceId" && c.IsNullable);
|
||||
Assert.Contains(columns, c => c.Table == "workOrders" && c.Name == "ServiceNameSnapshot"
|
||||
&& c.IsNullable && c.MaxLength == 200);
|
||||
Assert.Contains(operations.OfType<CreateIndexOperation>(), i =>
|
||||
i.Table == "workOrders" && i.Columns.SequenceEqual(new[] { "ServiceId" }));
|
||||
Assert.Contains(operations.OfType<AddForeignKeyOperation>(), fk =>
|
||||
fk.Table == "workOrders"
|
||||
&& fk.PrincipalTable == "Services"
|
||||
&& fk.OnDelete == ReferentialAction.Restrict);
|
||||
|
||||
var sql = string.Join("\n", operations.OfType<SqlOperation>().Select(o => o.Sql));
|
||||
Assert.Contains("UPDATE w", sql);
|
||||
Assert.Contains("ServiceNameSnapshot", sql);
|
||||
Assert.Contains("LEFT JOIN [Services]", sql);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Up_SeedsActiveProblemValuesIdempotently_WhenSqlServerAvailable()
|
||||
{
|
||||
var masterConnectionString = await ResolveMasterConnectionStringAsync();
|
||||
if (masterConnectionString == null)
|
||||
return;
|
||||
|
||||
var dbName = $"SH303Registry_{Guid.NewGuid():N}";
|
||||
var connectionString = WithDatabase(masterConnectionString, dbName);
|
||||
|
||||
try
|
||||
{
|
||||
await CreateDatabaseAsync(masterConnectionString, dbName);
|
||||
|
||||
await using var connection = new SqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using (var seedSource = connection.CreateCommand())
|
||||
{
|
||||
seedSource.CommandText =
|
||||
"""
|
||||
CREATE TABLE DropdownOptions (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
Category nvarchar(max) NOT NULL,
|
||||
Value nvarchar(max) NOT NULL,
|
||||
ParentValue nvarchar(max) NULL,
|
||||
SortOrder int NOT NULL,
|
||||
IsActive bit NOT NULL
|
||||
);
|
||||
CREATE TABLE CompletionDocTemplates (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
Name nvarchar(200) NOT NULL,
|
||||
ServiceKey nvarchar(max) NOT NULL,
|
||||
TemplateUrl nvarchar(max) NOT NULL,
|
||||
IsActive bit NOT NULL,
|
||||
IsDeleted bit NULL,
|
||||
createdby nvarchar(max) NULL,
|
||||
DeleterUserId nvarchar(max) NULL,
|
||||
DeletionTime datetime2 NULL,
|
||||
CreatedDate datetime2 NULL,
|
||||
LastModificationTime datetime2 NULL,
|
||||
LastModifierUserId int NULL
|
||||
);
|
||||
INSERT INTO DropdownOptions (Category, Value, SortOrder, IsActive) VALUES
|
||||
(N'Problem', N'Leak', 0, 1),
|
||||
(N'Problem', N'leak', 1, 1),
|
||||
(N'Problem', N'No Power', 2, 1),
|
||||
(N'Problem', N'Not Cooling', 3, 1),
|
||||
(N'Problem', N'Not Heating', 4, 1),
|
||||
(N'Problem', N'Alarm Fault', 5, 1),
|
||||
(N'Problem', N'Broken/Damaged', 6, 1),
|
||||
(N'Problem', N'Retired Ghost', 7, 0),
|
||||
(N'Trade', N'Electrical', 0, 1);
|
||||
""";
|
||||
await seedSource.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using var context = new ApplicationDbContext(
|
||||
new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlServer(connection).Options);
|
||||
await ApplyMigrationAsync(context, new SH303_ServicesRegistry());
|
||||
|
||||
await using (var servicesCmd = connection.CreateCommand())
|
||||
{
|
||||
servicesCmd.CommandText =
|
||||
"""
|
||||
SELECT [Name], [Trade], [IconKey]
|
||||
FROM [Services]
|
||||
ORDER BY [Name];
|
||||
""";
|
||||
await using var reader = await servicesCmd.ExecuteReaderAsync();
|
||||
var rows = new List<string>();
|
||||
while (await reader.ReadAsync())
|
||||
rows.Add($"{reader.GetString(0)}|{reader.GetString(1)}|{reader.GetString(2)}");
|
||||
|
||||
// Repeated case variants collapse onto one deterministic row and the
|
||||
// inactive value plus the non-Problem category never seed.
|
||||
var expected = new[]
|
||||
{
|
||||
"Alarm Fault|Fire & Life Safety|fire-life-safety",
|
||||
"Broken/Damaged|General Building & Handyman|general-building-handyman",
|
||||
"Leak|Plumbing & Water Systems|plumbing-water-systems",
|
||||
"No Power|Electrical|electrical",
|
||||
"Not Cooling|HVAC|hvac",
|
||||
"Not Heating|HVAC|hvac"
|
||||
};
|
||||
Assert.Equal(expected, rows.OrderBy(r => r).ToArray());
|
||||
}
|
||||
|
||||
await using (var typesCmd = connection.CreateCommand())
|
||||
{
|
||||
typesCmd.CommandText =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM [ServiceWorkOrderTypes] t
|
||||
JOIN [Services] s ON s.[Id] = t.[ServiceId]
|
||||
WHERE t.[WorkOrderType] IN (2, 3, 6);
|
||||
""";
|
||||
Assert.Equal(18, Convert.ToInt32(await typesCmd.ExecuteScalarAsync())); // 6 services x 3 supported types
|
||||
}
|
||||
|
||||
await using (var untouchedCmd = connection.CreateCommand())
|
||||
{
|
||||
untouchedCmd.CommandText = "SELECT COUNT(*) FROM [DropdownOptions];";
|
||||
Assert.Equal(9, Convert.ToInt32(await untouchedCmd.ExecuteScalarAsync()));
|
||||
}
|
||||
|
||||
// Re-running only the seed statements must be a no-op (idempotence).
|
||||
var before = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
|
||||
await ApplySeedStatementsAsync(context);
|
||||
var after = await CountAsync(connection, "SELECT COUNT(*) FROM [Services];");
|
||||
Assert.Equal(before, after);
|
||||
|
||||
var migration = new SH303_ServicesRegistry();
|
||||
await ApplyMigrationAsync(context, migration, "Down");
|
||||
Assert.Equal(0, await CountAsync(connection, "SELECT COUNT(*) FROM sys.tables WHERE name IN ('Services', 'ServiceWorkOrderTypes');"));
|
||||
|
||||
await ApplyMigrationAsync(context, migration, "Up");
|
||||
Assert.Equal(6, await CountAsync(connection, "SELECT COUNT(*) FROM [Services];"));
|
||||
Assert.Equal(18, await CountAsync(connection, "SELECT COUNT(*) FROM [ServiceWorkOrderTypes];"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DropDatabaseAsync(masterConnectionString, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MigrationOperation> BuildOperations(string direction)
|
||||
=> BuildOperations<SH303_ServicesRegistry>(direction);
|
||||
|
||||
private static List<MigrationOperation> BuildOperations<TMigration>(string direction)
|
||||
where TMigration : Migration, new()
|
||||
{
|
||||
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
|
||||
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"Migration.{direction} not found.");
|
||||
operation.Invoke(new TMigration(), [builder]);
|
||||
return builder.Operations;
|
||||
}
|
||||
|
||||
private static async Task ApplyMigrationAsync(ApplicationDbContext context, Migration migration, string direction = "Up")
|
||||
{
|
||||
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"Migration.{direction} not found.");
|
||||
operation.Invoke(migration, [builder]);
|
||||
|
||||
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||
var commands = sqlGenerator.Generate(builder.Operations, context.Model);
|
||||
foreach (var command in commands)
|
||||
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||
}
|
||||
|
||||
private static async Task ApplySeedStatementsAsync(ApplicationDbContext context)
|
||||
{
|
||||
var sqlOperations = BuildOperations("Up").OfType<SqlOperation>().ToList();
|
||||
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||
var commands = sqlGenerator.Generate(sqlOperations, context.Model);
|
||||
foreach (var command in commands)
|
||||
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||
}
|
||||
|
||||
private static async Task<int> CountAsync(SqlConnection connection, string commandText)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = commandText;
|
||||
return Convert.ToInt32(await command.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveMasterConnectionStringAsync()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SH303_SQLSERVER_MASTER");
|
||||
var candidate = !string.IsNullOrWhiteSpace(configured)
|
||||
? WithDatabase(configured, "master")
|
||||
: OperatingSystem.IsWindows() ? LocalDbMaster : null;
|
||||
|
||||
if (candidate == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(candidate);
|
||||
await connection.OpenAsync();
|
||||
return candidate;
|
||||
}
|
||||
catch (SqlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateDatabaseAsync(string masterConnectionString, string dbName)
|
||||
{
|
||||
await using var connection = new SqlConnection(masterConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"CREATE DATABASE {QuoteSqlServerIdentifier(dbName)};";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task DropDatabaseAsync(string masterConnectionString, string dbName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var quoted = QuoteSqlServerIdentifier(dbName);
|
||||
await using var connection = new SqlConnection(masterConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"""
|
||||
IF DB_ID(@dbName) IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE {quoted};
|
||||
END
|
||||
""";
|
||||
command.Parameters.AddWithValue("@dbName", dbName);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup for ephemeral SQL Server databases.
|
||||
}
|
||||
}
|
||||
|
||||
private static string QuoteSqlServerIdentifier(string name) =>
|
||||
"[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
|
||||
|
||||
private static string WithDatabase(string connectionString, string database)
|
||||
{
|
||||
var builder = new SqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
InitialCatalog = database
|
||||
};
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1043,6 +1043,24 @@ public class WorkOrderBoardServiceTests
|
|||
Assert.Equal("09:00", dto.ApptTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapRawRow_PmEditTakesPrecedenceOverServiceSnapshot()
|
||||
{
|
||||
var row = RawRow(
|
||||
scheduledDate: new DateTime(2026, 6, 23),
|
||||
scheduledStart: null,
|
||||
scheduledEnd: null,
|
||||
dispatchApptDate: null) with
|
||||
{
|
||||
Trade = "Updated service label",
|
||||
ServiceNameSnapshot = "Original service label"
|
||||
};
|
||||
|
||||
var dto = WorkOrderBoardService.MapRawRow(row, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Updated service label", dto.Pm);
|
||||
}
|
||||
|
||||
private static WorkOrderBoardRawRow RawRow(
|
||||
DateTime? scheduledDate,
|
||||
DateTime? scheduledStart,
|
||||
|
|
@ -1055,6 +1073,8 @@ public class WorkOrderBoardServiceTests
|
|||
CarriedOver: 0,
|
||||
IsAddOn: false,
|
||||
WorkOrderType: null,
|
||||
ServiceId: null,
|
||||
ServiceNameSnapshot: null,
|
||||
SiteCode: null,
|
||||
LocationName: null,
|
||||
PocName: null,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue