Merge branch 'dev' into feat/ab/sh-303-services-registry

This commit is contained in:
Alexandre Brandizzi 2026-09-17 01:12:07 -03:00 • committed by GitHub
commit 2e983b1f6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 7495 additions and 55 deletions

View file

@ -0,0 +1,54 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DashboardControllerTests
{
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
private static DashboardController NewController(Mock<IDashboardService> service) =>
new(service.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
// Guards the Stats wire object, not the service DTO. GetKpiCountsAsync and
// DashboardStatsDTO already carried these three counts; the regression this
// pins is the controller's anonymous response silently dropping them, which
// is what left the tiles with nothing to read.
[Fact]
public async Task GetStats_SerialisesKpiCountsOnWireObject()
{
var service = new Mock<IDashboardService>();
service.Setup(s => s.GetStatsAsync(
It.IsAny<ClaimsPrincipal>(),
It.IsAny<DashboardStatsQueryDTO>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new DashboardStatsDTO
{
ScheduledTomorrow = 3,
PendingUplifts = 5,
AvetaPending = 7
});
var result = await NewController(service).GetStats(
new DashboardStatsQueryDTO(), CancellationToken.None);
var body = result.Should().BeOfType<OkObjectResult>().Subject.Value!;
((int)Prop(body, "scheduledTomorrow")).Should().Be(3);
((int)Prop(body, "pendingUplifts")).Should().Be(5);
((int)Prop(body, "avetaPending")).Should().Be(7);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,80 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class TeamPermissionOverrideDataServiceTests
{
[Fact]
public async Task GetUserAsync_ReturnsIdentityRoleAndStoredOverrides()
{
await using var context = NewContext();
SeedUserWithRole(context, "u1", "Dispatcher");
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = "u1",
PermissionKey = "deleteSites",
State = UserPermissionState.Deny
});
await context.SaveChangesAsync();
var result = await new TeamPermissionOverrideDataService(context)
.GetUserAsync("u1", CancellationToken.None);
result.Should().NotBeNull();
result!.RoleName.Should().Be("Dispatcher");
result.Overrides["deleteSites"].Should().Be(UserPermissionState.Deny);
}
[Fact]
public async Task SetOverrideAsync_UpsertsOneCompositeKey()
{
await using var context = NewContext();
SeedUserWithRole(context, "u1", "Scheduler");
await context.SaveChangesAsync();
var service = new TeamPermissionOverrideDataService(context);
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Allow, CancellationToken.None);
await service.SetOverrideAsync("u1", "deleteSites", UserPermissionState.Deny, CancellationToken.None);
var rows = await context.UserPermissionOverrides.ToListAsync();
rows.Should().ContainSingle();
rows[0].State.Should().Be(UserPermissionState.Deny);
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static void SeedUserWithRole(ApplicationDbContext context, string userId, string roleName)
{
context.Users.Add(new ApplicationUser
{
Id = userId,
UserName = userId,
NormalizedUserName = userId.ToUpperInvariant(),
Email = userId + "@example.com",
NormalizedEmail = (userId + "@example.com").ToUpperInvariant()
});
context.Roles.Add(new IdentityRole
{
Id = "role-1",
Name = roleName,
NormalizedName = roleName.ToUpperInvariant()
});
context.UserRoles.Add(new IdentityUserRole<string>
{
UserId = userId,
RoleId = "role-1"
});
}
}

View file

@ -0,0 +1,106 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while
/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order,
/// and an unknown id keeps the existing "ID not found!" response.
/// </summary>
public class WorkOrderGetByIdBindingTests
{
private const int ExistingId = 374;
private const int UnknownId = 999_999;
private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly);
using var provider = services.BuildServiceProvider();
return provider.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController))
.Where(cad => cad.ActionConstraints!
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
.Any(c => c.HttpMethods.Contains("GET")))
.Single(cad => string.Equals(
cad.AttributeRouteInfo?.Template?.Trim('/'),
$"api/WorkOrder/{relativeTemplate}",
StringComparison.Ordinal));
}
[Theory]
[InlineData("GetWorkorderById", "Query")]
[InlineData("{id:int}", "Path")]
public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource)
{
var action = ActionForTemplate(relativeTemplate);
var id = action.Parameters.Single(p => p.Name == "id");
id.BindingInfo.Should().NotBeNull();
id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource);
}
private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController()
{
var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" };
var service = new Mock<IWorkOrderService>();
service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny<int?>())).ReturnsAsync(existing);
service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny<int?>()))
.ReturnsAsync((WorkOrderDetailReadModel?)null);
var controller = new WorkOrderController(
service.Object,
Mock.Of<IWorkOrderAccountResolver>(),
Mock.Of<ILogger<WorkOrderController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
return (controller, existing);
}
[Fact]
public async Task Existing_id_returns_the_same_work_order_through_both_routes()
{
var (controller, existing) = NewController();
var byQuery = await controller.GetWorkorderById(ExistingId);
var byRoute = await controller.GetWorkorderByRouteId(ExistingId);
byQuery.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
byRoute.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
}
[Fact]
public async Task Unknown_id_keeps_the_not_found_response_through_both_routes()
{
var (controller, _) = NewController();
foreach (var result in new[]
{
await controller.GetWorkorderById(UnknownId),
await controller.GetWorkorderByRouteId(UnknownId),
})
{
var body = result.Should().BeOfType<BadRequestObjectResult>().Which.Value
.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Be("ID not found!");
}
}
}

