feat(backend): Implement User Invitation System (Phase 4)

Add complete user invitation system to enable multi-user tenants.

Changes:
- Created Invitation domain entity with 7-day expiration
- Implemented InviteUserCommand with security validation
- Implemented AcceptInvitationCommand (creates user + assigns role)
- Implemented GetPendingInvitationsQuery
- Implemented CancelInvitationCommand
- Added TenantInvitationsController with tenant-scoped endpoints
- Added public invitation acceptance endpoint to AuthController
- Created database migration for invitations table
- Registered InvitationRepository in DI container
- Created domain event handlers for audit trail

Security Features:
- Cannot invite as TenantOwner or AIAgent roles
- Cross-tenant validation on all endpoints
- Secure token generation and hashing
- RequireTenantAdmin policy for invite/list
- RequireTenantOwner policy for cancel

This UNBLOCKS 3 skipped Day 6 tests (RemoveUserFromTenant).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 22:02:56 +01:00
parent 1cf0ef0d9c
commit 4594ebef84
26 changed files with 1736 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
/// <summary>
/// Query to get all pending invitations for a tenant
/// </summary>
public sealed record GetPendingInvitationsQuery(
Guid TenantId
) : IRequest<List<InvitationDto>>;
/// <summary>
/// DTO for invitation data
/// </summary>
public record InvitationDto(
Guid Id,
string Email,
string Role,
string InvitedByName,
Guid InvitedById,
DateTime CreatedAt,
DateTime ExpiresAt);

View File

@@ -0,0 +1,57 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
public class GetPendingInvitationsQueryHandler : IRequestHandler<GetPendingInvitationsQuery, List<InvitationDto>>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly ILogger<GetPendingInvitationsQueryHandler> _logger;
public GetPendingInvitationsQueryHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
ILogger<GetPendingInvitationsQueryHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_logger = logger;
}
public async Task<List<InvitationDto>> Handle(GetPendingInvitationsQuery request, CancellationToken cancellationToken)
{
var tenantId = TenantId.Create(request.TenantId);
// Get all pending invitations for the tenant
var invitations = await _invitationRepository.GetPendingByTenantAsync(tenantId, cancellationToken);
// Get all unique inviter user IDs
var inviterIds = invitations.Select(i => (Guid)i.InvitedBy).Distinct().ToList();
// Fetch all inviters in one query
var inviters = await _userRepository.GetByIdsAsync(inviterIds, cancellationToken);
var inviterDict = inviters.ToDictionary(u => u.Id, u => u.FullName.Value);
// Map to DTOs
var dtos = invitations.Select(i => new InvitationDto(
Id: i.Id,
Email: i.Email,
Role: i.Role.ToString(),
InvitedByName: inviterDict.TryGetValue(i.InvitedBy, out var name) ? name : "Unknown",
InvitedById: i.InvitedBy,
CreatedAt: i.CreatedAt,
ExpiresAt: i.ExpiresAt
)).ToList();
_logger.LogInformation(
"Retrieved {Count} pending invitations for tenant {TenantId}",
dtos.Count,
request.TenantId);
return dtos;
}
}