shoc-backend/SeaHaven.Services/Implementation/TeamMemberService.cs

415 lines
16 KiB
C#
Raw Normal View History

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;
private readonly ITeamMemberInviteService _inviteService;
public TeamMemberService(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IUserServiceAreaDataService areaDataService,
ITeamPermissionOverrideDataService permissionDataService,
IUserDataService userDataService,
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
{
_userManager = userManager;
_roleManager = roleManager;
_areaDataService = areaDataService;
_permissionDataService = permissionDataService;
_userDataService = userDataService;
_permissionService = permissionService;
_inviteService = inviteService;
}
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 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
};
IssuedTeamMemberInvite? invite = null;
try
{
2026-09-23 03:35:23 -03:00
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);
invite = await _inviteService.CreateAsync(user.Id, transactionCancellationToken);
2026-09-23 03:35:23 -03:00
},
cancellationToken);
}
2026-09-23 03:35:23 -03:00
catch (TeamMemberCreateException exception)
{
2026-09-23 03:35:23 -03:00
return Failure(exception.Message);
}
// The member and their invite commit together; the email goes out only after
// commit, so a rolled-back member never receives a link.
var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
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,
InviteEmailSent = inviteEmailSent
}
};
}
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 };
2026-09-23 03:35:23 -03:00
private sealed class TeamMemberCreateException : Exception
{
public TeamMemberCreateException(string error) : base(error)
{
}
}
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
new() { Success = false, Error = error };
}