View file

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

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -17,17 +18,158 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("Stats")]
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
public async Task<IActionResult> GetStats(
[FromQuery] DashboardStatsQueryDTO query,
CancellationToken cancellationToken)
{
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken);
return Ok(new
{
total = stats.Total,
open = stats.Open,
notDispatched = stats.NotDispatched,
completed = stats.Completed
completed = stats.Completed,
scheduledTomorrow = stats.ScheduledTomorrow,
pendingUplifts = stats.PendingUplifts,
avetaPending = stats.AvetaPending,
breakdown = stats.Breakdown,
dueCount = stats.DueCount,
completedDueCount = stats.CompletedDueCount,
completionRate = stats.CompletionRate,
averageResolutionDays = stats.AverageResolutionDays
});
}
[HttpGet("Workload")]
public async Task<IActionResult> GetWorkload(
[FromQuery] DashboardStatsQueryDTO query,
[FromQuery] int page = 1,
CancellationToken cancellationToken = default)
{
try
{
return Ok(await _dashboardService.GetWorkloadAsync(
User, query, page, cancellationToken));
}
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
catch (ArgumentOutOfRangeException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("Performance")]
public async Task<IActionResult> GetPerformance(
[FromQuery] DashboardStatsQueryDTO query,
[FromQuery] int page = 1,
CancellationToken cancellationToken = default)
{
try
{
return Ok(await _dashboardService.GetPerformanceAsync(
User, query, page, cancellationToken));
}
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
catch (ArgumentOutOfRangeException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("Regions")]
public async Task<IActionResult> GetRegions(
[FromQuery] DashboardStatsQueryDTO query,
CancellationToken cancellationToken = default)
{
try
{
return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken));
}
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
}
[HttpGet("Trend")]
public async Task<IActionResult> GetTrend(
[FromQuery] DashboardTrendQueryDTO query,
CancellationToken cancellationToken = default)
{
try
{
var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken);
return Ok(new
{
granularity = trend.Granularity,
today = trend.Today,
buckets = trend.Buckets.Select(bucket => new
{
date = bucket.Date,
label = bucket.Label,
total = bucket.Total,
open = bucket.Open,
completed = bucket.Completed,
canceled = bucket.Canceled,
overdue = bucket.Overdue,
isCurrent = bucket.IsCurrent
})
});
}
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
catch (ArgumentOutOfRangeException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("VendorInsights")]
public async Task<IActionResult> GetVendorInsights(CancellationToken cancellationToken = default)
{
try
{
return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken));
}
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
}
}
}

View file

@ -36,11 +36,11 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, string? states = null, string? sortBy = null, string? sortDirection = null, CancellationToken cancellationToken = default)
{
try
{
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken);
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, states, cancellationToken, sortBy, sortDirection);
var data = pagedResult.Items.Select(l => new
{
@ -58,6 +58,8 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = l.Email,
Status = l.Status,
AccountId = l.AccountId,
ClientName = l.AccountName,
AccountName = l.AccountName,
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
});

View file

@ -0,0 +1,65 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers;
[Authorize]
[ApiController]
[Route("api/User/{userId}/Permissions")]
public sealed class TeamPermissionController : ControllerBase
{
private readonly ITeamPermissionService _permissionService;
public TeamPermissionController(ITeamPermissionService permissionService)
{
_permissionService = permissionService;
}
[HttpGet]
public async Task<IActionResult> GetProfile(
string userId,
CancellationToken cancellationToken)
{
var result = await _permissionService.GetProfileAsync(userId, User, cancellationToken);
return ToActionResult(result);
}
[HttpPut("{permissionKey}")]
public async Task<IActionResult> SetOverride(
string userId,
string permissionKey,
[FromBody] SetTeamPermissionOverrideDTO request,
CancellationToken cancellationToken)
{
if (!Enum.IsDefined(request.State))
return BadRequest(new Response { Status = "Error", Message = "Invalid permission state." });
var result = await _permissionService.SetOverrideAsync(
userId,
permissionKey,
request.State,
User,
cancellationToken);
return ToActionResult(result);
}
private static IActionResult ToActionResult(
TeamPermissionResult<TeamPermissionProfileDTO> result)
{
return result.Status switch
{
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
new Response { Status = "Error", Message = "User not found." }),
TeamPermissionResultStatus.InvalidPermissionKey => new BadRequestObjectResult(
new Response { Status = "Error", Message = "Unknown permission key." }),
_ => new BadRequestObjectResult(
new Response { Status = "Error", Message = "Unable to update permissions." })
};
}
}

View file

