From 4594ebef8451c1fd438aa6830db6dbb95da9cb56 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 3 Nov 2025 22:02:56 +0100 Subject: [PATCH] feat(backend): Implement User Invitation System (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/AuthController.cs | 36 ++ .../TenantInvitationsController.cs | 139 +++++ .../AcceptInvitationCommand.cs | 12 + .../AcceptInvitationCommandHandler.cs | 131 +++++ .../CancelInvitationCommand.cs | 12 + .../CancelInvitationCommandHandler.cs | 48 ++ .../Commands/InviteUser/InviteUserCommand.cs | 14 + .../InviteUser/InviteUserCommandHandler.cs | 135 +++++ .../InvitationAcceptedEventHandler.cs | 31 ++ .../InvitationCancelledEventHandler.cs | 30 ++ .../EventHandlers/UserInvitedEventHandler.cs | 32 ++ .../GetPendingInvitationsQuery.cs | 22 + .../GetPendingInvitationsQueryHandler.cs | 57 ++ .../Events/InvitationAcceptedEvent.cs | 16 + .../Events/InvitationCancelledEvent.cs | 14 + .../Invitations/Events/UserInvitedEvent.cs | 17 + .../Aggregates/Invitations/Invitation.cs | 139 +++++ .../Aggregates/Invitations/InvitationId.cs | 33 ++ .../Repositories/IInvitationRepository.cs | 45 ++ .../DependencyInjection.cs | 1 + .../Configurations/InvitationConfiguration.cs | 96 ++++ .../Persistence/IdentityDbContext.cs | 2 + .../20251103210023_AddInvitations.Designer.cs | 486 ++++++++++++++++++ .../20251103210023_AddInvitations.cs | 58 +++ .../IdentityDbContextModelSnapshot.cs | 63 +++ .../Repositories/InvitationRepository.cs | 67 +++ 26 files changed, 1736 insertions(+) create mode 100644 colaflow-api/src/ColaFlow.API/Controllers/TenantInvitationsController.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommand.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommandHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationAcceptedEventHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationCancelledEventHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserInvitedEventHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQuery.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQueryHandler.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationAcceptedEvent.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationCancelledEvent.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/UserInvitedEvent.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Invitation.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/InvitationId.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IInvitationRepository.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/InvitationConfiguration.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.Designer.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.cs create mode 100644 colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/InvitationRepository.cs diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs index 806f256..fcb6002 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs @@ -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." }); } + + /// + /// Accept an invitation and create/join tenant + /// + [HttpPost("invitations/accept")] + [AllowAnonymous] + public async Task 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); diff --git a/colaflow-api/src/ColaFlow.API/Controllers/TenantInvitationsController.cs b/colaflow-api/src/ColaFlow.API/Controllers/TenantInvitationsController.cs new file mode 100644 index 0000000..bcff224 --- /dev/null +++ b/colaflow-api/src/ColaFlow.API/Controllers/TenantInvitationsController.cs @@ -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; + +/// +/// Controller for managing tenant invitations +/// +[ApiController] +[Route("api/tenants/{tenantId}/invitations")] +[Authorize] +public class TenantInvitationsController(IMediator mediator) : ControllerBase +{ + /// + /// Invite a user to the tenant by email + /// + [HttpPost] + [Authorize(Policy = "RequireTenantAdmin")] + public async Task 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 }); + } + } + + /// + /// Get all pending invitations for the tenant + /// + [HttpGet] + [Authorize(Policy = "RequireTenantAdmin")] + public async Task 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); + } + + /// + /// Cancel a pending invitation + /// + [HttpDelete("{invitationId}")] + [Authorize(Policy = "RequireTenantOwner")] + public async Task 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 }); + } + } +} + +/// +/// Request to invite a user to a tenant +/// +public record InviteUserRequest(string Email, string Role); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommand.cs new file mode 100644 index 0000000..b423db3 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation; + +/// +/// Command to accept an invitation and create/add user to tenant +/// +public sealed record AcceptInvitationCommand( + string Token, + string FullName, + string Password +) : IRequest; // Returns user ID diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommandHandler.cs new file mode 100644 index 0000000..5ff7de2 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AcceptInvitation/AcceptInvitationCommandHandler.cs @@ -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 +{ + private readonly IInvitationRepository _invitationRepository; + private readonly IUserRepository _userRepository; + private readonly IUserTenantRoleRepository _userTenantRoleRepository; + private readonly ISecurityTokenService _tokenService; + private readonly IPasswordHasher _passwordHasher; + private readonly ILogger _logger; + + public AcceptInvitationCommandHandler( + IInvitationRepository invitationRepository, + IUserRepository userRepository, + IUserTenantRoleRepository userTenantRoleRepository, + ISecurityTokenService tokenService, + IPasswordHasher passwordHasher, + ILogger logger) + { + _invitationRepository = invitationRepository; + _userRepository = userRepository; + _userTenantRoleRepository = userTenantRoleRepository; + _tokenService = tokenService; + _passwordHasher = passwordHasher; + _logger = logger; + } + + public async Task 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommand.cs new file mode 100644 index 0000000..f81ce2c --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation; + +/// +/// Command to cancel a pending invitation +/// +public sealed record CancelInvitationCommand( + Guid InvitationId, + Guid TenantId, + Guid CancelledBy +) : IRequest; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommandHandler.cs new file mode 100644 index 0000000..8be138b --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/CancelInvitation/CancelInvitationCommandHandler.cs @@ -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 +{ + private readonly IInvitationRepository _invitationRepository; + private readonly ILogger _logger; + + public CancelInvitationCommandHandler( + IInvitationRepository invitationRepository, + ILogger logger) + { + _invitationRepository = invitationRepository; + _logger = logger; + } + + public async Task 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommand.cs new file mode 100644 index 0000000..32d6c7b --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser; + +/// +/// Command to invite a user to a tenant +/// +public sealed record InviteUserCommand( + Guid TenantId, + string Email, + string Role, + Guid InvitedBy, + string BaseUrl +) : IRequest; // Returns invitation ID diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommandHandler.cs new file mode 100644 index 0000000..a2e5099 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/InviteUser/InviteUserCommandHandler.cs @@ -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 +{ + 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 _logger; + + public InviteUserCommandHandler( + IInvitationRepository invitationRepository, + IUserRepository userRepository, + IUserTenantRoleRepository userTenantRoleRepository, + ITenantRepository tenantRepository, + ISecurityTokenService tokenService, + IEmailService emailService, + IEmailTemplateService templateService, + ILogger logger) + { + _invitationRepository = invitationRepository; + _userRepository = userRepository; + _userTenantRoleRepository = userTenantRoleRepository; + _tenantRepository = tenantRepository; + _tokenService = tokenService; + _emailService = emailService; + _templateService = templateService; + _logger = logger; + } + + public async Task Handle(InviteUserCommand request, CancellationToken cancellationToken) + { + var tenantId = TenantId.Create(request.TenantId); + var invitedBy = UserId.Create(request.InvitedBy); + + // Validate role + if (!Enum.TryParse(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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationAcceptedEventHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationAcceptedEventHandler.cs new file mode 100644 index 0000000..5c92b61 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationAcceptedEventHandler.cs @@ -0,0 +1,31 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Identity.Application.EventHandlers; + +/// +/// Event handler for InvitationAcceptedEvent - logs acceptance +/// +public class InvitationAcceptedEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public InvitationAcceptedEventHandler(ILogger 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationCancelledEventHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationCancelledEventHandler.cs new file mode 100644 index 0000000..0ff4c97 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/InvitationCancelledEventHandler.cs @@ -0,0 +1,30 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Identity.Application.EventHandlers; + +/// +/// Event handler for InvitationCancelledEvent - logs cancellation +/// +public class InvitationCancelledEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public InvitationCancelledEventHandler(ILogger 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserInvitedEventHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserInvitedEventHandler.cs new file mode 100644 index 0000000..73e388f --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserInvitedEventHandler.cs @@ -0,0 +1,32 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Identity.Application.EventHandlers; + +/// +/// Event handler for UserInvitedEvent - logs invitation +/// +public class UserInvitedEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public UserInvitedEventHandler(ILogger 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQuery.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQuery.cs new file mode 100644 index 0000000..5376ec3 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQuery.cs @@ -0,0 +1,22 @@ +using MediatR; + +namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations; + +/// +/// Query to get all pending invitations for a tenant +/// +public sealed record GetPendingInvitationsQuery( + Guid TenantId +) : IRequest>; + +/// +/// DTO for invitation data +/// +public record InvitationDto( + Guid Id, + string Email, + string Role, + string InvitedByName, + Guid InvitedById, + DateTime CreatedAt, + DateTime ExpiresAt); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQueryHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQueryHandler.cs new file mode 100644 index 0000000..330e656 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetPendingInvitations/GetPendingInvitationsQueryHandler.cs @@ -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> +{ + private readonly IInvitationRepository _invitationRepository; + private readonly IUserRepository _userRepository; + private readonly ILogger _logger; + + public GetPendingInvitationsQueryHandler( + IInvitationRepository invitationRepository, + IUserRepository userRepository, + ILogger logger) + { + _invitationRepository = invitationRepository; + _userRepository = userRepository; + _logger = logger; + } + + public async Task> 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; + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationAcceptedEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationAcceptedEvent.cs new file mode 100644 index 0000000..a8edad0 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationAcceptedEvent.cs @@ -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; + +/// +/// Domain event raised when an invitation is accepted +/// +public sealed record InvitationAcceptedEvent( + InvitationId InvitationId, + TenantId TenantId, + string Email, + TenantRole Role +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationCancelledEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationCancelledEvent.cs new file mode 100644 index 0000000..982df0a --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/InvitationCancelledEvent.cs @@ -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; + +/// +/// Domain event raised when an invitation is cancelled +/// +public sealed record InvitationCancelledEvent( + InvitationId InvitationId, + TenantId TenantId, + string Email +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/UserInvitedEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/UserInvitedEvent.cs new file mode 100644 index 0000000..1e6c138 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Events/UserInvitedEvent.cs @@ -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; + +/// +/// Domain event raised when a user is invited to a tenant +/// +public sealed record UserInvitedEvent( + InvitationId InvitationId, + TenantId TenantId, + string Email, + TenantRole Role, + UserId InvitedBy +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Invitation.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Invitation.cs new file mode 100644 index 0000000..8528448 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/Invitation.cs @@ -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; + +/// +/// Invitation aggregate root - represents a user invitation to a tenant +/// +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() + { + } + + /// + /// Creates a new invitation + /// + 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; + } + + /// + /// Accepts the invitation + /// + 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)); + } + + /// + /// Cancels the invitation (by setting expiration to now) + /// + 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)); + } + + /// + /// Validates the invitation state before acceptance + /// + public void ValidateForAcceptance() + { + if (IsExpired) + throw new InvalidOperationException("Invitation has expired"); + + if (IsAccepted) + throw new InvalidOperationException("Invitation has already been accepted"); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/InvitationId.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/InvitationId.cs new file mode 100644 index 0000000..29375cc --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Invitations/InvitationId.cs @@ -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 GetAtomicValues() + { + yield return Value; + } + + public override string ToString() => Value.ToString(); + + // Implicit conversion + public static implicit operator Guid(InvitationId invitationId) => invitationId.Value; +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IInvitationRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IInvitationRepository.cs new file mode 100644 index 0000000..6ae4194 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IInvitationRepository.cs @@ -0,0 +1,45 @@ +using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations; +using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; + +namespace ColaFlow.Modules.Identity.Domain.Repositories; + +/// +/// Repository interface for Invitation aggregate +/// +public interface IInvitationRepository +{ + /// + /// Gets an invitation by its ID + /// + Task GetByIdAsync(InvitationId id, CancellationToken cancellationToken = default); + + /// + /// Gets an invitation by token hash (for acceptance flow) + /// + Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default); + + /// + /// Gets all pending invitations for a tenant + /// + Task> GetPendingByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default); + + /// + /// Gets a pending invitation by email and tenant (for duplicate check) + /// + Task GetPendingByEmailAndTenantAsync(string email, TenantId tenantId, CancellationToken cancellationToken = default); + + /// + /// Adds a new invitation + /// + Task AddAsync(Invitation invitation, CancellationToken cancellationToken = default); + + /// + /// Updates an existing invitation + /// + Task UpdateAsync(Invitation invitation, CancellationToken cancellationToken = default); + + /// + /// Deletes an invitation + /// + Task DeleteAsync(Invitation invitation, CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs index e35af0d..fd016aa 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs @@ -39,6 +39,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Application Services services.AddScoped(); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/InvitationConfiguration.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/InvitationConfiguration.cs new file mode 100644 index 0000000..cb07fd9 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/InvitationConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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() + .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); + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs index a113add..1b80fe3 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs @@ -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 UserTenantRoles => Set(); public DbSet EmailVerificationTokens => Set(); public DbSet PasswordResetTokens => Set(); + public DbSet Invitations => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.Designer.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.Designer.cs new file mode 100644 index 0000000..86fc9c6 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.Designer.cs @@ -0,0 +1,486 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .HasColumnType("uuid") + .HasColumnName("invited_by"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("token_hash"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("MaxProjects") + .HasColumnType("integer") + .HasColumnName("max_projects"); + + b.Property("MaxStorageGB") + .HasColumnType("integer") + .HasColumnName("max_storage_gb"); + + b.Property("MaxUsers") + .HasColumnType("integer") + .HasColumnName("max_users"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("plan"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("slug"); + + b.Property("SsoConfig") + .HasColumnType("jsonb") + .HasColumnName("sso_config"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suspended_at"); + + b.Property("SuspensionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("suspension_reason"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("device_info"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByToken") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("replaced_by_token"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("revoked_reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthProvider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("auth_provider"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("EmailVerificationToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email_verification_token"); + + b.Property("EmailVerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("email_verified_at"); + + b.Property("ExternalEmail") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_email"); + + b.Property("ExternalUserId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_user_id"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("full_name"); + + b.Property("JobTitle") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_title"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_hash"); + + b.Property("PasswordResetToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_reset_token"); + + b.Property("PasswordResetTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_reset_token_expires_at"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("phone_number"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("assigned_at"); + + b.Property("AssignedByUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_by_user_id"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("ip_address"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("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 + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.cs new file mode 100644 index 0000000..b8959b0 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103210023_AddInvitations.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddInvitations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "invitations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + role = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + token_hash = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: false), + accepted_at = table.Column(type: "timestamp with time zone", nullable: true), + invited_by = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "invitations"); + } + } +} diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index 959bbc2..1fad521 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -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("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .HasColumnType("uuid") + .HasColumnName("invited_by"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("token_hash"); + + b.Property("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("Id") diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/InvitationRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/InvitationRepository.cs new file mode 100644 index 0000000..512b063 --- /dev/null +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/InvitationRepository.cs @@ -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 GetByIdAsync(InvitationId id, CancellationToken cancellationToken = default) + { + return await context.Invitations + .FirstOrDefaultAsync(i => i.Id == id, cancellationToken); + } + + public async Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + { + return await context.Invitations + .FirstOrDefaultAsync(i => i.TokenHash == tokenHash, cancellationToken); + } + + public async Task> 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 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); + } +}