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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user