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