mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Merge branch 'dev' into feat/ab/sh-207-uplift-queue-flags
This commit is contained in:
commit
b9bcaea9f3
49 changed files with 10154 additions and 107 deletions
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
60
Api.SeaHavenIndustries.Tests/AccountOwnerDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class AccountOwnerDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_MarksOnlyConfiguredUser()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser { Id = "old-owner", UserName = "old@example.com", IsAccountOwner = true },
|
||||
new ApplicationUser { Id = "configured-owner", UserName = "configured@example.com" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new AccountOwnerDataService(context);
|
||||
(await service.EnsureConfiguredOwnerAsync(" configured-owner ", CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
(await context.Users.SingleAsync(user => user.Id == "old-owner")).IsAccountOwner.Should().BeFalse();
|
||||
(await context.Users.SingleAsync(user => user.Id == "configured-owner")).IsAccountOwner.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_WithNoConfiguration_ClearsExistingOwner()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner@example.com", IsAccountOwner = true });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new AccountOwnerDataService(context);
|
||||
(await service.EnsureConfiguredOwnerAsync(null, CancellationToken.None)).Should().BeTrue();
|
||||
(await service.EnsureConfiguredOwnerAsync(" ", CancellationToken.None)).Should().BeFalse();
|
||||
|
||||
(await context.Users.SingleAsync()).IsAccountOwner.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureConfiguredOwnerAsync_RejectsUnknownUser()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var service = new AccountOwnerDataService(context);
|
||||
|
||||
var action = () => service.EnsureConfiguredOwnerAsync("missing", CancellationToken.None);
|
||||
|
||||
await action.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("The configured account owner user was not found.");
|
||||
}
|
||||
}
|
||||
|
|
@ -270,11 +270,13 @@ public class DashboardServiceTests
|
|||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalDispatchers.Should().Be(1);
|
||||
result.Items.Should().ContainSingle();
|
||||
result.Items[0].DispatcherName.Should().Be("Ada One");
|
||||
result.TotalDispatchers.Should().Be(2);
|
||||
result.Items.Select(item => item.DispatcherName)
|
||||
.Should().ContainInOrder("Ada One", "Ben Two");
|
||||
result.Items[0].TotalCount.Should().Be(2);
|
||||
result.Items[0].OpenCount.Should().Be(1);
|
||||
result.Items[1].TotalCount.Should().Be(0);
|
||||
result.Items[1].OpenCount.Should().Be(0);
|
||||
result.PageSize.Should().Be(10);
|
||||
|
||||
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
||||
|
|
@ -444,6 +446,275 @@ public class DashboardServiceTests
|
|||
result.Items[0].RescheduleRate.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_DispatcherQuerySelectionCannotBroadenScope()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var query = new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" };
|
||||
var stats = await NewService(ctx).GetStatsAsync(
|
||||
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
|
||||
|
||||
stats.Total.Should().Be(2);
|
||||
stats.Open.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_AdminCanSelectIndividualDispatcherOrMine()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder { AccountId = 1, Status = "Done", AssignTo = "admin-1" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var service = NewService(ctx);
|
||||
|
||||
var selected = await service.GetStatsAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
||||
CancellationToken.None);
|
||||
selected.Total.Should().Be(3);
|
||||
selected.Open.Should().Be(3);
|
||||
|
||||
var mine = await service.GetStatsAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "mine" },
|
||||
CancellationToken.None);
|
||||
mine.Total.Should().Be(1);
|
||||
mine.Completed.Should().Be(1);
|
||||
|
||||
var all = await service.GetStatsAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO(),
|
||||
CancellationToken.None);
|
||||
all.Total.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ManagerSelectionScopesStats()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var stats = await NewService(ctx).GetStatsAsync(
|
||||
AccountUser(1, "manager-1", "Manager"),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
||||
CancellationToken.None);
|
||||
|
||||
stats.Total.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_SchedulerSelectionScopesStats()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var stats = await NewService(ctx).GetStatsAsync(
|
||||
AccountUser(1, "scheduler-1", "Scheduler"),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-2" },
|
||||
CancellationToken.None);
|
||||
|
||||
stats.Total.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDashboardAsync_UnauthorizedRoleFailsClosed()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var service = NewService(ctx);
|
||||
var user = AccountUser(1, "tech-1", "Technician");
|
||||
|
||||
var statsAct = () => service.GetStatsAsync(
|
||||
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
var statsException = await statsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||
statsException.Which.Code.Should().Be("Forbidden");
|
||||
|
||||
var workloadAct = () => service.GetWorkloadAsync(
|
||||
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
|
||||
var workloadException = await workloadAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||
workloadException.Which.Code.Should().Be("Forbidden");
|
||||
|
||||
var performanceAct = () => service.GetPerformanceAsync(
|
||||
user, new DashboardStatsQueryDTO(), 1, CancellationToken.None);
|
||||
var performanceException = await performanceAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||
performanceException.Which.Code.Should().Be("Forbidden");
|
||||
|
||||
var regionsAct = () => service.GetRegionsAsync(
|
||||
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||
var regionsException = await regionsAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||
regionsException.Which.Code.Should().Be("Forbidden");
|
||||
|
||||
var trendAct = () => service.GetTrendAsync(
|
||||
user, new DashboardTrendQueryDTO(), CancellationToken.None);
|
||||
var trendException = await trendAct.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||
trendException.Which.Code.Should().Be("Forbidden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWorkloadAsync_IndividualSelectionPreservesRosterWithZeroRows()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.Roles.Add(new IdentityRole
|
||||
{
|
||||
Id = "dispatcher-role",
|
||||
Name = "Dispatcher",
|
||||
NormalizedName = "DISPATCHER"
|
||||
});
|
||||
ctx.Users.AddRange(
|
||||
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
|
||||
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
|
||||
ctx.UserRoles.AddRange(
|
||||
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
|
||||
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
|
||||
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var workload = await NewService(ctx).GetWorkloadAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
workload.TotalDispatchers.Should().Be(2);
|
||||
workload.Items.Should().HaveCount(2);
|
||||
workload.Items[0].DispatcherId.Should().Be("dispatcher-1");
|
||||
workload.Items[0].TotalCount.Should().Be(2);
|
||||
workload.Items[0].OpenCount.Should().Be(2);
|
||||
workload.Items[1].DispatcherId.Should().Be("dispatcher-2");
|
||||
workload.Items[1].TotalCount.Should().Be(0);
|
||||
workload.Items[1].OpenCount.Should().Be(0);
|
||||
workload.Page.Should().Be(1);
|
||||
workload.PageSize.Should().Be(10);
|
||||
|
||||
var performance = await NewService(ctx).GetPerformanceAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO { DispatcherId = "dispatcher-1" },
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
performance.TotalDispatchers.Should().Be(2);
|
||||
performance.Items.Should().HaveCount(2);
|
||||
performance.Items[0].DispatcherId.Should().Be("dispatcher-1");
|
||||
performance.Items[0].AssignedCount.Should().Be(2);
|
||||
performance.Items[1].DispatcherId.Should().Be("dispatcher-2");
|
||||
performance.Items[1].AssignedCount.Should().Be(0);
|
||||
performance.Items[1].CompletedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPerformanceAsync_PopulatesAssignedAndCompletedCounts()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.Roles.Add(new IdentityRole
|
||||
{
|
||||
Id = "dispatcher-role",
|
||||
Name = "Dispatcher",
|
||||
NormalizedName = "DISPATCHER"
|
||||
});
|
||||
ctx.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" });
|
||||
ctx.UserRoles.Add(new IdentityUserRole<string>
|
||||
{
|
||||
UserId = "dispatcher-1",
|
||||
RoleId = "dispatcher-role"
|
||||
});
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
AccountId = 1,
|
||||
AssignTo = "dispatcher-1",
|
||||
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
AccountId = 1,
|
||||
AssignTo = "dispatcher-1",
|
||||
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
AccountId = 1,
|
||||
AssignTo = "dispatcher-1",
|
||||
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
|
||||
var result = await NewService(ctx).GetPerformanceAsync(
|
||||
AccountUser(1),
|
||||
new DashboardStatsQueryDTO(),
|
||||
1,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Items.Should().ContainSingle();
|
||||
result.Items[0].AssignedCount.Should().Be(3);
|
||||
result.Items[0].CompletedCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_StatusDistributionBucketsResolvedStatuses()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
AccountId = 1,
|
||||
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder { AccountId = 1, Status = "In Progress" },
|
||||
new WorkOrder { AccountId = 1, Status = "Done" },
|
||||
new WorkOrder { AccountId = 1, Status = "Done" },
|
||||
new WorkOrder { AccountId = 1, Status = "Unparseable" });
|
||||
ctx.SaveChanges();
|
||||
|
||||
var stats = await NewService(ctx).GetStatsAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO(),
|
||||
CancellationToken.None);
|
||||
|
||||
stats.StatusDistribution.Should().HaveCount(4);
|
||||
stats.StatusDistribution
|
||||
.Select(bucket => (bucket.Status, bucket.Count))
|
||||
.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
(Status: "Completed", Count: 2),
|
||||
(Status: "In Progress", Count: 1),
|
||||
(Status: "Scheduled", Count: 1),
|
||||
(Status: "Unknown", Count: 1)
|
||||
});
|
||||
stats.StatusDistribution.Sum(bucket => bucket.Count).Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_StatusDistributionIsEmptyWithoutWorkOrders()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var stats = await NewService(ctx).GetStatsAsync(
|
||||
AccountUser(1, "admin-1"),
|
||||
new DashboardStatsQueryDTO(),
|
||||
CancellationToken.None);
|
||||
|
||||
stats.StatusDistribution.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
||||
{
|
||||
|
|
|
|||
404
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
404
Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class TeamMemberServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_DispatcherRequiresAtLeastOneServiceArea()
|
||||
{
|
||||
var userManager = UserManager();
|
||||
var roleManager = RoleManager();
|
||||
var service = new TeamMemberService(
|
||||
userManager.Object,
|
||||
roleManager.Object,
|
||||
Mock.Of<IUserServiceAreaDataService>(),
|
||||
Mock.Of<ITeamPermissionOverrideDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<ITeamPermissionService>());
|
||||
|
||||
var result = await service.CreateAsync(
|
||||
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Be("At least one service area is required for a Dispatcher.");
|
||||
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RejectsUnknownColorAndPermission()
|
||||
{
|
||||
var service = NewService(out var userManager, out _, out _, out _, out _, out _);
|
||||
|
||||
var badColor = await service.CreateAsync(
|
||||
ValidRequest() with { Color = "#000000" },
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
var badPermission = await service.CreateAsync(
|
||||
ValidRequest() with
|
||||
{
|
||||
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
||||
{
|
||||
["not-a-permission"] = UserPermissionState.Allow
|
||||
}
|
||||
},
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
badColor.Error.Should().Be("Color must be selected from the accessible palette.");
|
||||
badPermission.Error.Should().Be("Unknown permission key: not-a-permission.");
|
||||
userManager.Verify(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides()
|
||||
{
|
||||
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
|
||||
ApplicationUser? created = null;
|
||||
userManager
|
||||
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||
.Callback<ApplicationUser>(user =>
|
||||
{
|
||||
user.Id = "new-user";
|
||||
created = user;
|
||||
})
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager
|
||||
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
userManager
|
||||
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||
|
||||
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
created.Should().NotBeNull();
|
||||
created!.PasswordHash.Should().BeNull();
|
||||
created.EmailConfirmed.Should().BeFalse();
|
||||
created.PendingRegistration.Should().BeTrue();
|
||||
created.PhoneNumber.Should().Be("555-0100");
|
||||
result.Member!.ServiceAreas.Should().Equal("East", "West");
|
||||
areas.Verify(data => data.ReplaceAsync("new-user", It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny<CancellationToken>()), Times.Once);
|
||||
overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ProtectsAccountOwnerFromRoleChangeAndDeactivation()
|
||||
{
|
||||
var service = NewService(
|
||||
out var userManager,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out var userData,
|
||||
out _);
|
||||
var owner = new ApplicationUser
|
||||
{
|
||||
Id = "owner",
|
||||
FirstName = "Primary",
|
||||
Email = "owner@example.com",
|
||||
IsAccountOwner = true
|
||||
};
|
||||
userManager.Setup(manager => manager.FindByIdAsync(owner.Id)).ReturnsAsync(owner);
|
||||
userManager.Setup(manager => manager.GetRolesAsync(owner)).ReturnsAsync(new List<string> { "Admin" });
|
||||
userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
owner.Id,
|
||||
new UpdateTeamMemberRequestDTO
|
||||
{
|
||||
Name = "Primary",
|
||||
Role = "Dispatcher",
|
||||
Color = "#F59E0B",
|
||||
Email = owner.Email,
|
||||
ServiceAreas = new[] { "East" },
|
||||
IsActive = false
|
||||
},
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Be("Forbidden");
|
||||
userData.Verify(data => data.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ReplacesAreasAndOverridesAndReturnsFullDetail()
|
||||
{
|
||||
var service = NewService(
|
||||
out var userManager,
|
||||
out var roleManager,
|
||||
out var areas,
|
||||
out var overrides,
|
||||
out var userData,
|
||||
out var permissions);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = "dispatcher",
|
||||
FirstName = "Taylor",
|
||||
Email = "old@example.com",
|
||||
Color = "#F59E0B",
|
||||
UniqueName = "Active"
|
||||
};
|
||||
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
||||
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Dispatcher" });
|
||||
userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||
userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com"))
|
||||
.Callback<ApplicationUser, string>((member, email) => member.Email = email)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com"))
|
||||
.Callback<ApplicationUser, string>((member, email) => member.UserName = email)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
userManager.Setup(manager => manager.UpdateSecurityStampAsync(user))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { "Central" });
|
||||
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
|
||||
{
|
||||
UserId = user.Id,
|
||||
RoleName = "Dispatcher",
|
||||
Permissions = new[]
|
||||
{
|
||||
new TeamPermissionValueDTO
|
||||
{
|
||||
PermissionKey = "createSites",
|
||||
OverrideState = UserPermissionState.Deny,
|
||||
IsGranted = false
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
user.Id,
|
||||
new UpdateTeamMemberRequestDTO
|
||||
{
|
||||
Name = "Taylor Updated",
|
||||
Role = "Dispatcher",
|
||||
Color = "#0D9488",
|
||||
Email = "new@example.com",
|
||||
Phone = "555-0101",
|
||||
ServiceAreas = new[] { "West" },
|
||||
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
||||
{
|
||||
["createSites"] = UserPermissionState.Deny
|
||||
}
|
||||
},
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Member!.Name.Should().Be("Taylor Updated");
|
||||
result.Member.Email.Should().Be("new@example.com");
|
||||
result.Member.ServiceAreas.Should().Equal("Central");
|
||||
result.Member.Permissions.Should().ContainSingle(permission => permission.PermissionKey == "createSites");
|
||||
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||
userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once);
|
||||
areas.Verify(data => data.ReplaceAsync(user.Id, It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "West" })), It.IsAny<CancellationToken>()), Times.Once);
|
||||
overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny<CancellationToken>()), Times.Once);
|
||||
overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_SavesUnchangedNameWithoutDuplicatingRoleLikeLastName()
|
||||
{
|
||||
var service = NewService(
|
||||
out var userManager,
|
||||
out var roleManager,
|
||||
out var areas,
|
||||
out _,
|
||||
out var userData,
|
||||
out var permissions);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = "admin",
|
||||
FirstName = "Legacy",
|
||||
LastName = "Manager",
|
||||
Email = "legacy@example.com",
|
||||
Color = "#F59E0B",
|
||||
UniqueName = "Active"
|
||||
};
|
||||
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
||||
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Admin" });
|
||||
userManager.Setup(manager => manager.FindByEmailAsync(user.Email!)).ReturnsAsync(user);
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Admin")).ReturnsAsync(true);
|
||||
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<string>());
|
||||
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
|
||||
{
|
||||
UserId = user.Id,
|
||||
Permissions = Array.Empty<TeamPermissionValueDTO>()
|
||||
}));
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
user.Id,
|
||||
new UpdateTeamMemberRequestDTO
|
||||
{
|
||||
Name = "Legacy Manager",
|
||||
Role = "Admin",
|
||||
Color = "#F59E0B",
|
||||
Email = user.Email
|
||||
},
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
user.FirstName.Should().Be("Legacy");
|
||||
user.LastName.Should().Be("Manager");
|
||||
result.Member!.Name.Should().Be("Legacy Manager");
|
||||
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_LeavesOmittedOverridesUnchanged()
|
||||
{
|
||||
var service = NewService(
|
||||
out var userManager,
|
||||
out var roleManager,
|
||||
out var areas,
|
||||
out var overrides,
|
||||
out var userData,
|
||||
out var permissions);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = "dispatcher",
|
||||
FirstName = "Taylor",
|
||||
Email = "taylor@example.com",
|
||||
Color = "#F59E0B",
|
||||
UniqueName = "Active"
|
||||
};
|
||||
userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
||||
userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Dispatcher" });
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<string>());
|
||||
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
|
||||
{
|
||||
UserId = user.Id,
|
||||
Permissions = Array.Empty<TeamPermissionValueDTO>()
|
||||
}));
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
user.Id,
|
||||
new UpdateTeamMemberRequestDTO
|
||||
{
|
||||
Name = "Taylor",
|
||||
Role = "Dispatcher",
|
||||
Color = "#F59E0B",
|
||||
Email = user.Email,
|
||||
ServiceAreas = new[] { "East" }
|
||||
},
|
||||
Admin(),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
overrides.Verify(data => data.ClearOverridesAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
overrides.Verify(data => data.SetOverridesAsync(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
|
||||
{
|
||||
var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _);
|
||||
userManager
|
||||
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
|
||||
userManager
|
||||
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||
.ThrowsAsync(new DbUpdateException("duplicate key", new Exception()));
|
||||
|
||||
var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Be("Email is already in use.");
|
||||
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
|
||||
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||
}
|
||||
|
||||
private static TeamMemberService NewService(
|
||||
out Mock<UserManager<ApplicationUser>> userManager,
|
||||
out Mock<RoleManager<IdentityRole>> roleManager,
|
||||
out Mock<IUserServiceAreaDataService> areas,
|
||||
out Mock<ITeamPermissionOverrideDataService> overrides,
|
||||
out Mock<IUserDataService> userData,
|
||||
out Mock<ITeamPermissionService> permissions)
|
||||
{
|
||||
userManager = UserManager();
|
||||
roleManager = RoleManager();
|
||||
areas = new Mock<IUserServiceAreaDataService>();
|
||||
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
||||
userData = new Mock<IUserDataService>();
|
||||
permissions = new Mock<ITeamPermissionService>();
|
||||
return new TeamMemberService(
|
||||
userManager.Object,
|
||||
roleManager.Object,
|
||||
areas.Object,
|
||||
overrides.Object,
|
||||
userData.Object,
|
||||
permissions.Object);
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> UserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
|
||||
Mock.Of<IPasswordHasher<ApplicationUser>>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
Mock.Of<ILookupNormalizer>(),
|
||||
new IdentityErrorDescriber(),
|
||||
Mock.Of<IServiceProvider>(),
|
||||
Mock.Of<ILogger<UserManager<ApplicationUser>>>());
|
||||
}
|
||||
|
||||
private static Mock<RoleManager<IdentityRole>> RoleManager()
|
||||
{
|
||||
var store = new Mock<IRoleStore<IdentityRole>>();
|
||||
return new Mock<RoleManager<IdentityRole>>(
|
||||
store.Object,
|
||||
Array.Empty<IRoleValidator<IdentityRole>>(),
|
||||
Mock.Of<ILookupNormalizer>(),
|
||||
new IdentityErrorDescriber(),
|
||||
Mock.Of<ILogger<RoleManager<IdentityRole>>>());
|
||||
}
|
||||
|
||||
private static CreateTeamMemberRequestDTO ValidRequest() => new()
|
||||
{
|
||||
Name = "Taylor Dispatcher",
|
||||
Role = "dispatcher",
|
||||
Color = "#F59E0B",
|
||||
Email = "taylor@example.com",
|
||||
Phone = "555-0100",
|
||||
ServiceAreas = new[] { "east", "West" },
|
||||
PermissionOverrides = new Dictionary<string, UserPermissionState>
|
||||
{
|
||||
["deleteSites"] = UserPermissionState.Allow
|
||||
}
|
||||
};
|
||||
|
||||
private static ClaimsPrincipal Admin() =>
|
||||
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
|
||||
}
|
||||
|
|
@ -654,6 +654,32 @@ public sealed class UpliftQueueReadTests
|
|||
total.AutoApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exposure_AutoApprovedSumsGrantedAmountsPerCreationPath()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-A");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
// Vendor-portal rows (createdby null) store the requested new NTE total:
|
||||
// 1000 -> 1500 grants 500, then 1500 -> 1800 grants 300.
|
||||
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 2), requested: 1500m, currentNte: 1000m),
|
||||
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 4), requested: 1800m, currentNte: 1500m),
|
||||
// Work-order-path rows (createdby set) store the granted increment: 200.
|
||||
Request(dispatch, "NoApprovalRequired", new DateTime(2026, 3, 5), decided: new DateTime(2026, 3, 6), requested: 200m, currentNte: 1800m, createdBy: "user-7"));
|
||||
await context.SaveChangesAsync();
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var exposure = await data.GetApprovedExposureForWorkOrdersAsync(
|
||||
new[] { workOrder.Id }, CancellationToken.None);
|
||||
|
||||
// Granted exposure is 500 + 300 + 200; summing raw RequestedNTE would
|
||||
// double-count whole NTE totals and report 3500.
|
||||
var total = exposure.Should().ContainSingle(e => e.WorkOrderId == workOrder.Id).Subject;
|
||||
total.AutoApprovedTotal.Should().Be(1000m);
|
||||
total.AdminApprovedTotal.Should().Be(0m);
|
||||
}
|
||||
|
||||
// --- Read authorization (tier roles) ---
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -90,6 +90,23 @@ public class UserServiceTests
|
|||
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade()
|
||||
{
|
||||
var user = IdentityTestHelpers.User("owner");
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||
|
||||
var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), CancellationToken.None);
|
||||
|
||||
outcome.Success.Should().BeFalse();
|
||||
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
|
||||
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade()
|
||||
{
|
||||
|
|
@ -138,6 +155,26 @@ public class UserServiceTests
|
|||
outcome.Error.Should().Be(UserMutationErrors.UserNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation()
|
||||
{
|
||||
var user = IdentityTestHelpers.User("owner");
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||
userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||
|
||||
var outcome = await service.EditUserAsync(
|
||||
new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" },
|
||||
Principal("Admin"),
|
||||
CancellationToken.None);
|
||||
|
||||
outcome.Success.Should().BeFalse();
|
||||
outcome.Error.Should().Be(UserMutationErrors.Forbidden);
|
||||
userData.Verify(u => u.UpdateUserAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserProfile_MapsCreatedDateToAddedDate()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dueCount = stats.DueCount,
|
||||
completedDueCount = stats.CompletedDueCount,
|
||||
completionRate = stats.CompletionRate,
|
||||
averageResolutionDays = stats.AverageResolutionDays
|
||||
averageResolutionDays = stats.AverageResolutionDays,
|
||||
statusDistribution = stats.StatusDistribution
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
67
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
67
Api.SeaHavenIndustries/Controllers/TeamMemberController.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/team-members")]
|
||||
public sealed class TeamMemberController : ControllerBase
|
||||
{
|
||||
private readonly ITeamMemberService _teamMemberService;
|
||||
|
||||
public TeamMemberController(ITeamMemberService teamMemberService)
|
||||
{
|
||||
_teamMemberService = teamMemberService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(
|
||||
CreateTeamMemberRequestDTO request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _teamMemberService.CreateAsync(request, User, cancellationToken);
|
||||
if (!outcome.Success)
|
||||
{
|
||||
if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal))
|
||||
return Forbid();
|
||||
|
||||
return BadRequest(new { message = outcome.Error });
|
||||
}
|
||||
|
||||
return Ok(outcome.Member);
|
||||
}
|
||||
|
||||
[HttpGet("{userId}")]
|
||||
public async Task<IActionResult> Get(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _teamMemberService.GetAsync(userId, User, cancellationToken);
|
||||
return ToActionResult(outcome);
|
||||
}
|
||||
|
||||
[HttpPut("{userId}")]
|
||||
public async Task<IActionResult> Update(
|
||||
string userId,
|
||||
UpdateTeamMemberRequestDTO request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _teamMemberService.UpdateAsync(userId, request, User, cancellationToken);
|
||||
return ToActionResult(outcome);
|
||||
}
|
||||
|
||||
private IActionResult ToActionResult(TeamMemberOperationOutcomeDTO outcome)
|
||||
{
|
||||
if (outcome.Success)
|
||||
return Ok(outcome.Member);
|
||||
|
||||
if (string.Equals(outcome.Error, "Forbidden", StringComparison.Ordinal))
|
||||
return Forbid();
|
||||
|
||||
if (string.Equals(outcome.Error, "Team member not found.", StringComparison.Ordinal))
|
||||
return NotFound(new { message = outcome.Error });
|
||||
|
||||
return BadRequest(new { message = outcome.Error });
|
||||
}
|
||||
}
|
||||
|
|
@ -222,6 +222,13 @@ app.UseAuthorization();
|
|||
|
||||
app.MapControllers();
|
||||
|
||||
using (var ownerScope = app.Services.CreateScope())
|
||||
{
|
||||
await ownerScope.ServiceProvider
|
||||
.GetRequiredService<IAccountOwnerDesignationService>()
|
||||
.EnsureConfiguredOwnerAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
//if (app.Environment.IsDevelopment())
|
||||
//{
|
||||
// using var scope = app.Services.CreateScope();
|
||||
|
|
|
|||
|
|
@ -176,6 +176,12 @@ namespace Data.SeaHavenIndustries
|
|||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.AccountId);
|
||||
|
||||
builder.Entity<ApplicationUser>()
|
||||
.HasIndex(u => u.IsAccountOwner)
|
||||
.IsUnique()
|
||||
.HasFilter("IsAccountOwner = 1")
|
||||
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner");
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasIndex(c => c.NormalizedName)
|
||||
.IsUnique();
|
||||
|
|
@ -304,6 +310,26 @@ namespace Data.SeaHavenIndustries
|
|||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||
});
|
||||
|
||||
builder.Entity<UserServiceArea>(entity =>
|
||||
{
|
||||
entity.HasKey(area => new { area.UserId, area.Area });
|
||||
|
||||
entity.Property(area => area.UserId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
entity.Property(area => area.Area)
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.HasOne(area => area.User)
|
||||
.WithMany(user => user.ServiceAreas)
|
||||
.HasForeignKey(area => area.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(area => new { area.UserId, area.Area })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserServiceAreas_UserId_Area");
|
||||
});
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -361,6 +387,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<JobTitle> JobTitles { get; set; }
|
||||
public DbSet<Region> Regions { get; set; }
|
||||
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
|
|
@ -440,12 +467,16 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Initials { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public int? Type { get; set; } // 1 for users 0 for admin
|
||||
public bool IsAccountOwner { get; set; }
|
||||
public bool? PendingRegistration { get; set; }
|
||||
public DateTime? PendingRegistrationCreatedDate { get; set; }
|
||||
/// <summary>Optional CRM account membership for server-derived media scope (SH-221).</summary>
|
||||
public int? AccountId { get; set; }
|
||||
[ForeignKey(nameof(AccountId))]
|
||||
public virtual Accounts? Account { get; set; }
|
||||
public ICollection<Template>? Templates { get; set; }
|
||||
public ICollection<WorkOrder>? WorkOrders { get; set; }
|
||||
public ICollection<UserServiceArea> ServiceAreas { get; set; } = new List<UserServiceArea>();
|
||||
}
|
||||
public class FullAuditEntity
|
||||
{
|
||||
|
|
|
|||
9
Data.SeaHavenIndustries/Auth/UserServiceArea.cs
Normal file
9
Data.SeaHavenIndustries/Auth/UserServiceArea.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Data.SeaHavenIndustries;
|
||||
|
||||
/// <summary>Canonical service area assigned to a team member.</summary>
|
||||
public sealed class UserServiceArea
|
||||
{
|
||||
public string UserId { get; set; } = null!;
|
||||
public string Area { get; set; } = null!;
|
||||
public ApplicationUser User { get; set; } = null!;
|
||||
}
|
||||
4018
Data.SeaHavenIndustries/Migrations/20260916211933_SH329_PrimaryAccountOwner.Designer.cs
generated
Normal file
4018
Data.SeaHavenIndustries/Migrations/20260916211933_SH329_PrimaryAccountOwner.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH329_PrimaryAccountOwner : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAccountOwner",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUsers_IsAccountOwner",
|
||||
table: "AspNetUsers",
|
||||
column: "IsAccountOwner",
|
||||
unique: true,
|
||||
filter: "IsAccountOwner = 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AspNetUsers_IsAccountOwner",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAccountOwner",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
4056
Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs
generated
Normal file
4056
Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH325_PendingTeamMember : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PendingRegistration",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "PendingRegistrationCreatedDate",
|
||||
table: "AspNetUsers",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserServiceAreas",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||
Area = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserServiceAreas", x => new { x.UserId, x.Area });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserServiceAreas_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserServiceAreas_UserId_Area",
|
||||
table: "UserServiceAreas",
|
||||
columns: new[] { "UserId", "Area" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserServiceAreas");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PendingRegistration",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PendingRegistrationCreatedDate",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +220,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Initials")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsAccountOwner")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -246,6 +249,12 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("PendingRegistration")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("PendingRegistrationCreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -275,6 +284,11 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("IsAccountOwner")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_AspNetUsers_IsAccountOwner")
|
||||
.HasFilter("IsAccountOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
|
|
@ -2209,6 +2223,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("UserPermissionOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Area")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.HasKey("UserId", "Area");
|
||||
|
||||
b.HasIndex("UserId", "Area")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_UserServiceAreas_UserId_Area");
|
||||
|
||||
b.ToTable("UserServiceAreas");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -3794,6 +3827,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||
.WithMany("ServiceAreas")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||
|
|
@ -4045,6 +4089,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("ServiceAreas");
|
||||
|
||||
b.Navigation("Templates");
|
||||
|
||||
b.Navigation("WorkOrders");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ namespace SeaHaven.DataServices.Dto
|
|||
public string? Name { get; init; }
|
||||
public DateTime? CreatedDate { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public bool? PendingRegistration { get; init; }
|
||||
public IReadOnlyList<string> ServiceAreas { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class UserProfileData
|
||||
|
|
|
|||
|
|
@ -121,6 +121,40 @@ namespace SeaHaven.DataServices.Helpers
|
|||
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyRegionFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? regions)
|
||||
{
|
||||
if (regions == null || regions.Count == 0)
|
||||
return query;
|
||||
|
||||
var normalized = regions
|
||||
.Where(region => !string.IsNullOrWhiteSpace(region))
|
||||
.Select(region => region.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (normalized.Count == 0)
|
||||
return query;
|
||||
|
||||
var knownRegions = normalized
|
||||
.Where(region => WorkOrderBoardRegions.Names.Contains(region) && region != "Unmapped/Other")
|
||||
.SelectMany(WorkOrderBoardRegions.StorageValuesFor)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var includeUnmapped = normalized.Contains("Unmapped/Other", StringComparer.Ordinal);
|
||||
var hasKnownRegion = knownRegions.Count > 0;
|
||||
|
||||
if (!hasKnownRegion && !includeUnmapped)
|
||||
return query.Where(_ => false);
|
||||
|
||||
return query.Where(w =>
|
||||
(hasKnownRegion && w.Locations != null && w.Locations.State != null && knownRegions.Contains(w.Locations.State))
|
||||
|| (includeUnmapped && (w.Locations == null
|
||||
|| w.Locations.State == null
|
||||
|| w.Locations.State == ""
|
||||
|| !WorkOrderBoardRegions.KnownStorageValues.Contains(w.Locations.State))));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyStatusFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<LifecycleStatus>? statuses)
|
||||
|
|
|
|||
82
SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs
Normal file
82
SeaHaven.DataServices/Helpers/WorkOrderBoardRegions.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardRegions
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Names =
|
||||
["East", "Central", "West", "California", "Unmapped/Other"];
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string[]> RegionStateCodes =
|
||||
new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["East"] = ["CT", "DE", "GA", "ME", "MD", "MA", "NH", "NJ", "NY", "NC", "PA", "RI", "SC", "VT", "VA", "WV"],
|
||||
["Central"] = ["AL", "AR", "FL", "IL", "IN", "IA", "KS", "KY", "LA", "MI", "MN", "MS", "MO", "NE", "ND", "OH", "OK", "SD", "TN", "WI"],
|
||||
["West"] = ["AK", "AZ", "CO", "HI", "ID", "MT", "NV", "NM", "OR", "TX", "UT", "WA", "WY"],
|
||||
["California"] = ["CA"]
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> StateNames =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["AL"] = "ALABAMA",
|
||||
["AK"] = "ALASKA",
|
||||
["AZ"] = "ARIZONA",
|
||||
["AR"] = "ARKANSAS",
|
||||
["CA"] = "CALIFORNIA",
|
||||
["CO"] = "COLORADO",
|
||||
["CT"] = "CONNECTICUT",
|
||||
["DE"] = "DELAWARE",
|
||||
["FL"] = "FLORIDA",
|
||||
["GA"] = "GEORGIA",
|
||||
["HI"] = "HAWAII",
|
||||
["ID"] = "IDAHO",
|
||||
["IL"] = "ILLINOIS",
|
||||
["IN"] = "INDIANA",
|
||||
["IA"] = "IOWA",
|
||||
["KS"] = "KANSAS",
|
||||
["KY"] = "KENTUCKY",
|
||||
["LA"] = "LOUISIANA",
|
||||
["ME"] = "MAINE",
|
||||
["MD"] = "MARYLAND",
|
||||
["MA"] = "MASSACHUSETTS",
|
||||
["MI"] = "MICHIGAN",
|
||||
["MN"] = "MINNESOTA",
|
||||
["MS"] = "MISSISSIPPI",
|
||||
["MO"] = "MISSOURI",
|
||||
["MT"] = "MONTANA",
|
||||
["NE"] = "NEBRASKA",
|
||||
["NV"] = "NEVADA",
|
||||
["NH"] = "NEW HAMPSHIRE",
|
||||
["NJ"] = "NEW JERSEY",
|
||||
["NM"] = "NEW MEXICO",
|
||||
["NY"] = "NEW YORK",
|
||||
["NC"] = "NORTH CAROLINA",
|
||||
["ND"] = "NORTH DAKOTA",
|
||||
["OH"] = "OHIO",
|
||||
["OK"] = "OKLAHOMA",
|
||||
["OR"] = "OREGON",
|
||||
["PA"] = "PENNSYLVANIA",
|
||||
["RI"] = "RHODE ISLAND",
|
||||
["SC"] = "SOUTH CAROLINA",
|
||||
["SD"] = "SOUTH DAKOTA",
|
||||
["TN"] = "TENNESSEE",
|
||||
["TX"] = "TEXAS",
|
||||
["UT"] = "UTAH",
|
||||
["VT"] = "VERMONT",
|
||||
["VA"] = "VIRGINIA",
|
||||
["WA"] = "WASHINGTON",
|
||||
["WV"] = "WEST VIRGINIA",
|
||||
["WI"] = "WISCONSIN",
|
||||
["WY"] = "WYOMING"
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<string> StorageValuesFor(string region)
|
||||
=> RegionStateCodes.TryGetValue(region, out var codes)
|
||||
? codes.SelectMany(code => new[] { code, StateNames[code] }).ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
public static IReadOnlySet<string> KnownStorageValues { get; } =
|
||||
RegionStateCodes.Values
|
||||
.SelectMany(codes => codes.SelectMany(code => new[] { code, StateNames[code] }))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class AccountOwnerDataService : IAccountOwnerDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public AccountOwnerDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureConfiguredOwnerAsync(
|
||||
string? configuredUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerId = string.IsNullOrWhiteSpace(configuredUserId)
|
||||
? null
|
||||
: configuredUserId.Trim();
|
||||
|
||||
if (ownerId is not null)
|
||||
{
|
||||
var configuredUserExists = await _context.Users
|
||||
.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == ownerId, cancellationToken);
|
||||
|
||||
if (!configuredUserExists)
|
||||
throw new InvalidOperationException("The configured account owner user was not found.");
|
||||
}
|
||||
|
||||
var ownerRows = await _context.Users
|
||||
.Where(user => user.IsAccountOwner || (ownerId != null && user.Id == ownerId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var changed = false;
|
||||
foreach (var user in ownerRows)
|
||||
{
|
||||
var shouldBeOwner = ownerId is not null && user.Id == ownerId;
|
||||
if (user.IsAccountOwner != shouldBeOwner)
|
||||
{
|
||||
user.IsAccountOwner = shouldBeOwner;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +166,6 @@ namespace SeaHaven.DataServices.Implementation
|
|||
on userRole.RoleId equals role.Id
|
||||
where user.IsDeleted != true
|
||||
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
||||
&& (dispatcherId == null || user.Id == dispatcherId)
|
||||
select new
|
||||
{
|
||||
user.Id,
|
||||
|
|
@ -178,6 +177,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
if (accountId is int scopedAccountId)
|
||||
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||
|
||||
if (dispatcherId != null)
|
||||
workOrders = workOrders.Where(w => w.AssignTo == dispatcherId);
|
||||
|
||||
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||
{
|
||||
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||
|
|
|
|||
|
|
@ -51,7 +51,25 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD
|
|||
};
|
||||
}
|
||||
|
||||
public async Task SetOverrideAsync(
|
||||
public Task SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return SetOverrideCoreAsync(userId, permissionKey, state, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SetOverridesAsync(
|
||||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var pair in overrides)
|
||||
await SetOverrideCoreAsync(userId, pair.Key, pair.Value, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SetOverrideCoreAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
|
|
@ -84,11 +102,9 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD
|
|||
}
|
||||
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.
|
||||
// A concurrent write (create or PUT) inserted the same composite key
|
||||
// between our existence check and this save. Converge on the caller's intent.
|
||||
_context.Entry(addition).State = EntityState.Detached;
|
||||
|
||||
var winner = await _context.UserPermissionOverrides
|
||||
.SingleAsync(
|
||||
permission => permission.UserId == userId
|
||||
|
|
@ -98,4 +114,11 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD
|
|||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearOverridesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.UserPermissionOverrides
|
||||
.Where(item => item.UserId == userId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -374,15 +374,17 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Select(g => new
|
||||
{
|
||||
DispatchId = g.Key,
|
||||
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
||||
.Sum(x => (decimal?)x.RequestedNTE),
|
||||
// The two creation paths store different meanings in RequestedNTE.
|
||||
// Vendor-portal rows store the requested new NTE total, so the
|
||||
// granted amount is RequestedNTE - CurrentNTE; work-order-path rows
|
||||
// store the granted increment directly. Vendor sessions have no
|
||||
// identity user, so createdby is null only on vendor-portal rows. Summing
|
||||
// granted amounts keeps sequential approvals from double-counting whole
|
||||
// NTE totals.
|
||||
// NTE totals. This applies to both buckets.
|
||||
AutoApproved = g.Where(x => x.Status == "NoApprovalRequired")
|
||||
.Sum(x => (decimal?)(x.createdby == null
|
||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
||||
: x.RequestedNTE)),
|
||||
AdminApproved = g.Where(x => x.Status == "Approved")
|
||||
.Sum(x => (decimal?)(x.createdby == null
|
||||
? x.RequestedNTE - (x.CurrentNTE ?? 0m)
|
||||
|
|
|
|||
|
|
@ -42,11 +42,22 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Select(user => new UserListRowData
|
||||
{
|
||||
Id = user.Id,
|
||||
RoleName = user.PhoneNumber,
|
||||
RoleName = (
|
||||
from userRole in _context.UserRoles
|
||||
join role in _context.Roles on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == user.Id
|
||||
select role.Name).FirstOrDefault() ?? user.PhoneNumber,
|
||||
Email = user.Email,
|
||||
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
|
||||
CreatedDate = user.CreatedDate,
|
||||
Status = user.UniqueName
|
||||
Status = user.UniqueName,
|
||||
Phone = user.Contact ?? user.PhoneNumber,
|
||||
Color = user.Color,
|
||||
PendingRegistration = user.PendingRegistration,
|
||||
ServiceAreas = user.ServiceAreas
|
||||
.OrderBy(area => area.Area)
|
||||
.Select(area => area.Area)
|
||||
.ToList()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
|
@ -93,6 +104,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(user => user.Id == userId)
|
||||
.Select(user => user.IsAccountOwner)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToUpperInvariant();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation;
|
||||
|
||||
public sealed class UserServiceAreaDataService : IUserServiceAreaDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public UserServiceAreaDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task ReplaceAsync(
|
||||
string userId,
|
||||
IReadOnlyCollection<string> areas,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _context.UserServiceAreas
|
||||
.Where(area => area.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (existing.Count > 0)
|
||||
_context.UserServiceAreas.RemoveRange(existing);
|
||||
|
||||
await _context.UserServiceAreas.AddRangeAsync(
|
||||
areas.Select(area => new UserServiceArea { UserId = userId, Area = area }),
|
||||
cancellationToken);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetForUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.UserServiceAreas
|
||||
.AsNoTracking()
|
||||
.Where(area => area.UserId == userId)
|
||||
.OrderBy(area => area.Area)
|
||||
.Select(area => area.Area)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
else
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface IAccountOwnerDataService
|
||||
{
|
||||
Task<bool> EnsureConfiguredOwnerAsync(string? configuredUserId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -11,4 +11,11 @@ public interface ITeamPermissionOverrideDataService
|
|||
string permissionKey,
|
||||
UserPermissionState state,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task SetOverridesAsync(
|
||||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ClearOverridesAsync(string userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken);
|
||||
Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken);
|
||||
Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken);
|
||||
Task<bool> IsAccountOwnerAsync(string userId, CancellationToken cancellationToken);
|
||||
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
|
||||
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces;
|
||||
|
||||
public interface IUserServiceAreaDataService
|
||||
{
|
||||
Task ReplaceAsync(
|
||||
string userId,
|
||||
IReadOnlyCollection<string> areas,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<string>> GetForUserAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
DateOnly DateTo,
|
||||
bool UnscheduledOnly,
|
||||
IReadOnlyList<string>? Sites,
|
||||
IReadOnlyList<string>? Regions,
|
||||
IReadOnlyList<WorkOrderType>? Types,
|
||||
bool Overdue,
|
||||
IReadOnlyList<string>? Dispatchers,
|
||||
|
|
|
|||
|
|
@ -126,5 +126,25 @@ public sealed class TeamPermissionServiceTests
|
|||
LastSet = (userId, permissionKey, state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetOverridesAsync(
|
||||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var (permissionKey, state) in overrides)
|
||||
{
|
||||
_overrides[permissionKey] = state;
|
||||
LastSet = (userId, permissionKey, state);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
_overrides.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
SeaHaven.Services/Configuration/AccountOwnerOptions.cs
Normal file
8
SeaHaven.Services/Configuration/AccountOwnerOptions.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace SeaHaven.Services.Configuration;
|
||||
|
||||
public sealed class AccountOwnerOptions
|
||||
{
|
||||
public const string SectionName = "TeamMembers:AccountOwner";
|
||||
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
31
SeaHaven.Services/Constants/TeamMemberConstants.cs
Normal file
31
SeaHaven.Services/Constants/TeamMemberConstants.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace SeaHaven.Services.Constants;
|
||||
|
||||
public static class TeamMemberConstants
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Roles =
|
||||
new[] { "Dispatcher", "Scheduler", "Admin" };
|
||||
|
||||
public static readonly IReadOnlyList<string> ServiceAreas =
|
||||
new[] { "East", "Central", "West", "California" };
|
||||
|
||||
public static readonly IReadOnlySet<string> Colors = new HashSet<string>(
|
||||
new[]
|
||||
{
|
||||
"#F59E0B", "#0D9488", "#6366F1", "#F43F5E", "#7C3AED",
|
||||
"#0EA5E9", "#16A34A", "#EA580C", "#0891B2", "#C026D3"
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static string? CanonicalRole(string? value) => Canonical(value, Roles);
|
||||
|
||||
public static string? CanonicalArea(string? value) => Canonical(value, ServiceAreas);
|
||||
|
||||
private static string? Canonical(string? value, IReadOnlyList<string> allowed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
return allowed.FirstOrDefault(item =>
|
||||
string.Equals(item, value.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
public DateOnly? DateTo { get; set; }
|
||||
public string? DispatcherId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
||||
|
|
@ -63,6 +64,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Color { get; init; }
|
||||
public decimal CompletionRate { get; init; }
|
||||
public decimal? AverageResolutionDays { get; init; }
|
||||
public int AssignedCount { get; init; }
|
||||
public int CompletedCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardRegionResponseDTO
|
||||
|
|
@ -108,6 +111,13 @@ namespace SeaHaven.Services.DTOs
|
|||
public int CompletedDueCount { get; set; }
|
||||
public decimal CompletionRate { get; set; }
|
||||
public decimal? AverageResolutionDays { get; set; }
|
||||
public IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DashboardStatusBucketDTO
|
||||
{
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DashboardBreakdownDTO
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ namespace SeaHaven.Services.DTOs
|
|||
public string Date { get; set; } = string.Empty;
|
||||
public string? Status { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public bool? PendingRegistration { get; set; }
|
||||
public IReadOnlyList<string> ServiceAreas { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public class UserProfileRowDTO
|
||||
|
|
|
|||
59
SeaHaven.Services/DTOs/TeamMemberDTOs.cs
Normal file
59
SeaHaven.Services/DTOs/TeamMemberDTOs.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs;
|
||||
|
||||
public sealed record CreateTeamMemberRequestDTO
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Role { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public IReadOnlyList<string>? ServiceAreas { get; init; }
|
||||
public IReadOnlyDictionary<string, UserPermissionState>? PermissionOverrides { get; init; }
|
||||
}
|
||||
|
||||
public class TeamMemberCreatedDTO
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Role { get; init; }
|
||||
public required string Color { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public required IReadOnlyList<string> ServiceAreas { get; init; }
|
||||
public bool PendingRegistration { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO
|
||||
{
|
||||
public string Status { get; init; } = "Active";
|
||||
public bool IsAccountOwner { get; init; }
|
||||
public IReadOnlyList<TeamPermissionValueDTO> Permissions { get; init; } = Array.Empty<TeamPermissionValueDTO>();
|
||||
}
|
||||
|
||||
public sealed record UpdateTeamMemberRequestDTO
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Role { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public IReadOnlyList<string>? ServiceAreas { get; init; }
|
||||
public IReadOnlyDictionary<string, UserPermissionState>? PermissionOverrides { get; init; }
|
||||
public bool IsActive { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed class TeamMemberOperationOutcomeDTO
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public TeamMemberDetailDTO? Member { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CreateTeamMemberOutcomeDTO
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public TeamMemberCreatedDTO? Member { get; init; }
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ namespace SeaHaven.Services.DTOs
|
|||
/// </summary>
|
||||
public bool UnscheduledOnly { get; set; }
|
||||
public List<string>? Sites { get; set; }
|
||||
public List<string>? Regions { get; set; }
|
||||
public List<WorkOrderType>? Types { get; set; }
|
||||
/// <summary>When true, include past-due (isPastDue) rows. Combined with Types via OR.</summary>
|
||||
public bool Overdue { get; set; }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ namespace SeaHaven.Services.DependencyInjection
|
|||
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
|
||||
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
|
||||
services.Configure<VendorDocumentsOptions>(configuration.GetSection(VendorDocumentsOptions.SectionName));
|
||||
services.Configure<AccountOwnerOptions>(configuration.GetSection(AccountOwnerOptions.SectionName));
|
||||
|
||||
services.AddOptions<WorkOrderWebhookOptions>()
|
||||
.Bind(configuration.GetSection(WorkOrderWebhookOptions.SectionName))
|
||||
|
|
|
|||
|
|
@ -61,9 +61,26 @@ public static class DashboardMetrics
|
|||
dueCount,
|
||||
completedDueCount,
|
||||
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
|
||||
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2));
|
||||
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2),
|
||||
BuildStatusDistribution(workOrders));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DashboardStatusBucketDTO> BuildStatusDistribution(
|
||||
IReadOnlyList<DashboardWorkOrder> workOrders)
|
||||
=> workOrders
|
||||
.GroupBy(workOrder => workOrder.LifecycleStatus
|
||||
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
|
||||
.Select(group => new DashboardStatusBucketDTO
|
||||
{
|
||||
Status = group.Key.HasValue
|
||||
? LifecycleStatusMapper.ToFeLabel(group.Key.Value)
|
||||
: "Unknown",
|
||||
Count = group.Count()
|
||||
})
|
||||
.OrderByDescending(bucket => bucket.Count)
|
||||
.ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
private static bool IsPastDue(
|
||||
DashboardWorkOrder workOrder,
|
||||
LifecycleStatus? status,
|
||||
|
|
@ -110,4 +127,5 @@ public sealed record DashboardMetricResult(
|
|||
int DueCount,
|
||||
int CompletedDueCount,
|
||||
decimal CompletionRate,
|
||||
decimal? AverageResolutionDays);
|
||||
decimal? AverageResolutionDays,
|
||||
IReadOnlyList<DashboardStatusBucketDTO> StatusDistribution);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
using SeaHaven.DataServices.Helpers;
|
||||
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 readonly IReadOnlyList<string> Names = WorkOrderBoardRegions.Names;
|
||||
|
||||
public static string Resolve(string? state)
|
||||
{
|
||||
|
|
@ -13,9 +13,13 @@ public static class DashboardRegions
|
|||
if (code is null)
|
||||
return "Unmapped/Other";
|
||||
|
||||
return StateToRegion.TryGetValue(code, out var region)
|
||||
? region
|
||||
: "Unmapped/Other";
|
||||
foreach (var region in Names.Where(region => region != "Unmapped/Other"))
|
||||
{
|
||||
if (WorkOrderBoardRegions.StorageValuesFor(region).Contains(code))
|
||||
return region;
|
||||
}
|
||||
|
||||
return "Unmapped/Other";
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, int> Count(
|
||||
|
|
@ -27,59 +31,4 @@ public static class DashboardRegions
|
|||
|
||||
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"
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation;
|
||||
|
||||
public sealed class AccountOwnerDesignationService : IAccountOwnerDesignationService
|
||||
{
|
||||
private readonly IAccountOwnerDataService _dataService;
|
||||
private readonly IOptions<AccountOwnerOptions> _options;
|
||||
|
||||
public AccountOwnerDesignationService(
|
||||
IAccountOwnerDataService dataService,
|
||||
IOptions<AccountOwnerOptions> options)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task EnsureConfiguredOwnerAsync(CancellationToken cancellationToken) =>
|
||||
_dataService.EnsureConfiguredOwnerAsync(_options.Value.UserId, cancellationToken);
|
||||
}
|
||||
|
|
@ -28,11 +28,7 @@ namespace SeaHaven.Services.Implementation
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var dispatcherId = user.IsInRole("Dispatcher")
|
||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
: null;
|
||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||
|
||||
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
|
||||
var kpis = await _dataService.GetKpiCountsAsync(
|
||||
|
|
@ -63,7 +59,8 @@ namespace SeaHaven.Services.Implementation
|
|||
DueCount = metrics.DueCount,
|
||||
CompletedDueCount = metrics.CompletedDueCount,
|
||||
CompletionRate = metrics.CompletionRate,
|
||||
AverageResolutionDays = metrics.AverageResolutionDays
|
||||
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||
StatusDistribution = metrics.StatusDistribution
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,11 +74,7 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new ArgumentOutOfRangeException(nameof(page));
|
||||
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var dispatcherId = user.IsInRole("Dispatcher")
|
||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
: null;
|
||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||
|
||||
var rows = await _dataService.GetDispatcherWorkloadAsync(
|
||||
accountId,
|
||||
|
|
@ -123,11 +116,7 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new ArgumentOutOfRangeException(nameof(page));
|
||||
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var dispatcherId = user.IsInRole("Dispatcher")
|
||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
: null;
|
||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||
|
||||
var directory = await _dataService.GetDispatcherWorkloadAsync(
|
||||
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
||||
|
|
@ -145,7 +134,9 @@ namespace SeaHaven.Services.Implementation
|
|||
DispatcherName = dispatcher.DispatcherName,
|
||||
Color = dispatcher.Color,
|
||||
CompletionRate = metrics.CompletionRate,
|
||||
AverageResolutionDays = metrics.AverageResolutionDays
|
||||
AverageResolutionDays = metrics.AverageResolutionDays,
|
||||
AssignedCount = metrics.Total,
|
||||
CompletedCount = metrics.Completed
|
||||
};
|
||||
})
|
||||
.OrderByDescending(row => row.CompletionRate)
|
||||
|
|
@ -169,11 +160,7 @@ namespace SeaHaven.Services.Implementation
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var dispatcherId = user.IsInRole("Dispatcher")
|
||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
: null;
|
||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||
|
||||
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
|
||||
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
|
||||
|
|
@ -196,11 +183,7 @@ namespace SeaHaven.Services.Implementation
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
var dispatcherId = user.IsInRole("Dispatcher")
|
||||
? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
: null;
|
||||
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
var dispatcherId = ResolveDispatcherScope(user, query);
|
||||
|
||||
var today = DashboardBusinessTime.Today();
|
||||
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
|
||||
|
|
@ -404,5 +387,33 @@ namespace SeaHaven.Services.Implementation
|
|||
TotalVendors = rows.Count
|
||||
};
|
||||
}
|
||||
|
||||
private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query)
|
||||
{
|
||||
if (user.IsInRole("Dispatcher"))
|
||||
{
|
||||
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
return dispatcherId;
|
||||
}
|
||||
|
||||
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
||||
{
|
||||
var selected = query.DispatcherId;
|
||||
if (string.IsNullOrWhiteSpace(selected))
|
||||
return null;
|
||||
if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var identity = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(identity))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
||||
return identity;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
396
SeaHaven.Services/Implementation/TeamMemberService.cs
Normal file
396
SeaHaven.Services/Implementation/TeamMemberService.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using System.Net.Mail;
|
||||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation;
|
||||
|
||||
public sealed class TeamMemberService : ITeamMemberService
|
||||
{
|
||||
private const string ActiveStatus = "Active";
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly RoleManager<IdentityRole> _roleManager;
|
||||
private readonly IUserServiceAreaDataService _areaDataService;
|
||||
private readonly ITeamPermissionOverrideDataService _permissionDataService;
|
||||
private readonly IUserDataService _userDataService;
|
||||
private readonly ITeamPermissionService _permissionService;
|
||||
|
||||
public TeamMemberService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole> roleManager,
|
||||
IUserServiceAreaDataService areaDataService,
|
||||
ITeamPermissionOverrideDataService permissionDataService,
|
||||
IUserDataService userDataService,
|
||||
ITeamPermissionService permissionService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_areaDataService = areaDataService;
|
||||
_permissionDataService = permissionDataService;
|
||||
_userDataService = userDataService;
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
||||
CreateTeamMemberRequestDTO request,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (caller?.IsInRole("Admin") != true)
|
||||
return Failure("Forbidden");
|
||||
|
||||
var validation = Validate(request, out var role, out var color, out var email, out var areas, out var overrides);
|
||||
if (validation is not null)
|
||||
return Failure(validation);
|
||||
|
||||
if (await _userManager.FindByEmailAsync(email!) is not null)
|
||||
return Failure("Email is already in use.");
|
||||
|
||||
var roleError = await EnsureRoleAsync(role!);
|
||||
if (roleError is not null)
|
||||
return Failure(roleError);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
EmailConfirmed = false,
|
||||
FirstName = request.Name!.Trim(),
|
||||
Contact = request.Phone?.Trim(),
|
||||
PhoneNumber = request.Phone?.Trim(),
|
||||
Color = color,
|
||||
UniqueName = ActiveStatus,
|
||||
CreatedDate = now,
|
||||
PendingRegistration = true,
|
||||
PendingRegistrationCreatedDate = now
|
||||
};
|
||||
|
||||
IdentityResult createResult;
|
||||
try
|
||||
{
|
||||
createResult = await _userManager.CreateAsync(user);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A concurrent create won the race on the unique user-name index
|
||||
// between the FindByEmailAsync check above and this insert. Surface
|
||||
// the same conflict message instead of letting the database
|
||||
// exception bubble up as a 500.
|
||||
return Failure("Email is already in use.");
|
||||
}
|
||||
|
||||
if (!createResult.Succeeded)
|
||||
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
|
||||
|
||||
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
||||
if (!addRoleResult.Succeeded)
|
||||
{
|
||||
await _userManager.DeleteAsync(user);
|
||||
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
||||
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _userManager.DeleteAsync(user);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new CreateTeamMemberOutcomeDTO
|
||||
{
|
||||
Success = true,
|
||||
Member = new TeamMemberCreatedDTO
|
||||
{
|
||||
Id = user.Id,
|
||||
Name = user.FirstName!,
|
||||
Role = role!,
|
||||
Color = color!,
|
||||
Email = user.Email,
|
||||
Phone = user.PhoneNumber,
|
||||
ServiceAreas = areas!,
|
||||
PendingRegistration = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<TeamMemberOperationOutcomeDTO> GetAsync(
|
||||
string userId,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (caller?.IsInRole("Admin") != true)
|
||||
return OperationFailure("Forbidden");
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user is null)
|
||||
return OperationFailure("Team member not found.");
|
||||
|
||||
return await OperationSuccessAsync(user, caller, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
|
||||
string userId,
|
||||
UpdateTeamMemberRequestDTO request,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (caller?.IsInRole("Admin") != true)
|
||||
return OperationFailure("Forbidden");
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user is null)
|
||||
return OperationFailure("Team member not found.");
|
||||
|
||||
var existingRoles = await _userManager.GetRolesAsync(user);
|
||||
var existingRole = existingRoles.FirstOrDefault();
|
||||
var candidate = new CreateTeamMemberRequestDTO
|
||||
{
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Color = request.Color,
|
||||
Email = request.Email,
|
||||
Phone = request.Phone,
|
||||
ServiceAreas = request.ServiceAreas,
|
||||
PermissionOverrides = request.PermissionOverrides
|
||||
};
|
||||
var validation = Validate(candidate, out var role, out var color, out var email, out var areas, out var overrides);
|
||||
if (validation is not null)
|
||||
return OperationFailure(validation);
|
||||
|
||||
var roleChanged = !string.Equals(existingRole, role, StringComparison.OrdinalIgnoreCase);
|
||||
var isOwner = await _userDataService.IsAccountOwnerAsync(user.Id, cancellationToken);
|
||||
if (isOwner && (roleChanged || !request.IsActive))
|
||||
return OperationFailure("Forbidden");
|
||||
|
||||
var duplicate = await _userManager.FindByEmailAsync(email!);
|
||||
if (duplicate is not null && !string.Equals(duplicate.Id, user.Id, StringComparison.Ordinal))
|
||||
return OperationFailure("Email is already in use.");
|
||||
|
||||
var roleError = await EnsureRoleAsync(role!);
|
||||
if (roleError is not null)
|
||||
return OperationFailure(roleError);
|
||||
|
||||
ApplyName(user, candidate.Name!.Trim());
|
||||
user.Contact = candidate.Phone?.Trim();
|
||||
user.PhoneNumber = candidate.Phone?.Trim();
|
||||
user.Color = color;
|
||||
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
|
||||
user.IsDeleted = !request.IsActive;
|
||||
|
||||
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var emailResult = await _userManager.SetEmailAsync(user, email!);
|
||||
if (!emailResult.Succeeded)
|
||||
return OperationFailure(emailResult.Errors.FirstOrDefault()?.Description ?? "Unable to update email.");
|
||||
|
||||
var userNameResult = await _userManager.SetUserNameAsync(user, email!);
|
||||
if (!userNameResult.Succeeded)
|
||||
return OperationFailure(userNameResult.Errors.FirstOrDefault()?.Description ?? "Unable to update email.");
|
||||
|
||||
var securityStampResult = await _userManager.UpdateSecurityStampAsync(user);
|
||||
if (!securityStampResult.Succeeded)
|
||||
return OperationFailure(securityStampResult.Errors.FirstOrDefault()?.Description ?? "Unable to update email.");
|
||||
}
|
||||
|
||||
if (roleChanged)
|
||||
{
|
||||
if (existingRoles.Count > 0)
|
||||
{
|
||||
var removeRoleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles);
|
||||
if (!removeRoleResult.Succeeded)
|
||||
return OperationFailure(removeRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
|
||||
}
|
||||
|
||||
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
||||
if (!addRoleResult.Succeeded)
|
||||
return OperationFailure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
|
||||
}
|
||||
|
||||
await _userDataService.UpdateUserAsync(user, cancellationToken);
|
||||
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
||||
if (roleChanged || request.PermissionOverrides is not null)
|
||||
{
|
||||
await _permissionDataService.ClearOverridesAsync(user.Id, cancellationToken);
|
||||
if (request.PermissionOverrides is not null)
|
||||
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
|
||||
}
|
||||
|
||||
return await OperationSuccessAsync(user, caller, cancellationToken);
|
||||
}
|
||||
|
||||
private static void ApplyName(ApplicationUser user, string fullName)
|
||||
{
|
||||
var current = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
|
||||
if (string.Equals(fullName, current, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
var separator = fullName.IndexOf(' ');
|
||||
if (separator < 0)
|
||||
{
|
||||
user.FirstName = fullName;
|
||||
user.LastName = null;
|
||||
return;
|
||||
}
|
||||
|
||||
user.FirstName = fullName[..separator].Trim();
|
||||
user.LastName = fullName[(separator + 1)..].Trim();
|
||||
}
|
||||
|
||||
private async Task<string?> EnsureRoleAsync(string role)
|
||||
{
|
||||
if (await _roleManager.RoleExistsAsync(role))
|
||||
return null;
|
||||
|
||||
var roleResult = await _roleManager.CreateAsync(new IdentityRole(role));
|
||||
return roleResult.Succeeded
|
||||
? null
|
||||
: roleResult.Errors.FirstOrDefault()?.Description ?? "Unable to create role.";
|
||||
}
|
||||
|
||||
private async Task<TeamMemberOperationOutcomeDTO> OperationSuccessAsync(
|
||||
ApplicationUser user,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken);
|
||||
var areas = await _areaDataService.GetForUserAsync(user.Id, cancellationToken);
|
||||
return new TeamMemberOperationOutcomeDTO
|
||||
{
|
||||
Success = true,
|
||||
Member = new TeamMemberDetailDTO
|
||||
{
|
||||
Id = user.Id,
|
||||
Name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(),
|
||||
Role = TeamMemberConstants.CanonicalRole(roles.FirstOrDefault()) ?? roles.FirstOrDefault() ?? "",
|
||||
Color = user.Color ?? "",
|
||||
Email = user.Email,
|
||||
Phone = user.Contact ?? user.PhoneNumber,
|
||||
ServiceAreas = areas,
|
||||
PendingRegistration = user.PendingRegistration == true,
|
||||
Status = user.UniqueName ?? "Active",
|
||||
IsAccountOwner = user.IsAccountOwner,
|
||||
Permissions = profile?.Value?.Permissions ?? Array.Empty<TeamPermissionValueDTO>()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Validate(
|
||||
CreateTeamMemberRequestDTO request,
|
||||
out string? role,
|
||||
out string? color,
|
||||
out string? email,
|
||||
out IReadOnlyList<string>? areas,
|
||||
out IReadOnlyDictionary<string, UserPermissionState>? overrides)
|
||||
{
|
||||
role = TeamMemberConstants.CanonicalRole(request.Role);
|
||||
color = request.Color?.Trim();
|
||||
email = request.Email?.Trim();
|
||||
areas = NormalizeAreas(request.ServiceAreas, out var invalidArea);
|
||||
overrides = NormalizeOverrides(request.PermissionOverrides, out var invalidPermission);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return "Name is required.";
|
||||
if (role is null)
|
||||
return "Role must be Dispatcher, Scheduler, or Admin.";
|
||||
if (color is null || !TeamMemberConstants.Colors.Contains(color))
|
||||
return "Color must be selected from the accessible palette.";
|
||||
if (!IsValidEmail(email))
|
||||
return "A valid email is required.";
|
||||
if (invalidArea is not null)
|
||||
return $"Unknown service area: {invalidArea}.";
|
||||
if (string.Equals(role, "Dispatcher", StringComparison.Ordinal)
|
||||
&& (areas is null || areas.Count == 0))
|
||||
return "At least one service area is required for a Dispatcher.";
|
||||
if (!string.Equals(role, "Dispatcher", StringComparison.Ordinal)
|
||||
&& areas is { Count: > 0 })
|
||||
return "Service areas are only available for Dispatchers.";
|
||||
if (invalidPermission is not null)
|
||||
return $"Unknown permission key: {invalidPermission}.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeAreas(
|
||||
IReadOnlyList<string>? values,
|
||||
out string? invalidArea)
|
||||
{
|
||||
invalidArea = null;
|
||||
var normalized = new List<string>();
|
||||
foreach (var value in values ?? Array.Empty<string>())
|
||||
{
|
||||
var canonical = TeamMemberConstants.CanonicalArea(value);
|
||||
if (canonical is null)
|
||||
{
|
||||
invalidArea = value;
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (!normalized.Contains(canonical, StringComparer.Ordinal))
|
||||
normalized.Add(canonical);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, UserPermissionState> NormalizeOverrides(
|
||||
IReadOnlyDictionary<string, UserPermissionState>? values,
|
||||
out string? invalidPermission)
|
||||
{
|
||||
invalidPermission = null;
|
||||
var normalized = new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in values ?? new Dictionary<string, UserPermissionState>())
|
||||
{
|
||||
var canonical = TeamPermissionKeys.All.FirstOrDefault(key =>
|
||||
string.Equals(key, pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (canonical is null)
|
||||
{
|
||||
invalidPermission = pair.Key;
|
||||
return new Dictionary<string, UserPermissionState>();
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(pair.Value))
|
||||
{
|
||||
invalidPermission = pair.Key;
|
||||
return new Dictionary<string, UserPermissionState>();
|
||||
}
|
||||
|
||||
normalized[canonical] = pair.Value;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var address = new MailAddress(value);
|
||||
return string.Equals(address.Address, value, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
|
||||
new() { Success = false, Error = error };
|
||||
|
||||
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
|
||||
new() { Success = false, Error = error };
|
||||
}
|
||||
|
|
@ -38,7 +38,11 @@ namespace SeaHaven.Services.Implementation
|
|||
Name = s.Name,
|
||||
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
|
||||
Status = s.Status,
|
||||
Id = s.Id
|
||||
Id = s.Id,
|
||||
Phone = s.Phone,
|
||||
Color = s.Color,
|
||||
PendingRegistration = s.PendingRegistration,
|
||||
ServiceAreas = s.ServiceAreas
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +140,9 @@ namespace SeaHaven.Services.Implementation
|
|||
if (exist == null)
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
||||
|
||||
if (await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
||||
|
||||
exist.FirstName = dto.Name;
|
||||
if (string.IsNullOrWhiteSpace(dto.Email))
|
||||
throw new ArgumentException("Email is required.", nameof(dto));
|
||||
|
|
@ -174,6 +181,9 @@ namespace SeaHaven.Services.Implementation
|
|||
if (data == null)
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
||||
|
||||
if (await _userDataService.IsAccountOwnerAsync(data.Id, cancellationToken))
|
||||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
|
||||
|
||||
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
|
||||
return new AddUserOutcomeDTO { Success = true };
|
||||
}
|
||||
|
|
@ -181,7 +191,7 @@ namespace SeaHaven.Services.Implementation
|
|||
public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
|
||||
if (exist != null)
|
||||
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
|
||||
{
|
||||
exist.IsDeleted = true;
|
||||
await _userDataService.UpdateUserAsync(exist, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ namespace SeaHaven.Services.Implementation
|
|||
dateRange.To,
|
||||
query.UnscheduledOnly,
|
||||
query.Sites,
|
||||
query.Regions,
|
||||
query.Types,
|
||||
query.Overdue,
|
||||
query.Dispatchers,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace SeaHaven.Services.Interfaces;
|
||||
|
||||
public interface IAccountOwnerDesignationService
|
||||
{
|
||||
Task EnsureConfiguredOwnerAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
21
SeaHaven.Services/Interfaces/ITeamMemberService.cs
Normal file
21
SeaHaven.Services/Interfaces/ITeamMemberService.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces;
|
||||
|
||||
public interface ITeamMemberService
|
||||
{
|
||||
Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
||||
CreateTeamMemberRequestDTO request,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
Task<TeamMemberOperationOutcomeDTO> GetAsync(
|
||||
string userId,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
|
||||
string userId,
|
||||
UpdateTeamMemberRequestDTO request,
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -228,6 +228,47 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
Assert.Equal("BK5", result.Items.First().SiteCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_FiltersByCanonicalRegionAndUnmappedBucket()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var date = new DateTime(2026, 6, 23);
|
||||
context.Locations.AddRange(
|
||||
new Locations { Id = 1, State = "WA" },
|
||||
new Locations { Id = 2, State = "CALIFORNIA" },
|
||||
new Locations { Id = 3, State = "Atlantis" },
|
||||
new Locations { Id = 4, State = null });
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder { Id = 1, LocationId = 1, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 2, LocationId = 2, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 3, LocationId = 3, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled },
|
||||
new WorkOrder { Id = 4, LocationId = 4, ScheduledDate = date, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||
dataService, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
var query = new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = new DateOnly(2026, 6, 22),
|
||||
DateTo = new DateOnly(2026, 6, 30),
|
||||
PageSize = 50
|
||||
};
|
||||
|
||||
query.Regions = new List<string> { "West" };
|
||||
var west = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
Assert.Equal(new[] { 1 }, west.Items.Select(item => item.Id));
|
||||
|
||||
query.Regions = new List<string> { "California" };
|
||||
var california = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
Assert.Equal(new[] { 2 }, california.Items.Select(item => item.Id));
|
||||
|
||||
query.Regions = new List<string> { "Unmapped/Other" };
|
||||
var unmapped = await service.SearchAsync(query, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||
Assert.Equal(new[] { 3, 4 }, unmapped.Items.Select(item => item.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ClampsPageSizeToMax()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ public class WorkOrderMediaConcurrencyRelationalTests
|
|||
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
if (index.GetFilter() != null && index.GetDatabaseName() != "IX_AspNetUsers_IsAccountOwner")
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue