Skip to content

Commit

Permalink
Resolves #540
Browse files Browse the repository at this point in the history
  • Loading branch information
sei-bstein committed Nov 22, 2024
1 parent 1379d9b commit ab48e0e
Show file tree
Hide file tree
Showing 13 changed files with 302 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ await _validatorService
.AddValidator((req, ctx) =>
{
if (gameId.IsEmpty())
ctx.AddValidationException(new TeamHasNoPlayersException(request.TeamId));
{
throw new ResourceNotFound<Team>(request.TeamId);
}
})
.Validate(request, cancellationToken);

Expand Down
1 change: 1 addition & 0 deletions src/Gameboard.Api/Features/Player/PlayerExceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ internal RegistrationIsClosed(string gameId, string addlMessage = null) :
internal class SessionAlreadyStarted : GameboardValidationException
{
internal SessionAlreadyStarted(string playerId, string why) : base($"Player {playerId}'s session was started. {why}.") { }
internal SessionAlreadyStarted(string teamId) : base($"Session for team {teamId} already started.") { }
}

internal class SessionNotActive : GameboardException
Expand Down
137 changes: 137 additions & 0 deletions src/Gameboard.Api/Features/Teams/Requests/AddToTeam/AddToTeam.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Gameboard.Api.Common.Services;
using Gameboard.Api.Data;
using Gameboard.Api.Features.Games;
using Gameboard.Api.Features.Player;
using Gameboard.Api.Services;
using Gameboard.Api.Structure.MediatR;
using MediatR;
using Microsoft.EntityFrameworkCore;
using ServiceStack;

namespace Gameboard.Api.Features.Teams;

public record AddToTeamCommand(string TeamId, string UserId) : IRequest<AddToTeamResponse>;