@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpGet("{id:int}")]
// SH-374: two actions, one per route, so each binds id from the source its route provides.
// A single action carrying both routes inferred id as [FromRoute] and the query route got 0.
[HttpGet("GetWorkorderById")]
public async Task<IActionResult> GetWorkorderById(int id)
public Task<IActionResult> GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id);
[HttpGet("{id:int}")]
public Task<IActionResult> GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id);
private async Task<IActionResult> GetWorkorderDetail(int id)
{
try
{

View file

@ -272,6 +272,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; }
@ -328,6 +354,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()
{

View file

@ -0,0 +1,18 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// Per-person team permission override, keyed by user and canonical
/// permission key. Explicit Allow/Deny rows take precedence over role defaults;
/// <see cref="UserPermissionState.Unset"/> (and a missing row) falls back to
/// the role default.
/// </summary>
public class UserPermissionOverride
{
public string UserId { get; set; } = null!;
public string PermissionKey { get; set; } = null!;
public UserPermissionState State { get; set; } = UserPermissionState.Unset;
public virtual ApplicationUser? User { get; set; }
}
}

View file

@ -0,0 +1,13 @@
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>
/// Tri-state state of a per-person team permission override.
/// A missing row behaves the same as <see cref="Unset"/>.
/// </summary>
public enum UserPermissionState
{
Unset = 0,
Allow = 1,
Deny = 2
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -2187,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")
@ -3752,6 +3774,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")

View file

@ -0,0 +1,11 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Dto;
public sealed class TeamPermissionUserData
{
public required string UserId { get; init; }
public string? RoleName { get; init; }
public IReadOnlyDictionary<string, UserPermissionState> Overrides { get; init; }
= new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
}

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -13,9 +15,15 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken)
public async Task<DashboardCounts> GetWorkOrderCountsAsync(
int? accountId,
CancellationToken cancellationToken)
{
var query = _context.workOrders.Where(w => w.istemplate != true);
if (accountId is int scopedAccountId)
{
query = query.Where(w => w.AccountId == scopedAccountId);
}
var total = await query.CountAsync(cancellationToken);
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);
@ -30,5 +38,194 @@ namespace SeaHaven.DataServices.Implementation
Completed = completed
};
}
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken)
{
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
if (dispatcherId != null)
query = query.Where(w => w.AssignTo == dispatcherId);
if (dateFrom is DateOnly from && dateTo is DateOnly to)
{
var fromDate = from.ToDateTime(TimeOnly.MinValue);
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
query = query.Where(w =>
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
}
return await query
.Select(w => new DashboardWorkOrder(
w.Id,
w.AssignTo,
w.WorkOrderType,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.ScheduledDate,
w.OriginalDate,
w.CompletedDate,
w.Locations == null ? null : w.Locations.State,
w.RescheduleCount,
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
? null
: w.PrimaryDispatch.Vendor.CompanyId,
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|| w.PrimaryDispatch.Vendor.Company == null
? null
: w.PrimaryDispatch.Vendor.Company.Name,
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
&& w.PrimaryDispatch.Vendor.IsActive,
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
&& w.PrimaryDispatch.Vendor.Company != null
&& w.PrimaryDispatch.Vendor.Company.IsDeleted == true))
.ToListAsync(cancellationToken);
}
public async Task<DashboardKpiCounts> GetKpiCountsAsync(
int? accountId,
string? dispatcherId,
DateOnly today,
CancellationToken cancellationToken)
{
var tomorrow = today.AddDays(1);
var dayAfterTomorrow = tomorrow.AddDays(1);
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dispatcherId != null)
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
var scheduledTomorrow = await workOrders
.Where(w => w.ScheduledDate >= tomorrow.ToDateTime(TimeOnly.MinValue)
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue))
.CountAsync(cancellationToken);
var avetaPending = await workOrders
.Where(w => w.ScheduledDate >= today.ToDateTime(TimeOnly.MinValue)
&& w.ScheduledDate < dayAfterTomorrow.ToDateTime(TimeOnly.MinValue)
&& w.AvetaRequired
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& (w.LifecycleStatus != null
|| ((w.LegacyStatus ?? w.Status) != "Done"
&& (w.LegacyStatus ?? w.Status) != "Completed"
&& (w.LegacyStatus ?? w.Status) != "Complete"
&& (w.LegacyStatus ?? w.Status) != "Closed"
&& (w.LegacyStatus ?? w.Status) != "Canceled"
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
&& !w.workOrderAttachments!.Any(attachment =>
(attachment.IsDeleted == null || attachment.IsDeleted == false)
&& attachment.Category == WorkOrderMediaCategory.Aveta))
.CountAsync(cancellationToken);
var pendingUplifts = _context.DispatchUpliftRequests
.AsNoTracking()
.Where(request => request.Status == "Pending"
&& (request.IsDeleted == null || request.IsDeleted == false)
&& request.Dispatch != null
&& (request.Dispatch.IsDeleted == null || request.Dispatch.IsDeleted == false));
if (accountId is int account)
{
pendingUplifts = pendingUplifts.Where(request =>
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AccountId == account)
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
link.WorkOrder != null && link.WorkOrder.AccountId == account));
}
if (dispatcherId != null)
{
pendingUplifts = pendingUplifts.Where(request =>
(request.Dispatch!.WorkOrder != null && request.Dispatch.WorkOrder.AssignTo == dispatcherId)
|| request.Dispatch.DispatchWorkOrders!.Any(link =>
link.WorkOrder != null && link.WorkOrder.AssignTo == dispatcherId));
}
return new DashboardKpiCounts(
scheduledTomorrow,
await pendingUplifts.CountAsync(cancellationToken),
avetaPending);
}
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
int? accountId,
string? dispatcherId,
DateOnly? dateFrom,
DateOnly? dateTo,
CancellationToken cancellationToken)
{
var dispatcherUsers = from user in _context.Users.AsNoTracking()
join userRole in _context.UserRoles
on user.Id equals userRole.UserId
join role in _context.Roles
on userRole.RoleId equals role.Id
where user.IsDeleted != true
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
&& (dispatcherId == null || user.Id == dispatcherId)
select new
{
user.Id,
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
user.Color
};
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId is int scopedAccountId)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
if (dateFrom is DateOnly from && dateTo is DateOnly to)
{
var fromDate = from.ToDateTime(TimeOnly.MinValue);
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
workOrders = workOrders.Where(w =>
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
}
var counts = await workOrders
.Where(w => w.AssignTo != null)
.GroupBy(w => w.AssignTo!)
.Select(group => new
{
DispatcherId = group.Key,
TotalCount = group.Count(),
OpenCount = group.Count(w =>
w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& (w.LifecycleStatus != null
|| (w.LegacyStatus ?? w.Status) != "Done"
&& (w.LegacyStatus ?? w.Status) != "Completed"
&& (w.LegacyStatus ?? w.Status) != "Complete"
&& (w.LegacyStatus ?? w.Status) != "Closed"
&& (w.LegacyStatus ?? w.Status) != "Canceled"
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
})
.ToListAsync(cancellationToken);
var dispatcherList = await dispatcherUsers
.Distinct()
.ToListAsync(cancellationToken);
return dispatcherList
.GroupJoin(
counts,
user => user.Id,
count => count.DispatcherId,
(user, matchingCounts) =>
{
var count = matchingCounts.SingleOrDefault();
return new DashboardDispatcherWorkload(
user.Id,
user.Name,
user.Color,
count?.TotalCount ?? 0,
count?.OpenCount ?? 0);
})
.ToList();
}
}
}

