Refactor
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled

This commit is contained in:
Yaojia Wang
2025-11-03 21:02:14 +01:00
parent 5c541ddb79
commit a220e5d5d7
64 changed files with 3867 additions and 732 deletions

View File

@@ -1,36 +1,25 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleCommand, Unit>
public class AssignUserRoleCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IUserRepository userRepository,
ITenantRepository tenantRepository)
: IRequestHandler<AssignUserRoleCommand, Unit>
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
public AssignUserRoleCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IUserRepository userRepository,
ITenantRepository tenantRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_userRepository = userRepository;
_tenantRepository = tenantRepository;
}
public async Task<Unit> Handle(AssignUserRoleCommand request, CancellationToken cancellationToken)
{
// Validate user exists
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
throw new InvalidOperationException("User not found");
// Validate tenant exists
var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken);
var tenant = await tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken);
if (tenant == null)
throw new InvalidOperationException("Tenant not found");
@@ -43,18 +32,16 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
throw new InvalidOperationException("AIAgent role cannot be assigned manually");
// Check if user already has a role in this tenant
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
var existingRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
request.UserId,
request.TenantId,
cancellationToken);
TenantRole? previousRole = existingRole?.Role;
if (existingRole != null)
{
// Update existing role
existingRole.UpdateRole(role, request.AssignedBy);
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
existingRole.UpdateRole(role, Guid.Empty); // OperatorUserId can be set from HttpContext in controller
await userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
}
else
{
@@ -63,22 +50,11 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
UserId.Create(request.UserId),
TenantId.Create(request.TenantId),
role,
UserId.Create(request.AssignedBy));
null); // AssignedByUserId can be set from HttpContext in controller
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
await userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
}
// Raise domain event for role assignment
user.RaiseRoleAssignedEvent(
TenantId.Create(request.TenantId),
role,
previousRole,
request.AssignedBy
);
// Save changes to persist event
await _userRepository.UpdateAsync(user, cancellationToken);
return Unit.Value;
}
}

View File