internal sealed class AddToTeamCommandHandler
(
IActingUserService actingUser,
PlayerService playerService,
IStore store,
ITeamService teamService,
IValidatorService validator
) : IRequestHandler<AddToTeamCommand, AddToTeamResponse>
{
private readonly IActingUserService _actingUser = actingUser;
private readonly PlayerService _playerService = playerService;
private readonly IStore _store = store;
private readonly ITeamService _teamService = teamService;
private readonly IValidatorService _validator = validator;

public async Task<AddToTeamResponse> Handle(AddToTeamCommand request, CancellationToken cancellationToken)
{
await _validator
.Auth
(
c => c
.RequirePermissions(Users.PermissionKey.Teams_Enroll)
.Unless
(
async () => await _store
.WithNoTracking<Data.Player>()
.Where(p => p.TeamId == request.TeamId && p.Role == PlayerRole.Manager)
.Where(p => p.UserId == _actingUser.Get().Id)
.AnyAsync(cancellationToken)
)
)
.AddValidator(async ctx =>
{
// team hasn't started playing
var team = await _teamService.GetTeam(request.TeamId);
if (team.SessionBegin.IsNotEmpty())
{
ctx.AddValidationException(new SessionAlreadyStarted(request.TeamId));
}

// team's current roster has to be < max
var gameId = await _teamService.GetGameId(request.TeamId, cancellationToken);
var maxTeamSize = await _store
.WithNoTracking<Data.Game>()
.Where(g => g.Id == gameId)
.Select(g => g.MaxTeamSize)
.SingleAsync(cancellationToken);

if (team.Members.Count() >= maxTeamSize)
{
ctx.AddValidationException(new TeamIsFull(new SimpleEntity { Id = team.TeamId, Name = team.ApprovedName }, team.Members.Count(), maxTeamSize));
}

// if the player is joining a competitive team, they can't have played this game
// competitively before
if (team.Mode == PlayerMode.Competition)
{
var priorPlayer = await _store
.WithNoTracking<Data.Player>()
.Where(p => p.UserId == request.UserId && p.TeamId != team.TeamId)
.Where(p => p.GameId == team.GameId)
.Where(p => p.Mode == PlayerMode.Competition)
.WhereDateIsNotEmpty(p => p.SessionBegin)
.Select(p => new
{
p.Id,
p.ApprovedName,
Game = new SimpleEntity { Id = p.GameId, Name = p.Game.Name },
User = new SimpleEntity { Id = p.UserId, Name = p.User.ApprovedName },
p.SessionBegin,
p.TeamId
})
.SingleOrDefaultAsync(cancellationToken);

if (priorPlayer is not null)
{
ctx.AddValidationException(new UserAlreadyPlayed(priorPlayer.User, priorPlayer.Game, priorPlayer.TeamId, priorPlayer.SessionBegin));
}
}
})
//
.Validate(cancellationToken);

// first find the team they're meant to join
var team = await _teamService.GetTeam(request.TeamId);

// first ensure the person is enrolled
var existingPlayerId = await _store
.WithNoTracking<Data.Player>()
.Where(p => p.UserId == request.UserId)
.Where(p => p.TeamId != request.TeamId)
.WhereDateIsEmpty(p => p.SessionBegin)
.Where(p => p.GameId == team.GameId)
.Select(p => p.Id)
.SingleOrDefaultAsync(cancellationToken);

if (existingPlayerId.IsEmpty())
{
var existingPlayer = await _playerService.Enroll
(
new NewPlayer { GameId = team.GameId, UserId = request.UserId },
_actingUser.Get(),
cancellationToken
);

existingPlayerId = existingPlayer.Id;
}

var players = await _teamService.AddPlayers(request.TeamId, cancellationToken, existingPlayerId);
var addedPlayer = players.Single();

return new AddToTeamResponse
{
Game = new SimpleEntity { Id = addedPlayer.GameId, Name = addedPlayer.GameName },
Player = new SimpleEntity { Id = addedPlayer.Id, Name = addedPlayer.ApprovedName },
Team = new SimpleEntity { Id = team.TeamId, Name = team.ApprovedName },
User = new SimpleEntity { Id = addedPlayer.UserId, Name = addedPlayer.UserApprovedName }
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using Gameboard.Api.Structure;

namespace Gameboard.Api.Features.Teams;

public sealed class AddToTeamResponse
{
public required SimpleEntity Game { get; set; }
public required SimpleEntity Player { get; set; }
public required SimpleEntity Team { get; set; }
public required SimpleEntity User { get; set; }
}

internal sealed class UserAlreadyPlayed : GameboardValidationException
{
public UserAlreadyPlayed(SimpleEntity user, SimpleEntity game, string teamId, DateTimeOffset sessionStart)
: base($"""User "{user.Name}" already played game {game.Name} on {sessionStart} (team {teamId})""") { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ await _store
// TODO: kinda yucky. Want to share logic about what it means to be added to a team, but all the validation around that
// is in teamService
var playersToAdd = createdPlayers.Where(p => p.Id != captainPlayer.Id).Select(p => p.Id).ToArray();
await _teamService.AddPlayers(captainPlayer.TeamId, cancellationToken, playersToAdd);
if (playersToAdd.Length > 0)
{
await _teamService.AddPlayers(captainPlayer.TeamId, cancellationToken, playersToAdd);
}

// make the captain the actual captain
await _teamService.PromoteCaptain(captainPlayer.TeamId, captainPlayer.Id, actingUser, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Gameboard.Api.Common.Services;
using Gameboard.Api.Data;
using Gameboard.Api.Features.Player;
using Gameboard.Api.Features.Users;
using Gameboard.Api.Structure.MediatR;
using Gameboard.Api.Structure.MediatR.Validators;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Gameboard.Api.Features.Teams;

public record RemoveFromTeamCommand(string PlayerId) : IRequest<RemoveFromTeamResponse>;

internal sealed class RemoveFromTeamHandler
(
IGuidService guids,
EntityExistsValidator<Data.Player> playerExists,
IStore store,
IValidatorService validatorService
) : IRequestHandler<RemoveFromTeamCommand, RemoveFromTeamResponse>
{
private readonly IGuidService _guids = guids;
private readonly EntityExistsValidator<Data.Player> _playerExists = playerExists;
private readonly IStore _store = store;
private readonly IValidatorService _validator = validatorService;

public async Task<RemoveFromTeamResponse> Handle(RemoveFromTeamCommand request, CancellationToken cancellationToken)
{
await _validator
.Auth(c => c.RequirePermissions(PermissionKey.Teams_Enroll))
.AddValidator(_playerExists.UseValue(request.PlayerId))
.AddValidator(async ctx =>
{
var playerData = await _store
.WithNoTracking<Data.Player>()
.Where(p => p.Id == request.PlayerId)
.Select(p => new
{
p.Id,
p.ApprovedName,
p.SessionBegin,
p.Role,
p.TeamId
})
.SingleOrDefaultAsync(cancellationToken);

// if they started the session already, tough nuggets
if (!playerData.SessionBegin.IsEmpty())
{
ctx.AddValidationException(new SessionAlreadyStarted(request.PlayerId, "This player can't be removed from the team."));
}

// you can't remove the captain (unenroll them instead)
if (playerData.Role == PlayerRole.Manager)
{
ctx.AddValidationException(new CantRemoveCaptain(new SimpleEntity { Id = playerData.Id, Name = playerData.ApprovedName }, playerData.TeamId));
}

// in theory the last remaining player should be the captain and should get caught by above,
// but because the schema is weird (shoutout #553), check anyway
var hasRemainingTeammates = await _store
.WithNoTracking<Data.Player>()
.Where(p => p.TeamId == playerData.TeamId)
.Where(p => p.Id != request.PlayerId)
.AnyAsync(cancellationToken);

if (!hasRemainingTeammates)
{
ctx.AddValidationException(new CantRemoveLastTeamMember(new SimpleEntity { Id = playerData.Id, Name = playerData.ApprovedName }, playerData.TeamId));
}
})
.Validate(cancellationToken);

await _store
.WithNoTracking<Data.Player>()
.Where(p => p.Id == request.PlayerId)
.ExecuteUpdateAsync(up => up.SetProperty(p => p.TeamId, _guids.Generate()), cancellationToken);

return await _store
.WithNoTracking<Data.Player>()
.Where(p => p.Id == request.PlayerId)
.Select(p => new RemoveFromTeamResponse
{
Player = new SimpleEntity { Id = p.Id, Name = p.ApprovedName },
Game = new SimpleEntity { Id = p.GameId, Name = p.Game.Name },
TeamId = p.TeamId,
UserId = new SimpleEntity { Id = p.UserId, Name = p.User.ApprovedName }
})
.SingleAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Gameboard.Api.Features.Teams;

public record RemoveFromTeamResponse
{
public required SimpleEntity Game { get; set; }
public required SimpleEntity Player { get; set; }
public required string TeamId { get; set; }
public required SimpleEntity UserId { get; set; }
}
4 changes: 3 additions & 1 deletion src/Gameboard.Api/Features/Teams/Services/TeamService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,9 @@ public async Task PromoteCaptain(string teamId, string newCaptainPlayerId, User
.ToListAsync(cancellationToken);

if (teamPlayers.Count == 0)
throw new TeamHasNoPlayersException(teamId);
{
throw new ResourceNotFound<Team>(teamId);
}

var captainFound = false;
foreach (var player in teamPlayers)
Expand Down
8 changes: 8 additions & 0 deletions src/Gameboard.Api/Features/Teams/TeamController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ ITeamService teamService
private readonly IMediator _mediator = mediator;
private readonly ITeamService _teamService = teamService;

[HttpDelete("{teamId}/players/{playerId}")]
public Task<RemoveFromTeamResponse> RemovePlayer([FromRoute] string teamId, [FromRoute] string playerId)
=> _mediator.Send(new RemoveFromTeamCommand(playerId));

[HttpPut("{teamId}/players")]
public Task<AddToTeamResponse> AddUser([FromRoute] string teamId, [FromBody] AddToTeamCommand request)
=> _mediator.Send(request);

[HttpGet("{teamId}")]
public async Task<Team> GetTeam(string teamId)
=> await _mediator.Send(new GetTeamQuery(teamId, _actingUserService.Get()));
Expand Down
19 changes: 14 additions & 5 deletions src/Gameboard.Api/Features/Teams/TeamExceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ public CantJoinTeamBecausePlayerCount(string gameId, int playersToJoin, int team
: base($"Can't add {playersToJoin} player(s) to the team. This team has {teamSizeCurrent} player(s) (min team size is {teamSizeMin}, max team size is {teamSizeMax}).") { }
}

internal class CantRemoveCaptain : GameboardValidationException
{
public CantRemoveCaptain(SimpleEntity player, string teamId)
: base($"Can't remove player {player.Name} from the team {teamId} - they're the captain.") { }
}

internal class CantRemoveLastTeamMember : GameboardValidationException
{
public CantRemoveLastTeamMember(SimpleEntity player, string teamId)
: base($"""Can't remove the last member ("{player.Name}") of a team {teamId}.""") { }
}

internal class CantResolveTeamFromCode : GameboardValidationException
{
internal CantResolveTeamFromCode(string code, string[] teamIds)
Expand Down Expand Up @@ -65,11 +77,6 @@ internal RequiresSameSponsor(string gameId, string managerPlayerId, string manag
: base($"Game {gameId} requires that all players have the same sponsor. The inviting player {managerPlayerId} has sponsor {managerSponsor}, while player {playerId} has sponsor {playerSponsor}.") { }
}

internal class TeamHasNoPlayersException : GameboardValidationException
{
public TeamHasNoPlayersException(string teamId) : base($"Team {teamId} has no players.") { }
}

internal class TeamsAreFromMultipleGames : GameboardException
{
public TeamsAreFromMultipleGames(IEnumerable<string> teamIds, IEnumerable<string> gameIds)
Expand All @@ -78,6 +85,8 @@ public TeamsAreFromMultipleGames(IEnumerable<string> teamIds, IEnumerable<string

internal class TeamIsFull : GameboardValidationException
{
internal TeamIsFull(SimpleEntity team, int teamSize, int maxTeamSize)
: base($"""Team {team.Name} has {teamSize} players, and the max team size is {maxTeamSize}.""") { }
internal TeamIsFull(string invitingPlayerId, int teamSize, int maxTeamSize)
: base($"Inviting player {invitingPlayerId} has {teamSize} players on their team, and the max team size for this game is {maxTeamSize}.") { }
}
Expand Down
7 changes: 4 additions & 3 deletions src/Gameboard.Api/Features/Teams/TeamsModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public class Team
public string GameId { get; set; }
public DateTimeOffset SessionBegin { get; set; }
public DateTimeOffset SessionEnd { get; set; }
public PlayerMode Mode { get; set; }
public int Rank { get; set; }
public int Score { get; set; }
public long Time { get; set; }
Expand All @@ -87,9 +88,9 @@ public class Team
public required SimpleEntity AdvancedFromPlayer { get; set; }
public required string AdvancedFromTeamId { get; set; }
public required double? AdvancedWithScore { get; set; }
public IEnumerable<TeamChallenge> Challenges { get; set; } = new List<TeamChallenge>();
public IEnumerable<TeamMember> Members { get; set; } = new List<TeamMember>();
public IEnumerable<Sponsor> Sponsors { get; set; } = new List<Sponsor>();
public IEnumerable<TeamChallenge> Challenges { get; set; } = [];
public IEnumerable<TeamMember> Members { get; set; } = [];
public IEnumerable<Sponsor> Sponsors { get; set; } = [];
}

public class TeamSummary
Expand Down
5 changes: 3 additions & 2 deletions src/Gameboard.Api/Features/Ticket/TicketController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
namespace Gameboard.Api.Controllers;

[Authorize]
public class TicketController(
public class TicketController
(
IActingUserService actingUserService,
ILogger<ChallengeController> logger,
IDistributedCache cache,
Expand All @@ -28,7 +29,7 @@ public class TicketController(
TicketService ticketService,
IHubContext<AppHub, IAppHubEvent> hub,
IMapper mapper
) : GameboardLegacyController(actingUserService, logger, cache, validator)
) : GameboardLegacyController(actingUserService, logger, cache, validator)
{
private readonly IUserRolePermissionsService _permissionsService = permissionsService;
TicketService TicketService { get; } = ticketService;
Expand Down
Loading

0 comments on commit ab48e0e

Please sign in to comment.