Add per-user phase navigation with results toggle

This commit is contained in:
2026-02-04 21:43:12 +01:00
parent b64a33d833
commit e5e27af0af
24 changed files with 507 additions and 88 deletions

View File

@@ -6,6 +6,7 @@ Also see the other related files: API.md, IIS.md, SPEC.md
- After every iteration, do a git commit with a brief summary of the changes as a commit message. - After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent. - If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent.
- After changing the backend, feel free to build the project and migrate the dn. If this is blocked by a running dotnet process, feel free to kill the process and retry the operation once.
- Keep changes small and testable - Keep changes small and testable
- Avoid introducing new dependencies unless they remove complexity. - Avoid introducing new dependencies unless they remove complexity.
- Keep endpoint logic in `Endpoints/` and shared helpers/DTOs in their folders to avoid Program.cs bloat. - Keep endpoint logic in `Endpoints/` and shared helpers/DTOs in their folders to avoid Program.cs bloat.

View File

@@ -4,4 +4,4 @@ public record SetNameRequest(string Name);
public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers); public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers); public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
public record VoteRequest(int SuggestionId, int Score); public record VoteRequest(int SuggestionId, int Score);
public record PhaseRequest(GameList.Domain.Phase Phase); public record ResultsOpenRequest(bool ResultsOpen);

View File

@@ -26,6 +26,7 @@ public class AppDbContext : DbContext
builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordHash).IsRequired();
builder.Property(p => p.PasswordSalt).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired();
builder.Property(p => p.IsAdmin).HasDefaultValue(false); builder.Property(p => p.IsAdmin).HasDefaultValue(false);
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
builder.HasMany(p => p.Suggestions) builder.HasMany(p => p.Suggestions)
.WithOne(s => s.Player!) .WithOne(s => s.Player!)
.HasForeignKey(s => s.PlayerId) .HasForeignKey(s => s.PlayerId)
@@ -62,7 +63,7 @@ public class AppDbContext : DbContext
builder.HasData(new AppState builder.HasData(new AppState
{ {
Id = 1, Id = 1,
CurrentPhase = Phase.Suggest, ResultsOpen = false,
UpdatedAt = DateTimeOffset.UnixEpoch UpdatedAt = DateTimeOffset.UnixEpoch
}); });
}); });

View File

@@ -0,0 +1,222 @@
// <auto-generated />
using System;
using GameList.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace GameList.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260204203441_PerUserPhases")]
partial class PerUserPhases
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("GameList.Domain.AppState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AppState");
b.HasData(
new
{
Id = 1,
ResultsOpen = false,
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
});
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.ToTable("Suggestions");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("SuggestionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SuggestionId");
b.HasIndex("PlayerId", "SuggestionId")
.IsUnique();
b.ToTable("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Suggestions")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Votes")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
.WithMany("Votes")
.HasForeignKey("SuggestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
b.Navigation("Suggestion");
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Navigation("Suggestions");
b.Navigation("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class PerUserPhases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrentPhase",
table: "AppState");
migrationBuilder.AddColumn<int>(
name: "CurrentPhase",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "ResultsOpen",
table: "AppState",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ResultsOpen",
table: "AppState");
migrationBuilder.DropColumn(
name: "CurrentPhase",
table: "Players");
migrationBuilder.AddColumn<int>(
name: "CurrentPhase",
table: "AppState",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}

View File

@@ -23,7 +23,7 @@ namespace GameList.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("CurrentPhase") b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
@@ -37,7 +37,7 @@ namespace GameList.Data.Migrations
new new
{ {
Id = 1, Id = 1,
CurrentPhase = 0, ResultsOpen = false,
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
}); });
}); });
@@ -51,6 +51,11 @@ namespace GameList.Data.Migrations
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName") b.Property<string>("DisplayName")
.HasMaxLength(16) .HasMaxLength(16)
.HasColumnType("TEXT"); .HasColumnType("TEXT");

