fix(dashboard): scope stats to authenticated accounts (SH-336)

This commit is contained in:
Alexandre Brandizzi 2026-09-16 17:12:03 -03:00
parent 4b772c8db3
commit efd13b6f60
6 changed files with 99 additions and 20 deletions

View file

@ -1,7 +1,10 @@
using System.Security.Claims;
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using FluentAssertions; using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using Xunit; using Xunit;
@ -17,33 +20,90 @@ public class DashboardServiceTests
return new ApplicationDbContext(options); return new ApplicationDbContext(options);
} }
private static DashboardService NewService(ApplicationDbContext ctx) => private static DashboardService NewService(ApplicationDbContext ctx)
new(new DashboardDataService(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) => private static ClaimsPrincipal AccountUser(int accountId)
new() { Status = status, istemplate = isTemplate, AssignTo = assignTo }; {
var claims = new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.Role, "Dispatcher")
};
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] [Fact]
public async Task GetStatsAsync_CountsByStatusExcludingTemplates() public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
{ {
using var ctx = NewContext(); using var ctx = NewContext();
ctx.workOrders.AddRange( ctx.workOrders.AddRange(
Wo("Open", isTemplate: false), new WorkOrder { AccountId = 1, Status = "Open", istemplate = false },
Wo("Open", isTemplate: false, assignTo: "u1"), new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" },
Wo("In Progress"), new WorkOrder { AccountId = 1, Status = "In Progress" },
Wo("On Hold"), new WorkOrder { AccountId = 1, Status = "On Hold" },
Wo("Done"), new WorkOrder { AccountId = 1, Status = "Done" },
Wo("Done"), new WorkOrder { AccountId = 1, Status = "Done" },
Wo("Open", isTemplate: true), new WorkOrder { AccountId = 1, Status = "Open", istemplate = true },
Wo("Cancelled", isTemplate: false) new WorkOrder { AccountId = 1, Status = "Cancelled" },
new WorkOrder { AccountId = 2, Status = "Open" },
new WorkOrder { Status = "Open" }
); );
ctx.SaveChanges(); ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None); var stats = await NewService(ctx).GetStatsAsync(AccountUser(1), CancellationToken.None);
stats.Total.Should().Be(7); stats.Total.Should().Be(7);
stats.Open.Should().Be(4); stats.Open.Should().Be(4);
stats.NotDispatched.Should().Be(1); stats.NotDispatched.Should().Be(1);
stats.Completed.Should().Be(2); 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(), CancellationToken.None);
stats.Total.Should().Be(2);
stats.Open.Should().Be(1);
stats.Completed.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, CancellationToken.None);
var exception = await act.Should().ThrowAsync<WorkOrderBoardValidationException>();
exception.Which.Code.Should().Be("Forbidden");
}
} }

View file

@ -19,7 +19,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("Stats")] [HttpGet("Stats")]
public async Task<IActionResult> GetStats(CancellationToken cancellationToken) public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
{ {
var stats = await _dashboardService.GetStatsAsync(cancellationToken); var stats = await _dashboardService.GetStatsAsync(User, cancellationToken);
return Ok(new return Ok(new
{ {

View file

@ -13,9 +13,15 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _context = context;
} }
public async Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken) public async Task<DashboardCounts> GetWorkOrderCountsAsync(
int? accountId,
CancellationToken cancellationToken)
{ {
var query = _context.workOrders.Where(w => w.istemplate != true); var query = _context.workOrders.Where(w => w.istemplate != true);
if (accountId is int scopedAccountId)
{
query = query.Where(w => w.AccountId == scopedAccountId);
}
var total = await query.CountAsync(cancellationToken); var total = await query.CountAsync(cancellationToken);
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken); var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);

View file

@ -2,7 +2,9 @@ namespace SeaHaven.DataServices.Interfaces
{ {
public interface IDashboardDataService public interface IDashboardDataService
{ {
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken); Task<DashboardCounts> GetWorkOrderCountsAsync(
int? accountId,
CancellationToken cancellationToken);
} }
public class DashboardCounts public class DashboardCounts

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -7,15 +8,22 @@ namespace SeaHaven.Services.Implementation
public class DashboardService : IDashboardService public class DashboardService : IDashboardService
{ {
private readonly IDashboardDataService _dataService; private readonly IDashboardDataService _dataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public DashboardService(IDashboardDataService dataService) public DashboardService(
IDashboardDataService dataService,
IWorkOrderAccountResolver accountResolver)
{ {
_dataService = dataService; _dataService = dataService;
_accountResolver = accountResolver;
} }
public async Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken) public async Task<DashboardStatsDTO> GetStatsAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{ {
var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken); var accountId = _accountResolver.ResolveAccountFilter(user);
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
return new DashboardStatsDTO return new DashboardStatsDTO
{ {

View file

@ -1,9 +1,12 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces namespace SeaHaven.Services.Interfaces
{ {
public interface IDashboardService public interface IDashboardService
{ {
Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken); Task<DashboardStatsDTO> GetStatsAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken);
} }
} }