mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
CreateAsync checks FindByEmailAsync and then inserts, so two concurrent creates with the same email can both pass the check and hit the unique user-name index. That DbUpdateException was unhandled and surfaced as a 500. Catch it at the CreateAsync call and return the existing "Email is already in use." message, matching the check-then-insert converge pattern already used by SetOverrideCoreAsync.
228 lines
8.1 KiB
C#
228 lines
8.1 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;
|
|
|
|
public TeamMemberService(
|
|
UserManager<ApplicationUser> userManager,
|
|
RoleManager<IdentityRole> roleManager,
|
|
IUserServiceAreaDataService areaDataService,
|
|
ITeamPermissionOverrideDataService permissionDataService)
|
|
{
|
|
_userManager = userManager;
|
|
_roleManager = roleManager;
|
|
_areaDataService = areaDataService;
|
|
_permissionDataService = permissionDataService;
|
|
}
|
|
|
|
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.");
|
|
|
|
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 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
|
|
};
|
|
|
|
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.
|
|
return Failure("Email is already in use.");
|
|
}
|
|
|
|
if (!createResult.Succeeded)
|
|
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
|
|
|
|
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
|
if (!addRoleResult.Succeeded)
|
|
{
|
|
await _userManager.DeleteAsync(user);
|
|
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
|
|
}
|
|
|
|
try
|
|
{
|
|
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
|
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
await _userManager.DeleteAsync(user);
|
|
throw;
|
|
}
|
|
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|