Add per-user phase navigation with results toggle
This commit is contained in:
@@ -11,15 +11,17 @@ public static class AdminEndpoints
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin");
|
||||
|
||||
admin.MapPost("/phase", async ([FromBody] Contracts.PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
admin.MapPost("/results", async ([FromBody] Contracts.ResultsOpenRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
|
||||
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.CurrentPhase = request.Phase;
|
||||
state.ResultsOpen = request.ResultsOpen;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { state.CurrentPhase, state.UpdatedAt });
|
||||
var currentState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new { currentState.ResultsOpen, currentState.UpdatedAt });
|
||||
});
|
||||
|
||||
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
@@ -29,12 +31,13 @@ public static class AdminEndpoints
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest));
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.CurrentPhase = Phase.Suggest;
|
||||
state.ResultsOpen = false;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { state.CurrentPhase, state.UpdatedAt });
|
||||
return Results.Ok(new { Phase = Phase.Suggest, state.ResultsOpen, state.UpdatedAt });
|
||||
});
|
||||
|
||||
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
@@ -54,7 +57,7 @@ public static class AdminEndpoints
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt });
|
||||
return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ internal static class EndpointHelpers
|
||||
return existing;
|
||||
}
|
||||
|
||||
public static async Task<Phase> GetPhase(AppDbContext db)
|
||||
public static async Task<Phase> GetPhase(AppDbContext db, Guid playerId)
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return state.CurrentPhase;
|
||||
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
return player?.CurrentPhase ?? Phase.Suggest;
|
||||
}
|
||||
|
||||
public static IResult PhaseMismatch(Phase required, Phase current) =>
|
||||
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." });
|
||||
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." });
|
||||
|
||||
public static string? TrimTo(string? input, int max) =>
|
||||
string.IsNullOrWhiteSpace(input)
|
||||
@@ -138,7 +138,7 @@ internal static class EndpointHelpers
|
||||
public static AppState NewAppState() => new()
|
||||
{
|
||||
Id = 1,
|
||||
CurrentPhase = Phase.Suggest,
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = DateTimeOffset.UnixEpoch
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ public static class ResultsEndpoints
|
||||
"/api/results",
|
||||
async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
if (phase != Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
var appState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
if (!appState.ResultsOpen)
|
||||
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
||||
|
||||
var results = await db
|
||||
.Suggestions.AsNoTracking()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -9,12 +10,16 @@ public static class StateEndpoints
|
||||
{
|
||||
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/state", async (AppDbContext db) =>
|
||||
app.MapGet("/api/state", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
var summary = new
|
||||
{
|
||||
state.CurrentPhase,
|
||||
CurrentPhase = player.CurrentPhase,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
Players = await db.Players.CountAsync(),
|
||||
Suggestions = await db.Suggestions.CountAsync(),
|
||||
@@ -27,7 +32,36 @@ public static class StateEndpoints
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin });
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, player.CurrentPhase });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
|
||||
var next = NextPhase(player.CurrentPhase);
|
||||
var appState = await db.AppState.FirstAsync();
|
||||
|
||||
if (next == Phase.Results && !appState.ResultsOpen)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
|
||||
}
|
||||
|
||||
player.CurrentPhase = next;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/phase/prev", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
|
||||
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
||||
await db.SaveChangesAsync();
|
||||
var appState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
});
|
||||
|
||||
app.MapPost("/api/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
@@ -46,4 +80,20 @@ public static class StateEndpoints
|
||||
return Results.Ok(new { player.Id, player.DisplayName });
|
||||
});
|
||||
}
|
||||
|
||||
private static Phase NextPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Suggest => Phase.Reveal,
|
||||
Phase.Reveal => Phase.Vote,
|
||||
Phase.Vote => Phase.Results,
|
||||
_ => Phase.Results
|
||||
};
|
||||
|
||||
private static Phase PrevPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Results => Phase.Vote,
|
||||
Phase.Vote => Phase.Reveal,
|
||||
Phase.Reveal => Phase.Suggest,
|
||||
_ => Phase.Suggest
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,12 +12,11 @@ public static class SuggestEndpoints
|
||||
{
|
||||
app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
var mine = await db.Suggestions.AsNoTracking()
|
||||
.Where(s => s.PlayerId == player.Id)
|
||||
.Select(s => new
|
||||
@@ -44,10 +43,6 @@ public static class SuggestEndpoints
|
||||
|
||||
app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) =>
|
||||
{
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
|
||||
@@ -67,6 +62,9 @@ public static class SuggestEndpoints
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
{
|
||||
@@ -104,7 +102,7 @@ public static class SuggestEndpoints
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (!isAdmin && phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
@@ -128,7 +126,7 @@ public static class SuggestEndpoints
|
||||
{
|
||||
if (player is null) return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
}
|
||||
@@ -186,12 +184,11 @@ public static class SuggestEndpoints
|
||||
|
||||
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
if (phase < Phase.Reveal)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase);
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase < Phase.Reveal)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase);
|
||||
|
||||
var all = await db.Suggestions.AsNoTracking()
|
||||
.Include(s => s.Player)
|
||||
|
||||
@@ -12,12 +12,11 @@ public static class VoteEndpoints
|
||||
{
|
||||
app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var votes = await db.Votes.AsNoTracking()
|
||||
.Where(v => v.PlayerId == player.Id)
|
||||
.Select(v => new { v.SuggestionId, v.Score })
|
||||
@@ -28,15 +27,14 @@ public static class VoteEndpoints
|
||||
|
||||
app.MapPost("/api/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var phase = await EndpointHelpers.GetPhase(db);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
if (request.Score is < 0 or > 10)
|
||||
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||
|
||||
Reference in New Issue
Block a user