mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
422 lines
17 KiB
C#
422 lines
17 KiB
C#
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
|
|
{
|
|
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);
|
|
},
|
|
cancellationToken);
|
|
}
|
|
catch (TeamMemberCreateException exception)
|
|
{
|
|
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;
|
|
|
|
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
|
|
if (emailChanged)
|
|
{
|
|
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);
|
|
}
|
|
|
|
// An invite sent to the old address, and any code confirmed through it, must not register the new one.
|
|
bool? inviteEmailSent = null;
|
|
if (emailChanged && user.PendingRegistration == true)
|
|
inviteEmailSent = await _inviteService.ReissueAsync(user, cancellationToken);
|
|
|
|
return await OperationSuccessAsync(user, caller, cancellationToken, inviteEmailSent);
|
|
}
|
|
|
|
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,
|
|
bool? inviteEmailSent = null)
|
|
{
|
|
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,
|
|
InviteEmailSent = inviteEmailSent,
|
|
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 };
|
|
|
|
private sealed class TeamMemberCreateException : Exception
|
|
{
|
|
public TeamMemberCreateException(string error) : base(error)
|
|
{
|
|
}
|
|
}
|
|
|
|
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
|
|
new() { Success = false, Error = error };
|
|
}
|