From abb9874c9897802760b689de5f6e514618c152d8 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 7 Feb 2026 13:27:02 +0100 Subject: [PATCH] Refactor state transitions into workflow service --- Endpoints/StateEndpoints.cs | 89 +++---------------------- Endpoints/StateWorkflowService.cs | 106 ++++++++++++++++++++++++++++++ Program.cs | 1 + 3 files changed, 115 insertions(+), 81 deletions(-) create mode 100644 Endpoints/StateWorkflowService.cs diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 2319927..b439139 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -1,7 +1,4 @@ using GameList.Data; -using GameList.Domain; -using GameList.Contracts; -using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; @@ -11,111 +8,41 @@ public static class StateEndpoints { var group = app.MapGroup("/api").RequireAuthorization(); - group.MapGet("/state", async (HttpContext ctx, AppDbContext db) => + group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); - var state = await db.AppState.AsNoTracking().FirstAsync(); - var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); - var summary = new StateSummaryResponse( - phase, - player.VotesFinal, - player.HasJoker, - state.ResultsOpen, - state.UpdatedAt, - await db.Players.CountAsync(), - await db.Suggestions.CountAsync(), - await db.Votes.CountAsync() - ); - return Results.Ok(summary); + return await service.GetStateAsync(player); }); - group.MapGet("/me", async (HttpContext ctx, AppDbContext db) => + group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); - var state = await db.AppState.AsNoTracking().FirstAsync(); - var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); - return Results.Ok(new MeResponse( - player.Id, - player.Username, - player.DisplayName, - player.IsAdmin, - phase, - player.VotesFinal, - player.HasJoker - )); + return await service.GetMeAsync(player); }); - group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db) => + group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); - var appState = await db.AppState.FirstAsync(); - var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); - var next = NextPhase(player.CurrentPhase); - - if (next == Phase.Vote) - { - var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id); - if (!hasSuggestions) - { - if (reconciled) - await db.SaveChangesAsync(); - return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase."); - } - } - - if (next == Phase.Results && !appState.ResultsOpen) - { - if (reconciled) - await db.SaveChangesAsync(); - return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); - } - - player.CurrentPhase = next; - player.VotesFinal = false; // moving forward clears any prior finalize - await db.SaveChangesAsync(); - return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); + return await service.NextPhaseAsync(player); }); - group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db) => + group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); - var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); - if (!isAdmin) - { - return EndpointHelpers.BadRequestError("Only admins can move backward."); - } - - var appState = await db.AppState.FirstAsync(); - EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); - player.CurrentPhase = PrevPhase(player.CurrentPhase); - player.VotesFinal = false; - await db.SaveChangesAsync(); - return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); + return await service.PrevPhaseAsync(player); }); } - - private static Phase NextPhase(Phase current) => current switch - { - Phase.Suggest => Phase.Vote, - _ => Phase.Results - }; - - private static Phase PrevPhase(Phase current) => current switch - { - Phase.Results => Phase.Vote, - _ => Phase.Suggest - }; } diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs new file mode 100644 index 0000000..0055f72 --- /dev/null +++ b/Endpoints/StateWorkflowService.cs @@ -0,0 +1,106 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +internal sealed class StateWorkflowService(AppDbContext db) +{ + public async Task GetStateAsync(Player player) + { + var state = await db.AppState.AsNoTracking().FirstAsync(); + var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); + var summary = new StateSummaryResponse( + phase, + player.VotesFinal, + player.HasJoker, + state.ResultsOpen, + state.UpdatedAt, + await db.Players.CountAsync(), + await db.Suggestions.CountAsync(), + await db.Votes.CountAsync() + ); + return Results.Ok(summary); + } + + public async Task GetMeAsync(Player player) + { + var state = await db.AppState.AsNoTracking().FirstAsync(); + var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); + return Results.Ok(new MeResponse( + player.Id, + player.Username, + player.DisplayName, + player.IsAdmin, + phase, + player.VotesFinal, + player.HasJoker + )); + } + + public async Task NextPhaseAsync(Player player) + { + var appState = await db.AppState.FirstAsync(); + var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); + + try + { + var next = NextPhase(player.CurrentPhase); + + if (next == Phase.Vote) + { + var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id); + if (!hasSuggestions) + return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase."); + } + + if (next == Phase.Results && !appState.ResultsOpen) + return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); + + player.CurrentPhase = next; + player.VotesFinal = false; // moving forward clears any prior finalize + shouldSave = true; + return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); + } + finally + { + if (shouldSave) + await db.SaveChangesAsync(); + } + } + + public async Task PrevPhaseAsync(Player player) + { + if (!player.IsAdmin) + return EndpointHelpers.BadRequestError("Only admins can move backward."); + + var appState = await db.AppState.FirstAsync(); + var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); + + try + { + player.CurrentPhase = PrevPhase(player.CurrentPhase); + player.VotesFinal = false; + shouldSave = true; + return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); + } + finally + { + if (shouldSave) + await db.SaveChangesAsync(); + } + } + + private static Phase NextPhase(Phase current) => current switch + { + Phase.Suggest => Phase.Vote, + _ => Phase.Results + }; + + private static Phase PrevPhase(Phase current) => current switch + { + Phase.Results => Phase.Vote, + _ => Phase.Suggest + }; +} diff --git a/Program.cs b/Program.cs index 8dc6090..9a980f5 100644 --- a/Program.cs +++ b/Program.cs @@ -39,6 +39,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });