Compare commits

...

19 commits

Author SHA1 Message Date
Alexandre Brandizzi
db9a94f335
Merge pull request #132 from Sea-Haven-Industries/feat/ab/sh-328-permission-overrides
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-328: expose per-person permission overrides
2026-09-16 20:52:16 -03:00
Alexandre Brandizzi
21b7083160
Merge branch 'dev' into feat/ab/sh-328-permission-overrides 2026-09-16 20:44:20 -03:00
Alexandre Brandizzi
1c0865ae15
Merge pull request #136 from Sea-Haven-Industries/feat/ab/sh-350-dashboard-trend
SH-350: Add dashboard trend metrics
2026-09-16 20:42:14 -03:00
Alexandre Brandizzi
63bda30595
Merge branch 'dev' into feat/ab/sh-328-permission-overrides 2026-09-16 20:36:19 -03:00
Alexandre Brandizzi
0bda64efc4
Merge branch 'dev' into feat/ab/sh-350-dashboard-trend 2026-09-16 20:36:14 -03:00
Alexandre Brandizzi
452d666547
Merge pull request #142 from Sea-Haven-Industries/fix/ab/sh-374-getworkorderbyid-binding
SH-374: return the work order from GetWorkorderById?id=
2026-09-16 20:34:16 -03:00
Alexandre Brandizzi
af441894d7 fix(permissions): clean up overrides on user delete and converge concurrent override writes (SH-328)
DeleteUserWithCascadeAsync never removed UserPermissionOverrides rows, and
the FK on UserId is Restrict. Once an override was saved for a member, the
admin hard-delete (DeleteUserAsync -> DeleteUserWithCascadeAsync) failed the
foreign key inside the transaction and the controller surfaced it as a 400,
so the user was never deleted. Add an explicit ExecuteDelete on
UserPermissionOverrides before the user is removed, matching how UserRoles is
already cleared in the same method (no schema change).

SetOverrideAsync was check-then-insert on the composite key with no
DbUpdateException handling, so two concurrent PUTs for the same
(UserId, PermissionKey) let the loser violate PK_UserPermissionOverrides and
return 500. Catch the conflict, detach the pending insert, and converge by
updating the persisted row to the caller's requested state.
2026-09-16 20:27:26 -03:00
Alexandre Brandizzi
fed45f423e
Merge branch 'dev' into feat/ab/sh-350-dashboard-trend 2026-09-16 20:26:23 -03:00
Alexandre Brandizzi
7bdcc61651 fix(work-orders): bind GetWorkorderById id from the query string (SH-374) 2026-09-16 20:15:30 -03:00
Alexandre Brandizzi
5877f7817f fix(dashboard): bucket trend by calendar ScheduledDate (SH-350)
ScheduledDate is persisted as a midnight calendar value (board create writes
request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a
datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and
converted to America/New_York, so midnight became 19:00/20:00 the previous day
and every board-scheduled work order fell into the prior bucket: a job scheduled
today read as yesterday and overdue, and the Today bucket showed zero.

Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching
DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the
same rows. Rewrite the daily and yearly trend tests to assert calendar-date
classification (the removed conversion had encoded the shift into their
expectations) and add a regression test that a midnight-today work order stays
in the Today bucket and is not overdue.
2026-09-16 19:57:09 -03:00
Alexandre Brandizzi
9d7efa34a6 feat(dashboard): add trend metrics (SH-350) 2026-09-16 18:42:26 -03:00
Alexandre Brandizzi
3358ea5058 feat(dashboard): add vendor insights (SH-349) 2026-09-16 18:10:42 -03:00
Alexandre Brandizzi
d4afcced87 feat(dashboard): add regional work order metrics (SH-348) 2026-09-16 18:03:38 -03:00
Alexandre Brandizzi
441735d39d feat(permissions): expose team member permission overrides (SH-328) 2026-09-16 18:02:02 -03:00
Alexandre Brandizzi
0194748e3b feat(dashboard): add dispatcher performance (SH-347) 2026-09-16 17:58:08 -03:00
Alexandre Brandizzi
d6c3cd2e24 feat(permissions): add team member permission policy foundation (SH-327) 2026-09-16 17:48:37 -03:00
Alexandre Brandizzi
9b39d0e5ec feat(dashboard): add dispatcher workload (SH-346) 2026-09-16 17:43:49 -03:00
Alexandre Brandizzi
a2e749c72d feat(dashboard): add core dashboard metrics (SH-343) 2026-09-16 17:28:21 -03:00
Alexandre Brandizzi
efd13b6f60 fix(dashboard): scope stats to authenticated accounts (SH-336) 2026-09-16 17:12:03 -03:00
33 changed files with 6893 additions and 31 deletions

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,647 @@ 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_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

@ -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,155 @@ 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,
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

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

@ -225,6 +225,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; }
@ -279,6 +305,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

@ -2072,6 +2072,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")
@ -3616,6 +3638,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,130 @@ 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<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

@ -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,51 @@
namespace SeaHaven.DataServices.Interfaces
{
using Data.SeaHavenIndustries.Enums;
public interface IDashboardDataService
{
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
Task<DashboardCounts> GetWorkOrderCountsAsync(
int? accountId,
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; }

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,118 @@
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 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

@ -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,388 @@ 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 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,
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

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

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