View file

@ -136,15 +136,21 @@ namespace SeaHaven.DataServices.Implementation
int pageSize,
string? search,
IReadOnlyCollection<string>? states,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? sortBy = null,
string? sortDirection = null)
{
var query = _context.Locations.AsNoTracking();
IQueryable<Locations> query = _context.Locations
.AsNoTracking()
.Include(l => l.Account);
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(l =>
l.Name!.Contains(search) ||
l.City!.Contains(search));
l.Address1!.Contains(search) ||
l.City!.Contains(search) ||
l.Account != null && l.Account.Name!.Contains(search));
}
if (states is { Count: > 0 })
@ -152,9 +158,10 @@ namespace SeaHaven.DataServices.Implementation
query = query.Where(l => l.State != null && states.Contains(l.State.Trim().ToUpper()));
}
query = ApplyListSort(query, sortBy, sortDirection);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(l => l.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
@ -162,6 +169,33 @@ namespace SeaHaven.DataServices.Implementation
return (items, totalCount);
}
private static IQueryable<Locations> ApplyListSort(IQueryable<Locations> query, string? sortBy, string? sortDirection)
{
var descending = string.Equals(sortDirection?.Trim(), "desc", StringComparison.OrdinalIgnoreCase);
IOrderedQueryable<Locations> ordered = (sortBy?.Trim().ToLowerInvariant()) switch
{
"code" => descending ? query.OrderByDescending(l => l.Name) : query.OrderBy(l => l.Name),
"client" => descending ? query.OrderByDescending(l => l.Account!.Name) : query.OrderBy(l => l.Account!.Name),
"address" => descending ? query.OrderByDescending(l => l.Address1) : query.OrderBy(l => l.Address1),
"city" => descending ? query.OrderByDescending(l => l.City) : query.OrderBy(l => l.City),
"state" => descending ? query.OrderByDescending(l => l.State) : query.OrderBy(l => l.State),
"poc" => descending
? query.OrderByDescending(FirstActiveContactName)
: query.OrderBy(FirstActiveContactName),
_ => query.OrderBy(l => l.Name)
};
return ordered.ThenBy(l => l.Id);
}
private static readonly System.Linq.Expressions.Expression<Func<Locations, string?>> FirstActiveContactName =
l => l.Contacts
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => c.FirstName)
.FirstOrDefault();
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations

View file

@ -0,0 +1,101 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation;
public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideDataService
{
private readonly ApplicationDbContext _context;
public TeamPermissionOverrideDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<TeamPermissionUserData?> GetUserAsync(
string userId,
CancellationToken cancellationToken)
{
var userExists = await _context.Users
.AsNoTracking()
.AnyAsync(user => user.Id == userId, cancellationToken);
if (!userExists)
return null;
var roleName = await (
from userRole in _context.UserRoles.AsNoTracking()
join role in _context.Roles.AsNoTracking()
on userRole.RoleId equals role.Id
where userRole.UserId == userId
select role.Name)
.FirstOrDefaultAsync(cancellationToken);
var overrides = await _context.UserPermissionOverrides
.AsNoTracking()
.Where(permission => permission.UserId == userId)
.ToDictionaryAsync(
permission => permission.PermissionKey,
permission => permission.State,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
return new TeamPermissionUserData
{
UserId = userId,
RoleName = roleName,
Overrides = overrides
};
}
public async Task SetOverrideAsync(
string userId,
string permissionKey,
UserPermissionState state,
CancellationToken cancellationToken)
{
var existing = await _context.UserPermissionOverrides
.SingleOrDefaultAsync(
permission => permission.UserId == userId
&& permission.PermissionKey == permissionKey,
cancellationToken);
if (existing is not null)
{
existing.State = state;
await _context.SaveChangesAsync(cancellationToken);
return;
}
var addition = new UserPermissionOverride
{
UserId = userId,
PermissionKey = permissionKey,
State = state
};
await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken);
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// A concurrent PUT inserted the same (UserId, PermissionKey) between our
// existence check and this save, violating PK_UserPermissionOverrides.
// Converge on the caller's intent by updating the persisted row.
_context.Entry(addition).State = EntityState.Detached;
var winner = await _context.UserPermissionOverrides
.SingleAsync(
permission => permission.UserId == userId
&& permission.PermissionKey == permissionKey,
cancellationToken);
winner.State = state;
await _context.SaveChangesAsync(cancellationToken);
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Dto;
namespace SeaHaven.DataServices.Interfaces;
public interface ITeamPermissionOverrideDataService
{
Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken);
Task SetOverrideAsync(
string userId,
string permissionKey,
UserPermissionState state,
CancellationToken cancellationToken);
}

View file

@ -0,0 +1,248 @@
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using Xunit;
namespace SeaHaven.Services.Tests;
public class TeamPermissionPolicyTests
{
private static readonly string[] DispatcherKeys =
{
TeamPermissionKeys.CreateVendors,
TeamPermissionKeys.EditVendors,
TeamPermissionKeys.DeactivateVendors,
TeamPermissionKeys.CreateSites,
TeamPermissionKeys.EditSites,
TeamPermissionKeys.CreateWorkOrders,
TeamPermissionKeys.EditOthersWorkOrders,
TeamPermissionKeys.CancelWorkOrders,
TeamPermissionKeys.RequestUplifts,
TeamPermissionKeys.AutoApproveUplifts
};
private static readonly string[] SchedulerOnlyKeys =
{
TeamPermissionKeys.DeleteSites,
TeamPermissionKeys.CreateServices,
TeamPermissionKeys.EditServices,
TeamPermissionKeys.CreateCompletionDocTemplates,
TeamPermissionKeys.EditCompletionDocTemplates,
TeamPermissionKeys.ViewAllDispatchersOnDashboard
};
private static readonly string[] AdminOnlyKeys =
{
TeamPermissionKeys.ManageTeamMembers,
TeamPermissionKeys.ChangeTeamMemberRole,
TeamPermissionKeys.DeactivateServices,
TeamPermissionKeys.DeleteCompletionDocTemplates,
TeamPermissionKeys.DeleteWorkOrders,
TeamPermissionKeys.ReviewUplifts
};
private static IReadOnlyDictionary<string, UserPermissionState> Overrides(
string key, UserPermissionState state) =>
new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase)
{
[key] = state
};
[Fact]
public void Registry_Exposes_Exactly_The_22_Prototype_Keys()
{
TeamPermissionKeys.All.Should().HaveCount(22);
TeamPermissionKeys.KnownKeys.Should().HaveCount(22);
TeamPermissionKeys.All.Should().OnlyHaveUniqueItems();
TeamPermissionKeys.All.Should().OnlyContain(key => !string.IsNullOrWhiteSpace(key));
TeamPermissionKeys.All.Should().BeEquivalentTo(new[]
{
"manageTeamMembers",
"changeTeamMemberRole",
"createVendors",
"editVendors",
"deactivateVendors",
"createSites",
"editSites",
"deleteSites",
"createServices",
"editServices",
"deactivateServices",
"createCompletionDocTemplates",
"editCompletionDocTemplates",
"deleteCompletionDocTemplates",
"createWorkOrders",
"editOthersWorkOrders",
"cancelWorkOrders",
"deleteWorkOrders",
"requestUplifts",
"autoApproveUplifts",
"reviewUplifts",
"viewAllDispatchersOnDashboard"
});
}
[Fact]
public void Registry_IsKnown_Is_Case_Insensitive_And_Rejects_Unknown_Keys()
{
TeamPermissionKeys.IsKnown("CREATEVENDORS").Should().BeTrue();
TeamPermissionKeys.IsKnown("autoApproveUplifts").Should().BeTrue();
TeamPermissionKeys.IsKnown("nope.unknown").Should().BeFalse();
TeamPermissionKeys.IsKnown("").Should().BeFalse();
TeamPermissionKeys.IsKnown(null).Should().BeFalse();
}
[Fact]
public void Dispatcher_Gets_Exactly_Prototype_Defaults()
{
var policy = new TeamPermissionPolicy();
foreach (var key in TeamPermissionKeys.All)
{
var expected = DispatcherKeys.Contains(key, StringComparer.Ordinal);
policy.IsAllowed("Dispatcher", key).Should().Be(expected,
$"Dispatcher default for '{key}' should be {expected}");
}
}
[Fact]
public void Scheduler_Gets_Exactly_Prototype_Defaults()
{
var policy = new TeamPermissionPolicy();
foreach (var key in TeamPermissionKeys.All)
{
var expected = SchedulerOnlyKeys.Contains(key, StringComparer.Ordinal)
|| DispatcherKeys.Contains(key, StringComparer.Ordinal)
&& key is not TeamPermissionKeys.RequestUplifts
&& key is not TeamPermissionKeys.AutoApproveUplifts;
policy.IsAllowed("Scheduler", key).Should().Be(expected,
$"Scheduler default for '{key}' should be {expected}");
}
}
[Fact]
public void Admin_Has_Effective_Access_To_Every_Key()
{
var policy = new TeamPermissionPolicy();
foreach (var key in TeamPermissionKeys.All)
policy.IsAllowed("Admin", key).Should().BeTrue($"Admin should hold '{key}'");
}
[Fact]
public void Role_Recognition_Is_Case_Insensitive()
{
var policy = new TeamPermissionPolicy();
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.CreateVendors).Should().BeTrue();
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.DeleteSites).Should().BeTrue();
policy.IsAllowed("admin", TeamPermissionKeys.ReviewUplifts).Should().BeTrue();
policy.IsAllowed("dIsPaTcHeR", TeamPermissionKeys.DeleteSites).Should().BeFalse();
policy.IsAllowed("SCHEDULER", TeamPermissionKeys.AutoApproveUplifts).Should().BeFalse();
}
[Theory]
[InlineData("Manager")]
[InlineData("OfficeAdmin")]
[InlineData("")]
[InlineData(null)]
public void Unknown_And_Legacy_Roles_Receive_No_Permissions(string? role)
{
var policy = new TeamPermissionPolicy();
foreach (var key in TeamPermissionKeys.All)
policy.IsAllowed(role, key).Should().BeFalse(
$"unknown/legacy role '{role}' must not hold '{key}'");
}
[Fact]
public void Unknown_Role_Gains_Nothing_Even_With_Allow_Overrides()
{
var policy = new TeamPermissionPolicy();
foreach (var key in TeamPermissionKeys.All)
policy.IsAllowed("Manager", key, Overrides(key, UserPermissionState.Allow))
.Should().BeFalse($"an unknown role must not be elevated via '{key}'");
}
[Fact]
public void Allow_Override_Grants_Key_Outside_Role_Default()
{
var policy = new TeamPermissionPolicy();
policy.IsAllowed("Scheduler", TeamPermissionKeys.RequestUplifts)
.Should().BeFalse();
policy.IsAllowed(
"Scheduler",
TeamPermissionKeys.RequestUplifts,
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Allow))
.Should().BeTrue();
}
[Fact]
public void Deny_Override_Removes_Key_Inside_Role_Default()
{
var policy = new TeamPermissionPolicy();
policy.IsAllowed("Dispatcher", TeamPermissionKeys.CreateVendors)
.Should().BeTrue();
policy.IsAllowed(
"Dispatcher",
TeamPermissionKeys.CreateVendors,
Overrides(TeamPermissionKeys.CreateVendors, UserPermissionState.Deny))
.Should().BeFalse();
}
[Fact]
public void Unset_Override_And_Missing_Row_Fall_Back_To_Role_Default()
{
var policy = new TeamPermissionPolicy();
policy.IsAllowed(
"Dispatcher",
TeamPermissionKeys.CreateWorkOrders,
Overrides(TeamPermissionKeys.CreateWorkOrders, UserPermissionState.Unset))
.Should().BeTrue();
policy.IsAllowed(
"Scheduler",
TeamPermissionKeys.RequestUplifts,
Overrides(TeamPermissionKeys.RequestUplifts, UserPermissionState.Unset))
.Should().BeFalse();
policy.IsAllowed(
"Scheduler",
TeamPermissionKeys.RequestUplifts,
new Dictionary<string, UserPermissionState>())
.Should().BeFalse();
}
[Fact]
public void Admin_Is_Immune_To_Overrides()
{
var policy = new TeamPermissionPolicy();
foreach (var key in TeamPermissionKeys.All)
{
policy.IsAllowed("Admin", key, Overrides(key, UserPermissionState.Deny))
.Should().BeTrue($"Admin must keep '{key}' despite a Deny override");
policy.IsAllowed("ADMIN", key, Overrides(key, UserPermissionState.Unset))
.Should().BeTrue();
}
}
[Fact]
public void Unknown_Permission_Key_Is_Denied_For_Every_Role()
{
var policy = new TeamPermissionPolicy();
policy.IsAllowed("Admin", "nope.unknown").Should().BeFalse();
policy.IsAllowed("Dispatcher", "nope.unknown").Should().BeFalse();
policy.IsAllowed("Admin", "nope.unknown", Overrides("nope.unknown", UserPermissionState.Allow))
.Should().BeFalse();
}
}

