diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index 4b39ede..e2295ff 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -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(), - Mock.Of()); + Mock.Of(), + Mock.Of(), + Mock.Of()); var result = await service.CreateAsync( ValidRequest() with { ServiceAreas = Array.Empty() }, @@ -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())) @@ -95,17 +98,136 @@ public sealed class TeamMemberServiceTests overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny()), 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 { "Admin" }); + userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny())).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(), It.IsAny()), 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 { "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())) + .ReturnsAsync(new[] { "Central" }); + permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(TeamPermissionResult.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 + { + ["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()), Times.Once); + areas.Verify(data => data.ReplaceAsync(user.Id, It.Is>(value => value.SequenceEqual(new[] { "West" })), It.IsAny()), Times.Once); + overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny()), Times.Once); + overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny()), Times.Once); + } + private static TeamMemberService NewService( out Mock> userManager, out Mock> roleManager, out Mock areas, - out Mock overrides) + out Mock overrides, + out Mock userData, + out Mock permissions) { userManager = UserManager(); roleManager = RoleManager(); areas = new Mock(); overrides = new Mock(); - return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.Object); + userData = new Mock(); + permissions = new Mock(); + return new TeamMemberService( + userManager.Object, + roleManager.Object, + areas.Object, + overrides.Object, + userData.Object, + permissions.Object); } private static Mock> UserManager() diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs index d09c6bd..f6537b6 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -33,4 +33,35 @@ public sealed class TeamMemberController : ControllerBase return Ok(outcome.Member); } + + [HttpGet("{userId}")] + public async Task Get(string userId, CancellationToken cancellationToken) + { + var outcome = await _teamMemberService.GetAsync(userId, User, cancellationToken); + return ToActionResult(outcome); + } + + [HttpPut("{userId}")] + public async Task 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 }); + } } diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs index 3449313..e177534 100644 --- a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -98,4 +98,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); + } } diff --git a/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs index 416c6c9..79d9b9b 100644 --- a/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ITeamPermissionOverrideDataService.cs @@ -16,4 +16,6 @@ public interface ITeamPermissionOverrideDataService string userId, IReadOnlyDictionary overrides, CancellationToken cancellationToken); + + Task ClearOverridesAsync(string userId, CancellationToken cancellationToken); } diff --git a/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs b/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs index b22042f..33bda2f 100644 --- a/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs +++ b/SeaHaven.Services.Tests/TeamPermissionServiceTests.cs @@ -140,5 +140,11 @@ public sealed class TeamPermissionServiceTests return Task.CompletedTask; } + + public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) + { + _overrides.Clear(); + return Task.CompletedTask; + } } } diff --git a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs index 8c430b9..814e279 100644 --- a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs +++ b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs @@ -13,7 +13,7 @@ public sealed record CreateTeamMemberRequestDTO public IReadOnlyDictionary? 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 Permissions { get; init; } = Array.Empty(); +} + +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? ServiceAreas { get; init; } + public IReadOnlyDictionary? 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; } diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index ad01254..5e85e89 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -17,17 +17,23 @@ public sealed class TeamMemberService : ITeamMemberService private readonly RoleManager _roleManager; private readonly IUserServiceAreaDataService _areaDataService; private readonly ITeamPermissionOverrideDataService _permissionDataService; + private readonly IUserDataService _userDataService; + private readonly ITeamPermissionService _permissionService; public TeamMemberService( UserManager userManager, RoleManager 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 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 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 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 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 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() + } + }; + } + 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 }; } diff --git a/SeaHaven.Services/Interfaces/ITeamMemberService.cs b/SeaHaven.Services/Interfaces/ITeamMemberService.cs index 36016cf..702625f 100644 --- a/SeaHaven.Services/Interfaces/ITeamMemberService.cs +++ b/SeaHaven.Services/Interfaces/ITeamMemberService.cs @@ -9,4 +9,13 @@ public interface ITeamMemberService CreateTeamMemberRequestDTO request, ClaimsPrincipal caller, CancellationToken cancellationToken); + Task GetAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken); + Task UpdateAsync( + string userId, + UpdateTeamMemberRequestDTO request, + ClaimsPrincipal caller, + CancellationToken cancellationToken); }