View File

@@ -3,6 +3,6 @@ namespace GameList.Domain;
public class AppState public class AppState
{ {
public int Id { get; set; } = 1; public int Id { get; set; } = 1;
public Phase CurrentPhase { get; set; } = Phase.Suggest; public bool ResultsOpen { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch;
} }

View File

@@ -20,6 +20,7 @@ public class Player
public DateTimeOffset? LastLoginAt { get; set; } public DateTimeOffset? LastLoginAt { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
public Phase CurrentPhase { get; set; } = Phase.Suggest;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

View File

@@ -11,15 +11,17 @@ public static class AdminEndpoints
{ {
var admin = app.MapGroup("/api/admin"); 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(); if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
var state = await db.AppState.FirstAsync(); var state = await db.AppState.FirstAsync();
state.CurrentPhase = request.Phase; state.ResultsOpen = request.ResultsOpen;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); 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) => admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
@@ -29,12 +31,13 @@ public static class AdminEndpoints
await db.Votes.ExecuteDeleteAsync(); await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync(); await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest));
var state = await db.AppState.FirstAsync(); var state = await db.AppState.FirstAsync();
state.CurrentPhase = Phase.Suggest; state.ResultsOpen = false;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); 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) => admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
@@ -54,7 +57,7 @@ public static class AdminEndpoints
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt }); return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt });
}); });
} }
} }

View File

@@ -18,14 +18,14 @@ internal static class EndpointHelpers
return existing; 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(); var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == playerId);
return state.CurrentPhase; return player?.CurrentPhase ?? Phase.Suggest;
} }
public static IResult PhaseMismatch(Phase required, Phase current) => 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) => public static string? TrimTo(string? input, int max) =>
string.IsNullOrWhiteSpace(input) string.IsNullOrWhiteSpace(input)
@@ -138,7 +138,7 @@ internal static class EndpointHelpers
public static AppState NewAppState() => new() public static AppState NewAppState() => new()
{ {
Id = 1, Id = 1,
CurrentPhase = Phase.Suggest, ResultsOpen = false,
UpdatedAt = DateTimeOffset.UnixEpoch UpdatedAt = DateTimeOffset.UnixEpoch
}; };
} }

View File

@@ -12,13 +12,15 @@ public static class ResultsEndpoints
"/api/results", "/api/results",
async (HttpContext ctx, AppDbContext db) => 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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return Results.Unauthorized(); 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 var results = await db
.Suggestions.AsNoTracking() .Suggestions.AsNoTracking()

View File

@@ -1,5 +1,6 @@
using GameList.Contracts; using GameList.Contracts;
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -9,12 +10,16 @@ public static class StateEndpoints
{ {
public static void MapStateEndpoints(this IEndpointRouteBuilder app) 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 state = await db.AppState.AsNoTracking().FirstAsync();
var summary = new var summary = new
{ {
state.CurrentPhase, CurrentPhase = player.CurrentPhase,
state.ResultsOpen,
state.UpdatedAt, state.UpdatedAt,
Players = await db.Players.CountAsync(), Players = await db.Players.CountAsync(),
Suggestions = await db.Suggestions.CountAsync(), Suggestions = await db.Suggestions.CountAsync(),
@@ -27,7 +32,36 @@ public static class StateEndpoints
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); 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) => 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 }); 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
};
} }

View File

@@ -12,12 +12,11 @@ public static class SuggestEndpoints
{ {
app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) => 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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); 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() var mine = await db.Suggestions.AsNoTracking()
.Where(s => s.PlayerId == player.Id) .Where(s => s.PlayerId == player.Id)
.Select(s => new .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) => 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) if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
{ {
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." }); 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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); 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)) if (string.IsNullOrWhiteSpace(player.DisplayName))
{ {
@@ -104,7 +102,7 @@ public static class SuggestEndpoints
if (player is null) return Results.Unauthorized(); if (player is null) return Results.Unauthorized();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config); 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) if (!isAdmin && phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
@@ -128,7 +126,7 @@ public static class SuggestEndpoints
{ {
if (player is null) return Results.Unauthorized(); 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) if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
} }
@@ -186,12 +184,11 @@ public static class SuggestEndpoints
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) => 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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); 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() var all = await db.Suggestions.AsNoTracking()
.Include(s => s.Player) .Include(s => s.Player)

View File

@@ -12,12 +12,11 @@ public static class VoteEndpoints
{ {
app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) => 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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); 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() var votes = await db.Votes.AsNoTracking()
.Where(v => v.PlayerId == player.Id) .Where(v => v.PlayerId == player.Id)
.Select(v => new { v.SuggestionId, v.Score }) .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) => 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) if (request.Score is < 0 or > 10)
return Results.BadRequest(new { error = "Score must be between 0 and 10." }); return Results.BadRequest(new { error = "Score must be between 0 and 10." });
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); 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)) if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." }); return Results.BadRequest(new { error = "Set a display name before voting." });

View File

@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseAppHost>false</UseAppHost>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -122,31 +122,39 @@ function setupHandlers() {
}); });
} }
$("set-phase").addEventListener("click", async () => { const prevPhaseBtn = $("prev-phase");
const phase = $("phase-select").value; if (prevPhaseBtn) {
try { prevPhaseBtn.addEventListener("click", async () => {
await adminApi.setPhase(phase); try {
toast(t("admin.phaseUpdated")); const resp = await api.prevPhase();
state.prevPhase = state.phase; state.prevPhase = state.phase;
state.phase = phase; state.phase = resp.currentPhase;
state.votesRendered = false; state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
renderPhasePill(); state.votesRendered = false;
$("phase-select").dataset.userEditing = ""; renderPhasePill();
await refreshPhaseData(); await refreshPhaseData();
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
} }
});
const phaseSelect = $("phase-select");
["focus", "input", "click"].forEach((evt) => {
phaseSelect.addEventListener(evt, () => {
phaseSelect.dataset.userEditing = "1";
}); });
}); }
phaseSelect.addEventListener("blur", () => {
phaseSelect.dataset.userEditing = ""; const nextPhaseBtn = $("next-phase");
}); if (nextPhaseBtn) {
nextPhaseBtn.addEventListener("click", async () => {
try {
const resp = await api.nextPhase();
state.prevPhase = state.phase;
state.phase = resp.currentPhase;
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
state.votesRendered = false;
renderPhasePill();
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
});
}
$("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone"))); $("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone"))); $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
@@ -182,6 +190,22 @@ function setupHandlers() {
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden"))); adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
adminClose.addEventListener("click", () => togglePanel(false)); adminClose.addEventListener("click", () => togglePanel(false));
} }
const resultsToggle = $("results-open");
if (resultsToggle) {
resultsToggle.addEventListener("change", async (e) => {
const desired = !!e.target.checked;
try {
const resp = await adminApi.setResultsOpen(desired);
state.resultsOpen = resp.resultsOpen;
renderPhasePill();
toast(t("admin.resultsUpdated"));
} catch (err) {
e.target.checked = !desired;
toast(err.message, true);
}
});
}
} }
async function adminAction(fn, successMessage) { async function adminAction(fn, successMessage) {

View File

@@ -108,6 +108,33 @@ button .chip {
border-color: #a83a35; border-color: #a83a35;
} }
.nav-btn {
min-width: 64px;
font-weight: 700;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
}
.badge.warning {
background: #fff0d6;
color: #7a4a00;
border-color: #f0c66b;
}
.toggle-row {
display: flex;
gap: 8px;
align-items: center;
font-weight: 600;
}
.vote-controls { .vote-controls {
display: flex; display: flex;
gap: 10px; gap: 10px;

View File

@@ -83,6 +83,10 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.status-center {
flex-wrap: wrap;
justify-content: center;
}
.logo-mark { .logo-mark {
height: 65px; height: 65px;
margin: -10px; margin: -10px;

View File

@@ -71,7 +71,10 @@
<a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a> <a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a>
</div> </div>
<div class="status-center"> <div class="status-center">
<button id="prev-phase" class="chip nav-btn" type="button" data-i18n="nav.prev">Back</button>
<span id="phase-pill" data-i18n="phase.loading">Loading…</span> <span id="phase-pill" data-i18n="phase.loading">Loading…</span>
<button id="next-phase" class="chip nav-btn" type="button" data-i18n="nav.next">Next</button>
<span class="badge warning hidden" id="results-lock" data-i18n="admin.resultsLocked">Results locked by admin</span>
<span class="counts" id="counts"></span> <span class="counts" id="counts"></span>
</div> </div>
<div class="status-right"> <div class="status-right">
@@ -130,15 +133,10 @@
<h3 data-i18n="admin.title">Admin</h3> <h3 data-i18n="admin.title">Admin</h3>
<button id="admin-close" class="ghost"></button> <button id="admin-close" class="ghost"></button>
</div> </div>
<div class="stack horizontal"> <label class="stack toggle-row">
<select id="phase-select"> <input type="checkbox" id="results-open" />
<option value="Suggest" data-i18n="phase.suggest">Suggest</option> <span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
<option value="Reveal" data-i18n="phase.reveal">Reveal</option> </label>
<option value="Vote" data-i18n="phase.vote">Vote</option>
<option value="Results" data-i18n="phase.results">Results</option>
</select>
<button id="set-phase" data-i18n="admin.setPhase">Set phase</button>
</div>
<div class="stack horizontal"> <div class="stack horizontal">
<button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button> <button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button>
<button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button> <button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>

View File

@@ -46,10 +46,12 @@ export const api = {
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }), vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
results: () => request("/api/results"), results: () => request("/api/results"),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
}; };
export const adminApi = { export const adminApi = {
setPhase: (phase) => request("/api/admin/phase", { method: "POST", body: { phase } }), setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
reset: () => request("/api/admin/reset", { method: "POST" }), reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }), factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
}; };

View File

@@ -8,6 +8,7 @@ export async function loadState() {
state.me = me; state.me = me;
state.prevPhase = state.phase; state.prevPhase = state.phase;
state.phase = stateData.currentPhase; state.phase = stateData.currentPhase;
state.resultsOpen = stateData.resultsOpen;
state.counts = stateData; state.counts = stateData;
if (state.prevPhase !== state.phase && state.phase === "Vote") { if (state.prevPhase !== state.phase && state.phase === "Vote") {
state.votesRendered = false; state.votesRendered = false;
@@ -52,7 +53,7 @@ export async function loadVoteData() {
} }
export async function loadResults() { export async function loadResults() {
if (state.phase !== "Results") return; if (state.phase !== "Results" || !state.resultsOpen) return;
state.results = await api.results(); state.results = await api.results();
renderResults(); renderResults();
} }

View File

@@ -31,6 +31,10 @@ const translations = {
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}", "counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"nav.prev": "Back",
"nav.next": "Next",
"nav.waitingForResults": "Waiting…",
"suggest.title": "Suggest games (up to 5)", "suggest.title": "Suggest games (up to 5)",
"suggest.new": "Add new suggestion", "suggest.new": "Add new suggestion",
"suggest.addButton": "Suggest a game", "suggest.addButton": "Suggest a game",
@@ -79,10 +83,11 @@ const translations = {
"admin.title": "Admin", "admin.title": "Admin",
"admin.tools": "Admin tools", "admin.tools": "Admin tools",
"admin.setPhase": "Set phase", "admin.resultsOpenToggle": "Allow results phase",
"admin.resultsLocked": "Results locked by admin",
"admin.resultsUpdated": "Results availability updated",
"admin.reset": "Reset (keep players)", "admin.reset": "Reset (keep players)",
"admin.factoryReset": "Factory reset", "admin.factoryReset": "Factory reset",
"admin.phaseUpdated": "Phase updated",
"admin.resetDone": "Reset complete", "admin.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete", "admin.factoryResetDone": "Factory reset complete",
@@ -138,6 +143,10 @@ const translations = {
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}", "counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"nav.prev": "Zurück",
"nav.next": "Weiter",
"nav.waitingForResults": "Warten…",
"suggest.title": "Schlage Spiele vor (bis zu 5)", "suggest.title": "Schlage Spiele vor (bis zu 5)",
"suggest.new": "Neuen Vorschlag hinzufügen", "suggest.new": "Neuen Vorschlag hinzufügen",
"suggest.addButton": "Spiel vorschlagen", "suggest.addButton": "Spiel vorschlagen",
@@ -186,10 +195,11 @@ const translations = {
"admin.title": "Admin", "admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge", "admin.tools": "Admin-Werkzeuge",
"admin.setPhase": "Phase setzen", "admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
"admin.reset": "Zurücksetzen (Spieler behalten)", "admin.reset": "Zurücksetzen (Spieler behalten)",
"admin.factoryReset": "Werkseinstellung", "admin.factoryReset": "Werkseinstellung",
"admin.phaseUpdated": "Phase aktualisiert",
"admin.resetDone": "Zurücksetzen abgeschlossen", "admin.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen",

View File

@@ -4,6 +4,7 @@ export const state = {
me: null, me: null,
phase: null, phase: null,
prevPhase: null, prevPhase: null,
resultsOpen: false,
counts: null, counts: null,
mySuggestions: [], mySuggestions: [],
allSuggestions: [], allSuggestions: [],
@@ -17,6 +18,7 @@ export function clearUserState() {
state.me = null; state.me = null;
state.phase = null; state.phase = null;
state.prevPhase = null; state.prevPhase = null;
state.resultsOpen = false;
state.counts = null; state.counts = null;
state.mySuggestions = []; state.mySuggestions = [];
state.allSuggestions = []; state.allSuggestions = [];

View File

@@ -65,7 +65,8 @@ export function handleAuthError(err, clearUserState) {
export function renderPhasePill() { export function renderPhasePill() {
const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null; const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null;
$("phase-pill").textContent = phaseKey ? "" : t("phase.loading"); const pill = $("phase-pill");
if (pill) pill.textContent = phaseKey ? t(`phase.${phaseKey}`) : t("phase.loading");
document.querySelectorAll(".phase-view").forEach((el) => document.querySelectorAll(".phase-view").forEach((el) =>
el.classList.add("hidden"), el.classList.add("hidden"),
); );
@@ -77,9 +78,27 @@ export function renderPhasePill() {
}; };
const id = viewMap[state.phase]; const id = viewMap[state.phase];
if (id) $(id).classList.remove("hidden"); if (id) $(id).classList.remove("hidden");
const phaseSelect = $("phase-select");
if (phaseSelect && !phaseSelect.dataset.userEditing) { const prevBtn = $("prev-phase");
phaseSelect.value = state.phase || "Suggest"; if (prevBtn) prevBtn.disabled = state.phase === "Suggest";
const nextBtn = $("next-phase");
if (nextBtn) {
const atResults = state.phase === "Results";
const locked = !state.resultsOpen && state.phase === "Vote";
nextBtn.disabled = atResults || locked;
nextBtn.textContent = locked ? t("nav.waitingForResults") : t("nav.next");
}
const resultsLock = $("results-lock");
if (resultsLock) {
resultsLock.classList.toggle("hidden", state.resultsOpen);
resultsLock.textContent = t("admin.resultsLocked");
}
const adminResultsToggle = $("results-open");
if (adminResultsToggle) {
adminResultsToggle.checked = !!state.resultsOpen;
} }
} }