View file

@ -0,0 +1,130 @@
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class TeamPermissionServiceTests
{
[Fact]
public async Task GetProfile_ReturnsEveryKeyAndRoleDefaults()
{
var data = new FakeDataService("u1", "Dispatcher");
var result = await Service(data).GetProfileAsync("u1", Admin(), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value!.Permissions.Should().HaveCount(22);
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeFalse();
result.Value.Permissions.Should().OnlyContain(p => p.OverrideState == UserPermissionState.Unset);
}
[Fact]
public async Task SetOverride_ChangesOnlyOnePermissionAndPersistsCanonicalKey()
{
var data = new FakeDataService("u1", "Dispatcher");
var result = await Service(data).SetOverrideAsync(
"u1", "DELETEsites", UserPermissionState.Allow, Admin(), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value!.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).IsGranted.Should().BeTrue();
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.DeleteSites).OverrideState.Should().Be(UserPermissionState.Allow);
result.Value.Permissions.Single(p => p.PermissionKey == TeamPermissionKeys.CreateSites).IsGranted.Should().BeTrue();
data.LastSet.Should().Be(("u1", TeamPermissionKeys.DeleteSites, UserPermissionState.Allow));
}
[Fact]
public async Task SetOverride_RejectsUnknownKeyBeforeDataAccess()
{
var data = new FakeDataService("u1", "Dispatcher");
var result = await Service(data).SetOverrideAsync(
"u1", "not-a-permission", UserPermissionState.Allow, Admin(), CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.InvalidPermissionKey);
data.GetCalls.Should().Be(0);
}
[Fact]
public async Task NonAdminCannotReadOrWritePermissions()
{
var data = new FakeDataService("u1", "Dispatcher");
var service = Service(data);
var read = await service.GetProfileAsync("u1", User("Dispatcher"), CancellationToken.None);
var write = await service.SetOverrideAsync("u1", TeamPermissionKeys.CreateSites, UserPermissionState.Deny, User("Dispatcher"), CancellationToken.None);
read.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
write.Status.Should().Be(TeamPermissionResultStatus.Forbidden);
data.GetCalls.Should().Be(0);
}
[Fact]
public async Task UnknownRoleReceivesNoGrantEvenWhenAnOverrideIsSet()
{
var data = new FakeDataService("u1", "Legacy");
var result = await Service(data).SetOverrideAsync(
"u1", TeamPermissionKeys.CreateSites, UserPermissionState.Allow, Admin(), CancellationToken.None);
result.Value!.Permissions.Should().OnlyContain(p => !p.IsGranted);
}
[Fact]
public async Task MissingUserReturnsNotFound()
{
var data = new FakeDataService(null, null);
var result = await Service(data).GetProfileAsync("missing", Admin(), CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.NotFound);
}
private static TeamPermissionService Service(FakeDataService data) =>
new(data, new TeamPermissionPolicy());
private static ClaimsPrincipal Admin() => User("Admin");
private static ClaimsPrincipal User(string role) =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, role) }, "test"));
private sealed class FakeDataService : ITeamPermissionOverrideDataService
{
private readonly string? _userId;
private readonly string? _roleName;
private readonly Dictionary<string, UserPermissionState> _overrides = new(StringComparer.OrdinalIgnoreCase);
public FakeDataService(string? userId, string? roleName)
{
_userId = userId;
_roleName = roleName;
}
public int GetCalls { get; private set; }
public (string UserId, string PermissionKey, UserPermissionState State)? LastSet { get; private set; }
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
{
GetCalls++;
return Task.FromResult<TeamPermissionUserData?>(_userId != userId
? null
: new TeamPermissionUserData
{
UserId = userId,
RoleName = _roleName,
Overrides = new Dictionary<string, UserPermissionState>(_overrides, StringComparer.OrdinalIgnoreCase)
});
}
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken)
{
_overrides[permissionKey] = state;
LastSet = (userId, permissionKey, state);
return Task.CompletedTask;
}
}
}

