Skip to content

Commit

Permalink
Team Management - Invitations (#7)
Browse files Browse the repository at this point in the history
* add domain logic for invitation management
* add necessary logic to user access module (generating users)
* add endpoint tests for invitation management
* fix endpoint authorization requirement
* refactoring (naming, namespaces, ...)
  • Loading branch information
skrasekmichael authored Apr 24, 2024
1 parent fa1ad47 commit dd4c976
Show file tree
Hide file tree
Showing 47 changed files with 2,213 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using RailwayResult;

namespace TeamUp.Common.Contracts.Errors;

public sealed record EventualConsistencyError(string Key, string Message) : Error(Key, Message);
5 changes: 4 additions & 1 deletion src/Common/TeamUp.Common.Endpoints/EndpointGroupBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ public EndpointGroupBuilder WithTags(params string[] tags)

public EndpointGroupBuilder Configure(Func<RouteGroupBuilder, RouteGroupBuilder> configureGroup)
{
Group = configureGroup(Group);
foreach (var group in _subGroups)
{
group.Group = configureGroup(group.Group);
}
return this;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\..\..\Common\TeamUp.Common.Contracts\TeamUp.Common.Contracts.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using RailwayResult;

using TeamUp.Common.Application;
using TeamUp.TeamManagement.Contracts;
using TeamUp.TeamManagement.Contracts.Invitations.AcceptInvitation;
using TeamUp.TeamManagement.Domain.Aggregates.Invitations;

namespace TeamUp.TeamManagement.Application.Invitations;

internal sealed class AcceptInvitationCommandHandler : ICommandHandler<AcceptInvitationCommand>
{
private readonly IInvitationDomainService _invitationDomainService;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;

public AcceptInvitationCommandHandler(IInvitationDomainService invitationDomainService, IUnitOfWork<TeamManagementModuleId> unitOfWork)
{
_invitationDomainService = invitationDomainService;
_unitOfWork = unitOfWork;
}

public async Task<Result> Handle(AcceptInvitationCommand command, CancellationToken ct)
{
return await _invitationDomainService
.AcceptInvitationAsync(command.InitiatorId, command.InvitationId, ct)
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Microsoft.Extensions.Logging;

using RailwayResult;
using RailwayResult.Errors;
using RailwayResult.FunctionalExtensions;

using TeamUp.Common.Application;
using TeamUp.Common.Contracts.Errors;
using TeamUp.Common.Domain;
using TeamUp.Notifications.Contracts;
using TeamUp.TeamManagement.Contracts;
using TeamUp.TeamManagement.Domain.Aggregates.Invitations;
using TeamUp.TeamManagement.Domain.Aggregates.Invitations.IntegrationEvents;
using TeamUp.TeamManagement.Domain.Aggregates.Users;

namespace TeamUp.TeamManagement.Application.Invitations;

internal sealed class CreateInvitationRequestCreatedEventHandler : IIntegrationEventHandler<CreateInvitationRequestCreatedIntegrationEvent>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<CreateInvitationRequestCreatedEventHandler> _logger;
private readonly InvitationFactory _invitationFactory;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;
private readonly IIntegrationEventPublisher<TeamManagementModuleId> _publisher;

public CreateInvitationRequestCreatedEventHandler(IUserRepository userRepository, ILogger<CreateInvitationRequestCreatedEventHandler> logger, InvitationFactory invitationFactory, IUnitOfWork<TeamManagementModuleId> unitOfWork, IIntegrationEventPublisher<TeamManagementModuleId> publisher)
{
_userRepository = userRepository;
_logger = logger;
_invitationFactory = invitationFactory;
_unitOfWork = unitOfWork;
_publisher = publisher;
}

public async Task<Result> Handle(CreateInvitationRequestCreatedIntegrationEvent integrationEvent, CancellationToken ct)
{
var user = await _userRepository.GetUserByIdAsync(integrationEvent.UserId, ct);
if (user is null)
{
_logger.LogWarning("User {userId} is not yet created for inviting to team {teamId}.", integrationEvent.UserId, integrationEvent.TeamId);
return new EventualConsistencyError("TeamManagement.Users.NotFound", "User not found when inviting.");
}

var result = await _invitationFactory
.CreateAndAddInvitationAsync(user.Id, integrationEvent.TeamId, ct)
.Tap(invitation =>
{
var message = new EmailCreatedIntegrationEvent
{
Email = user.Email,
Subject = "You were invited to team!",
Message = $"Accept invitation link for {invitation.Id}."
};

_publisher.Publish(message);
})
.ThenAsync(_ => _unitOfWork.SaveChangesAsync(ct));

if (result.IsFailure)
{
if (result.Error is ConflictError error)
{
_logger.LogWarning("Invitation of user {userId} to team {teamId} probably already exist. {error}", integrationEvent.UserId, integrationEvent.TeamId, error);
return Result.Success;
}
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;

using RailwayResult;

using TeamUp.Common.Application;
using TeamUp.Common.Contracts;
using TeamUp.TeamManagement.Contracts.Invitations;
using TeamUp.TeamManagement.Contracts.Invitations.GetMyInvitations;

namespace TeamUp.TeamManagement.Application.Invitations;

internal sealed class GetMyInvitationsQueryHandler : IQueryHandler<GetMyInvitationsQuery, Collection<InvitationResponse>>
{
private readonly ITeamManagementQueryContext _appQueryContext;

public GetMyInvitationsQueryHandler(ITeamManagementQueryContext appQueryContext)
{
_appQueryContext = appQueryContext;
}

public async Task<Result<Collection<InvitationResponse>>> Handle(GetMyInvitationsQuery query, CancellationToken ct)
{
var invitations = await _appQueryContext.Invitations
.Where(invitation => invitation.RecipientId == query.InitiatorId)
.Select(invitation => new InvitationResponse
{
Id = invitation.Id,
TeamName = _appQueryContext.Teams.First(team => team.Id == invitation.TeamId).Name,
CreatedUtc = invitation.CreatedUtc
})
.ToListAsync(ct);

return new Collection<InvitationResponse>(invitations);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;

using RailwayResult;
using RailwayResult.FunctionalExtensions;

using TeamUp.Common.Application;
using TeamUp.Common.Contracts;
using TeamUp.TeamManagement.Contracts.Invitations;
using TeamUp.TeamManagement.Contracts.Invitations.GetTeamInvitations;
using TeamUp.TeamManagement.Domain.Aggregates.Teams;

namespace TeamUp.TeamManagement.Application.Invitations;

internal sealed class GetTeamInvitationsQueryHandler : IQueryHandler<GetTeamInvitationsQuery, Collection<TeamInvitationResponse>>
{
private readonly ITeamManagementQueryContext _appQueryContext;

public GetTeamInvitationsQueryHandler(ITeamManagementQueryContext appQueryContext)
{
_appQueryContext = appQueryContext;
}

public async Task<Result<Collection<TeamInvitationResponse>>> Handle(GetTeamInvitationsQuery query, CancellationToken ct)
{
var teamWithInitiator = await _appQueryContext.Teams
.Select(team => new
{
team.Id,
Initiaotor = team.Members
.Select(member => new { member.UserId, member.Role })
.FirstOrDefault(member => member.UserId == query.InitiatorId),
})
.FirstOrDefaultAsync(team => team.Id == query.TeamId, ct);

return await teamWithInitiator
.EnsureNotNull(TeamErrors.TeamNotFound)
.EnsureNotNull(team => team.Initiaotor, TeamErrors.NotMemberOfTeam)
.Ensure(team => team.Initiaotor!.Role.CanInviteTeamMembers(), TeamErrors.UnauthorizedToReadInvitationList)
.ThenAsync(team =>
{
return _appQueryContext.Invitations
.Where(invitation => invitation.TeamId == query.TeamId)
.Select(invitation => new TeamInvitationResponse
{
Id = invitation.Id,
Email = _appQueryContext.Users
.Select(user => new { user.Id, user.Email })
.First(user => user.Id == invitation.RecipientId).Email,
CreatedUtc = invitation.CreatedUtc
})
.ToListAsync(ct);
})
.Then(invitations => new Collection<TeamInvitationResponse>(invitations));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using RailwayResult;

using TeamUp.Common.Application;
using TeamUp.TeamManagement.Contracts;
using TeamUp.TeamManagement.Contracts.Invitations.InviteUser;
using TeamUp.TeamManagement.Domain.Aggregates.Invitations;

namespace TeamUp.TeamManagement.Application.Invitations;

internal sealed class InviteUserCommandHandler : ICommandHandler<InviteUserCommand>
{
private readonly IInvitationDomainService _invitationDomainService;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;

public InviteUserCommandHandler(IInvitationDomainService invitationDomainService, IUnitOfWork<TeamManagementModuleId> unitOfWork)
{
_invitationDomainService = invitationDomainService;
_unitOfWork = unitOfWork;
}

public Task<Result> Handle(InviteUserCommand command, CancellationToken ct)
{
return _invitationDomainService
.InviteUserAsync(command.InitiatorId, command.TeamId, command.Email, ct)
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using RailwayResult;

using TeamUp.Common.Application;
using TeamUp.TeamManagement.Contracts;
using TeamUp.TeamManagement.Contracts.Invitations.RemoveInvitation;
using TeamUp.TeamManagement.Domain.Aggregates.Invitations;

namespace TeamUp.TeamManagement.Application.Invitations;

internal sealed class RemoveInvitationCommandHandler : ICommandHandler<RemoveInvitationCommand>
{
private readonly IInvitationDomainService _invitationDomainService;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;

public RemoveInvitationCommandHandler(IInvitationDomainService invitationDomainService, IUnitOfWork<TeamManagementModuleId> unitOfWork)
{
_invitationDomainService = invitationDomainService;
_unitOfWork = unitOfWork;
}

public Task<Result> Handle(RemoveInvitationCommand command, CancellationToken ct)
{
return _invitationDomainService
.RemoveInvitationAsync(command.InitiatorId, command.InvitationId, ct)
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<None Remove="Teams\ChangeOwnershipCommandHandler.cs~RF67c66b7a.TMP" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\Common\TeamUp.Common.Application\TeamUp.Common.Application.csproj" />
<ProjectReference Include="..\..\Notifications\TeamUp.Notifications.Contracts\TeamUp.Notifications.Contracts.csproj" />
<ProjectReference Include="..\TeamUp.TeamManagement.Domain\TeamUp.TeamManagement.Domain.csproj" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using FluentValidation;

using TeamUp.Common.Contracts;
using TeamUp.UserAccess.Contracts;

namespace TeamUp.TeamManagement.Contracts.Invitations.AcceptInvitation;

public sealed record AcceptInvitationCommand : ICommand
{
public required UserId InitiatorId { get; init; }
public required InvitationId InvitationId { get; init; }

public sealed class Validator : AbstractValidator<AcceptInvitationCommand>
{
public Validator()
{
RuleFor(x => x.InitiatorId).NotEmpty();
RuleFor(x => x.InvitationId).NotEmpty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using TeamUp.Common.Contracts;
using TeamUp.UserAccess.Contracts;

namespace TeamUp.TeamManagement.Contracts.Invitations.GetMyInvitations;

public sealed record GetMyInvitationsQuery : IQuery<Collection<InvitationResponse>>
{
public required UserId InitiatorId { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using TeamUp.Common.Contracts;
using TeamUp.TeamManagement.Contracts.Teams;
using TeamUp.UserAccess.Contracts;

namespace TeamUp.TeamManagement.Contracts.Invitations.GetTeamInvitations;

public sealed record GetTeamInvitationsQuery : IQuery<Collection<TeamInvitationResponse>>
{
public required UserId InitiatorId { get; init; }
public required TeamId TeamId { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TeamUp.TeamManagement.Contracts.Invitations;

public sealed class InvitationResponse
{
public required InvitationId Id { get; init; }
public required string TeamName { get; init; }
public required DateTime CreatedUtc { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using FluentValidation;

using TeamUp.Common.Contracts;
using TeamUp.TeamManagement.Contracts.Teams;
using TeamUp.UserAccess.Contracts;

namespace TeamUp.TeamManagement.Contracts.Invitations.InviteUser;

public sealed record InviteUserCommand : ICommand
{
public required UserId InitiatorId { get; init; }
public required TeamId TeamId { get; init; }
public required string Email { get; init; }

public sealed class Validator : AbstractValidator<InviteUserCommand>
{
public Validator()
{
RuleFor(x => x.InitiatorId).NotEmpty();
RuleFor(x => x.TeamId).NotEmpty();
RuleFor(x => x.Email).EmailAddress();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;

using TeamUp.TeamManagement.Contracts.Teams;

namespace TeamUp.TeamManagement.Contracts.Invitations.InviteUser;

public sealed class InviteUserRequest
{
public required TeamId TeamId { get; init; }

[DataType(DataType.EmailAddress)]
public required string Email { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using FluentValidation;

using TeamUp.Common.Contracts;
using TeamUp.UserAccess.Contracts;

namespace TeamUp.TeamManagement.Contracts.Invitations.RemoveInvitation;

public sealed record RemoveInvitationCommand : ICommand
{
public required UserId InitiatorId { get; init; }
public required InvitationId InvitationId { get; init; }

public sealed class Validator : AbstractValidator<RemoveInvitationCommand>
{
public Validator()
{
RuleFor(x => x.InitiatorId).NotEmpty();
RuleFor(x => x.InvitationId).NotEmpty();
}
}
}
Loading

0 comments on commit dd4c976

Please sign in to comment.