122 lines
4.2 KiB
C#
122 lines
4.2 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
|
|
public class UserService : IUserService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly ICurrentUserService _currentUser;
|
|
|
|
public UserService(AppDbContext db, ICurrentUserService currentUser)
|
|
{
|
|
_db = db;
|
|
_currentUser = currentUser;
|
|
}
|
|
|
|
public async Task<List<ClinicalUserResponse>> ListAsync()
|
|
{
|
|
var users = await _db.ClinicalUsers
|
|
.AsNoTracking()
|
|
.OrderBy(u => u.Username)
|
|
.ToListAsync();
|
|
return users.Select(Map).ToList();
|
|
}
|
|
|
|
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
|
|
{
|
|
ValidateUsername(req.Username);
|
|
ValidatePassword(req.Password);
|
|
ValidateDisplayName(req.DisplayName);
|
|
var role = ParseRole(req.Role);
|
|
|
|
var exists = await _db.ClinicalUsers.AnyAsync(u => u.Username == req.Username);
|
|
if (exists)
|
|
throw new ConflictException(
|
|
$"Username '{req.Username}' is already taken.",
|
|
"USERNAME_CONFLICT");
|
|
|
|
var user = new ClinicalUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Username = req.Username.Trim(),
|
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
|
|
DisplayName = req.DisplayName.Trim(),
|
|
Role = role,
|
|
IsActive = true,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
_db.ClinicalUsers.Add(user);
|
|
await _db.SaveChangesAsync();
|
|
return Map(user);
|
|
}
|
|
|
|
public async Task<ClinicalUserResponse> UpdateAsync(Guid id, UpdateUserRequest req)
|
|
{
|
|
var user = await _db.ClinicalUsers.FindAsync(id);
|
|
if (user is null)
|
|
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
|
|
|
if (req.DisplayName is not null)
|
|
{
|
|
ValidateDisplayName(req.DisplayName);
|
|
user.DisplayName = req.DisplayName.Trim();
|
|
}
|
|
|
|
if (req.Role is not null)
|
|
user.Role = ParseRole(req.Role);
|
|
|
|
if (req.IsActive is not null)
|
|
{
|
|
if (!req.IsActive.Value && _currentUser.UserId == id)
|
|
throw new ValidationException(
|
|
"You cannot deactivate your own account.",
|
|
"SELF_DEACTIVATION_DENIED");
|
|
user.IsActive = req.IsActive.Value;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Map(user);
|
|
}
|
|
|
|
private static ClinicalUserResponse Map(ClinicalUser u) =>
|
|
new(u.Id, u.Username, u.DisplayName, u.Role.ToDbString(), u.IsActive, u.CreatedAt, u.LastLoginAt);
|
|
|
|
private static void ValidateUsername(string username)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(username))
|
|
throw new ValidationException("Username is required.", "USERNAME_REQUIRED");
|
|
if (username.Trim().Length > 100)
|
|
throw new ValidationException("Username must be 100 characters or fewer.", "USERNAME_TOO_LONG");
|
|
}
|
|
|
|
private static void ValidatePassword(string password)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
|
|
if (password.Length < 8)
|
|
throw new ValidationException("Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
|
|
}
|
|
|
|
private static void ValidateDisplayName(string displayName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(displayName))
|
|
throw new ValidationException("Display name is required.", "DISPLAY_NAME_REQUIRED");
|
|
if (displayName.Trim().Length > 200)
|
|
throw new ValidationException("Display name must be 200 characters or fewer.", "DISPLAY_NAME_TOO_LONG");
|
|
}
|
|
|
|
private static ClinicalRole ParseRole(string role)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(role))
|
|
throw new ValidationException("Role is required.", "ROLE_REQUIRED");
|
|
try
|
|
{
|
|
return ClinicalRoleExtensions.FromDbString(role.Trim().ToUpperInvariant());
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
throw new ValidationException(
|
|
"Role must be one of: NURSE, PHYSICIAN, ADMIN, INTEGRATION.",
|
|
"INVALID_ROLE");
|
|
}
|
|
}
|
|
}
|