View file

@ -0,0 +1,67 @@
namespace SeaHaven.Services.Constants
{
/// <summary>
/// Canonical, immutable registry of team-members permission keys.
/// Exactly the 22 approved prototype keys; stored overrides must reference
/// these keys. Keys are stable identifiers and must never be renamed.
/// </summary>
public static class TeamPermissionKeys
{
public const string ManageTeamMembers = "manageTeamMembers";
public const string ChangeTeamMemberRole = "changeTeamMemberRole";
public const string CreateVendors = "createVendors";
public const string EditVendors = "editVendors";
public const string DeactivateVendors = "deactivateVendors";
public const string CreateSites = "createSites";
public const string EditSites = "editSites";
public const string DeleteSites = "deleteSites";
public const string CreateServices = "createServices";
public const string EditServices = "editServices";
public const string DeactivateServices = "deactivateServices";
public const string CreateCompletionDocTemplates = "createCompletionDocTemplates";
public const string EditCompletionDocTemplates = "editCompletionDocTemplates";
public const string DeleteCompletionDocTemplates = "deleteCompletionDocTemplates";
public const string CreateWorkOrders = "createWorkOrders";
public const string EditOthersWorkOrders = "editOthersWorkOrders";
public const string CancelWorkOrders = "cancelWorkOrders";
public const string DeleteWorkOrders = "deleteWorkOrders";
public const string RequestUplifts = "requestUplifts";
public const string AutoApproveUplifts = "autoApproveUplifts";
public const string ReviewUplifts = "reviewUplifts";
public const string ViewAllDispatchersOnDashboard = "viewAllDispatchersOnDashboard";
private static readonly IReadOnlyList<string> AllKeys = Array.AsReadOnly(new[]
{
ManageTeamMembers,
ChangeTeamMemberRole,
CreateVendors,
EditVendors,
DeactivateVendors,
CreateSites,
EditSites,
DeleteSites,
CreateServices,
EditServices,
DeactivateServices,
CreateCompletionDocTemplates,
EditCompletionDocTemplates,
DeleteCompletionDocTemplates,
CreateWorkOrders,
EditOthersWorkOrders,
CancelWorkOrders,
DeleteWorkOrders,
RequestUplifts,
AutoApproveUplifts,
ReviewUplifts,
ViewAllDispatchersOnDashboard
});
public static IReadOnlyList<string> All => AllKeys;
public static IReadOnlySet<string> KnownKeys { get; } =
new HashSet<string>(AllKeys, StringComparer.OrdinalIgnoreCase);
public static bool IsKnown(string? permissionKey) =>
!string.IsNullOrWhiteSpace(permissionKey) && KnownKeys.Contains(permissionKey);
}
}

