Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 26 additions & 82 deletions src/ScrumPoker.API/Controllers/RoomController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using ScrumPoker.Application.Features.Commands.ResetVotes;
using ScrumPoker.Application.Features.Commands.Vote;
using ScrumPoker.Application.Features.Queries.GetGameState;
using ScrumPoker.Domain.Rooms;
using Wolverine;

namespace ScrumPoker.API.Controllers;
Expand All @@ -32,141 +33,84 @@ public RoomController(IMessageBus bus)
public async Task<IActionResult> Create([FromBody] CreateRoomRequest request, CancellationToken ct)
{
var command = new CreateRoomCommand(request.PlayerName, request.CardSet);
var room = await _bus.InvokeAsync<Domain.Rooms.Room>(command, ct);
var room = await _bus.InvokeAsync<Room>(command, ct);
Response.Headers.Location = $"/api/rooms/{room.Id}";
return StatusCode(201);
}

[HttpGet("{id:int}")]
[ProducesResponseType(typeof(GameStateResponse), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> Get([FromRoute] int id, CancellationToken ct)
{
if (id <= 0)
{
return BadRequest();
}

var result = await _bus.InvokeAsync<GetGameStateResult>(new GetGameStateQuery(id), ct);

return result switch
{
GetGameStateResult.Success(var room) => Ok(GameStateMapper.ToResponse(room)),
GetGameStateResult.RoomNotFound => NotFound(),
_ => throw new InvalidOperationException($"Unknown result type: {result.GetType().Name}")
};
var room = await _bus.InvokeAsync<Room?>(new GetGameStateQuery(id), ct);
return room is null ? NotFound() : Ok(GameStateMapper.ToResponse(room));
}

[HttpPost("{id:int}/join")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
[ProducesResponseType(409)]
public async Task<IActionResult> Join([FromRoute] int id, [FromBody] JoinRoomRequest request, CancellationToken ct)
{
if (id <= 0)
try
{
return BadRequest();
var room = await _bus.InvokeAsync<Room?>(new JoinRoomCommand(id, request.PlayerName), ct);
return room is null ? NotFound() : StatusCode(201);
}

var command = new JoinRoomCommand(id, request.PlayerName);
var result = await _bus.InvokeAsync<JoinRoomResult>(command, ct);

return result switch
catch (InvalidOperationException ex) when (ex.Message.Contains("already in room"))
{
JoinRoomResult.Success => StatusCode(201),
JoinRoomResult.RoomNotFound => NotFound(),
JoinRoomResult.PlayerAlreadyInRoom => Conflict(),
_ => throw new InvalidOperationException($"Unknown result type: {result.GetType().Name}")
};
return Conflict();
}
}

[HttpPost("{id:int}/vote")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> Vote([FromRoute] int id, [FromBody] VoteRequest request, CancellationToken ct)
{
if (id <= 0)
try
{
return BadRequest();
var room = await _bus.InvokeAsync<Room?>(new VoteCommand(id, request.PlayerName, request.Value), ct);
return room is null ? NotFound() : StatusCode(201);
}

var command = new VoteCommand(id, request.PlayerName, request.Value);
var result = await _bus.InvokeAsync<VoteResult>(command, ct);

return result switch
catch (InvalidOperationException)
{
VoteResult.Success => StatusCode(201),
VoteResult.RoomNotFound => NotFound(),
VoteResult.PlayerNotInRoom => NotFound(),
_ => throw new InvalidOperationException($"Unknown result type: {result.GetType().Name}")
};
return NotFound();
}
}

[HttpPost("{id:int}/reveal")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> Reveal([FromRoute] int id, CancellationToken ct)
{
if (id <= 0)
{
return BadRequest();
}

var result = await _bus.InvokeAsync<RevealVotesResult>(new RevealVotesCommand(id), ct);

return result switch
{
RevealVotesResult.Success => StatusCode(201),
RevealVotesResult.RoomNotFound => NotFound(),
_ => throw new InvalidOperationException($"Unknown result type: {result.GetType().Name}")
};
var room = await _bus.InvokeAsync<Room?>(new RevealVotesCommand(id), ct);
return room is null ? NotFound() : StatusCode(201);
}

[HttpPost("{id:int}/reset")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> Reset([FromRoute] int id, CancellationToken ct)
{
if (id <= 0)
{
return BadRequest();
}

var result = await _bus.InvokeAsync<ResetVotesResult>(new ResetVotesCommand(id), ct);

return result switch
{
ResetVotesResult.Success => StatusCode(201),
ResetVotesResult.RoomNotFound => NotFound(),
_ => throw new InvalidOperationException($"Unknown result type: {result.GetType().Name}")
};
var room = await _bus.InvokeAsync<Room?>(new ResetVotesCommand(id), ct);
return room is null ? NotFound() : StatusCode(201);
}

[HttpPost("{id:int}/leave")]
[ProducesResponseType(204)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<IActionResult> Leave([FromRoute] int id, [FromBody] LeaveRoomRequest request, CancellationToken ct)
{
if (id <= 0)
try
{
return BadRequest();
var room = await _bus.InvokeAsync<Room?>(new LeaveRoomCommand(id, request.PlayerName), ct);
return room is null ? NotFound() : NoContent();
}

var command = new LeaveRoomCommand(id, request.PlayerName);
var result = await _bus.InvokeAsync<LeaveRoomResult>(command, ct);

return result switch
catch (InvalidOperationException)
{
LeaveRoomResult.Success => NoContent(),
LeaveRoomResult.RoomNotFound => NotFound(),
LeaveRoomResult.PlayerNotInRoom => NotFound(),
_ => throw new InvalidOperationException($"Unknown result type: {result.GetType().Name}")
};
return NotFound();
}
}
}
2 changes: 1 addition & 1 deletion src/ScrumPoker.API/Controllers/WarmupController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public async Task<IActionResult> Warmup(
var ttl = TimeSpan.FromSeconds(settings.Value.ExpirationSeconds);
var room = await bus.InvokeAsync<Room>(
new CreateRoomCommand("warmup", ["1", "2"], ttl, IsWarmup: true));
_ = await bus.InvokeAsync<GetGameStateResult>(new GetGameStateQuery(room.Id));
_ = await bus.InvokeAsync<Room?>(new GetGameStateQuery(room.Id));
return Ok(room.Id);
}
}
37 changes: 14 additions & 23 deletions src/ScrumPoker.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Npgsql;
using AspNetCore.Swagger.Themes;

using FluentValidation;
Expand All @@ -13,28 +12,12 @@
using ScrumPoker.Persistence;
using Wolverine;

using Microsoft.Extensions.Logging;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddRedisClient("redis");
var statsConnStr = builder.Configuration.GetConnectionString("statsdb");
if (statsConnStr is not null &&
(statsConnStr.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) ||
statsConnStr.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase)))
{
var uri = new Uri(statsConnStr);
var userInfo = uri.UserInfo?.Split(':', 2);
statsConnStr = new NpgsqlConnectionStringBuilder
{
Host = uri.Host,
Port = uri.Port > 0 ? uri.Port : 5432,
Database = uri.AbsolutePath.TrimStart('/'),
Username = userInfo?[0],
Password = userInfo?.Length > 1 ? userInfo[1] : null,
SslMode = SslMode.Require,
}.ConnectionString;
builder.Configuration["ConnectionStrings:statsdb"] = statsConnStr;
}
builder.AddNpgsqlDataSource("statsdb");