@@ -2,42 +2,25 @@ using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDto>
public class LoginCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService,
IUserTenantRoleRepository userTenantRoleRepository)
: IRequestHandler<LoginCommand, LoginResponseDto>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
private readonly IJwtService _jwtService;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenService _refreshTokenService;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
public LoginCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService,
IUserTenantRoleRepository userTenantRoleRepository)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
_userTenantRoleRepository = userTenantRoleRepository;
}
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
{
// 1. Find tenant
var slug = TenantSlug.Create(request.TenantSlug);
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
var tenant = await tenantRepository.GetBySlugAsync(slug, cancellationToken);
if (tenant == null)
{
throw new UnauthorizedAccessException("Invalid credentials");
@@ -45,20 +28,20 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
// 2. Find user
var email = Email.Create(request.Email);
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
var user = await userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
if (user == null)
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 3. Verify password
if (user.PasswordHash == null || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
if (user.PasswordHash == null || !passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 4. Get user's tenant role
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
user.Id,
tenant.Id,
cancellationToken);
@@ -68,25 +51,19 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
throw new InvalidOperationException($"User {user.Id} has no role assigned for tenant {tenant.Id}");
}
// 4.5. Record login and raise domain event
user.RecordLoginWithEvent(
TenantId.Create(tenant.Id),
request.IpAddress ?? "unknown",
request.UserAgent ?? "unknown"
);
// 5. Generate JWT token with role
var accessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
var accessToken = jwtService.GenerateToken(user, tenant, userTenantRole.Role);
// 6. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
var refreshToken = await refreshTokenService.GenerateRefreshTokenAsync(
user,
ipAddress: request.IpAddress,
userAgent: request.UserAgent,
ipAddress: null,
userAgent: null,
cancellationToken);
// 7. Save user changes (including domain event)
await _userRepository.UpdateAsync(user, cancellationToken);
// 7. Update last login time
user.RecordLogin();
await userRepository.UpdateAsync(user, cancellationToken);
// 8. Return result
return new LoginResponseDto

View File

@@ -6,38 +6,22 @@ using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
public class RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService,
IUserTenantRoleRepository userTenantRoleRepository)
: IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
private readonly IJwtService _jwtService;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenService _refreshTokenService;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
public RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService,
IUserTenantRoleRepository userTenantRoleRepository)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
_userTenantRoleRepository = userTenantRoleRepository;
}
public async Task<RegisterTenantResult> Handle(
RegisterTenantCommand request,
CancellationToken cancellationToken)
{
// 1. Validate slug uniqueness
var slug = TenantSlug.Create(request.TenantSlug);
var slugExists = await _tenantRepository.ExistsBySlugAsync(slug, cancellationToken);
var slugExists = await tenantRepository.ExistsBySlugAsync(slug, cancellationToken);
if (slugExists)
{
throw new InvalidOperationException($"Tenant slug '{request.TenantSlug}' is already taken");
@@ -50,17 +34,17 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
slug,
plan);
await _tenantRepository.AddAsync(tenant, cancellationToken);
await tenantRepository.AddAsync(tenant, cancellationToken);
// 3. Create admin user with hashed password
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
var hashedPassword = passwordHasher.HashPassword(request.AdminPassword);
var adminUser = User.CreateLocal(
TenantId.Create(tenant.Id),
Email.Create(request.AdminEmail),
hashedPassword,
FullName.Create(request.AdminFullName));
await _userRepository.AddAsync(adminUser, cancellationToken);
await userRepository.AddAsync(adminUser, cancellationToken);
// 4. Assign TenantOwner role to admin user
var tenantOwnerRole = UserTenantRole.Create(
@@ -68,13 +52,13 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
TenantId.Create(tenant.Id),
TenantRole.TenantOwner);
await _userTenantRoleRepository.AddAsync(tenantOwnerRole, cancellationToken);
await userTenantRoleRepository.AddAsync(tenantOwnerRole, cancellationToken);
// 5. Generate JWT token with role
var accessToken = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
var accessToken = jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
// 6. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
var refreshToken = await refreshTokenService.GenerateRefreshTokenAsync(
adminUser,
ipAddress: null,
userAgent: null,

View File

@@ -6,26 +6,16 @@ using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFromTenantCommand, Unit>
public class RemoveUserFromTenantCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository)
: IRequestHandler<RemoveUserFromTenantCommand, Unit>
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IUserRepository _userRepository;
public RemoveUserFromTenantCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_refreshTokenRepository = refreshTokenRepository;
_userRepository = userRepository;
}
public async Task<Unit> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
{
// Get user's role in tenant
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
request.UserId,
request.TenantId,
cancellationToken);
@@ -34,13 +24,13 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
throw new InvalidOperationException("User is not a member of this tenant");
// Check if this is the last TenantOwner
if (await _userTenantRoleRepository.IsLastTenantOwnerAsync(request.TenantId, request.UserId, cancellationToken))
if (await userTenantRoleRepository.IsLastTenantOwnerAsync(request.TenantId, request.UserId, cancellationToken))
{
throw new InvalidOperationException("Cannot remove the last TenantOwner from the tenant");
}
// Revoke all user's refresh tokens for this tenant
var userTokens = await _refreshTokenRepository.GetByUserAndTenantAsync(
var userTokens = await refreshTokenRepository.GetByUserAndTenantAsync(
request.UserId,
request.TenantId,
cancellationToken);
@@ -52,11 +42,11 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
if (userTokens.Any())
{
await _refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
await refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
}
// Raise domain event before deletion
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user != null)
{
user.RaiseRemovedFromTenantEvent(
@@ -65,11 +55,11 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
request.Reason ?? "User removed from tenant"
);
await _userRepository.UpdateAsync(user, cancellationToken);
await userRepository.UpdateAsync(user, cancellationToken);
}
// Remove user's role
await _userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
await userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
return Unit.Value;
}

View File

