mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
feat(team-members): add member detail editing (SH-326)
This commit is contained in:
parent
8a00476d38
commit
b9c916cb22
8 changed files with 344 additions and 13 deletions
|
|
@ -9,6 +9,7 @@ using Moq;
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -24,7 +25,9 @@ public sealed class TeamMemberServiceTests
|
|||
userManager.Object,
|
||||
roleManager.Object,
|
||||
Mock.Of<IUserServiceAreaDataService>(),
|
||||
Mock.Of<ITeamPermissionOverrideDataService>());
|
||||
Mock.Of<ITeamPermissionOverrideDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<ITeamPermissionService>());
|
||||
|
||||
var result = await service.CreateAsync(
|
||||
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
||||
|
|
@ -39,7 +42,7 @@ public sealed class TeamMemberServiceTests
|
|||
[Fact]
|
||||
public async Task Create_RejectsUnknownColorAndPermission()
|
||||
{
|
||||
var service = NewService(out var userManager, out _, out _, out _);
|
||||
var service = NewService(out var userManager, out _, out _, out _, out _, out _);
|
||||
|
||||
var badColor = await service.CreateAsync(
|
||||
ValidRequest() with { Color = "#000000" },
|
||||
|
|
@ -64,7 +67,7 @@ public sealed class TeamMemberServiceTests
|
|||
[Fact]
|
||||
public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides()
|
||||
{
|
||||
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides);
|
||||
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>()))
|
||||
|
|
@ -95,17 +98,136 @@ public sealed class TeamMemberServiceTests
|
|||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
private static TeamMemberService NewService(
|
||||
out Mock<UserManager<ApplicationUser>> userManager,
|
||||
out Mock<RoleManager<IdentityRole>> roleManager,
|
||||
out Mock<IUserServiceAreaDataService> areas,
|
||||
out Mock<ITeamPermissionOverrideDataService> overrides)
|
||||
out Mock<ITeamPermissionOverrideDataService> overrides,
|
||||
out Mock<IUserDataService> userData,
|
||||
out Mock<ITeamPermissionService> permissions)
|
||||
{
|
||||
userManager = UserManager();
|
||||
roleManager = RoleManager();
|
||||
areas = new Mock<IUserServiceAreaDataService>();
|
||||
overrides = new Mock<ITeamPermissionOverrideDataService>();
|
||||
return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.Object);
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -33,4 +33,35 @@ public sealed class TeamMemberController : ControllerBase
|
|||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,4 +113,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,6 @@ public interface ITeamPermissionOverrideDataService
|
|||
string userId,
|
||||
IReadOnlyDictionary<string, UserPermissionState> overrides,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ClearOverridesAsync(string userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,5 +140,11 @@ public sealed class TeamPermissionServiceTests
|
|||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
_overrides.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public sealed record CreateTeamMemberRequestDTO
|
|||
public IReadOnlyDictionary<string, UserPermissionState>? PermissionOverrides { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TeamMemberCreatedDTO
|
||||
public class TeamMemberCreatedDTO
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
|
|
@ -25,6 +25,32 @@ public sealed class TeamMemberCreatedDTO
|
|||
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; }
|
||||
|
|
|
|||
|
|
@ -17,17 +17,23 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
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)
|
||||
ITeamPermissionOverrideDataService permissionDataService,
|
||||
IUserDataService userDataService,
|
||||
ITeamPermissionService permissionService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_areaDataService = areaDataService;
|
||||
_permissionDataService = permissionDataService;
|
||||
_userDataService = userDataService;
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
||||
|
|
@ -45,12 +51,9 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
if (await _userManager.FindByEmailAsync(email!) is not null)
|
||||
return Failure("Email is already in use.");
|
||||
|
||||
if (!await _roleManager.RoleExistsAsync(role!))
|
||||
{
|
||||
var roleResult = await _roleManager.CreateAsync(new IdentityRole(role!));
|
||||
if (!roleResult.Succeeded)
|
||||
return Failure(roleResult.Errors.FirstOrDefault()?.Description ?? "Unable to create role.");
|
||||
}
|
||||
var roleError = await EnsureRoleAsync(role!);
|
||||
if (roleError is not null)
|
||||
return Failure(roleError);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var user = new ApplicationUser
|
||||
|
|
@ -107,6 +110,128 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
user.FirstName = candidate.Name!.Trim();
|
||||
user.Email = email;
|
||||
user.UserName = email;
|
||||
user.NormalizedEmail = email!.ToUpperInvariant();
|
||||
user.NormalizedUserName = email.ToUpperInvariant();
|
||||
user.Contact = candidate.Phone?.Trim();
|
||||
user.PhoneNumber = candidate.Phone?.Trim();
|
||||
user.Color = color;
|
||||
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
|
||||
user.IsDeleted = !request.IsActive;
|
||||
|
||||
if (roleChanged)
|
||||
{
|
||||
if (existingRoles.Count > 0)
|
||||
await _userManager.RemoveFromRolesAsync(user, existingRoles);
|
||||
await _userManager.AddToRoleAsync(user, role!);
|
||||
}
|
||||
|
||||
await _userDataService.UpdateUserAsync(user, cancellationToken);
|
||||
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
||||
await _permissionDataService.ClearOverridesAsync(user.Id, cancellationToken);
|
||||
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
|
||||
|
||||
return await OperationSuccessAsync(user, caller, cancellationToken);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -211,4 +336,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
|
||||
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
|
||||
new() { Success = false, Error = error };
|
||||
|
||||
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
|
||||
new() { Success = false, Error = error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,4 +9,13 @@ public interface ITeamMemberService
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue