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

@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
using ColaFlow.Modules.Identity.Application.Commands.Login;
using ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
using ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
using ColaFlow.Modules.Identity.Application.Services;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -208,6 +209,39 @@ public class AuthController(
return Ok(new { message = "Password reset successfully. Please login with your new password." });
}
/// <summary>
/// Accept an invitation and create/join tenant
/// </summary>
[HttpPost("invitations/accept")]
[AllowAnonymous]
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
{
try
{
var command = new AcceptInvitationCommand(
request.Token,
request.FullName,
request.Password);
var userId = await mediator.Send(command);
return Ok(new
{
userId,
message = "Invitation accepted successfully. You can now log in."
});
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to accept invitation");
return StatusCode(500, new { message = "Failed to accept invitation. Please try again." });
}
}
}
public record LoginRequest(
@@ -221,3 +255,5 @@ public record VerifyEmailRequest(string Token);
public record ForgotPasswordRequest(string Email, string TenantSlug);
public record ResetPasswordRequest(string Token, string NewPassword);
public record AcceptInvitationRequest(string Token, string FullName, string Password);

View File

@@ -0,0 +1,139 @@
using ColaFlow.Modules.Identity.Application.Commands.InviteUser;
using ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
using ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ColaFlow.API.Controllers;
/// <summary>
/// Controller for managing tenant invitations
/// </summary>
[ApiController]
[Route("api/tenants/{tenantId}/invitations")]
[Authorize]
public class TenantInvitationsController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Invite a user to the tenant by email
/// </summary>
[HttpPost]
[Authorize(Policy = "RequireTenantAdmin")]
public async Task<IActionResult> InviteUser(
[FromRoute] Guid tenantId,
[FromBody] InviteUserRequest request)
{
// SECURITY: Validate user belongs to target tenant
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
if (userTenantIdClaim == null)
return Unauthorized(new { error = "Tenant information not found in token" });
var userTenantId = Guid.Parse(userTenantIdClaim);
if (userTenantId != tenantId)
return StatusCode(403, new { error = "Access denied: You can only invite users to your own tenant" });
// Extract current user ID from claims
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
if (currentUserIdClaim == null)
return Unauthorized(new { error = "User ID not found in token" });
var currentUserId = Guid.Parse(currentUserIdClaim);
// Build base URL for invitation link
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var command = new InviteUserCommand(
tenantId,
request.Email,
request.Role,
currentUserId,
baseUrl);
try
{
var invitationId = await mediator.Send(command);
return Ok(new
{
invitationId,
message = "Invitation sent successfully",
email = request.Email,
role = request.Role
});
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Get all pending invitations for the tenant
/// </summary>
[HttpGet]
[Authorize(Policy = "RequireTenantAdmin")]
public async Task<IActionResult> GetPendingInvitations([FromRoute] Guid tenantId)
{
// SECURITY: Validate user belongs to target tenant
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
if (userTenantIdClaim == null)
return Unauthorized(new { error = "Tenant information not found in token" });
var userTenantId = Guid.Parse(userTenantIdClaim);
if (userTenantId != tenantId)
return StatusCode(403, new { error = "Access denied: You can only view invitations for your own tenant" });
var query = new GetPendingInvitationsQuery(tenantId);
var invitations = await mediator.Send(query);
return Ok(invitations);
}
/// <summary>
/// Cancel a pending invitation
/// </summary>
[HttpDelete("{invitationId}")]
[Authorize(Policy = "RequireTenantOwner")]
public async Task<IActionResult> CancelInvitation(
[FromRoute] Guid tenantId,
[FromRoute] Guid invitationId)
{
// SECURITY: Validate user belongs to target tenant
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
if (userTenantIdClaim == null)
return Unauthorized(new { error = "Tenant information not found in token" });
var userTenantId = Guid.Parse(userTenantIdClaim);
if (userTenantId != tenantId)
return StatusCode(403, new { error = "Access denied: You can only cancel invitations in your own tenant" });
// Extract current user ID from claims
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
if (currentUserIdClaim == null)
return Unauthorized(new { error = "User ID not found in token" });
var currentUserId = Guid.Parse(currentUserIdClaim);
var command = new CancelInvitationCommand(invitationId, tenantId, currentUserId);
try
{
await mediator.Send(command);
return Ok(new { message = "Invitation cancelled successfully" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
/// <summary>
/// Request to invite a user to a tenant
/// </summary>
public record InviteUserRequest(string Email, string Role);

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
/// <summary>
/// Command to accept an invitation and create/add user to tenant
/// </summary>
public sealed record AcceptInvitationCommand(
string Token,
string FullName,
string Password
) : IRequest<Guid>; // Returns user ID

View File

@@ -0,0 +1,131 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
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.Commands.AcceptInvitation;
public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCommand, Guid>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<AcceptInvitationCommandHandler> _logger;
public AcceptInvitationCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<AcceptInvitationCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_userTenantRoleRepository = userTenantRoleRepository;
_tokenService = tokenService;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<Guid> Handle(AcceptInvitationCommand request, CancellationToken cancellationToken)
{
// Hash the token to find the invitation
var tokenHash = _tokenService.HashToken(request.Token);
// Find invitation by token hash
var invitation = await _invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (invitation == null)
throw new InvalidOperationException("Invalid invitation token");
// Validate invitation is pending
invitation.ValidateForAcceptance();
var email = Email.Create(invitation.Email);
var fullName = FullName.Create(request.FullName);
// Check if user already exists in this tenant
var existingUser = await _userRepository.GetByEmailAsync(invitation.TenantId, email, cancellationToken);
User user;
if (existingUser != null)
{
// User already exists in this tenant
user = existingUser;
_logger.LogInformation(
"User {UserId} already exists in tenant {TenantId}, adding role",
user.Id,
invitation.TenantId);
}
else
{
// Create new user
var passwordHash = _passwordHasher.HashPassword(request.Password);
user = User.CreateLocal(
invitation.TenantId,
email,
passwordHash,
fullName);
await _userRepository.AddAsync(user, cancellationToken);
_logger.LogInformation(
"Created new user {UserId} for invitation acceptance in tenant {TenantId}",
user.Id,
invitation.TenantId);
}
// Check if user already has a role in this tenant
var userId = UserId.Create(user.Id);
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
userId,
invitation.TenantId,
cancellationToken);
if (existingRole != null)
{
// User already has a role - update it
existingRole.UpdateRole(invitation.Role, user.Id);
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
_logger.LogInformation(
"Updated role for user {UserId} in tenant {TenantId} to {Role}",
user.Id,
invitation.TenantId,
invitation.Role);
}
else
{
// Create new UserTenantRole mapping
var userTenantRole = UserTenantRole.Create(
userId,
invitation.TenantId,
invitation.Role,
invitation.InvitedBy);
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
_logger.LogInformation(
"Created role mapping for user {UserId} in tenant {TenantId} with role {Role}",
user.Id,
invitation.TenantId,
invitation.Role);
}
// Mark invitation as accepted
invitation.Accept();
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
_logger.LogInformation(
"Invitation {InvitationId} accepted by user {UserId}",
invitation.Id,
user.Id);
return user.Id;
}
}

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
/// <summary>
/// Command to cancel a pending invitation
/// </summary>
public sealed record CancelInvitationCommand(
Guid InvitationId,
Guid TenantId,
Guid CancelledBy
) : IRequest<Unit>;

View File

@@ -0,0 +1,48 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
public class CancelInvitationCommandHandler : IRequestHandler<CancelInvitationCommand, Unit>
{
private readonly IInvitationRepository _invitationRepository;
private readonly ILogger<CancelInvitationCommandHandler> _logger;
public CancelInvitationCommandHandler(
IInvitationRepository invitationRepository,
ILogger<CancelInvitationCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_logger = logger;
}
public async Task<Unit> Handle(CancelInvitationCommand request, CancellationToken cancellationToken)
{
var invitationId = InvitationId.Create(request.InvitationId);
var tenantId = TenantId.Create(request.TenantId);
// Get the invitation
var invitation = await _invitationRepository.GetByIdAsync(invitationId, cancellationToken);
if (invitation == null)
throw new InvalidOperationException($"Invitation {request.InvitationId} not found");
// Verify invitation belongs to the tenant (security check)
if (invitation.TenantId != tenantId)
throw new InvalidOperationException("Invitation does not belong to this tenant");
// Cancel the invitation
invitation.Cancel();
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
_logger.LogInformation(
"Invitation {InvitationId} cancelled by user {CancelledBy} in tenant {TenantId}",
request.InvitationId,
request.CancelledBy,
request.TenantId);
return Unit.Value;
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser;
/// <summary>
/// Command to invite a user to a tenant
/// </summary>
public sealed record InviteUserCommand(
Guid TenantId,
string Email,
string Role,
Guid InvitedBy,
string BaseUrl
) : IRequest<Guid>; // Returns invitation ID

View File

@@ -0,0 +1,135 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.Identity.Domain.Services;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser;
public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly ITenantRepository _tenantRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _templateService;
private readonly ILogger<InviteUserCommandHandler> _logger;
public InviteUserCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ITenantRepository tenantRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<InviteUserCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_userTenantRoleRepository = userTenantRoleRepository;
_tenantRepository = tenantRepository;
_tokenService = tokenService;
_emailService = emailService;
_templateService = templateService;
_logger = logger;
}
public async Task<Guid> Handle(InviteUserCommand request, CancellationToken cancellationToken)
{
var tenantId = TenantId.Create(request.TenantId);
var invitedBy = UserId.Create(request.InvitedBy);
// Validate role
if (!Enum.TryParse<TenantRole>(request.Role, out var role))
throw new ArgumentException($"Invalid role: {request.Role}");
// Check if tenant exists
var tenant = await _tenantRepository.GetByIdAsync(tenantId, cancellationToken);
if (tenant == null)
throw new InvalidOperationException($"Tenant {request.TenantId} not found");
// Check if inviter exists
var inviter = await _userRepository.GetByIdAsync(invitedBy, cancellationToken);
if (inviter == null)
throw new InvalidOperationException($"Inviter user {request.InvitedBy} not found");
var email = Email.Create(request.Email);
// Check if user already exists in this tenant
var existingUser = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
if (existingUser != null)
{
// Check if user already has a role in this tenant
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
UserId.Create(existingUser.Id),
tenantId,
cancellationToken);
if (existingRole != null)
throw new InvalidOperationException($"User with email {request.Email} is already a member of this tenant");
}
// Check for existing pending invitation
var existingInvitation = await _invitationRepository.GetPendingByEmailAndTenantAsync(
request.Email,
tenantId,
cancellationToken);
if (existingInvitation != null)
throw new InvalidOperationException($"A pending invitation already exists for {request.Email} in this tenant");
// Generate secure token
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
// Create invitation
var invitation = Invitation.Create(
tenantId,
request.Email,
role,
tokenHash,
invitedBy);
await _invitationRepository.AddAsync(invitation, cancellationToken);
// Send invitation email
var invitationLink = $"{request.BaseUrl}/accept-invitation?token={token}";
var htmlBody = _templateService.RenderInvitationEmail(
recipientName: request.Email.Split('@')[0], // Use email prefix as fallback name
tenantName: tenant.Name.Value,
inviterName: inviter.FullName.Value,
invitationUrl: invitationLink);
var emailMessage = new EmailMessage(
To: request.Email,
Subject: $"You've been invited to join {tenant.Name.Value} on ColaFlow",
HtmlBody: htmlBody,
PlainTextBody: $"You've been invited to join {tenant.Name.Value}. Click here to accept: {invitationLink}");
var emailSuccess = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
if (!emailSuccess)
{
_logger.LogWarning(
"Failed to send invitation email to {Email} for tenant {TenantId}",
request.Email,
request.TenantId);
}
else
{
_logger.LogInformation(
"Invitation sent to {Email} for tenant {TenantId} with role {Role}",
request.Email,
request.TenantId,
role);
}
return invitation.Id;
}
}

View File

@@ -0,0 +1,31 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for InvitationAcceptedEvent - logs acceptance
/// </summary>
public class InvitationAcceptedEventHandler : INotificationHandler<InvitationAcceptedEvent>
{
private readonly ILogger<InvitationAcceptedEventHandler> _logger;
public InvitationAcceptedEventHandler(ILogger<InvitationAcceptedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(InvitationAcceptedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Invitation accepted: Email={Email}, Tenant={TenantId}, Role={Role}",
notification.Email,
notification.TenantId,
notification.Role);
// Future: Could send welcome email, track conversion metrics, etc.
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,30 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for InvitationCancelledEvent - logs cancellation
/// </summary>
public class InvitationCancelledEventHandler : INotificationHandler<InvitationCancelledEvent>
{
private readonly ILogger<InvitationCancelledEventHandler> _logger;
public InvitationCancelledEventHandler(ILogger<InvitationCancelledEventHandler> logger)
{
_logger = logger;
}
public Task Handle(InvitationCancelledEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Invitation cancelled: Email={Email}, Tenant={TenantId}",
notification.Email,
notification.TenantId);
// Future: Could notify invited user, track cancellation metrics, etc.
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,32 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for UserInvitedEvent - logs invitation
/// </summary>
public class UserInvitedEventHandler : INotificationHandler<UserInvitedEvent>
{
private readonly ILogger<UserInvitedEventHandler> _logger;
public UserInvitedEventHandler(ILogger<UserInvitedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserInvitedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"User invited: Email={Email}, Tenant={TenantId}, Role={Role}, InvitedBy={InvitedBy}",
notification.Email,
notification.TenantId,
notification.Role,
notification.InvitedBy);
// Future: Could add analytics tracking, audit log entry, etc.
return Task.CompletedTask;
}
}

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;
}
}

View File

@@ -0,0 +1,16 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
/// <summary>
/// Domain event raised when an invitation is accepted
/// </summary>
public sealed record InvitationAcceptedEvent(
InvitationId InvitationId,
TenantId TenantId,
string Email,
TenantRole Role
) : DomainEvent;

View File

@@ -0,0 +1,14 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
/// <summary>
/// Domain event raised when an invitation is cancelled
/// </summary>
public sealed record InvitationCancelledEvent(
InvitationId InvitationId,
TenantId TenantId,
string Email
) : DomainEvent;

View File

@@ -0,0 +1,17 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Shared.Kernel.Events;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
/// <summary>
/// Domain event raised when a user is invited to a tenant
/// </summary>
public sealed record UserInvitedEvent(
InvitationId InvitationId,
TenantId TenantId,
string Email,
TenantRole Role,
UserId InvitedBy
) : DomainEvent;

View File

@@ -0,0 +1,139 @@
using ColaFlow.Shared.Kernel.Common;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
/// <summary>
/// Invitation aggregate root - represents a user invitation to a tenant
/// </summary>
public sealed class Invitation : AggregateRoot
{
// Identity
public new InvitationId Id { get; private set; } = null!;
// Association
public TenantId TenantId { get; private set; } = null!;
// Invitation details
public string Email { get; private set; } = string.Empty;
public TenantRole Role { get; private set; }
// Security
public string TokenHash { get; private set; } = string.Empty;
// Lifecycle
public DateTime ExpiresAt { get; private set; }
public DateTime? AcceptedAt { get; private set; }
public UserId InvitedBy { get; private set; } = null!;
// Timestamps
public DateTime CreatedAt { get; private set; }
public DateTime UpdatedAt { get; private set; }
// Status properties
public bool IsExpired => DateTime.UtcNow > ExpiresAt;
public bool IsAccepted => AcceptedAt.HasValue;
public bool IsPending => !IsAccepted && !IsExpired;
// Private constructor for EF Core
private Invitation() : base()
{
}
/// <summary>
/// Creates a new invitation
/// </summary>
public static Invitation Create(
TenantId tenantId,
string email,
TenantRole role,
string tokenHash,
UserId invitedBy)
{
// Validate role - cannot invite as TenantOwner or AIAgent
if (role == TenantRole.TenantOwner || role == TenantRole.AIAgent)
throw new InvalidOperationException($"Cannot invite users with role {role}");
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email cannot be empty", nameof(email));
if (string.IsNullOrWhiteSpace(tokenHash))
throw new ArgumentException("Token hash cannot be empty", nameof(tokenHash));
var invitation = new Invitation
{
Id = InvitationId.CreateUnique(),
TenantId = tenantId,
Email = email.ToLowerInvariant(),
Role = role,
TokenHash = tokenHash,
ExpiresAt = DateTime.UtcNow.AddDays(7), // 7-day expiration
InvitedBy = invitedBy,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
invitation.AddDomainEvent(new UserInvitedEvent(
invitation.Id,
tenantId,
email,
role,
invitedBy));
return invitation;
}
/// <summary>
/// Accepts the invitation
/// </summary>
public void Accept()
{
if (!IsPending)
{
if (IsExpired)
throw new InvalidOperationException("Invitation has expired");
if (IsAccepted)
throw new InvalidOperationException("Invitation has already been accepted");
throw new InvalidOperationException("Invitation is not in a valid state to be accepted");
}
AcceptedAt = DateTime.UtcNow;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new InvitationAcceptedEvent(
Id,
TenantId,
Email,
Role));
}
/// <summary>
/// Cancels the invitation (by setting expiration to now)
/// </summary>
public void Cancel()
{
if (!IsPending)
throw new InvalidOperationException("Cannot cancel non-pending invitation");
// Mark as expired to cancel
ExpiresAt = DateTime.UtcNow;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new InvitationCancelledEvent(Id, TenantId, Email));
}
/// <summary>
/// Validates the invitation state before acceptance
/// </summary>
public void ValidateForAcceptance()
{
if (IsExpired)
throw new InvalidOperationException("Invitation has expired");
if (IsAccepted)
throw new InvalidOperationException("Invitation has already been accepted");
}
}

View File

@@ -0,0 +1,33 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
public sealed class InvitationId : ValueObject
{
public Guid Value { get; }
private InvitationId(Guid value)
{
Value = value;
}
public static InvitationId CreateUnique() => new(Guid.NewGuid());
public static InvitationId Create(Guid value)
{
if (value == Guid.Empty)
throw new ArgumentException("Invitation ID cannot be empty", nameof(value));
return new InvitationId(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
// Implicit conversion
public static implicit operator Guid(InvitationId invitationId) => invitationId.Value;
}

View File

@@ -0,0 +1,45 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
namespace ColaFlow.Modules.Identity.Domain.Repositories;
/// <summary>
/// Repository interface for Invitation aggregate
/// </summary>
public interface IInvitationRepository
{
/// <summary>
/// Gets an invitation by its ID
/// </summary>
Task<Invitation?> GetByIdAsync(InvitationId id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an invitation by token hash (for acceptance flow)
/// </summary>
Task<Invitation?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all pending invitations for a tenant
/// </summary>
Task<List<Invitation>> GetPendingByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a pending invitation by email and tenant (for duplicate check)
/// </summary>
Task<Invitation?> GetPendingByEmailAndTenantAsync(string email, TenantId tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new invitation
/// </summary>
Task AddAsync(Invitation invitation, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing invitation
/// </summary>
Task UpdateAsync(Invitation invitation, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an invitation
/// </summary>
Task DeleteAsync(Invitation invitation, CancellationToken cancellationToken = default);
}

View File

@@ -39,6 +39,7 @@ public static class DependencyInjection
services.AddScoped<IUserTenantRoleRepository, UserTenantRoleRepository>();
services.AddScoped<IEmailVerificationTokenRepository, EmailVerificationTokenRepository>();
services.AddScoped<IPasswordResetTokenRepository, PasswordResetTokenRepository>();
services.AddScoped<IInvitationRepository, InvitationRepository>();
// Application Services
services.AddScoped<IJwtService, JwtService>();

View File

@@ -0,0 +1,96 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
public class InvitationConfiguration : IEntityTypeConfiguration<Invitation>
{
public void Configure(EntityTypeBuilder<Invitation> builder)
{
builder.ToTable("invitations");
// Primary Key
builder.HasKey(i => i.Id);
builder.Property(i => i.Id)
.HasConversion(
id => (Guid)id,
value => InvitationId.Create(value))
.HasColumnName("id");
// Tenant ID (foreign key)
builder.Property(i => i.TenantId)
.HasConversion(
id => (Guid)id,
value => TenantId.Create(value))
.IsRequired()
.HasColumnName("tenant_id");
// Invited By (User ID)
builder.Property(i => i.InvitedBy)
.HasConversion(
id => (Guid)id,
value => UserId.Create(value))
.IsRequired()
.HasColumnName("invited_by");
// Email
builder.Property(i => i.Email)
.HasMaxLength(255)
.IsRequired()
.HasColumnName("email");
// Role (enum stored as string)
builder.Property(i => i.Role)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired()
.HasColumnName("role");
// Security token hash
builder.Property(i => i.TokenHash)
.HasMaxLength(255)
.IsRequired()
.HasColumnName("token_hash");
// Lifecycle timestamps
builder.Property(i => i.ExpiresAt)
.IsRequired()
.HasColumnName("expires_at");
builder.Property(i => i.AcceptedAt)
.HasColumnName("accepted_at");
builder.Property(i => i.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.Property(i => i.UpdatedAt)
.IsRequired()
.HasColumnName("updated_at");
// Indexes
// Index for token lookup (invitation acceptance)
builder.HasIndex(i => i.TokenHash)
.IsUnique()
.HasDatabaseName("ix_invitations_token_hash");
// Index for tenant + email lookup (check for existing pending invitations)
builder.HasIndex(i => new { i.TenantId, i.Email })
.HasDatabaseName("ix_invitations_tenant_id_email");
// Index for tenant + status lookup (get pending invitations)
builder.HasIndex(i => new { i.TenantId, i.AcceptedAt, i.ExpiresAt })
.HasDatabaseName("ix_invitations_tenant_id_status");
// Unique constraint: Only one pending invitation per email per tenant
// Note: This is enforced at application level due to NULL handling complexity
// Could add a filtered unique index in migration if database supports it
// Ignore domain events
builder.Ignore(i => i.DomainEvents);
}
}

View File

@@ -1,5 +1,6 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Entities;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using ColaFlow.Shared.Kernel.Common;
@@ -20,6 +21,7 @@ public class IdentityDbContext(
public DbSet<UserTenantRole> UserTenantRoles => Set<UserTenantRole>();
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
public DbSet<Invitation> Invitations => Set<Invitation>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@@ -0,0 +1,486 @@
// <auto-generated />
using System;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20251103210023_AddInvitations")]
partial class AddInvitations
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Invitation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<Guid>("InvitedBy")
.HasColumnType("uuid")
.HasColumnName("invited_by");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("role");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("token_hash");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_invitations_token_hash");
b.HasIndex("TenantId", "Email")
.HasDatabaseName("ix_invitations_tenant_id_email");
b.HasIndex("TenantId", "AcceptedAt", "ExpiresAt")
.HasDatabaseName("ix_invitations_tenant_id_status");
b.ToTable("invitations", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("MaxProjects")
.HasColumnType("integer")
.HasColumnName("max_projects");
b.Property<int>("MaxStorageGB")
.HasColumnType("integer")
.HasColumnName("max_storage_gb");
b.Property<int>("MaxUsers")
.HasColumnType("integer")
.HasColumnName("max_users");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("plan");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("slug");
b.Property<string>("SsoConfig")
.HasColumnType("jsonb")
.HasColumnName("sso_config");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("status");
b.Property<DateTime?>("SuspendedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("suspended_at");
b.Property<string>("SuspensionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("suspension_reason");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_tenants_slug");
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("DeviceInfo")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("device_info");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("ip_address");
b.Property<string>("ReplacedByToken")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("replaced_by_token");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("RevokedReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("revoked_reason");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("token_hash");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("user_agent");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("ExpiresAt")
.HasDatabaseName("ix_refresh_tokens_expires_at");
b.HasIndex("TenantId")
.HasDatabaseName("ix_refresh_tokens_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_refresh_tokens_token_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_refresh_tokens_user_id");
b.ToTable("refresh_tokens", "identity");
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AuthProvider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("auth_provider");
b.Property<string>("AvatarUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("avatar_url");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email");
b.Property<string>("EmailVerificationToken")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email_verification_token");
b.Property<DateTime?>("EmailVerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("email_verified_at");
b.Property<string>("ExternalEmail")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_email");
b.Property<string>("ExternalUserId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_user_id");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("full_name");
b.Property<string>("JobTitle")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("job_title");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("password_hash");
b.Property<string>("PasswordResetToken")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("password_reset_token");
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("password_reset_token_expires_at");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone_number");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TenantId", "Email")
.IsUnique()
.HasDatabaseName("ix_users_tenant_id_email");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("AssignedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("assigned_at");
b.Property<Guid?>("AssignedByUserId")
.HasColumnType("uuid")
.HasColumnName("assigned_by_user_id");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("role");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("Role")
.HasDatabaseName("ix_user_tenant_roles_role");
b.HasIndex("TenantId")
.HasDatabaseName("ix_user_tenant_roles_tenant_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_tenant_roles_user_id");
b.HasIndex("UserId", "TenantId")
.IsUnique()
.HasDatabaseName("uq_user_tenant_roles_user_tenant");
b.ToTable("user_tenant_roles", "identity");
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<DateTime?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id");
b.HasIndex("TokenHash")
.HasDatabaseName("ix_email_verification_tokens_token_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_email_verification_tokens_user_id");
b.ToTable("email_verification_tokens", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.PasswordResetToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)")
.HasColumnName("ip_address");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<DateTime?>("UsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("used_at");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("user_agent");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("TokenHash")
.HasDatabaseName("ix_password_reset_tokens_token_hash");
b.HasIndex("UserId")
.HasDatabaseName("ix_password_reset_tokens_user_id");
b.HasIndex("UserId", "ExpiresAt", "UsedAt")
.HasDatabaseName("ix_password_reset_tokens_user_active");
b.ToTable("password_reset_tokens", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddInvitations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "invitations",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
token_hash = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
accepted_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
invited_by = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_invitations", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_invitations_tenant_id_email",
table: "invitations",
columns: new[] { "tenant_id", "email" });
migrationBuilder.CreateIndex(
name: "ix_invitations_tenant_id_status",
table: "invitations",
columns: new[] { "tenant_id", "accepted_at", "expires_at" });
migrationBuilder.CreateIndex(
name: "ix_invitations_token_hash",
table: "invitations",
column: "token_hash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "invitations");
}
}
}

View File

@@ -22,6 +22,69 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Invitation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<Guid>("InvitedBy")
.HasColumnType("uuid")
.HasColumnName("invited_by");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("role");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("token_hash");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_invitations_token_hash");
b.HasIndex("TenantId", "Email")
.HasDatabaseName("ix_invitations_tenant_id_email");
b.HasIndex("TenantId", "AcceptedAt", "ExpiresAt")
.HasDatabaseName("ix_invitations_tenant_id_status");
b.ToTable("invitations", (string)null);
});
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,67 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
public class InvitationRepository(IdentityDbContext context) : IInvitationRepository
{
public async Task<Invitation?> GetByIdAsync(InvitationId id, CancellationToken cancellationToken = default)
{
return await context.Invitations
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
public async Task<Invitation?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default)
{
return await context.Invitations
.FirstOrDefaultAsync(i => i.TokenHash == tokenHash, cancellationToken);
}
public async Task<List<Invitation>> GetPendingByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await context.Invitations
.Where(i => i.TenantId == tenantId &&
i.AcceptedAt == null &&
i.ExpiresAt > now)
.OrderByDescending(i => i.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task<Invitation?> GetPendingByEmailAndTenantAsync(
string email,
TenantId tenantId,
CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var normalizedEmail = email.ToLowerInvariant();
return await context.Invitations
.FirstOrDefaultAsync(i =>
i.TenantId == tenantId &&
i.Email == normalizedEmail &&
i.AcceptedAt == null &&
i.ExpiresAt > now,
cancellationToken);
}
public async Task AddAsync(Invitation invitation, CancellationToken cancellationToken = default)
{
await context.Invitations.AddAsync(invitation, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(Invitation invitation, CancellationToken cancellationToken = default)
{
context.Invitations.Update(invitation);
await context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(Invitation invitation, CancellationToken cancellationToken = default)
{
context.Invitations.Remove(invitation);
await context.SaveChangesAsync(cancellationToken);
}
}