@@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class TenantCreatedEventHandler : INotificationHandler<TenantCreatedEvent>
public sealed class TenantCreatedEventHandler(ILogger<TenantCreatedEventHandler> logger)
: INotificationHandler<TenantCreatedEvent>
{
private readonly ILogger<TenantCreatedEventHandler> _logger;
public TenantCreatedEventHandler(ILogger<TenantCreatedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"Tenant {TenantId} created with slug '{Slug}'",
notification.TenantId,
notification.Slug);

View File

@@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class UserLoggedInEventHandler : INotificationHandler<UserLoggedInEvent>
public sealed class UserLoggedInEventHandler(ILogger<UserLoggedInEventHandler> logger)
: INotificationHandler<UserLoggedInEvent>
{
private readonly ILogger<UserLoggedInEventHandler> _logger;
public UserLoggedInEventHandler(ILogger<UserLoggedInEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserLoggedInEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"User {UserId} logged in to tenant {TenantId} from IP {IpAddress}",
notification.UserId,
notification.TenantId.Value,

View File

@@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class UserRemovedFromTenantEventHandler : INotificationHandler<UserRemovedFromTenantEvent>
public sealed class UserRemovedFromTenantEventHandler(ILogger<UserRemovedFromTenantEventHandler> logger)
: INotificationHandler<UserRemovedFromTenantEvent>
{
private readonly ILogger<UserRemovedFromTenantEventHandler> _logger;
public UserRemovedFromTenantEventHandler(ILogger<UserRemovedFromTenantEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserRemovedFromTenantEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"User {UserId} removed from tenant {TenantId}. Removed by: {RemovedBy}. Reason: {Reason}",
notification.UserId,
notification.TenantId.Value,

View File

@@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
public sealed class UserRoleAssignedEventHandler : INotificationHandler<UserRoleAssignedEvent>
public sealed class UserRoleAssignedEventHandler(ILogger<UserRoleAssignedEventHandler> logger)
: INotificationHandler<UserRoleAssignedEvent>
{
private readonly ILogger<UserRoleAssignedEventHandler> _logger;
public UserRoleAssignedEventHandler(ILogger<UserRoleAssignedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"User {UserId} assigned role {Role} in tenant {TenantId}. Previous role: {PreviousRole}. Assigned by: {AssignedBy}",
notification.UserId,
notification.Role,

View File

@@ -5,19 +5,13 @@ using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug;
public class GetTenantBySlugQueryHandler : IRequestHandler<GetTenantBySlugQuery, TenantDto?>
public class GetTenantBySlugQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantBySlugQuery, TenantDto?>
{
private readonly ITenantRepository _tenantRepository;
public GetTenantBySlugQueryHandler(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
public async Task<TenantDto?> Handle(GetTenantBySlugQuery request, CancellationToken cancellationToken)
{
var slug = TenantSlug.Create(request.Slug);
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
var tenant = await tenantRepository.GetBySlugAsync(slug, cancellationToken);
if (tenant == null)
return null;

View File

@@ -4,24 +4,16 @@ using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers;
public class ListTenantUsersQueryHandler : IRequestHandler<ListTenantUsersQuery, PagedResultDto<UserWithRoleDto>>
public class ListTenantUsersQueryHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IUserRepository userRepository)
: IRequestHandler<ListTenantUsersQuery, PagedResultDto<UserWithRoleDto>>
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IUserRepository _userRepository;
public ListTenantUsersQueryHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IUserRepository userRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_userRepository = userRepository;
}
public async Task<PagedResultDto<UserWithRoleDto>> Handle(
ListTenantUsersQuery request,
CancellationToken cancellationToken)
{
var (roles, totalCount) = await _userTenantRoleRepository.GetTenantUsersWithRolesAsync(
var (roles, totalCount) = await userTenantRoleRepository.GetTenantUsersWithRolesAsync(
request.TenantId,
request.PageNumber,
request.PageSize,
@@ -32,7 +24,7 @@ public class ListTenantUsersQueryHandler : IRequestHandler<ListTenantUsersQuery,
foreach (var role in roles)
{
var user = await _userRepository.GetByIdAsync(role.UserId, cancellationToken);
var user = await userRepository.GetByIdAsync(role.UserId, cancellationToken);
if (user != null)
{