Compare commits

...

4 commits

Author SHA1 Message Date
Alexandre Brandizzi
3e301ac9da
Merge pull request #145 from Sea-Haven-Industries/feat/ab/sh-347-dashboard-contract
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-347: Scoped dashboard metrics contract
2026-09-17 12:34:22 -03:00
Alexandre Brandizzi
564bd70301 fix(dashboard): scope Trend by picker and admit Scheduler to dispatcher picker
ResolveDispatcherScope now admits the Scheduler role, which owns the
viewAllDispatchersOnDashboard permission, so it can load Stats, Workload,
Performance, Regions and Trend instead of being denied.

GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a
picker DispatcherId selection scopes Trend consistently with the other
endpoints and unauthorized roles fail closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-17 03:37:36 -03:00
Alexandre Brandizzi
f564e1b112 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-347-dashboard-contract
# Conflicts:
#	Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
2026-09-17 02:18:22 -03:00
Alexandre Brandizzi
dca8d898cb feat(dashboard): add scoped metrics contract (SH-347) 2026-09-16 21:37:17 -03:00
6 changed files with 347 additions and 34 deletions

View file

@ -270,11 +270,13 @@ public class DashboardServiceTests
1,
CancellationToken.None);
result.TotalDispatchers.Should().Be(1);
result.Items.Should().ContainSingle();
result.Items[0].DispatcherName.Should().Be("Ada One");
result.TotalDispatchers.Should().Be(2);
result.Items.Select(item => item.DispatcherName)
.Should().ContainInOrder("Ada One", "Ben Two");
result.Items[0].TotalCount.Should().Be(2);
result.Items[0].OpenCount.Should().Be(1);
result.Items[1].TotalCount.Should().Be(0);
result.Items[1].OpenCount.Should().Be(0);
result.PageSize.Should().Be(10);
var adminResult = await NewService(ctx).GetWorkloadAsync(
@ -444,6 +446,275 @@ public class DashboardServiceTests
result.Items[0].RescheduleRate.Should().Be(50);
}
[Fact]
public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" };
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
stats.Total.Should().Be(2);
stats.Open.Should().Be(2);
}
[Fact]
public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
ctx.SaveChanges();
var service = NewService(ctx);
var selected = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
selected.Total.Should().Be(3);
selected.Open.Should().Be(3);
var mine = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "mine" },
CancellationToken.None);
mine.Total.Should().Be(1);
mine.Completed.Should().Be(1);
var all = await service.GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
all.Total.Should().Be(4);
}
[Fact]
public async Task GetStatsAsync_ManagerSelectionScopesStats()
{
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, "manager-1", "Manager"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
stats.Total.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_SchedulerSelectionScopesStats()
{
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, "scheduler-1", "Scheduler"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
CancellationToken.None);
stats.Total.Should().Be(1);
}
[Fact]
public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed()
{
using var ctx = NewContext();
var service = NewService(ctx);
var user = AccountUser(1, "tech-1", "Technician");
var statsAct = () => service.GetStatsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var statsException = await statsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
statsException.Which.Code.Should().Be("Forbidden");
var workloadAct = () => service.GetWorkloadAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
workloadException.Which.Code.Should().Be("Forbidden");
var performanceAct = () => service.GetPerformanceAsync(
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
performanceException.Which.Code.Should().Be("Forbidden");
var regionsAct = () => service.GetRegionsAsync(
user, new DashboardStatsQueryDTO(), CancellationToken.None);
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
regionsException.Which.Code.Should().Be("Forbidden");
var trendAct = () => service.GetTrendAsync(
user, new DashboardTrendQueryDTO(), CancellationToken.None);
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
trendException.Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows()
{
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", Status = "Open" });
ctx.SaveChanges();
var workload = await NewService(ctx).GetWorkloadAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
1,
CancellationToken.None);
workload.TotalDispatchers.Should().Be(2);
workload.Items.Should().HaveCount(2);
workload.Items[0].DispatcherId.Should().Be("dispatcher-1");
workload.Items[0].TotalCount.Should().Be(2);
workload.Items[0].OpenCount.Should().Be(2);
workload.Items[1].DispatcherId.Should().Be("dispatcher-2");
workload.Items[1].TotalCount.Should().Be(0);
workload.Items[1].OpenCount.Should().Be(0);
workload.Page.Should().Be(1);
workload.PageSize.Should().Be(10);
var performance = await NewService(ctx).GetPerformanceAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
1,
CancellationToken.None);
performance.TotalDispatchers.Should().Be(2);
performance.Items.Should().HaveCount(2);
performance.Items[0].DispatcherId.Should().Be("dispatcher-1");
performance.Items[0].AssignedCount.Should().Be(2);
performance.Items[1].DispatcherId.Should().Be("dispatcher-2");
performance.Items[1].AssignedCount.Should().Be(0);
performance.Items[1].CompletedCount.Should().Be(0);
}
[Fact]
public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts()
{
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" });
ctx.UserRoles.Add(new IdentityUserRole<string>
{
UserId = "dispatcher-1",
RoleId = "dispatcher-role"
});
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
},
new WorkOrder
{
AccountId = 1,
AssignTo = "dispatcher-1",
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled
});
ctx.SaveChanges();
var result = await NewService(ctx).GetPerformanceAsync(
AccountUser(1),
new DashboardStatsQueryDTO(),
1,
CancellationToken.None);
result.Items.Should().ContainSingle();
result.Items[0].AssignedCount.Should().Be(3);
result.Items[0].CompletedCount.Should().Be(1);
}
[Fact]
public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
new WorkOrder
{
AccountId = 1,
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
},
new WorkOrder { AccountId = 1, Status = "In Progress" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Done" },
new WorkOrder { AccountId = 1, Status = "Unparseable" });
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
stats.StatusDistribution.Should().HaveCount(4);
stats.StatusDistribution
.Select(bucket => (bucket.Status, bucket.Count))
.Should().BeEquivalentTo(new[]
{
(Status: "Completed", Count: 2),
(Status: "In Progress", Count: 1),
(Status: "Scheduled", Count: 1),
(Status: "Unknown", Count: 1)
});
stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5);
}
[Fact]
public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders()
{
using var ctx = NewContext();
var stats = await NewService(ctx).GetStatsAsync(
AccountUser(1, "admin-1"),
new DashboardStatsQueryDTO(),
CancellationToken.None);
stats.StatusDistribution.Should().BeEmpty();
}
[Fact]
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
{

View file

@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,
completionRate = stats.CompletionRate,
averageResolutionDays = stats.AverageResolutionDays
averageResolutionDays = stats.AverageResolutionDays,
statusDistribution = stats.StatusDistribution
});
}