View file

@ -1,10 +1,121 @@
namespace SeaHaven.Services.DTOs
{
public class DashboardStatsQueryDTO
{
public DateOnly? DateFrom { get; set; }
public DateOnly? DateTo { get; set; }
}
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
{
public string? Range { get; set; }
public int? Year { get; set; }
}
public sealed class DashboardTrendResponseDTO
{
public string Granularity { get; init; } = "day";
public DateOnly Today { get; init; }
public IReadOnlyList<DashboardTrendBucketDTO> Buckets { get; init; } = [];
}
public sealed class DashboardTrendBucketDTO
{
public DateOnly Date { get; init; }
public string Label { get; init; } = string.Empty;
public int Total { get; init; }
public int Open { get; init; }
public int Completed { get; init; }
public int Canceled { get; init; }
public int Overdue { get; init; }
public bool IsCurrent { get; init; }
}
public sealed class DashboardWorkloadResponseDTO
{
public IReadOnlyList<DashboardWorkloadRowDTO> Items { get; init; } = [];
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalDispatchers { get; init; }
}
public sealed class DashboardWorkloadRowDTO
{
public string DispatcherId { get; init; } = string.Empty;
public string DispatcherName { get; init; } = string.Empty;
public string? Color { get; init; }
public int TotalCount { get; init; }
public int OpenCount { get; init; }
}
public sealed class DashboardPerformanceResponseDTO
{
public IReadOnlyList<DashboardPerformanceRowDTO> Items { get; init; } = [];
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalDispatchers { get; init; }
}
public sealed class DashboardPerformanceRowDTO
{
public string DispatcherId { get; init; } = string.Empty;
public string DispatcherName { get; init; } = string.Empty;
public string? Color { get; init; }
public decimal CompletionRate { get; init; }
public decimal? AverageResolutionDays { get; init; }
}
public sealed class DashboardRegionResponseDTO
{
public IReadOnlyList<DashboardRegionRowDTO> Items { get; init; } = [];
}
public sealed class DashboardRegionRowDTO
{
public string Region { get; init; } = string.Empty;
public int WorkOrderCount { get; init; }
}
public sealed class DashboardVendorInsightsResponseDTO
{
public IReadOnlyList<DashboardVendorInsightsRowDTO> Items { get; init; } = [];
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalVendors { get; init; }
}
public sealed class DashboardVendorInsightsRowDTO
{
public int VendorCompanyId { get; init; }
public string VendorCompanyName { get; init; } = string.Empty;
public decimal CompletionRate { get; init; }
public decimal RescheduleRate { get; init; }
public decimal? AverageResolutionDays { get; init; }
public int TotalJobs { get; init; }
}
public class DashboardStatsDTO
{
public int Total { get; set; }
public int Open { get; set; }
public int NotDispatched { get; set; }
public int Completed { get; set; }
public int ScheduledTomorrow { get; set; }
public int PendingUplifts { get; set; }
public int AvetaPending { get; set; }
public DashboardBreakdownDTO Breakdown { get; set; } = new();
public int DueCount { get; set; }
public int CompletedDueCount { get; set; }
public decimal CompletionRate { get; set; }
public decimal? AverageResolutionDays { get; set; }
}
public sealed class DashboardBreakdownDTO
{
public int PM { get; set; }
public int Emergency { get; set; }
public int Reactive { get; set; }
public int Overdue { get; set; }
public int Other { get; set; }
}
}

View file

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

View file

@ -0,0 +1,49 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs;
public sealed class TeamPermissionProfileDTO
{
public required string UserId { get; init; }
public string? RoleName { get; init; }
public required IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; }
}
public sealed class TeamPermissionValueDTO
{
public required string PermissionKey { get; init; }
public UserPermissionState OverrideState { get; init; }
public bool IsGranted { get; init; }
}
public sealed class SetTeamPermissionOverrideDTO
{
public UserPermissionState State { get; init; }
}
public enum TeamPermissionResultStatus
{
Success,
Forbidden,
NotFound,
InvalidPermissionKey
}
public sealed class TeamPermissionResult<T>
{
private TeamPermissionResult(TeamPermissionResultStatus status, T? value)
{
Status = status;
Value = value;
}
public TeamPermissionResultStatus Status { get; }
public T? Value { get; }
public bool IsSuccess => Status == TeamPermissionResultStatus.Success;
public static TeamPermissionResult<T> Success(T value) =>
new(TeamPermissionResultStatus.Success, value);
public static TeamPermissionResult<T> Failure(TeamPermissionResultStatus status) =>
new(status, default);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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