var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
Expand Down Expand Up @@ -74,11 +57,19 @@

var tracker = app.Services.GetRequiredService<PlayerConnectionTracker>();
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
tracker.PlayerRemoved = (roomId, playerName) =>
var logger = app.Services.GetRequiredService<ILogger<Program>>();
tracker.PlayerRemoved = async (roomId, playerName) =>
{
using var scope = scopeFactory.CreateScope();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
return bus.InvokeAsync(new LeaveRoomCommand(roomId, playerName));
try
{
using var scope = scopeFactory.CreateScope();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
await bus.InvokeAsync(new LeaveRoomCommand(roomId, playerName));
}
catch (Exception ex)
{
logger.LogWarning(ex, "PlayerRemoved cleanup failed for {PlayerName} in room {RoomId}", playerName, roomId);
}
};

app.MapDefaultEndpoints();
Expand Down
6 changes: 3 additions & 3 deletions src/ScrumPoker.Application/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ namespace ScrumPoker.Application;

public static class Bootstrap
{
public static IServiceCollection AddApplication(this IServiceCollection services, params Assembly[] extraHandlerAssemblies)
public static IServiceCollection AddApplication(this IServiceCollection services, Assembly? extraHandlerAssembly = null)
{
services.AddWolverine(opts =>
{
opts.Discovery.IncludeAssembly(typeof(Bootstrap).Assembly);
foreach (var assembly in extraHandlerAssemblies)
if (extraHandlerAssembly is not null)
{
opts.Discovery.IncludeAssembly(assembly);
opts.Discovery.IncludeAssembly(extraHandlerAssembly);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,21 @@ namespace ScrumPoker.Application.Features.Commands.JoinRoom;

public sealed class JoinRoomHandler(IRoomRepository repository, IMessageBus bus, IStatsRepository stats)
{
public async Task<JoinRoomResult> Handle(JoinRoomCommand command, CancellationToken ct)
public async Task<Room?> Handle(JoinRoomCommand command, CancellationToken ct)
{
var room = await repository.GetByIdAsync(command.RoomId, ct);
if (room is null)
{
return new JoinRoomResult.RoomNotFound();
}

try
{
var (updated, @event) = room.Join(command.PlayerName);
var saved = await repository.SaveAsync(updated, null, ct);
await bus.PublishAsync(@event);
if (room is null) return null;

if (!room.IsWarmup)
{
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
await stats.RecordPlayerJoinedAsync(monthKey, ct);
}
var (updated, @event) = room.Join(command.PlayerName);
var saved = await repository.SaveAsync(updated, null, ct);
await bus.PublishAsync(@event);

return new JoinRoomResult.Success(saved);
}
catch (InvalidOperationException)
if (!room.IsWarmup)
{
return new JoinRoomResult.PlayerAlreadyInRoom();
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
await stats.RecordPlayerJoinedAsync(monthKey, ct);
}

return saved;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
using ScrumPoker.Domain.Abstractions;
using ScrumPoker.Domain.Rooms;
using Wolverine;

namespace ScrumPoker.Application.Features.Commands.LeaveRoom;

public sealed class LeaveRoomHandler(IRoomRepository repository, IMessageBus bus)
{
public async Task<LeaveRoomResult> Handle(LeaveRoomCommand command, CancellationToken ct)
public async Task<Room?> Handle(LeaveRoomCommand command, CancellationToken ct)
{
var room = await repository.GetByIdAsync(command.RoomId, ct);
if (room is null)
{
return new LeaveRoomResult.RoomNotFound();
}
if (room is null) return null;

try
{
var (updated, @event) = room.Leave(command.PlayerName);
await repository.SaveAsync(updated, null, ct);
await bus.PublishAsync(@event);
return new LeaveRoomResult.Success(updated);
}
catch (InvalidOperationException)
{
return new LeaveRoomResult.PlayerNotInRoom();
}
var (updated, @event) = room.Leave(command.PlayerName);
var saved = await repository.SaveAsync(updated, null, ct);
await bus.PublishAsync(@event);
return saved;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
using ScrumPoker.Domain.Abstractions;
using ScrumPoker.Domain.Rooms;
using Wolverine;

namespace ScrumPoker.Application.Features.Commands.ResetVotes;

public sealed class ResetVotesHandler(IRoomRepository repository, IMessageBus bus)
{
public async Task<ResetVotesResult> Handle(ResetVotesCommand command, CancellationToken ct)
public async Task<Room?> Handle(ResetVotesCommand command, CancellationToken ct)
{
var room = await repository.GetByIdAsync(command.RoomId, ct);
if (room is null)
{
return new ResetVotesResult.RoomNotFound();
}
if (room is null) return null;

var (updated, @event) = room.ResetVotes();
await repository.SaveAsync(updated, null, ct);
var saved = await repository.SaveAsync(updated, null, ct);
await bus.PublishAsync(@event);
return new ResetVotesResult.Success(updated);
return saved;
}
}

This file was deleted.

Loading
Loading