Skip to content

Commit

Permalink
Team Management - Events (#6)
Browse files Browse the repository at this point in the history
* add domain logic for event management
* add event management endpoint tests
* fix registering query consumers
* refactoring
  • Loading branch information
skrasekmichael committed Apr 24, 2024
1 parent 5c59487 commit fa1ad47
Show file tree
Hide file tree
Showing 40 changed files with 2,763 additions and 7 deletions.
17 changes: 17 additions & 0 deletions src/Common/TeamUp.Common.Contracts/Collection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Collections;

namespace TeamUp.Common.Contracts;

public sealed class Collection<T> : IEnumerable<T>
{
public IReadOnlyCollection<T> Values { get; }

public Collection(IReadOnlyCollection<T> values)
{
Values = values;
}

public IEnumerator<T> GetEnumerator() => Values.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => Values.GetEnumerator();
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ public static class ResultToResponseExtensions
public static IResult ToResponse<TOut>(this Result<TOut> result, Func<TOut, IResult> success)
{
if (result.IsSuccess)
{
return success(result.Value);
}

return result.Error.ToResponse();
}

public static IResult ToResponse(this Result result, Func<IResult> success)
{
if (result.IsSuccess)
{
return success();
}

return result.Error.ToResponse();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace TeamUp.Common.Infrastructure.Extensions;
using System.Text.RegularExpressions;

internal static class TypesExtensions
namespace TeamUp.Common.Infrastructure.Extensions;

internal static partial class TypesExtensions
{
internal static Type? GetInterfaceWithGenericDefinition(this Type type, Type definitionType)
=> type.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == definitionType);
Expand All @@ -18,4 +20,18 @@ internal static bool ImplementInterfaceOfType(this Type type, Type interfaceType
var genericArgs = type.GetGenericArguments();
return index >= 0 && index < genericArgs.Length ? genericArgs[index] : null;
}

[GeneratedRegex("(?<!^)([A-Z][a-z]|(?<=[a-z])[A-Z0-9])", RegexOptions.Compiled)]
private static partial Regex AddDashBeforeCapitalLetterRegex();

public static string? ToKebabCase(this Type? type)
{
if (type is null)
{
return null;
}

var pascalCase = type.FullName!.Replace(".", "");
return AddDashBeforeCapitalLetterRegex().Replace(pascalCase, "-$1").ToLower();
}
}
5 changes: 4 additions & 1 deletion src/Common/TeamUp.Common.Infrastructure/Modules/Module.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public abstract class Module<TModuleId, TDatabaseContext> : IModule
{
private static readonly Type QueryType = typeof(IQuery<>);
private static readonly Type QueryConsumerType = typeof(QueryConsumerFacade<,>);
private static readonly Type QueryConsumerDefinitionType = typeof(QueryConsumerDefinition<,>);
private static readonly Type CommandType = typeof(ICommand);
private static readonly Type CommandConsumerType = typeof(CommandConsumerFacade<>);
private static readonly Type CommandWithResponseType = typeof(ICommand<>);
Expand Down Expand Up @@ -59,7 +60,9 @@ public void RegisterRequestConsumers(IBusRegistrationConfigurator cfg)
?? throw new InternalException($"Unexpected generic type when registering query consumer in module '{GetType().Name}'.");

var consumerType = QueryConsumerType.MakeGenericType(type, responseType);
cfg.AddConsumer(consumerType);
var consumerDefinition = QueryConsumerDefinitionType.MakeGenericType(type, responseType);

cfg.AddConsumer(consumerType, consumerDefinition);
continue;
}

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

using TeamUp.Common.Contracts;
using TeamUp.Common.Infrastructure.Extensions;

namespace TeamUp.Common.Infrastructure.Processing.Queries;

internal sealed class QueryConsumerDefinition<TQuery, TResponse> : ConsumerDefinition<QueryConsumerFacade<TQuery, TResponse>> where TQuery : class, IQuery<TResponse>
{
public QueryConsumerDefinition()
{
EndpointName = typeof(TQuery).ToKebabCase()!;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using RailwayResult;

using TeamUp.Common.Application;
using TeamUp.TeamManagement.Contracts;
using TeamUp.TeamManagement.Contracts.Events;
using TeamUp.TeamManagement.Contracts.Events.CreateEvent;
using TeamUp.TeamManagement.Domain.Aggregates.Events;

namespace TeamUp.TeamManagement.Application.Events;

internal sealed class CreateEventCommandHandler : ICommandHandler<CreateEventCommand, EventId>
{
private readonly IEventDomainService _eventDomainService;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;

public CreateEventCommandHandler(IEventDomainService eventDomainService, IUnitOfWork<TeamManagementModuleId> unitOfWork)
{
_eventDomainService = eventDomainService;
_unitOfWork = unitOfWork;
}

public async Task<Result<EventId>> Handle(CreateEventCommand command, CancellationToken ct)
{
return await _eventDomainService
.CreateEventAsync(
initiatorId: command.InitiatorId,
teamId: command.TeamId,
eventTypeId: command.EventTypeId,
fromUtc: command.FromUtc,
toUtc: command.ToUtc,
description: command.Description,
meetTime: command.MeetTime,
replyClosingTimeBeforeMeetTime: command.ReplyClosingTimeBeforeMeetTime,
ct: ct)
.TapAsync(_ => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;

using RailwayResult;
using RailwayResult.FunctionalExtensions;

using TeamUp.Common.Application;
using TeamUp.TeamManagement.Contracts.Events;
using TeamUp.TeamManagement.Contracts.Events.GetEvent;
using TeamUp.TeamManagement.Domain.Aggregates.Events;
using TeamUp.TeamManagement.Domain.Aggregates.Teams;

using EventResponse = TeamUp.TeamManagement.Contracts.Events.EventResponse;

namespace TeamUp.TeamManagement.Application.Events;

internal sealed class GetEventQueryHandler : IQueryHandler<GetEventQuery, EventResponse>
{
private readonly ITeamManagementQueryContext _appQueryContext;

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

public async Task<Result<EventResponse>> Handle(GetEventQuery query, CancellationToken ct)
{
var team = await _appQueryContext.Teams
.AsSplitQuery()
.Include(team => team.Members)
.Select(team => new
{
team.Id,
Event = _appQueryContext.Events
.AsSplitQuery()
.Where(e => e.Id == query.EventId && e.TeamId == team.Id)
.Select(e => new EventResponse
{
Description = e.Description,
EventTypeId = e.EventTypeId,
EventType = team.EventTypes.First(et => et.Id == e.EventTypeId).Name,
FromUtc = e.FromUtc,
ToUtc = e.ToUtc,
MeetTime = e.MeetTime,
ReplyClosingTimeBeforeMeetTime = e.ReplyClosingTimeBeforeMeetTime,
Status = e.Status,
EventResponses = e.EventResponses.Select(er => new EventResponseResponse
{
Message = er.Message,
TeamMemberId = er.TeamMemberId,
TeamMemberNickname = team.Members.First(member => member.Id == er.TeamMemberId).Nickname,
TimeStampUtc = er.TimeStampUtc,
Type = er.ReplyType
}).ToList()
})
.FirstOrDefault(),
Initiator = team.Members
.Select(member => member.UserId)
.FirstOrDefault(id => id == query.InitiatorId)
})
.FirstOrDefaultAsync(team => team.Id == query.TeamId, ct);

return team
.EnsureNotNull(TeamErrors.TeamNotFound)
.EnsureNotNull(team => team.Initiator, TeamErrors.NotMemberOfTeam)
.EnsureNotNull(team => team.Event, EventErrors.EventNotFound)
.Then(team => team.Event!);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;

using RailwayResult;
using RailwayResult.FunctionalExtensions;

using TeamUp.Common.Application;
using TeamUp.Common.Contracts;
using TeamUp.TeamManagement.Contracts.Events;
using TeamUp.TeamManagement.Contracts.Events.GetEvents;
using TeamUp.TeamManagement.Domain.Aggregates.Teams;

namespace TeamUp.TeamManagement.Application.Events;

internal sealed class GetEventsQueryHandlers : IQueryHandler<GetEventsQuery, Collection<EventSlimResponse>>
{
private readonly ITeamManagementQueryContext _appQueryContext;
private readonly IDateTimeProvider _dateTimeProvider;

public GetEventsQueryHandlers(ITeamManagementQueryContext appQueryContext, IDateTimeProvider dateTimeProvider)
{
_appQueryContext = appQueryContext;
_dateTimeProvider = dateTimeProvider;
}

public async Task<Result<Collection<EventSlimResponse>>> Handle(GetEventsQuery query, CancellationToken ct)
{
var from = query.FromUtc ?? _dateTimeProvider.UtcNow;
var team = await _appQueryContext.Teams
.Select(team => new
{
team.Id,
Events = _appQueryContext.Events
.AsSplitQuery()
.Where(e => e.TeamId == team.Id && e.ToUtc > from)
.Include(e => e.EventResponses)
.Select(e => new EventSlimResponse
{
Id = e.Id,
Description = e.Description,
FromUtc = e.FromUtc,
ToUtc = e.ToUtc,
Status = e.Status,
MeetTime = e.MeetTime,
ReplyClosingTimeBeforeMeetTime = e.ReplyClosingTimeBeforeMeetTime,
ReplyCount = e.EventResponses
.GroupBy(er => er.ReplyType)
.Select(x => new ReplyCountResponse
{
Type = x.Key,
Count = x.Count()
})
.ToList(),
EventType = team.EventTypes.First(et => et.Id == e.EventTypeId).Name
})
.OrderBy(e => e.FromUtc)
.ToList(),
Initiator = team.Members
.Select(member => member.UserId)
.FirstOrDefault(id => id == query.InitiatorId)
})
.FirstOrDefaultAsync(team => team.Id == query.TeamId, ct);

return team
.EnsureNotNull(TeamErrors.TeamNotFound)
.EnsureNotNull(team => team.Initiator, TeamErrors.NotMemberOfTeam)
.Then(team => new Collection<EventSlimResponse>(team.Events));
}
}
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.Events.RemoveEvent;
using TeamUp.TeamManagement.Domain.Aggregates.Events;

namespace TeamUp.TeamManagement.Application.Events;

internal sealed class RemoveEventCommandHandler : ICommandHandler<RemoveEventCommand>
{
private readonly IEventDomainService _eventDomainService;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;

public RemoveEventCommandHandler(IEventDomainService eventDomainService, IUnitOfWork<TeamManagementModuleId> unitOfWork)
{
_eventDomainService = eventDomainService;
_unitOfWork = unitOfWork;
}

public async Task<Result> Handle(RemoveEventCommand command, CancellationToken ct)
{
return await _eventDomainService
.DeleteEventAsync(command.InitiatorId, command.TeamId, command.EventId, ct)
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using RailwayResult;
using RailwayResult.Errors;
using RailwayResult.FunctionalExtensions;

using TeamUp.Common.Application;
using TeamUp.Common.Contracts;
using TeamUp.TeamManagement.Contracts;
using TeamUp.TeamManagement.Contracts.Events;
using TeamUp.TeamManagement.Contracts.Events.UpsertEventReply;
using TeamUp.TeamManagement.Domain.Aggregates.Events;
using TeamUp.TeamManagement.Domain.Aggregates.Teams;

namespace TeamUp.TeamManagement.Application.Events;

internal sealed class UpsertEventReplyCommandHandler : ICommandHandler<UpsertEventReplyCommand>
{
private readonly IEventRepository _eventRepository;
private readonly ITeamRepository _teamRepository;
private readonly IUnitOfWork<TeamManagementModuleId> _unitOfWork;
private readonly IDateTimeProvider _dateTimeProvider;

public UpsertEventReplyCommandHandler(
IEventRepository eventRepository,
ITeamRepository teamRepository,
IUnitOfWork<TeamManagementModuleId> unitOfWork,
IDateTimeProvider dateTimeProvider)
{
_eventRepository = eventRepository;
_teamRepository = teamRepository;
_unitOfWork = unitOfWork;
_dateTimeProvider = dateTimeProvider;
}

public async Task<Result> Handle(UpsertEventReplyCommand command, CancellationToken ct)
{
var team = await _teamRepository.GetTeamByIdAsync(command.TeamId, ct);
return await team
.EnsureNotNull(TeamErrors.TeamNotFound)
.Then(team => team.GetTeamMemberByUserId(command.InitiatorId))
.AndAsync(_ => _eventRepository.GetEventByIdAsync(command.EventId, ct))
.EnsureSecondNotNull(EventErrors.EventNotFound)
.Ensure((_, @event) => @event.TeamId == command.TeamId, EventErrors.EventNotFound)
.And((_, _) => MapRequestToReply(command))
.Then((member, @event, reply) => @event.SetMemberResponse(_dateTimeProvider, member.Id, reply))
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}

private static Result<EventReply> MapRequestToReply(UpsertEventReplyCommand command) => command.ReplyType switch
{
ReplyType.Yes => EventReply.Yes(),
ReplyType.Maybe => EventReply.Maybe(command.Message),
ReplyType.Delay => EventReply.Delay(command.Message),
ReplyType.No => EventReply.No(command.Message),
_ => new InternalError("InternalErrors.MissingSwitchCase", $"{nameof(MapRequestToReply)} does not implement case for type [{command.ReplyType}]")
};
}
Loading

0 comments on commit fa1ad47

Please sign in to comment.