View file

@ -166,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation
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,
@ -178,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation
if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
if (dateFrom is DateOnly from && dateTo is DateOnly to)
{
var fromDate = from.ToDateTime(TimeOnly.MinValue);

View file

@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs
{
public DateOnly? DateFrom { get; set; }
public DateOnly? DateTo { get; set; }
public string? DispatcherId { get; set; }
}
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs
public string? Color { get; init; }
public decimal CompletionRate { get; init; }
public decimal? AverageResolutionDays { get; init; }
public int AssignedCount { get; init; }
public int CompletedCount { get; init; }
}
public sealed class DashboardRegionResponseDTO
@ -108,6 +111,13 @@ namespace SeaHaven.Services.DTOs
public int CompletedDueCount { get; set; }
public decimal CompletionRate { get; set; }
public decimal? AverageResolutionDays { get; set; }
public IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution { get; set; } = [];
}
public sealed class DashboardStatusBucketDTO
{
public string Status { get; init; } = string.Empty;
public int Count { get; init; }
}
public sealed class DashboardBreakdownDTO

View file

@ -61,9 +61,26 @@ public static class DashboardMetrics
dueCount,
completedDueCount,
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2));
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2),
BuildStatusDistribution(workOrders));
}
private static IReadOnlyList<DashboardStatusBucketDTO> BuildStatusDistribution(
IReadOnlyList<DashboardWorkOrder> workOrders)
=> workOrders
.GroupBy(workOrder => workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
.Select(group => new DashboardStatusBucketDTO
{
Status = group.Key.HasValue
? LifecycleStatusMapper.ToFeLabel(group.Key.Value)
: "Unknown",
Count = group.Count()
})
.OrderByDescending(bucket => bucket.Count)
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
.ToList();
private static bool IsPastDue(
DashboardWorkOrder workOrder,
LifecycleStatus? status,
@ -110,4 +127,5 @@ public sealed record DashboardMetricResult(
int DueCount,
int CompletedDueCount,
decimal CompletionRate,
decimal? AverageResolutionDays);
decimal? AverageResolutionDays,
IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution);

View file

@ -28,11 +28,7 @@ namespace SeaHaven.Services.Implementation
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 dispatcherId = ResolveDispatcherScope(user, query);
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
var kpis = await _dataService.GetKpiCountsAsync(
@ -63,7 +59,8 @@ namespace SeaHaven.Services.Implementation
DueCount = metrics.DueCount,
CompletedDueCount = metrics.CompletedDueCount,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
AverageResolutionDays = metrics.AverageResolutionDays,
StatusDistribution = metrics.StatusDistribution
};
}
@ -77,11 +74,7 @@ namespace SeaHaven.Services.Implementation
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 dispatcherId = ResolveDispatcherScope(user, query);
var rows = await _dataService.GetDispatcherWorkloadAsync(
accountId,
@ -123,11 +116,7 @@ namespace SeaHaven.Services.Implementation
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 dispatcherId = ResolveDispatcherScope(user, query);
var directory = await _dataService.GetDispatcherWorkloadAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
@ -145,7 +134,9 @@ namespace SeaHaven.Services.Implementation
DispatcherName = dispatcher.DispatcherName,
Color = dispatcher.Color,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
AverageResolutionDays = metrics.AverageResolutionDays,
AssignedCount = metrics.Total,
CompletedCount = metrics.Completed
};
})
.OrderByDescending(row => row.CompletionRate)
@ -169,11 +160,7 @@ namespace SeaHaven.Services.Implementation
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 dispatcherId = ResolveDispatcherScope(user, query);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
@ -196,11 +183,7 @@ namespace SeaHaven.Services.Implementation
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 dispatcherId = ResolveDispatcherScope(user, query);
var today = DashboardBusinessTime.Today();
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
@ -404,5 +387,33 @@ namespace SeaHaven.Services.Implementation
TotalVendors = rows.Count
};
}
private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return dispatcherId;
}
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
{
var selected = query.DispatcherId;
if (string.IsNullOrWhiteSpace(selected))
return null;
if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase))
{
var identity = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(identity))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return identity;
}
return selected;
}
throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role.");
}
}
}