From fc5c7d5ca494c8080e1248db1631d8192d756ed4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 19:14:36 -0300 Subject: [PATCH] feat(team-members): support pending member creation (SH-325) --- .../TeamMemberServiceTests.cs | 153 + .../Controllers/TeamMemberController.cs | 36 + .../Auth/ApplicationDbContext.cs | 24 + .../Auth/UserServiceArea.cs | 9 + ...220437_SH325_PendingTeamMember.Designer.cs | 4056 +++++++++++++++++ .../20260916220437_SH325_PendingTeamMember.cs | 66 + .../ApplicationDbContextModelSnapshot.cs | 38 + SeaHaven.DataServices/Dto/UserReadModels.cs | 4 + .../TeamPermissionOverrideDataService.cs | 53 +- .../Implementation/UserDataService.cs | 15 +- .../UserServiceAreaDataService.cs | 46 + .../ITeamPermissionOverrideDataService.cs | 5 + .../Interfaces/IUserServiceAreaDataService.cs | 15 + .../TeamPermissionServiceTests.cs | 14 + .../Constants/TeamMemberConstants.cs | 31 + SeaHaven.Services/DTOs/IdentityDTOs.cs | 4 + SeaHaven.Services/DTOs/TeamMemberDTOs.cs | 33 + .../Implementation/TeamMemberService.cs | 214 + .../Implementation/UserService.cs | 6 +- .../Interfaces/ITeamMemberService.cs | 12 + 20 files changed, 4813 insertions(+), 21 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/TeamMemberController.cs create mode 100644 Data.SeaHavenIndustries/Auth/UserServiceArea.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260916220437_SH325_PendingTeamMember.cs create mode 100644 SeaHaven.DataServices/Implementation/UserServiceAreaDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IUserServiceAreaDataService.cs create mode 100644 SeaHaven.Services/Constants/TeamMemberConstants.cs create mode 100644 SeaHaven.Services/DTOs/TeamMemberDTOs.cs create mode 100644 SeaHaven.Services/Implementation/TeamMemberService.cs create mode 100644 SeaHaven.Services/Interfaces/ITeamMemberService.cs diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs new file mode 100644 index 0000000..4b39ede --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -0,0 +1,153 @@ +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +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(), + Mock.Of()); + + var result = await service.CreateAsync( + ValidRequest() with { ServiceAreas = Array.Empty() }, + 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()), Times.Never); + } + + [Fact] + public async Task Create_RejectsUnknownColorAndPermission() + { + var service = NewService(out var userManager, 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 + { + ["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()), Times.Never); + } + + [Fact] + public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides() + { + var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides); + ApplicationUser? created = null; + userManager + .Setup(manager => manager.CreateAsync(It.IsAny())) + .Callback(user => + { + user.Id = "new-user"; + created = user; + }) + .ReturnsAsync(IdentityResult.Success); + userManager + .Setup(manager => manager.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + userManager + .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "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>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny()), Times.Once); + overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny()), Times.Once); + } + + private static TeamMemberService NewService( + out Mock> userManager, + out Mock> roleManager, + out Mock areas, + out Mock overrides) + { + userManager = UserManager(); + roleManager = RoleManager(); + areas = new Mock(); + overrides = new Mock(); + return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.Object); + } + + private static Mock> UserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), + Mock.Of>(), + Array.Empty>(), + Array.Empty>(), + Mock.Of(), + new IdentityErrorDescriber(), + Mock.Of(), + Mock.Of>>()); + } + + private static Mock> RoleManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Array.Empty>(), + Mock.Of(), + new IdentityErrorDescriber(), + Mock.Of>>()); + } + + 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 + { + ["deleteSites"] = UserPermissionState.Allow + } + }; + + private static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test")); +} diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs new file mode 100644 index 0000000..d09c6bd --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -0,0 +1,36 @@ +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 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); + } +} diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 870a127..cfc544b 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -257,6 +257,26 @@ namespace Data.SeaHavenIndustries .IsUnique() .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); }); + + builder.Entity(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 Categories { get; set; } public DbSet Locations { get; set; } @@ -312,6 +332,7 @@ namespace Data.SeaHavenIndustries public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } public DbSet UserPermissionOverrides { get; set; } + public DbSet UserServiceAreas { get; set; } public override int SaveChanges() { @@ -392,12 +413,15 @@ namespace Data.SeaHavenIndustries 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; } /// Optional CRM account membership for server-derived media scope (SH-221). public int? AccountId { get; set; } [ForeignKey(nameof(AccountId))] public virtual Accounts? Account { get; set; } public ICollection