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 _userManager; 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, IUserDataService userDataService, ITeamPermissionService permissionService) { _userManager = userManager; _roleManager = roleManager; _areaDataService = areaDataService; _permissionDataService = permissionDataService; _userDataService = userDataService; _permissionService = permissionService; } public async Task 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 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 }; try { await _userDataService.ExecuteTransactionalAsync( async transactionCancellationToken => { var roleError = await EnsureRoleAsync(role!); if (roleError is not null) throw new TeamMemberCreateException(roleError); 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. throw new TeamMemberCreateException("Email is already in use."); } if (!createResult.Succeeded) throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member."); var addRoleResult = await _userManager.AddToRoleAsync(user, role!); if (!addRoleResult.Succeeded) throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role."); await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken); await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken); }, cancellationToken); } catch (TeamMemberCreateException exception) { return Failure(exception.Message); } 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 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); 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 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, out string? color, out string? email, out IReadOnlyList? areas, out IReadOnlyDictionary? 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 NormalizeAreas( IReadOnlyList? values, out string? invalidArea) { invalidArea = null; var normalized = new List(); foreach (var value in values ?? Array.Empty()) { var canonical = TeamMemberConstants.CanonicalArea(value); if (canonical is null) { invalidArea = value; return Array.Empty(); } if (!normalized.Contains(canonical, StringComparer.Ordinal)) normalized.Add(canonical); } return normalized; } private static IReadOnlyDictionary NormalizeOverrides( IReadOnlyDictionary? values, out string? invalidPermission) { invalidPermission = null; var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pair in values ?? new Dictionary()) { var canonical = TeamPermissionKeys.All.FirstOrDefault(key => string.Equals(key, pair.Key, StringComparison.OrdinalIgnoreCase)); if (canonical is null) { invalidPermission = pair.Key; return new Dictionary(); } if (!Enum.IsDefined(pair.Value)) { invalidPermission = pair.Key; return new Dictionary(); } 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 sealed class TeamMemberCreateException : Exception { public TeamMemberCreateException(string error) : base(error) { } } private static TeamMemberOperationOutcomeDTO OperationFailure(string error) => new() { Success = false, Error = error }; }