From fe6a9d5da4fe228649f153087807ece32be48a10 Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:37:46 +0100
Subject: [PATCH 01/11] Harden owner and suggestion invariants for concurrent
writes
---
API.md | 3 +
Data/AppDbContext.cs | 1 +
...enOwnerAndSuggestionInvariants.Designer.cs | 255 ++++++++++++++++++
...3323_HardenOwnerAndSuggestionInvariants.cs | 47 ++++
Data/Migrations/AppDbContextModelSnapshot.cs | 4 +
Endpoints/AuthEndpoints.cs | 14 +-
Endpoints/EndpointHelpers.cs | 18 ++
Endpoints/SuggestionWorkflowService.cs | 26 +-
Endpoints/VoteWorkflowService.cs | 54 +++-
GameList.Tests/AuthTests.cs | 30 +++
GameList.Tests/SuggestionTests.cs | 38 +++
README.md | 1 +
TESTS.md | 3 +
13 files changed, 472 insertions(+), 22 deletions(-)
create mode 100644 Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
create mode 100644 Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs
diff --git a/API.md b/API.md
index c70d4a3..043c835 100644
--- a/API.md
+++ b/API.md
@@ -11,6 +11,7 @@ POST /api/auth/logout
Display names are set during registration and are immutable afterward.
Passwords must be 8-128 chars and contain uppercase, lowercase and number.
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
+Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
## State (requires auth)
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
@@ -26,11 +27,13 @@ POST /api/suggestions — create (name required ≤100; max 5 per player; valida
PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time)
GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata
+Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`.
## Votes (requires auth + Vote phase)
GET /api/votes/mine
POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings
POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized status (blocks further vote edits when true)
+Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs
index 768558e..0e7bda9 100644
--- a/Data/AppDbContext.cs
+++ b/Data/AppDbContext.cs
@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op
builder.Property(p => p.PasswordSalt).IsRequired();
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
builder.Property(p => p.IsOwner).HasDefaultValue(false);
+ builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
builder.Property(p => p.HasJoker).HasDefaultValue(false);
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
diff --git a/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
new file mode 100644
index 0000000..34dde65
--- /dev/null
+++ b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
@@ -0,0 +1,255 @@
+//
+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("20260208203323_HardenOwnerAndSuggestionInvariants")]
+ partial class HardenOwnerAndSuggestionInvariants
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ResultsOpen")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrentPhase")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("DisplayName")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("HasJoker")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.Property("IsAdmin")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.Property("IsOwner")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.Property("LastLoginAt")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("PasswordSalt")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("TEXT");
+
+ b.Property("VotesFinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsOwner")
+ .IsUnique()
+ .HasFilter("IsOwner = 1");
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.ToTable("Players");
+ });
+
+ modelBuilder.Entity("GameList.Domain.Suggestion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("GameUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.Property("Genre")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("MaxPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("MinPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("ParentSuggestionId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ScreenshotUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.Property("YoutubeUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentSuggestionId");
+
+ b.HasIndex("PlayerId");
+
+ b.ToTable("Suggestions");
+ });
+
+ modelBuilder.Entity("GameList.Domain.Vote", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("PlayerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Score")
+ .HasColumnType("INTEGER");
+
+ b.Property("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.Suggestion", "ParentSuggestion")
+ .WithMany("LinkedSuggestions")
+ .HasForeignKey("ParentSuggestionId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("GameList.Domain.Player", "Player")
+ .WithMany("Suggestions")
+ .HasForeignKey("PlayerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ParentSuggestion");
+
+ 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("LinkedSuggestions");
+
+ b.Navigation("Votes");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs
new file mode 100644
index 0000000..2a83de1
--- /dev/null
+++ b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs
@@ -0,0 +1,47 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace GameList.Data.Migrations
+{
+ ///
+ public partial class HardenOwnerAndSuggestionInvariants : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateIndex(
+ name: "IX_Players_IsOwner",
+ table: "Players",
+ column: "IsOwner",
+ unique: true,
+ filter: "IsOwner = 1");
+
+ migrationBuilder.Sql(
+ """
+ CREATE TRIGGER IF NOT EXISTS TR_Suggestions_MaxFivePerPlayer
+ BEFORE INSERT ON Suggestions
+ WHEN
+ (SELECT COUNT(1) FROM Suggestions WHERE PlayerId = NEW.PlayerId) >= 5
+ AND (
+ COALESCE((SELECT HasJoker FROM Players WHERE Id = NEW.PlayerId), 0) = 0
+ OR COALESCE((SELECT CurrentPhase FROM Players WHERE Id = NEW.PlayerId), 0) != 2
+ )
+ BEGIN
+ SELECT RAISE(ABORT, 'suggestion_limit_exceeded');
+ END;
+ """
+ );
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;");
+
+ migrationBuilder.DropIndex(
+ name: "IX_Players_IsOwner",
+ table: "Players");
+ }
+ }
+}
diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs
index 88e7b95..eb5115e 100644
--- a/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -103,6 +103,10 @@ namespace GameList.Data.Migrations
b.HasKey("Id");
+ b.HasIndex("IsOwner")
+ .IsUnique()
+ .HasFilter("IsOwner = 1");
+
b.HasIndex("NormalizedUsername")
.IsUnique();
diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs
index 9c54d1e..d8c201f 100644
--- a/Endpoints/AuthEndpoints.cs
+++ b/Endpoints/AuthEndpoints.cs
@@ -68,7 +68,19 @@ public static class AuthEndpoints
};
db.Players.Add(player);
- await db.SaveChangesAsync();
+ try
+ {
+ await db.SaveChangesAsync();
+ }
+ catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
+ {
+ authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
+ return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
+ }
+ catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
+ {
+ return EndpointHelpers.ConflictError("Username already taken.");
+ }
if (isAdmin)
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs
index 061abc6..b301378 100644
--- a/Endpoints/EndpointHelpers.cs
+++ b/Endpoints/EndpointHelpers.cs
@@ -1,5 +1,6 @@
using GameList.Data;
using GameList.Domain;
+using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Sockets;
@@ -9,6 +10,9 @@ namespace GameList.Endpoints;
internal static class EndpointHelpers
{
+ public const string SingleOwnerIndexName = "IX_Players_IsOwner";
+ public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
+
public static async Task GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{
if (ctx.User.Identity?.IsAuthenticated != true)
@@ -108,6 +112,20 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
+ public static bool IsSqliteConstraintViolation(DbUpdateException ex)
+ {
+ return ex.InnerException is SqliteException sqliteEx
+ && sqliteEx.SqliteErrorCode == 19;
+ }
+
+ public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
+ {
+ if (!IsSqliteConstraintViolation(ex))
+ return false;
+
+ return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
+ }
+
private static IResult Problem(int statusCode, string title, string detail)
{
return Results.Problem(
diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs
index 242cc36..c1379f0 100644
--- a/Endpoints/SuggestionWorkflowService.cs
+++ b/Endpoints/SuggestionWorkflowService.cs
@@ -60,7 +60,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
- var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
+ var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
@@ -81,16 +81,24 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Add(suggestion);
- if (usingJoker)
+ try
{
- await db.Players
- .Where(p => p.Id == playerId)
- .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
- await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
- }
+ await db.SaveChangesAsync();
- await db.SaveChangesAsync();
- await tx.CommitAsync();
+ if (usingJoker)
+ {
+ await db.Players
+ .Where(p => p.Id == playerId)
+ .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
+ await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
+ }
+
+ await tx.CommitAsync();
+ }
+ catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
+ {
+ return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
+ }
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
}
diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs
index 3aeec65..a2171c3 100644
--- a/Endpoints/VoteWorkflowService.cs
+++ b/Endpoints/VoteWorkflowService.cs
@@ -2,6 +2,7 @@ using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace GameList.Endpoints;
@@ -71,26 +72,46 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
- foreach (var linkedSuggestionId in linkedIds)
+ for (var attempt = 0; attempt < 2; attempt++)
{
- var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
- if (vote == null)
+ foreach (var linkedSuggestionId in linkedIds)
{
- db.Votes.Add(new Vote
+ var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
+ if (vote == null)
{
- PlayerId = playerId,
- SuggestionId = linkedSuggestionId,
- Score = score
- });
+ db.Votes.Add(new Vote
+ {
+ PlayerId = playerId,
+ SuggestionId = linkedSuggestionId,
+ Score = score
+ });
+ }
+ else
+ {
+ vote.Score = score;
+ }
}
- else
+
+ try
{
- vote.Score = score;
+ await db.SaveChangesAsync();
+ return Results.Ok(new VoteUpsertResponse(linkedIds, score));
+ }
+ catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
+ {
+ DetachAddedVotes(db.ChangeTracker.Entries());
+
+ await db.Votes
+ .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
+ .ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
+
+ existingVotes = await db.Votes
+ .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
+ .ToListAsync();
}
}
- await db.SaveChangesAsync();
- return Results.Ok(new VoteUpsertResponse(linkedIds, score));
+ return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
}
public async Task SetFinalizeAsync(Guid playerId, bool final)
@@ -105,4 +126,13 @@ internal sealed class VoteWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
}
+
+ private static void DetachAddedVotes(IEnumerable> voteEntries)
+ {
+ foreach (var entry in voteEntries)
+ {
+ if (entry.State == EntityState.Added)
+ entry.State = EntityState.Detached;
+ }
+ }
}
diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs
index 46283f3..1c24f68 100644
--- a/GameList.Tests/AuthTests.cs
+++ b/GameList.Tests/AuthTests.cs
@@ -1,6 +1,8 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
+using GameList.Data;
+using GameList.Domain;
using GameList.Infrastructure;
using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore;
@@ -247,4 +249,32 @@ public class AuthTests
resp.EnsureSuccessStatusCode();
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
}
+
+ [Fact]
+ public async Task Owner_uniqueness_is_enforced_by_database_constraint()
+ {
+ await using var factory = new TestWebApplicationFactory();
+ var ownerClient = factory.CreateClientWithCookies();
+ await ownerClient.RegisterAsync("owner1", admin: true);
+
+ var thrown = await Assert.ThrowsAsync(() => factory.WithDbContextAsync(async db =>
+ {
+ var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
+ db.Players.Add(new Player
+ {
+ Id = Guid.NewGuid(),
+ Username = "owner2",
+ NormalizedUsername = "owner2",
+ PasswordHash = hash,
+ PasswordSalt = salt,
+ DisplayName = "Owner2",
+ IsOwner = true,
+ IsAdmin = true
+ });
+
+ await db.SaveChangesAsync();
+ }));
+
+ Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
+ }
}
diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs
index 46b6001..c107b6e 100644
--- a/GameList.Tests/SuggestionTests.cs
+++ b/GameList.Tests/SuggestionTests.cs
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
+using GameList.Domain;
using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore;
@@ -626,4 +627,41 @@ public class SuggestionTests
Assert.False(db.Votes.Any(v => v.SuggestionId == id));
});
}
+
+ [Fact]
+ public async Task Suggestion_limit_is_enforced_by_database_trigger_without_joker()
+ {
+ await using var factory = new TestWebApplicationFactory();
+ var client = factory.CreateClientWithCookies();
+ await client.RegisterAsync("dbcap");
+
+ var playerId = await factory.WithDbContextAsync(async db => await db.Players.Select(p => p.Id).SingleAsync());
+
+ await factory.WithDbContextAsync(async db =>
+ {
+ for (var i = 0; i < 5; i++)
+ {
+ db.Suggestions.Add(new Suggestion
+ {
+ PlayerId = playerId,
+ Name = $"Seed {i}"
+ });
+ }
+
+ await db.SaveChangesAsync();
+ });
+
+ var thrown = await Assert.ThrowsAsync(() => factory.WithDbContextAsync(async db =>
+ {
+ db.Suggestions.Add(new Suggestion
+ {
+ PlayerId = playerId,
+ Name = "Blocked by trigger"
+ });
+
+ await db.SaveChangesAsync();
+ }));
+
+ Assert.Contains("suggestion_limit_exceeded", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
+ }
}
diff --git a/README.md b/README.md
index 87e0774..dcb69c1 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- Authentication: username/password with HttpOnly `player` cookie.
- Admin authorization: authenticated account with `IsAdmin=true`.
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
+- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
- Gameplay phases: `Suggest`, `Vote`, `Results`.
- Storage: SQLite database under `App_Data/gamelist.db`.
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
diff --git a/TESTS.md b/TESTS.md
index a1ff9a4..0daecb8 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -34,6 +34,7 @@ stateDiagram-v2
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
+- Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks.
- `/api/auth/options` reports owner presence for registration UI behavior.
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
- Logout clears cookie.
@@ -50,6 +51,7 @@ stateDiagram-v2
### 3) Suggestions
- GET /mine returns only caller’s suggestions ordered by CreatedAt.
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
+- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
@@ -60,6 +62,7 @@ stateDiagram-v2
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
- POST /: creates or updates vote; rejects score outside 0–10; rejects when VotesFinal=true; enforces display name requirement and phase gating.
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
+- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
### 5) Results
From 2d2201d0a28b722e80069d5dae6554ebeef885dc Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:43:07 +0100
Subject: [PATCH 02/11] Decouple workflow services from HTTP result types
---
Contracts/Dtos.cs | 4 ++
Endpoints/AdminEndpoints.cs | 45 ++++++++++---
Endpoints/AdminWorkflowService.cs | 88 +++++++++++++-------------
Endpoints/EndpointHelpers.cs | 28 ++++++++
Endpoints/ResultsEndpoints.cs | 3 +-
Endpoints/ResultsWorkflowService.cs | 12 ++--
Endpoints/ServiceResult.cs | 36 +++++++++++
Endpoints/StateEndpoints.cs | 12 ++--
Endpoints/StateWorkflowService.cs | 22 +++----
Endpoints/SuggestEndpoints.cs | 17 +++--
Endpoints/SuggestionWorkflowService.cs | 64 +++++++++----------
Endpoints/VoteEndpoints.cs | 10 ++-
Endpoints/VoteWorkflowService.cs | 36 +++++------
README.md | 2 +-
14 files changed, 242 insertions(+), 137 deletions(-)
create mode 100644 Endpoints/ServiceResult.cs
diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs
index 199424e..ccfe022 100644
--- a/Contracts/Dtos.cs
+++ b/Contracts/Dtos.cs
@@ -6,8 +6,12 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList? LinkedIds = null, IReadOnlyList? LinkedTitles = null);
+public record SuggestionAllDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, string? Author, int? ParentSuggestionId, bool IsOwner, IReadOnlyList LinkedIds, IReadOnlyList LinkedTitles);
+
public record VoteRequest(int SuggestionId, int Score);
+public record VoteRecordDto(int SuggestionId, int Score);
+
public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final);
diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs
index 7aebdcc..c546e9b 100644
--- a/Endpoints/AdminEndpoints.cs
+++ b/Endpoints/AdminEndpoints.cs
@@ -11,14 +11,34 @@ public static class AdminEndpoints
{
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter();
- admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => await service.SetResultsOpenAsync(request.ResultsOpen));
+ admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
+ {
+ var result = await service.SetResultsOpenAsync(request.ResultsOpen);
+ return result.ToHttpResult(Results.Ok);
+ });
- admin.MapGet("/vote-status", async (AdminWorkflowService service) => await service.GetVoteStatusAsync());
+ admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
+ {
+ var result = await service.GetVoteStatusAsync();
+ return result.ToHttpResult(Results.Ok);
+ });
- admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
+ admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
+ {
+ var result = await service.GrantJokerAsync(request.PlayerId);
+ return result.ToHttpResult(Results.Ok);
+ });
- admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
- admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin));
+ admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) =>
+ {
+ var result = await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase);
+ return result.ToHttpResult(Results.Ok);
+ });
+ admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) =>
+ {
+ var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin);
+ return result.ToHttpResult(Results.Ok);
+ });
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{
@@ -26,7 +46,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
+ var result = await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
+ return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -35,7 +56,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
+ var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
+ return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -44,7 +66,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
+ var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
+ return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -53,7 +76,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.ResetAsync(player.Id, request.Password, ctx);
+ var result = await service.ResetAsync(player.Id, request.Password, ctx);
+ return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -62,7 +86,8 @@ public static class AdminEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.FactoryResetAsync(player.Id, request.Password, ctx);
+ var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
+ return result.ToHttpResult(Results.Ok);
});
}
}
diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs
index dac48e9..18c6819 100644
--- a/Endpoints/AdminWorkflowService.cs
+++ b/Endpoints/AdminWorkflowService.cs
@@ -8,7 +8,7 @@ namespace GameList.Endpoints;
internal sealed class AdminWorkflowService(AppDbContext db)
{
- public async Task SetResultsOpenAsync(bool resultsOpen)
+ public async Task> SetResultsOpenAsync(bool resultsOpen)
{
var state = await db.AppState.SingleAsync();
state.ResultsOpen = resultsOpen;
@@ -29,81 +29,81 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
var currentState = await db.AppState.AsNoTracking().SingleAsync();
- return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
+ return ServiceResult.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
}
- public async Task GetVoteStatusAsync()
+ public async Task> GetVoteStatusAsync()
{
var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.IsAdmin, p.IsOwner, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0;
- return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
+ return ServiceResult.Success(new VoteStatusResponse(voters, ready, waiting));
}
- public async Task GrantJokerAsync(Guid playerId)
+ public async Task> GrantJokerAsync(Guid playerId)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
- return EndpointHelpers.NotFoundError("Player not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Player not found."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Vote)
- return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker."));
player.HasJoker = true;
player.VotesFinal = false;
await db.SaveChangesAsync();
- return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
+ return ServiceResult.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker));
}
- public async Task SetPlayerPhaseAsync(Guid playerId, Phase phase)
+ public async Task> SetPlayerPhaseAsync(Guid playerId, Phase phase)
{
if (phase != Phase.Suggest)
- return EndpointHelpers.BadRequestError("Only transition to Suggest is supported.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Only transition to Suggest is supported."));
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
- return EndpointHelpers.NotFoundError("Player not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Player not found."));
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (currentPhase != Phase.Vote)
- return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase."));
player.CurrentPhase = Phase.Suggest;
player.VotesFinal = false;
await db.SaveChangesAsync();
- return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
+ return ServiceResult.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
}
- public async Task SetPlayerAdminAsync(Guid playerId, bool isAdmin)
+ public async Task> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
- return EndpointHelpers.NotFoundError("Player not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Player not found."));
if (player.IsOwner)
- return EndpointHelpers.BadRequestError("Owner permissions cannot be changed.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Owner permissions cannot be changed."));
player.IsAdmin = isAdmin;
await db.SaveChangesAsync();
- return Results.Ok(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
+ return ServiceResult.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
}
- public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
+ public async Task> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
- return passwordError;
+ return ServiceResult.Failure(passwordError);
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
- return EndpointHelpers.NotFoundError("Player not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Player not found."));
if (player.IsOwner)
- return EndpointHelpers.BadRequestError("Owner account cannot be deleted.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Owner account cannot be deleted."));
await using var tx = await db.Database.BeginTransactionAsync();
@@ -121,30 +121,30 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
- return Results.Ok(new AdminDeletePlayerResponse(playerId));
+ return ServiceResult.Success(new AdminDeletePlayerResponse(playerId));
}
- public async Task LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
+ public async Task> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote)
- return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
if (sourceSuggestionId == targetSuggestionId)
- return EndpointHelpers.BadRequestError("Pick two different games to link.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Pick two different games to link."));
var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
if (source is null || target is null)
- return EndpointHelpers.NotFoundError("Suggestion not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found."));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
- return EndpointHelpers.NotFoundError("Suggestion not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found."));
if (sourceRoot == targetRoot)
- return EndpointHelpers.BadRequestError("These games are already linked.");
+ return ServiceResult.Failure(ServiceError.BadRequest("These games are already linked."));
var affectedRootIds = new HashSet
{
@@ -176,23 +176,23 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
- return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
+ return ServiceResult.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
}
- public async Task UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
+ public async Task> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote)
- return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
if (target is null)
- return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0));
+ return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId))
- return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0));
+ return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0));
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
@@ -211,14 +211,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
- return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
+ return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
}
- public async Task ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
+ public async Task> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
- return passwordError;
+ return ServiceResult.Failure(passwordError);
await using var tx = await db.Database.BeginTransactionAsync();
@@ -232,14 +232,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
- return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
+ return ServiceResult.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
}
- public async Task FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
+ public async Task> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null)
- return passwordError;
+ return ServiceResult.Failure(passwordError);
await using var tx = await db.Database.BeginTransactionAsync();
@@ -254,24 +254,24 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
- return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
+ return ServiceResult.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
}
- private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
+ private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
{
if (string.IsNullOrWhiteSpace(password))
- return EndpointHelpers.BadRequestError("Admin password is required.");
+ return ServiceError.BadRequest("Admin password is required.");
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
if (admin is null)
- return EndpointHelpers.UnauthorizedError();
+ return ServiceError.Unauthorized();
var monitor = ctx.RequestServices.GetRequiredService();
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
if (!verified)
{
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
- return EndpointHelpers.BadRequestError("Invalid admin password.");
+ return ServiceError.BadRequest("Invalid admin password.");
}
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs
index b301378..f98e6d8 100644
--- a/Endpoints/EndpointHelpers.cs
+++ b/Endpoints/EndpointHelpers.cs
@@ -112,6 +112,22 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
+ public static IResult ToHttpResult(this ServiceResult result, Func onSuccess)
+ {
+ if (result.IsSuccess)
+ return onSuccess(result.Value!);
+
+ return ToHttpError(result.Error!);
+ }
+
+ public static IResult ToHttpResult(this ServiceResult result, Func onSuccess)
+ {
+ if (result.IsSuccess)
+ return onSuccess();
+
+ return ToHttpError(result.Error!);
+ }
+
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
@@ -160,6 +176,18 @@ internal static class EndpointHelpers
|| path.EndsWith(".avif", StringComparison.Ordinal);
}
+ private static IResult ToHttpError(ServiceError error)
+ {
+ return error.Code switch
+ {
+ ServiceErrorCode.BadRequest => BadRequestError(error.Detail),
+ ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail),
+ ServiceErrorCode.NotFound => NotFoundError(error.Detail),
+ ServiceErrorCode.Conflict => ConflictError(error.Detail),
+ _ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.")
+ };
+ }
+
public static HttpMessageHandler CreateImageValidationHandler()
{
return new SocketsHttpHandler
diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs
index 2840f0c..3863d6a 100644
--- a/Endpoints/ResultsEndpoints.cs
+++ b/Endpoints/ResultsEndpoints.cs
@@ -18,7 +18,8 @@ public static class ResultsEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.GetResultsAsync(player.Id);
+ var result = await service.GetResultsAsync(player.Id);
+ return result.ToHttpResult(Results.Ok);
});
}
}
diff --git a/Endpoints/ResultsWorkflowService.cs b/Endpoints/ResultsWorkflowService.cs
index aed8267..4453869 100644
--- a/Endpoints/ResultsWorkflowService.cs
+++ b/Endpoints/ResultsWorkflowService.cs
@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
internal sealed class ResultsWorkflowService(AppDbContext db)
{
- public async Task GetResultsAsync(Guid playerId)
+ public async Task>> GetResultsAsync(Guid playerId)
{
var appState = await db.AppState.AsNoTracking().SingleAsync();
if (!appState.ResultsOpen)
- return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
+ return ServiceResult>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Results)
- return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
+ return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
var results = await db
.Suggestions.AsNoTracking()
@@ -49,7 +49,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
- var shaped = results.Select(r =>
+ IReadOnlyList shaped = results.Select(r =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id)
@@ -80,8 +80,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
linkedIds,
linkedTitles
);
- });
+ }).ToList();
- return Results.Ok(shaped);
+ return ServiceResult>.Success(shaped);
}
}
diff --git a/Endpoints/ServiceResult.cs b/Endpoints/ServiceResult.cs
new file mode 100644
index 0000000..d3818c8
--- /dev/null
+++ b/Endpoints/ServiceResult.cs
@@ -0,0 +1,36 @@
+using GameList.Domain;
+
+namespace GameList.Endpoints;
+
+internal enum ServiceErrorCode
+{
+ BadRequest,
+ Unauthorized,
+ NotFound,
+ Conflict
+}
+
+internal sealed record ServiceError(ServiceErrorCode Code, string Detail)
+{
+ public static ServiceError BadRequest(string detail) => new(ServiceErrorCode.BadRequest, detail);
+
+ public static ServiceError Unauthorized(string detail = "Unauthorized") => new(ServiceErrorCode.Unauthorized, detail);
+
+ public static ServiceError NotFound(string detail) => new(ServiceErrorCode.NotFound, detail);
+
+ public static ServiceError Conflict(string detail) => new(ServiceErrorCode.Conflict, detail);
+
+ public static ServiceError PhaseMismatch(Phase required, Phase current) =>
+ BadRequest($"This endpoint is available in the {required} phase. Your current phase is {current}.");
+}
+
+internal readonly record struct Unit;
+
+internal readonly record struct ServiceResult(T? Value, ServiceError? Error)
+{
+ public bool IsSuccess => Error is null;
+
+ public static ServiceResult Success(T value) => new(value, null);
+
+ public static ServiceResult Failure(ServiceError error) => new(default, error);
+}
diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs
index b439139..fdbaad4 100644
--- a/Endpoints/StateEndpoints.cs
+++ b/Endpoints/StateEndpoints.cs
@@ -14,7 +14,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.GetStateAsync(player);
+ var result = await service.GetStateAsync(player);
+ return result.ToHttpResult(Results.Ok);
});
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -23,7 +24,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.GetMeAsync(player);
+ var result = await service.GetMeAsync(player);
+ return result.ToHttpResult(Results.Ok);
});
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -32,7 +34,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.NextPhaseAsync(player);
+ var result = await service.NextPhaseAsync(player);
+ return result.ToHttpResult(Results.Ok);
});
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -41,7 +44,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.PrevPhaseAsync(player);
+ var result = await service.PrevPhaseAsync(player);
+ return result.ToHttpResult(Results.Ok);
});
}
diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs
index 8c4512f..4344ce0 100644
--- a/Endpoints/StateWorkflowService.cs
+++ b/Endpoints/StateWorkflowService.cs
@@ -7,22 +7,22 @@ namespace GameList.Endpoints;
internal sealed class StateWorkflowService(AppDbContext db)
{
- public async Task GetStateAsync(Player player)
+ public async Task> GetStateAsync(Player player)
{
var state = await db.AppState.AsNoTracking().SingleAsync();
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 ServiceResult.Success(summary);
}
- public async Task GetMeAsync(Player player)
+ public async Task> GetMeAsync(Player player)
{
var state = await db.AppState.AsNoTracking().SingleAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
- return Results.Ok(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
+ return ServiceResult.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
}
- public async Task NextPhaseAsync(Player player)
+ public async Task> NextPhaseAsync(Player player)
{
var appState = await db.AppState.SingleAsync();
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -35,16 +35,16 @@ internal sealed class StateWorkflowService(AppDbContext db)
{
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.");
+ return ServiceResult.Failure(ServiceError.BadRequest("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.");
+ return ServiceResult.Failure(ServiceError.BadRequest("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));
+ return ServiceResult.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
finally
{
@@ -53,10 +53,10 @@ internal sealed class StateWorkflowService(AppDbContext db)
}
}
- public async Task PrevPhaseAsync(Player player)
+ public async Task> PrevPhaseAsync(Player player)
{
if (!player.IsAdmin)
- return EndpointHelpers.BadRequestError("Only admins can move backward.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Only admins can move backward."));
var appState = await db.AppState.SingleAsync();
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -64,7 +64,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false;
await db.SaveChangesAsync();
- return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
+ return ServiceResult.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
private static Phase NextPhase(Phase current) => current switch
diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs
index 6c78d4a..98e9219 100644
--- a/Endpoints/SuggestEndpoints.cs
+++ b/Endpoints/SuggestEndpoints.cs
@@ -17,7 +17,8 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.GetMineAsync(player.Id);
+ var result = await service.GetMineAsync(player.Id);
+ return result.ToHttpResult(Results.Ok);
});
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -26,7 +27,7 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.CreateAsync(
+ var result = await service.CreateAsync(
player.Id,
new SuggestionInput(
request.Name,
@@ -39,6 +40,8 @@ public static class SuggestEndpoints
request.MaxPlayers
)
);
+
+ return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
}).AddEndpointFilter(new PhaseOrJokerFilter());
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -47,7 +50,8 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.DeleteAsync(player.Id, id);
+ var result = await service.DeleteAsync(player.Id, id);
+ return result.ToHttpResult(Results.NoContent);
});
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -56,7 +60,7 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.UpdateAsync(
+ var result = await service.UpdateAsync(
player.Id,
id,
new SuggestionInput(
@@ -70,6 +74,8 @@ public static class SuggestEndpoints
request.MaxPlayers
)
);
+
+ return result.ToHttpResult(Results.Ok);
});
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -78,7 +84,8 @@ public static class SuggestEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.GetAllAsync(player.Id);
+ var result = await service.GetAllAsync(player.Id);
+ return result.ToHttpResult(Results.Ok);
});
}
}
diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs
index c1379f0..dd846f7 100644
--- a/Endpoints/SuggestionWorkflowService.cs
+++ b/Endpoints/SuggestionWorkflowService.cs
@@ -7,7 +7,7 @@ namespace GameList.Endpoints;
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
{
- public async Task GetMineAsync(Guid playerId)
+ public async Task>> GetMineAsync(Guid playerId)
{
var mine = await db.Suggestions
.AsNoTracking()
@@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
})
.ToListAsync();
- var ordered = mine
+ IReadOnlyList ordered = mine
.OrderBy(s => s.CreatedAt)
- .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
+ .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId))
+ .ToList();
- return Results.Ok(ordered);
+ return ServiceResult>.Success(ordered);
}
- public async Task CreateAsync(Guid playerId, SuggestionInput input)
+ public async Task> CreateAsync(Guid playerId, SuggestionInput input)
{
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
- return EndpointHelpers.BadRequestError(validationError);
+ return ServiceResult.Failure(ServiceError.BadRequest(validationError));
var playerState = await db.Players
.AsNoTracking()
@@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
if (phase != Phase.Suggest && !usingJoker)
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
- return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions."));
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5)
- return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
+ return ServiceResult.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
var suggestion = new Suggestion
{
@@ -97,13 +98,13 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
{
- return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
+ return ServiceResult.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
}
- return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
+ return ServiceResult.Success(new SuggestionCreatedResponse(suggestion.Id));
}
- public async Task DeleteAsync(Guid playerId, int suggestionId)
+ public async Task> DeleteAsync(Guid playerId, int suggestionId)
{
var actor = await db.Players
.AsNoTracking()
@@ -119,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Suggest)
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
}
var suggestion = isAdmin
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
if (suggestion == null)
- return EndpointHelpers.NotFoundError("Suggestion not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found."));
await using var tx = await db.Database.BeginTransactionAsync();
@@ -139,14 +140,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync();
await tx.CommitAsync();
- return Results.NoContent();
+ return ServiceResult.Success(default);
}
- public async Task UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
+ public async Task> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
{
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
- return EndpointHelpers.BadRequestError(validationError);
+ return ServiceResult.Failure(ServiceError.BadRequest(validationError));
var actor = await db.Players
.AsNoTracking()
@@ -159,17 +160,17 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
if (suggestion == null)
- return EndpointHelpers.NotFoundError("Suggestion not found.");
+ return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found."));
var isAdmin = actor.IsAdmin;
if (!isAdmin)
{
if (suggestion.PlayerId != playerId)
- return EndpointHelpers.UnauthorizedError();
+ return ServiceResult.Failure(ServiceError.Unauthorized());
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase == Phase.Results)
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
if (phase == Phase.Suggest)
{
@@ -177,7 +178,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
}
else if (phase != Phase.Vote)
{
- return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
}
ApplyEditableFields(suggestion, input);
@@ -190,7 +191,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
await db.SaveChangesAsync();
- return Results.Ok(new SuggestionUpdatedResponse(
+ return ServiceResult.Success(new SuggestionUpdatedResponse(
suggestion.Id,
suggestion.Name,
suggestion.Genre,
@@ -203,11 +204,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
));
}
- public async Task GetAllAsync(Guid playerId)
+ public async Task>> GetAllAsync(Guid playerId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase < Phase.Vote)
- return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
+ return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var all = await db.Suggestions
.AsNoTracking()
@@ -233,12 +234,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
- var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
+ IReadOnlyList ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
- return new
- {
+ return new SuggestionAllDto(
s.Id,
s.Name,
s.Genre,
@@ -251,12 +251,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
s.Author,
s.ParentSuggestionId,
s.IsOwner,
- LinkedIds = linkedIds,
- LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
- };
- });
+ linkedIds,
+ linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
+ );
+ }).ToList();
- return Results.Ok(ordered);
+ return ServiceResult>.Success(ordered);
}
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs
index 0fdbf75..54df5a0 100644
--- a/Endpoints/VoteEndpoints.cs
+++ b/Endpoints/VoteEndpoints.cs
@@ -17,7 +17,8 @@ public static class VoteEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.GetMineAsync(player.Id);
+ var result = await service.GetMineAsync(player.Id);
+ return result.ToHttpResult(Results.Ok);
});
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -25,7 +26,9 @@ public static class VoteEndpoints
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
+
+ var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
+ return result.ToHttpResult(Results.Ok);
});
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -34,7 +37,8 @@ public static class VoteEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
- return await service.SetFinalizeAsync(player.Id, request.Final);
+ var result = await service.SetFinalizeAsync(player.Id, request.Final);
+ return result.ToHttpResult(Results.Ok);
});
}
}
diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs
index a2171c3..82b75b3 100644
--- a/Endpoints/VoteWorkflowService.cs
+++ b/Endpoints/VoteWorkflowService.cs
@@ -8,29 +8,25 @@ namespace GameList.Endpoints;
internal sealed class VoteWorkflowService(AppDbContext db)
{
- public async Task GetMineAsync(Guid playerId)
+ public async Task>> GetMineAsync(Guid playerId)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
- return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
+ return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
- var votes = await db.Votes
+ IReadOnlyList votes = await db.Votes
.AsNoTracking()
.Where(v => v.PlayerId == playerId)
- .Select(v => new
- {
- v.SuggestionId,
- v.Score
- })
+ .Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
.ToListAsync();
- return Results.Ok(votes);
+ return ServiceResult>.Success(votes);
}
- public async Task UpsertAsync(Guid playerId, int suggestionId, int score)
+ public async Task> UpsertAsync(Guid playerId, int suggestionId, int score)
{
if (score is < 0 or > 10)
- return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
var playerState = await db.Players
.AsNoTracking()
@@ -43,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.FirstAsync();
if (playerState.VotesFinal)
- return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
- return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
- return EndpointHelpers.BadRequestError("Set a display name before voting.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Set a display name before voting."));
var linkMap = await db.Suggestions
.AsNoTracking()
@@ -62,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(suggestionId))
- return EndpointHelpers.BadRequestError("Suggestion not found.");
+ return ServiceResult.Failure(ServiceError.BadRequest("Suggestion not found."));
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
if (linkedIds.Count == 0)
@@ -95,7 +91,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
try
{
await db.SaveChangesAsync();
- return Results.Ok(new VoteUpsertResponse(linkedIds, score));
+ return ServiceResult.Success(new VoteUpsertResponse(linkedIds, score));
}
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
{
@@ -111,20 +107,20 @@ internal sealed class VoteWorkflowService(AppDbContext db)
}
}
- return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
+ return ServiceResult.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
}
- public async Task SetFinalizeAsync(Guid playerId, bool final)
+ public async Task> SetFinalizeAsync(Guid playerId, bool final)
{
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote)
- return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
+ return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var player = await db.Players.FirstAsync(p => p.Id == playerId);
player.VotesFinal = final;
await db.SaveChangesAsync();
- return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
+ return ServiceResult.Success(new VoteFinalizeResponse(player.VotesFinal));
}
private static void DetachAddedVotes(IEnumerable> voteEntries)
diff --git a/README.md b/README.md
index dcb69c1..14f357f 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
## Module Ownership
- `Program.cs`: startup wiring, middleware order, route registration.
-- `Endpoints/`: HTTP endpoint transport + request orchestration.
+- `Endpoints/`: endpoint adapters plus application workflow services (`ServiceResult` outputs mapped to HTTP at the edge).
- `Infrastructure/`: filters, middleware, identity helpers.
- `Data/`: EF Core `DbContext` and migrations.
- `Domain/`: entities and enums.
From acffbc199d41800463e5443ae538c570fa85721f Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:46:26 +0100
Subject: [PATCH 03/11] Remove startup migration and runtime frontend rewrites
---
GameList.Tests/HelperTests.cs | 41 ++---------------
.../Support/TestWebApplicationFactory.cs | 3 +-
IIS.md | 1 +
Program.cs | 46 -------------------
README.md | 9 ++--
TESTS.md | 2 +-
6 files changed, 12 insertions(+), 90 deletions(-)
diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs
index 4e60309..5533a73 100644
--- a/GameList.Tests/HelperTests.cs
+++ b/GameList.Tests/HelperTests.cs
@@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
-using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Net.Http.Json;
@@ -28,34 +27,10 @@ public class HelperTests
}
[Fact]
- public void UpdateIndexMetaBase_rewrites_content_value()
+ public void Program_does_not_include_runtime_index_rewrite_hook()
{
- var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
- Directory.CreateDirectory(webRoot);
- var index = Path.Combine(webRoot, "index.html");
- File.WriteAllText(index, "");
-
- var env = new FakeEnv { WebRootPath = webRoot };
- var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
- method.Invoke(null, [env, "/pick"]);
-
- var text = File.ReadAllText(index);
- Assert.Contains("content=\"/pick\"", text);
- }
-
- [Fact]
- public void UpdateIndexMetaBase_no_marker_no_change()
- {
- var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
- Directory.CreateDirectory(webRoot);
- var index = Path.Combine(webRoot, "index.html");
- File.WriteAllText(index, "");
-
- var env = new FakeEnv { WebRootPath = webRoot };
- var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
- method.Invoke(null, [env, "/pick"]);
-
- Assert.Equal("", File.ReadAllText(index));
+ var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
+ Assert.False(hasRewriteMethod);
}
[Fact]
@@ -349,16 +324,6 @@ public class HelperTests
Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal);
}
- private class FakeEnv : IWebHostEnvironment
- {
- public string ApplicationName { get; set; } = "";
- public IFileProvider WebRootFileProvider { get; set; } = null!;
- public string WebRootPath { get; set; } = "";
- public string EnvironmentName { get; set; } = "";
- public string ContentRootPath { get; set; } = "";
- public IFileProvider ContentRootFileProvider { get; set; } = null!;
- }
-
private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config)
{
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions"));
diff --git a/GameList.Tests/Support/TestWebApplicationFactory.cs b/GameList.Tests/Support/TestWebApplicationFactory.cs
index 5a4e1c3..e0655af 100644
--- a/GameList.Tests/Support/TestWebApplicationFactory.cs
+++ b/GameList.Tests/Support/TestWebApplicationFactory.cs
@@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory
services.Remove(descriptor);
}
- _connection = new SqliteConnection("Data Source=:memory:;Cache=Shared");
+ _connection = new SqliteConnection($"Data Source=file:tests-{Guid.NewGuid():N}?mode=memory&cache=shared");
_connection.Open();
services.AddDbContext(options => { options.UseSqlite(_connection); });
@@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
- db.Database.EnsureCreated();
db.Database.Migrate();
return host;
diff --git a/IIS.md b/IIS.md
index bad3291..fb2f536 100644
--- a/IIS.md
+++ b/IIS.md
@@ -8,6 +8,7 @@
## Publish
- From repo root: `dotnet publish -c Release -o publish`
+- Before first start (and after every new migration): run `dotnet ef database update` from repo root against the target environment.
- Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user).
- Set environment variables in web.config or IIS config:
- `ASPNETCORE_ENVIRONMENT=Production`
diff --git a/Program.cs b/Program.cs
index 97f6ab1..8748b25 100644
--- a/Program.cs
+++ b/Program.cs
@@ -146,7 +146,6 @@ var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath))
{
app.UsePathBase(basePath);
- UpdateIndexMetaBase(app.Environment, basePath);
}
app.UseGlobalExceptionLogging();
@@ -154,13 +153,6 @@ app.UseAuthentication();
app.UseMiddleware();
app.UseAuthorization();
-// Ensure database and migrations are applied on startup
-using (var scope = app.Services.CreateScope())
-{
- var db = scope.ServiceProvider.GetRequiredService();
- db.Database.Migrate();
-}
-
app.UseDefaultFiles();
app.UseStaticFiles();
@@ -274,42 +266,4 @@ static Task WriteUnauthorizedChallengeAsync(HttpContext context)
return context.Response.WriteAsJsonAsync(problem);
}
-static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
-{
- try
- {
- var indexPath = Path.Combine(env.WebRootPath, "index.html");
- if (!File.Exists(indexPath))
- return;
-
- var text = File.ReadAllText(indexPath);
- var marker = "name=\"app-base\"";
- var contentKey = "content=\"";
- var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
- if (markerIndex < 0)
- return;
-
- var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase);
- if (contentIndex < 0)
- return;
-
- var valueStart = contentIndex + contentKey.Length;
- var valueEnd = text.IndexOf('"', valueStart);
- if (valueEnd < 0)
- return;
-
- var current = text[valueStart..valueEnd];
- var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath;
- if (current == normalized)
- return;
-
- var updated = text[..valueStart] + normalized + text[valueEnd..];
- File.WriteAllText(indexPath, updated);
- }
- catch
- {
- // If we can't rewrite, continue; frontend can still be set manually.
- }
-}
-
public partial class Program;
diff --git a/README.md b/README.md
index 14f357f..fcb561c 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,13 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
1. Restore and build:
`dotnet build GameList.sln`
-2. Run tests:
+2. Apply DB migrations explicitly:
+ `dotnet ef database update`
+3. Run tests:
`dotnet test GameList.Tests/GameList.Tests.csproj`
-3. Run locally:
+4. Run locally:
`dotnet run --project GameList.csproj`
-4. Open:
+5. Open:
`http://localhost:5000` (or the URL shown by `dotnet run`)
## Frontend Tooling
@@ -28,6 +30,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
- Gameplay phases: `Suggest`, `Vote`, `Results`.
- Storage: SQLite database under `App_Data/gamelist.db`.
+- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
## Module Ownership
diff --git a/TESTS.md b/TESTS.md
index 0daecb8..a7b0d48 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -87,7 +87,7 @@ stateDiagram-v2
- EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext.
- IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, and private/reserved host range detection (IPv4/IPv6).
- BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids.
-- UpdateIndexMetaBase (Program.cs): rewrites app-base meta when BasePath set; no change when matching/marker missing; safe exceptions swallowed.
+- Program startup avoids runtime frontend file rewrites; BasePath remains purely configuration/deploy managed.
- Global exception handler returns 500 with JSON body and logs error.
- /health returns {status:"ok"}.
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes.
From d2ab8a676fdd213da7cc3bb6f96acb164accdc18 Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:48:07 +0100
Subject: [PATCH 04/11] Harden auth validation against null request fields
---
Contracts/AuthRequests.cs | 4 ++--
Endpoints/AuthEndpoints.cs | 10 ++++++----
Endpoints/AuthValidator.cs | 8 ++++----
GameList.Tests/AuthTests.cs | 23 +++++++++++++++++++++++
TESTS.md | 1 +
5 files changed, 36 insertions(+), 10 deletions(-)
diff --git a/Contracts/AuthRequests.cs b/Contracts/AuthRequests.cs
index 24fb124..4ccdf9b 100644
--- a/Contracts/AuthRequests.cs
+++ b/Contracts/AuthRequests.cs
@@ -1,5 +1,5 @@
namespace GameList.Contracts;
-public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey);
+public record RegisterRequest(string? Username, string? Password, string? DisplayName, string? AdminKey);
-public record LoginRequest(string Username, string Password);
+public record LoginRequest(string? Username, string? Password);
diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs
index d8c201f..b4da1a5 100644
--- a/Endpoints/AuthEndpoints.cs
+++ b/Endpoints/AuthEndpoints.cs
@@ -23,7 +23,7 @@ public static class AuthEndpoints
{
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
{
- authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username.Trim(), "validation-failed");
+ authAttemptMonitor.RecordFailure(ctx, "auth-register", NormalizeActor(request.Username), "validation-failed");
return EndpointHelpers.BadRequestError(registrationError);
}
@@ -31,7 +31,7 @@ public static class AuthEndpoints
if (exists)
return EndpointHelpers.ConflictError("Username already taken.");
- var (hash, salt) = PasswordHasher.HashPassword(request.Password);
+ var (hash, salt) = PasswordHasher.HashPassword(validated.Password);
var expectedAdminKey = config["ADMIN_PASSWORD"];
var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey);
if (wantsAdmin)
@@ -99,12 +99,12 @@ public static class AuthEndpoints
{
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
{
- authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username.Trim(), "validation-failed");
+ authAttemptMonitor.RecordFailure(ctx, "auth-login", NormalizeActor(request.Username), "validation-failed");
return EndpointHelpers.BadRequestError(loginError);
}
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
- if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
+ if (player == null || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt))
{
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
@@ -135,4 +135,6 @@ public static class AuthEndpoints
return Results.NoContent();
});
}
+
+ private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
}
diff --git a/Endpoints/AuthValidator.cs b/Endpoints/AuthValidator.cs
index 6d3dd09..26c4d6e 100644
--- a/Endpoints/AuthValidator.cs
+++ b/Endpoints/AuthValidator.cs
@@ -12,7 +12,7 @@ internal static class AuthValidator
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
{
- var username = (request.Username).Trim();
+ var username = (request.Username ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
{
validated = default;
@@ -61,14 +61,14 @@ internal static class AuthValidator
}
var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength);
- validated = new ValidatedRegistration(username, username.ToLowerInvariant(), displayName, adminKey);
+ validated = new ValidatedRegistration(username, username.ToLowerInvariant(), password, displayName, adminKey);
error = string.Empty;
return true;
}
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
{
- username = (request.Username).Trim();
+ username = (request.Username ?? string.Empty).Trim();
normalizedUsername = string.Empty;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
@@ -94,5 +94,5 @@ internal static class AuthValidator
return true;
}
- public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string DisplayName, string? AdminKey);
+ public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string Password, string DisplayName, string? AdminKey);
}
diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs
index 1c24f68..12595f4 100644
--- a/GameList.Tests/AuthTests.cs
+++ b/GameList.Tests/AuthTests.cs
@@ -212,6 +212,29 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
}
+ [Fact]
+ public async Task Register_and_login_with_null_fields_return_bad_request()
+ {
+ await using var factory = new TestWebApplicationFactory();
+ var client = factory.CreateClientWithCookies();
+
+ var register = await client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Username = (string?)null,
+ Password = (string?)null,
+ DisplayName = (string?)null,
+ AdminKey = (string?)null
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, register.StatusCode);
+
+ var login = await client.PostAsJsonAsync("/api/auth/login", new
+ {
+ Username = (string?)null,
+ Password = (string?)null
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode);
+ }
+
[Fact]
public async Task Non_admin_cannot_access_admin_routes()
{
diff --git a/TESTS.md b/TESTS.md
index a7b0d48..2383387 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -33,6 +33,7 @@ stateDiagram-v2
### 1) Authentication & Identity
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
+- Register/login null payload fields fail closed with `400` (no `500` on malformed JSON bodies).
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
- Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks.
- `/api/auth/options` reports owner presence for registration UI behavior.
From 368b4877bc86fbbe59836e288d882de78ed08787 Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:50:58 +0100
Subject: [PATCH 05/11] Parameterize FTP deployment with environment profiles
---
IIS.md | 2 +
README.md | 1 +
scripts/deploy-ftp.profile.sample.psd1 | 31 +++
scripts/deploy-ftp.ps1 | 271 ++++++++++++++++---------
4 files changed, 210 insertions(+), 95 deletions(-)
create mode 100644 scripts/deploy-ftp.profile.sample.psd1
diff --git a/IIS.md b/IIS.md
index fb2f536..02b64ed 100644
--- a/IIS.md
+++ b/IIS.md
@@ -22,6 +22,8 @@
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward.
- Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles.
- Frontend base path: set `` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).
+- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`.
+- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs.
## Permissions
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
diff --git a/README.md b/README.md
index fcb561c..473aba1 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- `wwwroot/`: static frontend assets.
- `GameList.Tests/`: integration and helper tests.
- `scripts/`: deployment scripts.
+ `scripts/deploy-ftp.ps1` is profile-driven via `scripts/deploy-ftp.profile.sample.psd1`.
## Operations
diff --git a/scripts/deploy-ftp.profile.sample.psd1 b/scripts/deploy-ftp.profile.sample.psd1
new file mode 100644
index 0000000..3b9aa41
--- /dev/null
+++ b/scripts/deploy-ftp.profile.sample.psd1
@@ -0,0 +1,31 @@
+@{
+ # Required publish settings
+ ProjectPath = "..\GameList.csproj"
+ Configuration = "Release"
+ Runtime = "win-x64"
+ PublishDir = "$env:TEMP\GameList-publish"
+ SelfContained = $false
+
+ # Required sync settings
+ WinScpPath = "C:\Program Files (x86)\WinSCP\WinSCP.com"
+ RemoteDir = "/httpdocs/picknplay"
+
+ # Preferred: use a named WinSCP stored session (no credential string in script)
+ WinScpSessionName = "picknplay-prod"
+
+ # Optional FTP URL fallback if no stored session is configured
+ # FtpHost = "example.com"
+ # FtpUser = "deploy-user"
+
+ # Optional IIS recycle and WinRM controls
+ RecycleAppPool = $true
+ AppPoolName = "picknplay-app-pool"
+ WinRmComputer = "example.com"
+ WinRmCredentialUser = "Administrator"
+ UseWinRmHttps = $true
+ WinRmAuth = "Basic"
+
+ # Optional remote migration
+ RunEfMigrations = $false
+ RemoteSitePath = "C:\Inetpub\vhosts\example.com\httpdocs\picknplay"
+}
diff --git a/scripts/deploy-ftp.ps1 b/scripts/deploy-ftp.ps1
index 4ae6d78..1c68717 100644
--- a/scripts/deploy-ftp.ps1
+++ b/scripts/deploy-ftp.ps1
@@ -1,157 +1,238 @@
-# Hard-coded deploy settings. Fill these in before running.
-$FtpHost = "xTr1m.com"
-$FtpUser = "xTr1m"
-$Password = $null # prompted at runtime
-$RemoteDir = "/httpdocs/picknplay"
-$ProjectPath = "..\\GameList.csproj"
-$Configuration = "Release"
-$Runtime = "win-x64"
-$PublishDir = "$env:TEMP\\GameList-publish"
-$SelfContained = $false
-$WinScpPath = "C:\\Users\\frank\\AppData\\Local\\Programs\\WinSCP\\WinSCP.com"
-$RecycleAppPool = $true
-$AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
-$WinRmComputer = "xTr1m.com"
-$WinRmCredentialUser = "Administrator"
-$UseWinRmHttps = $true # set false if using HTTP + TrustedHosts
-$RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\picknplay"
-$RunEfMigrations = $false # set to $false to skip remote database update
+param(
+ [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
+ [string]$Password,
+ [switch]$SkipRecycle,
+ [switch]$SkipMigrations
+)
-<#!
+<#
.SYNOPSIS
- Publish the app and mirror the output to an FTP-deployed IIS site.
+ Publish the app and mirror output to an FTP-deployed IIS site.
.DESCRIPTION
+ - Reads environment-specific settings from a PowerShell data file profile.
- Builds with dotnet publish.
- - Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files).
- - Optionally recycles the IIS app pool remotely via WinRM (no RDP needed).
-
-.PREREQS
- - WinSCP.com available in PATH or set $WinScpPath.
- - FTP user must have write/delete rights to $RemoteDir.
- - WinRM must be enabled for remote app pool recycle (set $RecycleAppPool = $false otherwise).
+ - Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
+ - Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
.EXAMPLE
- pwsh ./scripts/deploy-ftp.ps1
+ pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-Tool {
- param([string]$Name)
+ param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
- throw "Required tool '$Name' not found. Install it or update paths."
+ throw "Required tool '$Name' not found. Install it or update your deploy profile."
}
}
-Assert-Tool "dotnet"
-Assert-Tool $WinScpPath
+function Require-ConfigValue {
+ param(
+ [Parameter(Mandatory = $true)][hashtable]$Config,
+ [Parameter(Mandatory = $true)][string]$Key
+ )
+
+ if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
+ throw "Missing required deploy profile value '$Key'."
+ }
+}
+
+function Resolve-ProfilePath {
+ param(
+ [Parameter(Mandatory = $true)][string]$BaseDirectory,
+ [Parameter(Mandatory = $true)][string]$PathValue
+ )
+
+ $expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
+ if ([System.IO.Path]::IsPathRooted($expanded)) {
+ return $expanded
+ }
+
+ return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
+}
+
+function Read-PlainOrPrompt {
+ param(
+ [string]$Value,
+ [Parameter(Mandatory = $true)][string]$Prompt,
+ [bool]$Secure = $false
+ )
+
+ if (-not [string]::IsNullOrWhiteSpace($Value)) {
+ return $Value
+ }
-function Read-PlainOrPrompt([object]$Value, [string]$Prompt, [bool]$Secure = $false) {
- if ($Value -is [string] -and -not [string]::IsNullOrWhiteSpace($Value)) { return $Value }
if ($Secure) {
$pwd = Read-Host -Prompt $Prompt -AsSecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
- try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) }
+ try {
+ return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
+ }
finally {
- if ($ptr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
+ if ($ptr -ne [IntPtr]::Zero) {
+ [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
+ }
}
}
+
return Read-Host -Prompt $Prompt
}
-$Password = Read-PlainOrPrompt $Password "Password" $true
-$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain
+function Invoke-WinRmScript {
+ param(
+ [Parameter(Mandatory = $true)][hashtable]$Config,
+ [Parameter(Mandatory = $true)][string]$PasswordValue,
+ [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
+ [object[]]$ArgumentList = @()
+ )
+
+ Require-ConfigValue $Config "WinRmComputer"
+ Require-ConfigValue $Config "WinRmCredentialUser"
+
+ $secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
+ $cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
+
+ $invokeParams = @{
+ ComputerName = $Config.WinRmComputer
+ Credential = $cred
+ ScriptBlock = $ScriptBlock
+ ArgumentList = $ArgumentList
+ }
+
+ if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
+ $invokeParams["UseSSL"] = $true
+ }
+
+ if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
+ $invokeParams["Authentication"] = [string]$Config.WinRmAuth
+ }
+
+ Invoke-Command @invokeParams
+}
+
+if (-not (Test-Path $ProfilePath)) {
+ throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
+}
+
+$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
+$profileDirectory = Split-Path -Parent $resolvedProfilePath
+$config = Import-PowerShellDataFile -Path $resolvedProfilePath
+
+Require-ConfigValue $config "ProjectPath"
+Require-ConfigValue $config "Configuration"
+Require-ConfigValue $config "Runtime"
+Require-ConfigValue $config "PublishDir"
+Require-ConfigValue $config "WinScpPath"
+Require-ConfigValue $config "RemoteDir"
+
+$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
+$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
+
+if (-not $useStoredSession) {
+ Require-ConfigValue $config "FtpHost"
+ Require-ConfigValue $config "FtpUser"
+}
+
+$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
+$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
+$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
+$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
+$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
+$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
+$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
+$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
+
+$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
+$passwordForSession = if ($useStoredSession) { "" } else { Read-PlainOrPrompt -Value ($Password ?? $passwordFromEnv) -Prompt "FTP password" -Secure $true }
+$passwordForWinRm = if ($recycleAppPool -or $runEfMigrations) { Read-PlainOrPrompt -Value ($Password ?? $passwordFromEnv) -Prompt "WinRM password" -Secure $true } else { "" }
+
+Assert-Tool "dotnet"
+Assert-Tool $winScpPath
Write-Host "1) Publishing..." -ForegroundColor Cyan
-if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue }
-New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null
-$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir)
-if (-not $SelfContained) { $publishArgs += "--self-contained=false" }
+if (Test-Path $publishDir) {
+ Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
+
+$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
+if (-not $selfContained) {
+ $publishArgs += "--self-contained=false"
+}
dotnet @publishArgs
-if ($RecycleAppPool) {
+if ($recycleAppPool) {
+ Require-ConfigValue $config "AppPoolName"
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
- $sec = ConvertTo-SecureString $Password -AsPlainText -Force
- $cred = New-Object pscredential($WinRmCredentialUser, $sec)
- $invokeParams = @{
- ComputerName = $WinRmComputer
- Credential = $cred
- ScriptBlock = {
+ try {
+ Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
Import-Module WebAdministration
Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
- Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
+ Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
}
- if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
- if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
- try {
- Invoke-Command @invokeParams
- } catch {
+ catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
}
}
-Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan
-$tempScript = New-TemporaryFile
-@"
-option batch continue
-option confirm off
-open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost
-lcd $PublishDir
-cd $RemoteDir
-synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/"
-exit
-"@ | Set-Content -Path $tempScript -Encoding UTF8
+Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
+$openCommand = if ($useStoredSession) {
+ "open `"$winScpSessionName`""
+}
+else {
+ $ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
+ $ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
+ $ftpHost = [string]$config.FtpHost
+ "open ftp://$ftpUser`:$ftpPassword@$ftpHost"
+}
-& $WinScpPath "/ini=nul" "/script=$tempScript"
+$tempScript = New-TemporaryFile
+@(
+ "option batch continue"
+ "option confirm off"
+ $openCommand
+ "lcd `"$publishDir`""
+ "cd $([string]$config.RemoteDir)"
+ "synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
+ "exit"
+) | Set-Content -Path $tempScript -Encoding UTF8
+
+& $winScpPath "/ini=nul" "/script=$tempScript"
Remove-Item $tempScript -ErrorAction SilentlyContinue
-if ($RecycleAppPool) {
+if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
- $sec = ConvertTo-SecureString $Password -AsPlainText -Force
- $cred = New-Object pscredential($WinRmCredentialUser, $sec)
- $invokeParams = @{
- ComputerName = $WinRmComputer
- Credential = $cred
- ScriptBlock = {
+ try {
+ Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
Import-Module WebAdministration
Start-WebAppPool -Name $using:AppPoolName
}
}
- if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
- if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
- try {
- Invoke-Command @invokeParams
- } catch {
+ catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)."
}
}
-if ($RunEfMigrations) {
+if ($runEfMigrations) {
+ Require-ConfigValue $config "RemoteSitePath"
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
- $sec = ConvertTo-SecureString $Password -AsPlainText -Force
- $cred = New-Object pscredential($WinRmCredentialUser, $sec)
- $invokeParams = @{
- ComputerName = $WinRmComputer
- Credential = $cred
- ScriptBlock = {
+ try {
+ Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($sitePath)
Set-Location $sitePath
- if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) {
- throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false."
+ if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
+ throw "dotnet is not available on remote host."
}
+
dotnet ef database update --no-build
- }
- ArgumentList = @($RemoteSitePath)
+ } -ArgumentList @([string]$config.RemoteSitePath)
}
- if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
- if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
- try {
- Invoke-Command @invokeParams
- } catch {
+ catch {
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
}
}
From 726ba79fdf5462826c7163522d24aa257ee419ed Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:52:37 +0100
Subject: [PATCH 06/11] Enforce explicit test coverage thresholds in CI
---
.github/workflows/ci.yml | 5 ++++-
README.md | 3 ++-
TESTS.md | 7 ++++++-
scripts/check-coverage.ps1 | 43 ++++++++++++++++++++++++++++++++++++++
scripts/ci-local.ps1 | 8 +++++--
5 files changed, 61 insertions(+), 5 deletions(-)
create mode 100644 scripts/check-coverage.ps1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5600d54..63d1931 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,4 +40,7 @@ jobs:
run: dotnet build GameList.sln --no-restore -warnaserror
- name: Test
- run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal
+ run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
+
+ - name: Enforce coverage thresholds
+ run: pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
diff --git a/README.md b/README.md
index 473aba1..46bb622 100644
--- a/README.md
+++ b/README.md
@@ -60,4 +60,5 @@ GitHub Actions workflow: `.github/workflows/ci.yml`
- Restores dependencies
- Runs frontend lint and format checks
- Builds with warnings treated as errors
-- Runs `GameList.Tests`
+- Runs `GameList.Tests` with coverage collection
+- Enforces minimum coverage thresholds (line 90%, branch 70%)
diff --git a/TESTS.md b/TESTS.md
index 2383387..0c12478 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -94,7 +94,12 @@ stateDiagram-v2
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes.
- Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns.
+## Coverage Policy
+- CI and local script enforce Cobertura thresholds from test coverage collection.
+- Minimum line coverage: 90%.
+- Minimum branch coverage: 70%.
+
## Execution Notes
- Use named test data builders for players/suggestions to keep cases small and isolated.
- Reset in-memory DB per test to avoid cross-contamination; assert timestamps using time providers or approximate windows.
-- Cover success + failure for every endpoint status path to reach 100% line/branch coverage.
+- Cover success + failure for endpoint status paths and critical helper branches to stay above enforced thresholds.
diff --git a/scripts/check-coverage.ps1 b/scripts/check-coverage.ps1
new file mode 100644
index 0000000..05d0789
--- /dev/null
+++ b/scripts/check-coverage.ps1
@@ -0,0 +1,43 @@
+param(
+ [double]$MinLineRate = 0.90,
+ [double]$MinBranchRate = 0.70,
+ [string]$ResultsRoot = "GameList.Tests/TestResults"
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+if (-not (Test-Path $ResultsRoot)) {
+ throw "Coverage results folder not found: $ResultsRoot"
+}
+
+$coverageFile = Get-ChildItem -Path $ResultsRoot -Recurse -Filter "coverage.cobertura.xml" |
+ Sort-Object LastWriteTimeUtc -Descending |
+ Select-Object -First 1
+
+if ($null -eq $coverageFile) {
+ throw "No coverage.cobertura.xml found under $ResultsRoot"
+}
+
+[xml]$xml = Get-Content -Path $coverageFile.FullName
+$coverage = $xml.coverage
+
+if ($null -eq $coverage) {
+ throw "Coverage XML is missing root coverage node: $($coverageFile.FullName)"
+}
+
+[double]$lineRate = [double]$coverage.'line-rate'
+[double]$branchRate = [double]$coverage.'branch-rate'
+
+$linePercent = [Math]::Round($lineRate * 100, 2)
+$branchPercent = [Math]::Round($branchRate * 100, 2)
+$minLinePercent = [Math]::Round($MinLineRate * 100, 2)
+$minBranchPercent = [Math]::Round($MinBranchRate * 100, 2)
+
+Write-Host "Coverage source: $($coverageFile.FullName)"
+Write-Host ("Line coverage: {0}% (required >= {1}%)" -f $linePercent, $minLinePercent)
+Write-Host ("Branch coverage: {0}% (required >= {1}%)" -f $branchPercent, $minBranchPercent)
+
+if ($lineRate -lt $MinLineRate -or $branchRate -lt $MinBranchRate) {
+ throw "Coverage thresholds failed."
+}
diff --git a/scripts/ci-local.ps1 b/scripts/ci-local.ps1
index 42fe9a4..5d4a90b 100644
--- a/scripts/ci-local.ps1
+++ b/scripts/ci-local.ps1
@@ -53,13 +53,17 @@ try {
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
- dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal
+ dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage"
}
else {
- dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal
+ dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
}
}
+ Invoke-Step -Name "Enforce coverage thresholds" -Action {
+ pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
+ }
+
Write-Host "CI checks passed."
}
finally {
From d375b942ff0ec301065bb1eed141566af942edea Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 21:57:47 +0100
Subject: [PATCH 07/11] Reduce frontend polling load and clean stale UI hooks
---
Endpoints/SuggestionValidator.cs | 25 +-
Endpoints/SuggestionWorkflowService.cs | 15 +-
GameList.Tests/SuggestionTests.cs | 39 +++
TESTS.md | 2 +-
package.json | 4 +-
wwwroot/app.js | 418 ++++++++++++++-----------
wwwroot/index.html | 1 +
wwwroot/js/api.js | 150 +++++----
wwwroot/js/app-auth-handlers.js | 1 +
wwwroot/js/data.js | 51 ++-
wwwroot/js/dom.js | 3 +-
wwwroot/js/suggestions-ui.js | 10 -
wwwroot/js/votes-ui.js | 9 -
13 files changed, 447 insertions(+), 281 deletions(-)
diff --git a/Endpoints/SuggestionValidator.cs b/Endpoints/SuggestionValidator.cs
index 84ea3bf..6641788 100644
--- a/Endpoints/SuggestionValidator.cs
+++ b/Endpoints/SuggestionValidator.cs
@@ -1,8 +1,14 @@
+using System.Collections.Concurrent;
+
namespace GameList.Endpoints;
internal static class SuggestionValidator
{
- public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory)
+ private static readonly ConcurrentDictionary ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase);
+ private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15);
+ private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2);
+
+ public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true)
{
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
return "Name is required and must be <= 100 characters.";
@@ -10,7 +16,7 @@ internal static class SuggestionValidator
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
return "Screenshot URL must be http(s) and end with an image file extension.";
- if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory))
+ if (shouldValidateImageReachability && !await IsReachableImageCachedAsync(input.ScreenshotUrl, httpFactory))
return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB).";
if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
@@ -22,6 +28,21 @@ internal static class SuggestionValidator
return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
}
+ private static async Task IsReachableImageCachedAsync(string? url, IHttpClientFactory httpFactory)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ return true;
+
+ var normalized = url.Trim();
+ if (ImageReachabilityCache.TryGetValue(normalized, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
+ return cached.Reachable;
+
+ var reachable = await EndpointHelpers.IsReachableImageAsync(normalized, httpFactory);
+ var ttl = reachable ? ReachableCacheTtl : UnreachableCacheTtl;
+ ImageReachabilityCache[normalized] = (reachable, DateTimeOffset.UtcNow.Add(ttl));
+ return reachable;
+ }
+
private static string? ValidatePlayers(int? minPlayers, int? maxPlayers)
{
if (minPlayers is null && maxPlayers is null)
diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs
index dd846f7..08dc54b 100644
--- a/Endpoints/SuggestionWorkflowService.cs
+++ b/Endpoints/SuggestionWorkflowService.cs
@@ -145,10 +145,6 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
public async Task> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
{
- var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
- if (validationError is not null)
- return ServiceResult.Failure(ServiceError.BadRequest(validationError));
-
var actor = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
@@ -162,6 +158,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
if (suggestion == null)
return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found."));
+ var shouldValidateScreenshot = ShouldValidateScreenshotReachability(input.ScreenshotUrl, suggestion.ScreenshotUrl);
+ var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory, shouldValidateScreenshot);
+ if (validationError is not null)
+ return ServiceResult.Failure(ServiceError.BadRequest(validationError));
+
var isAdmin = actor.IsAdmin;
if (!isAdmin)
{
@@ -269,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
suggestion.MinPlayers = input.MinPlayers;
suggestion.MaxPlayers = input.MaxPlayers;
}
+
+ private static bool ShouldValidateScreenshotReachability(string? requestedScreenshotUrl, string? existingScreenshotUrl)
+ {
+ var normalizedRequested = EndpointHelpers.TrimTo(requestedScreenshotUrl, 2048);
+ return !string.Equals(normalizedRequested, existingScreenshotUrl, StringComparison.Ordinal);
+ }
}
diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs
index c107b6e..19cb02a 100644
--- a/GameList.Tests/SuggestionTests.cs
+++ b/GameList.Tests/SuggestionTests.cs
@@ -348,6 +348,45 @@ public class SuggestionTests
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
+ [Fact]
+ public async Task Update_does_not_revalidate_unchanged_screenshot_url()
+ {
+ await using var factory = new TestWebApplicationFactory();
+ var client = factory.CreateClientWithCookies();
+ await client.RegisterAsync("reval");
+
+ var create = await client.PostAsJsonAsync("/api/suggestions", new
+ {
+ Name = "Reachable once",
+ Genre = (string?)null,
+ Description = (string?)null,
+ ScreenshotUrl = "http://example.com/shot.png",
+ YoutubeUrl = (string?)null,
+ GameUrl = (string?)null,
+ MinPlayers = (int?)null,
+ MaxPlayers = (int?)null
+ });
+ create.EnsureSuccessStatusCode();
+ var createdPayload = await create.Content.ReadFromJsonAsync();
+ var suggestionId = createdPayload.GetProperty("id").GetInt32();
+
+ factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest));
+
+ var update = await client.PutAsJsonAsync($"/api/suggestions/{suggestionId}", new
+ {
+ Name = "Reachable once",
+ Genre = "Updated",
+ Description = (string?)null,
+ ScreenshotUrl = "http://example.com/shot.png",
+ YoutubeUrl = (string?)null,
+ GameUrl = (string?)null,
+ MinPlayers = (int?)null,
+ MaxPlayers = (int?)null
+ });
+
+ update.EnsureSuccessStatusCode();
+ }
+
[Fact]
public async Task Get_all_requires_vote_phase()
{
diff --git a/TESTS.md b/TESTS.md
index 0c12478..ad39e4a 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -55,7 +55,7 @@ stateDiagram-v2
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
-- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
+- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged.
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
diff --git a/package.json b/package.json
index 6c1b0d3..1eb02fc 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,8 @@
"type": "module",
"scripts": {
"lint": "eslint \"wwwroot/**/*.js\"",
- "format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"",
- "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\""
+ "format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"",
+ "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
},
"devDependencies": {
"@eslint/js": "9.21.0",
diff --git a/wwwroot/app.js b/wwwroot/app.js
index 1a94be1..473e22f 100644
--- a/wwwroot/app.js
+++ b/wwwroot/app.js
@@ -1,246 +1,282 @@
-import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
+import {
+ t,
+ setLanguage,
+ getLanguage,
+ initI18n,
+ onLanguageChange,
+ faqMarkdown,
+} from "./js/i18n.js";
import { state, clearUserState } from "./js/state.js";
import { toast } from "./js/dom.js";
import {
- handleAuthError,
- renderWelcome,
- renderPhasePill,
- renderCounts,
- renderMySuggestions,
- renderAllSuggestions,
- renderVotes,
- syncVoteScores,
- renderResults,
- renderPhaseTitles,
- updatePhaseNav,
- configureUiRuntime,
+ handleAuthError,
+ renderWelcome,
+ renderPhasePill,
+ renderCounts,
+ renderMySuggestions,
+ renderAllSuggestions,
+ renderVotes,
+ syncVoteScores,
+ renderResults,
+ renderPhaseTitles,
+ updatePhaseNav,
+ configureUiRuntime,
} from "./js/ui.js";
-import {
- loadSuggestData,
- loadVoteData,
- refreshPhaseData,
-} from "./js/data.js";
+import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
-const REFRESH_INTERVAL_MS = 4000;
+const REFRESH_MIN_MS = 3000;
+const REFRESH_MAX_MS = 20000;
let refreshInFlight = null;
let refreshTimerId = null;
let refreshSchedulerStarted = false;
+let unchangedRefreshCycles = 0;
+let nextRefreshDelayMs = REFRESH_MIN_MS;
async function runSerializedRefresh() {
- if (refreshInFlight) return refreshInFlight;
- refreshInFlight = refreshPhaseData().finally(() => {
- refreshInFlight = null;
- });
- return refreshInFlight;
+ if (refreshInFlight) return refreshInFlight;
+ refreshInFlight = refreshPhaseData().finally(() => {
+ refreshInFlight = null;
+ });
+ return refreshInFlight;
}
async function refreshWithUiErrorHandling() {
- try {
- await runSerializedRefresh();
- } catch (err) {
- if (!handleAuthError(err, clearUserState)) toast(err.message, true);
- }
+ try {
+ const changed = await runSerializedRefresh();
+ updateRefreshCadence(changed === true);
+ } catch (err) {
+ // Back off after transient failures to avoid hammering server/dependencies.
+ nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
+ if (!handleAuthError(err, clearUserState)) toast(err.message, true);
+ }
}
function scheduleNextRefresh() {
- refreshTimerId = window.setTimeout(async () => {
- if (!document.hidden && !state.adminStatusSelectActive) {
- await refreshWithUiErrorHandling();
- }
- scheduleNextRefresh();
- }, REFRESH_INTERVAL_MS);
+ refreshTimerId = window.setTimeout(async () => {
+ if (!document.hidden && !state.adminStatusSelectActive) {
+ await refreshWithUiErrorHandling();
+ }
+ scheduleNextRefresh();
+ }, nextRefreshDelayMs);
}
function startRefreshScheduler() {
- if (refreshSchedulerStarted) return;
- refreshSchedulerStarted = true;
+ if (refreshSchedulerStarted) return;
+ refreshSchedulerStarted = true;
- document.addEventListener("visibilitychange", () => {
- if (!document.hidden && !state.adminStatusSelectActive) {
- refreshWithUiErrorHandling();
+ document.addEventListener("visibilitychange", () => {
+ if (!document.hidden && !state.adminStatusSelectActive) {
+ unchangedRefreshCycles = 0;
+ nextRefreshDelayMs = baseRefreshDelayForPhase();
+ refreshWithUiErrorHandling();
+ }
+ });
+
+ if (refreshTimerId !== null) {
+ window.clearTimeout(refreshTimerId);
}
- });
+ scheduleNextRefresh();
+}
- if (refreshTimerId !== null) {
- window.clearTimeout(refreshTimerId);
- }
- scheduleNextRefresh();
+function updateRefreshCadence(changed) {
+ const base = baseRefreshDelayForPhase();
+ if (changed) {
+ unchangedRefreshCycles = 0;
+ nextRefreshDelayMs = base;
+ return;
+ }
+
+ unchangedRefreshCycles = Math.min(unchangedRefreshCycles + 1, 8);
+ const growth = Math.pow(1.35, unchangedRefreshCycles);
+ nextRefreshDelayMs = Math.min(Math.round(base * growth), REFRESH_MAX_MS);
+}
+
+function baseRefreshDelayForPhase() {
+ switch (state.phase) {
+ case "Vote":
+ return REFRESH_MIN_MS;
+ case "Suggest":
+ return 5000;
+ case "Results":
+ return 7000;
+ default:
+ return 5000;
+ }
}
configureUiRuntime({
- refreshPhaseData: runSerializedRefresh,
- loadSuggestData,
- loadVoteData,
- handleAuthError: (err) => handleAuthError(err, clearUserState),
+ refreshPhaseData: runSerializedRefresh,
+ loadSuggestData,
+ loadVoteData,
+ handleAuthError: (err) => handleAuthError(err, clearUserState),
});
function setupHandlers() {
- setupAuthHandlers({ runSerializedRefresh });
- setupAdminHandlers({ runSerializedRefresh });
- setupVoteNavigationHandlers({ runSerializedRefresh });
- setupLanguageSwitchers();
+ setupAuthHandlers({ runSerializedRefresh });
+ setupAdminHandlers({ runSerializedRefresh });
+ setupVoteNavigationHandlers({ runSerializedRefresh });
+ setupLanguageSwitchers();
- onLanguageChange(() => {
- updateLanguageButtons();
- renderWelcome();
- renderPhasePill();
- renderCounts();
- renderPhaseTitles();
- renderMySuggestions();
- renderAllSuggestions();
- if (state.phase === "Vote") {
- renderVotes();
- state.votesRendered = true;
- syncVoteScores();
- }
- if (state.phase === "Results") {
- renderResults();
- }
- updatePhaseNav();
- });
+ onLanguageChange(() => {
+ updateLanguageButtons();
+ renderWelcome();
+ renderPhasePill();
+ renderCounts();
+ renderPhaseTitles();
+ renderMySuggestions();
+ renderAllSuggestions();
+ if (state.phase === "Vote") {
+ renderVotes();
+ state.votesRendered = true;
+ syncVoteScores();
+ }
+ if (state.phase === "Results") {
+ renderResults();
+ }
+ updatePhaseNav();
+ });
- document.querySelectorAll(".help-chip").forEach((chip) => {
- chip.addEventListener("click", () => openFaqModal());
- });
+ document.querySelectorAll(".help-chip").forEach((chip) => {
+ chip.addEventListener("click", () => openFaqModal());
+ });
}
async function main() {
- await initI18n();
- setupHandlers();
- await refreshWithUiErrorHandling();
- startRefreshScheduler();
+ await initI18n();
+ setupHandlers();
+ await refreshWithUiErrorHandling();
+ startRefreshScheduler();
}
main();
function updateLanguageButtons() {
- document.querySelectorAll(".lang-button").forEach((btn) => {
- btn.textContent = "🌐";
- btn.title = t("lang.label");
- btn.setAttribute("aria-label", t("lang.label"));
- });
+ document.querySelectorAll(".lang-button").forEach((btn) => {
+ btn.textContent = "🌐";
+ btn.title = t("lang.label");
+ btn.setAttribute("aria-label", t("lang.label"));
+ });
}
function setupLanguageSwitchers() {
- const switches = document.querySelectorAll(".lang-switch");
- const closeAll = () =>
- switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden"));
+ const switches = document.querySelectorAll(".lang-switch");
+ const closeAll = () =>
+ switches.forEach((wrap) =>
+ wrap.querySelector(".lang-menu")?.classList.add("hidden"),
+ );
- switches.forEach((wrap) => {
- const btn = wrap.querySelector(".lang-button");
- const menu = wrap.querySelector(".lang-menu");
- if (!btn || !menu) return;
- btn.addEventListener("click", (e) => {
- e.preventDefault();
- const isHidden = menu.classList.contains("hidden");
- closeAll();
- if (isHidden) menu.classList.remove("hidden");
+ switches.forEach((wrap) => {
+ const btn = wrap.querySelector(".lang-button");
+ const menu = wrap.querySelector(".lang-menu");
+ if (!btn || !menu) return;
+ btn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const isHidden = menu.classList.contains("hidden");
+ closeAll();
+ if (isHidden) menu.classList.remove("hidden");
+ });
+ menu.querySelectorAll("[data-lang]").forEach((item) =>
+ item.addEventListener("click", () => {
+ const lang = item.dataset.lang;
+ if (lang) setLanguage(lang);
+ closeAll();
+ }),
+ );
});
- menu.querySelectorAll("[data-lang]").forEach((item) =>
- item.addEventListener("click", () => {
- const lang = item.dataset.lang;
- if (lang) setLanguage(lang);
- closeAll();
- }),
- );
- });
- document.addEventListener("click", (e) => {
- if (!e.target.closest(".lang-switch")) closeAll();
- });
+ document.addEventListener("click", (e) => {
+ if (!e.target.closest(".lang-switch")) closeAll();
+ });
- updateLanguageButtons();
+ updateLanguageButtons();
}
function markdownToHtml(md) {
- const lines = md.trim().split(/\r?\n/);
- const html = [];
- let inList = false;
- let inParagraph = false;
+ const lines = md.trim().split(/\r?\n/);
+ const html = [];
+ let inList = false;
+ let inParagraph = false;
- const escapeHtml = (text) =>
- text
- .replace(/&/g, "&")
- .replace(//g, ">");
+ const escapeHtml = (text) =>
+ text.replace(/&/g, "&").replace(//g, ">");
- const formatInline = (text) =>
- escapeHtml(text)
- .replace(/\*\*(.+?)\*\*/g, "$1")
- .replace(/`([^`]+)`/g, "$1");
+ const formatInline = (text) =>
+ escapeHtml(text)
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ .replace(/`([^`]+)`/g, "$1");
- const closeParagraph = () => {
- if (inParagraph) {
- html.push("
");
- inParagraph = false;
- }
- };
+ const closeParagraph = () => {
+ if (inParagraph) {
+ html.push("");
+ inParagraph = false;
+ }
+ };
- const closeList = () => {
- if (inList) {
- html.push("");
- inList = false;
- }
- };
+ const closeList = () => {
+ if (inList) {
+ html.push("");
+ inList = false;
+ }
+ };
- lines.forEach((rawLine) => {
- const line = rawLine.trimEnd();
- const trimmed = line.trim();
- if (!trimmed) {
- closeParagraph();
- closeList();
- return;
- }
+ lines.forEach((rawLine) => {
+ const line = rawLine.trimEnd();
+ const trimmed = line.trim();
+ if (!trimmed) {
+ closeParagraph();
+ closeList();
+ return;
+ }
- if (/^-{5,}$/.test(trimmed)) {
- closeParagraph();
- closeList();
- html.push('
');
- return;
- }
+ if (/^-{5,}$/.test(trimmed)) {
+ closeParagraph();
+ closeList();
+ html.push('
');
+ return;
+ }
- const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
- if (heading) {
- closeParagraph();
- closeList();
- const level = heading[1].length;
- const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
- html.push(`<${tag}>${formatInline(heading[2].trim())}${tag}>`);
- return;
- }
+ const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
+ if (heading) {
+ closeParagraph();
+ closeList();
+ const level = heading[1].length;
+ const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
+ html.push(`<${tag}>${formatInline(heading[2].trim())}${tag}>`);
+ return;
+ }
- if (/^[*-]\s+/.test(trimmed)) {
- closeParagraph();
- if (!inList) {
- html.push("");
- inList = true;
- }
- const text = trimmed.replace(/^[*-]\s+/, "");
- html.push(`- ${formatInline(text)}
`);
- return;
- }
+ if (/^[*-]\s+/.test(trimmed)) {
+ closeParagraph();
+ if (!inList) {
+ html.push("");
+ inList = true;
+ }
+ const text = trimmed.replace(/^[*-]\s+/, "");
+ html.push(`- ${formatInline(text)}
`);
+ return;
+ }
- if (!inParagraph) {
- html.push("");
- inParagraph = true;
- }
- html.push(formatInline(trimmed));
- });
+ if (!inParagraph) {
+ html.push("
");
+ inParagraph = true;
+ }
+ html.push(formatInline(trimmed));
+ });
- closeParagraph();
- closeList();
- return html.join("\n");
+ closeParagraph();
+ closeList();
+ return html.join("\n");
}
function openFaqModal() {
- const overlay = document.createElement("div");
- overlay.className = "edit-modal";
- const panel = document.createElement("div");
- panel.className = "edit-panel faq-panel";
- panel.innerHTML = `
+ const overlay = document.createElement("div");
+ overlay.className = "edit-modal";
+ const panel = document.createElement("div");
+ panel.className = "edit-panel faq-panel";
+ panel.innerHTML = `
`;
- const list = panel.querySelector(".faq-list");
- const lang = getLanguage();
- const md = faqMarkdown[lang] ?? faqMarkdown.en;
- list.innerHTML = markdownToHtml(md);
+ const list = panel.querySelector(".faq-list");
+ const lang = getLanguage();
+ const md = faqMarkdown[lang] ?? faqMarkdown.en;
+ list.innerHTML = markdownToHtml(md);
- const close = () => overlay.remove();
- overlay.addEventListener("click", (e) => {
- if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close();
- });
+ const close = () => overlay.remove();
+ overlay.addEventListener("click", (e) => {
+ if (
+ e.target.classList.contains("edit-modal") ||
+ e.target.classList.contains("lightbox-close")
+ )
+ close();
+ });
- overlay.appendChild(panel);
- document.body.appendChild(overlay);
+ overlay.appendChild(panel);
+ document.body.appendChild(overlay);
}
diff --git a/wwwroot/index.html b/wwwroot/index.html
index ab63a91..dea666c 100644
--- a/wwwroot/index.html
+++ b/wwwroot/index.html
@@ -99,6 +99,7 @@
+
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js
index e42f9bd..34b712f 100644
--- a/wwwroot/js/api.js
+++ b/wwwroot/js/api.js
@@ -5,77 +5,107 @@ const basePath = normalizeBase(rawBase);
const withBase = (path) => `${basePath}${path}`;
function normalizeBase(value) {
- if (!value) return "";
- if (!value.startsWith("/")) return `/${value}`;
- return value.endsWith("/") ? value.slice(0, -1) : value;
+ if (!value) return "";
+ if (!value.startsWith("/")) return `/${value}`;
+ return value.endsWith("/") ? value.slice(0, -1) : value;
}
async function request(path, { method = "GET", body } = {}) {
- const res = await fetch(withBase(path), {
- method,
- credentials: "same-origin",
- headers: defaultHeaders,
- body: body ? JSON.stringify(body) : undefined,
- });
+ const res = await fetch(withBase(path), {
+ method,
+ credentials: "same-origin",
+ headers: defaultHeaders,
+ body: body ? JSON.stringify(body) : undefined,
+ });
- if (!res.ok) {
- let msg = `${res.status}`;
- try {
- const data = await res.json();
- msg = data.error || data.detail || data.title || JSON.stringify(data);
- } catch { /* ignore */ }
- const err = new Error(msg);
- err.status = res.status;
- throw err;
- }
- return res.status === 204 ? null : res.json();
+ if (!res.ok) {
+ let msg = `${res.status}`;
+ try {
+ const data = await res.json();
+ msg =
+ data.error || data.detail || data.title || JSON.stringify(data);
+ } catch {
+ /* ignore */
+ }
+ const err = new Error(msg);
+ err.status = res.status;
+ throw err;
+ }
+ return res.status === 204 ? null : res.json();
}
export const api = {
- state: () => request("/api/state"),
- me: () => request("/api/me"),
- authOptions: () => request("/api/auth/options"),
- register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
- login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
- logout: () => request("/api/auth/logout", { method: "POST" }),
+ state: () => request("/api/state"),
+ me: () => request("/api/me"),
+ authOptions: () => request("/api/auth/options"),
+ register: (payload) =>
+ request("/api/auth/register", { method: "POST", body: payload }),
+ login: (payload) =>
+ request("/api/auth/login", { method: "POST", body: payload }),
+ logout: () => request("/api/auth/logout", { method: "POST" }),
- mySuggestions: () => request("/api/suggestions/mine"),
- createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
- deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }),
- updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }),
- allSuggestions: () => request("/api/suggestions/all"),
+ mySuggestions: () => request("/api/suggestions/mine"),
+ createSuggestion: (payload) =>
+ request("/api/suggestions", { method: "POST", body: payload }),
+ deleteSuggestion: (id) =>
+ request(`/api/suggestions/${id}`, { method: "DELETE" }),
+ updateSuggestion: (id, payload) =>
+ request(`/api/suggestions/${id}`, { method: "PUT", body: payload }),
+ allSuggestions: () => request("/api/suggestions/all"),
- myVotes: () => request("/api/votes/mine"),
- vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
- finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }),
+ myVotes: () => request("/api/votes/mine"),
+ vote: (suggestionId, score) =>
+ request("/api/votes", {
+ method: "POST",
+ body: { suggestionId, score },
+ }),
+ finalizeVotes: (final) =>
+ request("/api/votes/finalize", { method: "POST", body: { final } }),
- results: () => request("/api/results"),
- nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
- prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
+ results: () => request("/api/results"),
+ nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
+ prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
};
export const adminApi = {
- setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
- voteStatus: () => request("/api/admin/vote-status"),
- reset: (password) =>
- request("/api/admin/reset", { method: "POST", body: { password } }),
- factoryReset: (password) =>
- request("/api/admin/factory-reset", { method: "POST", body: { password } }),
- grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
- setPlayerAdmin: (playerId, isAdmin) =>
- request("/api/admin/player-admin", {
- method: "POST",
- body: { playerId, isAdmin },
- }),
- setPlayerPhase: (playerId, phase) =>
- request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
- deletePlayer: (playerId, password) =>
- request(`/api/admin/players/${playerId}`, {
- method: "DELETE",
- body: { password },
- }),
- linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
- request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
- unlinkSuggestions: (suggestionId) =>
- request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }),
+ setResultsOpen: (resultsOpen) =>
+ request("/api/admin/results", {
+ method: "POST",
+ body: { resultsOpen },
+ }),
+ voteStatus: () => request("/api/admin/vote-status"),
+ reset: (password) =>
+ request("/api/admin/reset", { method: "POST", body: { password } }),
+ factoryReset: (password) =>
+ request("/api/admin/factory-reset", {
+ method: "POST",
+ body: { password },
+ }),
+ grantJoker: (playerId) =>
+ request("/api/admin/joker", { method: "POST", body: { playerId } }),
+ setPlayerAdmin: (playerId, isAdmin) =>
+ request("/api/admin/player-admin", {
+ method: "POST",
+ body: { playerId, isAdmin },
+ }),
+ setPlayerPhase: (playerId, phase) =>
+ request("/api/admin/player-phase", {
+ method: "POST",
+ body: { playerId, phase },
+ }),
+ deletePlayer: (playerId, password) =>
+ request(`/api/admin/players/${playerId}`, {
+ method: "DELETE",
+ body: { password },
+ }),
+ linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
+ request("/api/admin/link-suggestions", {
+ method: "POST",
+ body: { sourceSuggestionId, targetSuggestionId },
+ }),
+ unlinkSuggestions: (suggestionId) =>
+ request("/api/admin/unlink-suggestions", {
+ method: "POST",
+ body: { suggestionId },
+ }),
};
diff --git a/wwwroot/js/app-auth-handlers.js b/wwwroot/js/app-auth-handlers.js
index 86402bf..03a8327 100644
--- a/wwwroot/js/app-auth-handlers.js
+++ b/wwwroot/js/app-auth-handlers.js
@@ -114,6 +114,7 @@ function setupLoginFormHandlers({
if (err?.status === 401)
return toast(t("auth.invalidCredentials"), true);
if (handleAuthError(err, clearUserState)) return;
+ toast(err?.message || t("toast.unexpected"), true);
}
});
}
diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js
index 5d8284c..652ff30 100644
--- a/wwwroot/js/data.js
+++ b/wwwroot/js/data.js
@@ -1,5 +1,20 @@
import { api, adminApi } from "./api.js";
-import { handleAuthError, renderAllSuggestions, renderCounts, renderMySuggestions, renderPhasePill, renderPhaseTitles, renderResults, renderVotes, renderWelcome, setAuthUI, syncVoteScores, updatePhaseNav, openResultsRelockModal, openSuggestionsChangedModal } from "./ui.js";
+import {
+ handleAuthError,
+ renderAllSuggestions,
+ renderCounts,
+ renderMySuggestions,
+ renderPhasePill,
+ renderPhaseTitles,
+ renderResults,
+ renderVotes,
+ renderWelcome,
+ setAuthUI,
+ syncVoteScores,
+ updatePhaseNav,
+ openResultsRelockModal,
+ openSuggestionsChangedModal,
+} from "./ui.js";
import { state, clearUserState } from "./state.js";
export async function loadState() {
@@ -86,18 +101,26 @@ export async function loadResults() {
}
export async function refreshPhaseData() {
+ const before = buildRefreshSnapshot();
try {
const prevPhase = state.phase;
const prevResultsOpen = state.resultsOpen;
await loadState();
- await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]);
+ await Promise.all([
+ loadSuggestData(),
+ loadSuggestionsData(),
+ loadResults(),
+ ]);
if (state.phase === "Vote") {
if (!state.votesRendered) await loadVoteData();
} else {
state.votesRendered = false;
await loadVoteData();
}
- if (state.me?.isAdmin) {
+ const adminCard = document.getElementById("admin-card");
+ const adminPanelVisible =
+ !!adminCard && !adminCard.classList.contains("hidden");
+ if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus();
}
if (
@@ -109,12 +132,34 @@ export async function refreshPhaseData() {
openResultsRelockModal();
}
updatePhaseNav();
+ const after = buildRefreshSnapshot();
+ return before !== after;
} catch (err) {
if (handleAuthError(err, clearUserState)) return;
throw err;
}
}
+function buildRefreshSnapshot() {
+ return JSON.stringify({
+ phase: state.phase,
+ resultsOpen: state.resultsOpen,
+ votesFinal: state.votesFinal,
+ hasJoker: state.hasJoker,
+ counts: state.counts
+ ? [
+ state.counts.players,
+ state.counts.suggestions,
+ state.counts.votes,
+ ]
+ : null,
+ mineCount: state.mySuggestions?.length ?? 0,
+ allSig: state.allSuggestionsSig ?? "",
+ voteCount: state.myVotes?.length ?? 0,
+ resultsCount: state.results?.length ?? 0,
+ });
+}
+
export function signatureSuggestions(list) {
return JSON.stringify(
list.map((s) => [
diff --git a/wwwroot/js/dom.js b/wwwroot/js/dom.js
index fec73c3..53f0474 100644
--- a/wwwroot/js/dom.js
+++ b/wwwroot/js/dom.js
@@ -1,6 +1,7 @@
export const $ = (id) => document.getElementById(id);
-const toastEl = typeof document !== "undefined" ? document.getElementById("toast") : null;
+const toastEl =
+ typeof document !== "undefined" ? document.getElementById("toast") : null;
export function toast(msg, isError = false) {
if (!toastEl) return;
diff --git a/wwwroot/js/suggestions-ui.js b/wwwroot/js/suggestions-ui.js
index 8b17bdd..2b9b640 100644
--- a/wwwroot/js/suggestions-ui.js
+++ b/wwwroot/js/suggestions-ui.js
@@ -49,16 +49,6 @@ export function renderMySuggestions() {
export function renderAllSuggestions() {
renderAdminLinker();
- const list = $("all-suggestions");
- if (!list) return;
- list.innerHTML = "";
- const allowEdit = true;
- const allowDelete = !!state.me?.isAdmin;
- sortByName(state.allSuggestions).forEach((s) =>
- list.appendChild(
- buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
- ),
- );
renderPhaseTitles();
}
diff --git a/wwwroot/js/votes-ui.js b/wwwroot/js/votes-ui.js
index 838fb2e..7f719d1 100644
--- a/wwwroot/js/votes-ui.js
+++ b/wwwroot/js/votes-ui.js
@@ -261,15 +261,6 @@ export function updatePhaseNav() {
}
}
- const voteNext = $("nav-vote-next");
- if (voteNext) {
- const locked = !state.resultsOpen && !isAdmin;
- voteNext.disabled = locked;
- voteNext.textContent = locked
- ? t("nav.waitingForResults")
- : t("nav.next");
- }
-
const adminResultsToggle = $("results-open");
if (adminResultsToggle) {
adminResultsToggle.textContent = state.resultsOpen
From de9123b260d3c86cf0ee764d8f08145f873404ca Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 22:33:09 +0100
Subject: [PATCH 08/11] Add local deploy wrappers and ignore private FTP
profile
---
.gitignore | 1 +
IIS.md | 1 +
README.md | 4 ++--
deploy.ps1 | 14 ++++++++++++++
scripts/deploy-ftp1.ps1 | 14 ++++++++++++++
5 files changed, 32 insertions(+), 2 deletions(-)
create mode 100644 deploy.ps1
create mode 100644 scripts/deploy-ftp1.ps1
diff --git a/.gitignore b/.gitignore
index ac1de16..0e6abc3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ node_modules/
# User secrets / configs
appsettings.Development.json
+scripts/deploy-ftp.profile.psd1
*.user
*.suo
diff --git a/IIS.md b/IIS.md
index 02b64ed..997d8e1 100644
--- a/IIS.md
+++ b/IIS.md
@@ -23,6 +23,7 @@
- Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles.
- Frontend base path: set `` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).
- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`.
+- Shortcut command: run `pwsh ./deploy.ps1` from repo root to deploy with the local profile directly.
- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs.
## Permissions
diff --git a/README.md b/README.md
index 46bb622..ed8e0e5 100644
--- a/README.md
+++ b/README.md
@@ -43,8 +43,8 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- `Contracts/`: request/response DTOs.
- `wwwroot/`: static frontend assets.
- `GameList.Tests/`: integration and helper tests.
-- `scripts/`: deployment scripts.
- `scripts/deploy-ftp.ps1` is profile-driven via `scripts/deploy-ftp.profile.sample.psd1`.
+- `scripts/`: deployment scripts (`scripts/deploy-ftp.ps1`, `scripts/deploy-ftp1.ps1`).
+- `deploy.ps1`: local shortcut wrapper that runs FTP deploy using `scripts/deploy-ftp.profile.psd1`.
## Operations
diff --git a/deploy.ps1 b/deploy.ps1
new file mode 100644
index 0000000..11de202
--- /dev/null
+++ b/deploy.ps1
@@ -0,0 +1,14 @@
+param(
+ [string]$Password,
+ [switch]$SkipRecycle,
+ [switch]$SkipMigrations
+)
+
+$deployScript = Join-Path $PSScriptRoot "scripts\deploy-ftp1.ps1"
+$profilePath = Join-Path $PSScriptRoot "scripts\deploy-ftp.profile.psd1"
+
+& $deployScript `
+ -ProfilePath $profilePath `
+ -Password $Password `
+ -SkipRecycle:$SkipRecycle `
+ -SkipMigrations:$SkipMigrations
diff --git a/scripts/deploy-ftp1.ps1 b/scripts/deploy-ftp1.ps1
new file mode 100644
index 0000000..91e4f0c
--- /dev/null
+++ b/scripts/deploy-ftp1.ps1
@@ -0,0 +1,14 @@
+param(
+ [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
+ [string]$Password,
+ [switch]$SkipRecycle,
+ [switch]$SkipMigrations
+)
+
+$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
+
+& $scriptPath `
+ -ProfilePath $ProfilePath `
+ -Password $Password `
+ -SkipRecycle:$SkipRecycle `
+ -SkipMigrations:$SkipMigrations
From 6eea5dcf328101f94233b9edb30cca5952328941 Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 22:38:08 +0100
Subject: [PATCH 09/11] Fix deploy profile data file compatibility
---
deploy.ps1 | 9 ++++++---
scripts/deploy-ftp.profile.sample.psd1 | 2 +-
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/deploy.ps1 b/deploy.ps1
index 11de202..bf03d4c 100644
--- a/deploy.ps1
+++ b/deploy.ps1
@@ -4,10 +4,13 @@ param(
[switch]$SkipMigrations
)
-$deployScript = Join-Path $PSScriptRoot "scripts\deploy-ftp1.ps1"
-$profilePath = Join-Path $PSScriptRoot "scripts\deploy-ftp.profile.psd1"
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
-& $deployScript `
+$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
+$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
+
+& $scriptPath `
-ProfilePath $profilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
diff --git a/scripts/deploy-ftp.profile.sample.psd1 b/scripts/deploy-ftp.profile.sample.psd1
index 3b9aa41..5e20b98 100644
--- a/scripts/deploy-ftp.profile.sample.psd1
+++ b/scripts/deploy-ftp.profile.sample.psd1
@@ -3,7 +3,7 @@
ProjectPath = "..\GameList.csproj"
Configuration = "Release"
Runtime = "win-x64"
- PublishDir = "$env:TEMP\GameList-publish"
+ PublishDir = "%TEMP%\GameList-publish"
SelfContained = $false
# Required sync settings
From bc0245c1d40e554ac9f99387f2f625ef03e992c1 Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 22:40:29 +0100
Subject: [PATCH 10/11] Fix deploy password prompt and WinRM app pool args
---
scripts/deploy-ftp.ps1 | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/scripts/deploy-ftp.ps1 b/scripts/deploy-ftp.ps1
index 1c68717..bdea593 100644
--- a/scripts/deploy-ftp.ps1
+++ b/scripts/deploy-ftp.ps1
@@ -146,8 +146,18 @@ $recycleAppPool = $recycleAppPool -and -not $SkipRecycle
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
-$passwordForSession = if ($useStoredSession) { "" } else { Read-PlainOrPrompt -Value ($Password ?? $passwordFromEnv) -Prompt "FTP password" -Secure $true }
-$passwordForWinRm = if ($recycleAppPool -or $runEfMigrations) { Read-PlainOrPrompt -Value ($Password ?? $passwordFromEnv) -Prompt "WinRM password" -Secure $true } else { "" }
+$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
+$needsFtpPassword = -not $useStoredSession
+$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
+$sharedPassword = ""
+
+if ($needsFtpPassword -or $needsWinRmPassword) {
+ $prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
+ $sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
+}
+
+$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
+$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
Assert-Tool "dotnet"
Assert-Tool $winScpPath
@@ -166,14 +176,16 @@ dotnet @publishArgs
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"
+ $appPoolName = [string]$config.AppPoolName
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
+ param($poolName)
Import-Module WebAdministration
- Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
+ Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
- }
+ } -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
@@ -209,9 +221,10 @@ if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
+ param($poolName)
Import-Module WebAdministration
- Start-WebAppPool -Name $using:AppPoolName
- }
+ Start-WebAppPool -Name $poolName
+ } -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)."
From 018fc47d9c4747ac79119f958e8cbe3959fd4cfd Mon Sep 17 00:00:00 2001
From: Frank Tovar
Date: Sun, 8 Feb 2026 22:42:47 +0100
Subject: [PATCH 11/11] Ignore test result coverage artifacts
---
.gitignore | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.gitignore b/.gitignore
index 0e6abc3..646165f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,10 @@ scripts/deploy-ftp.profile.psd1
# Logs
*.log
+# Test results / coverage artifacts
+TestResults/
+coverage.cobertura.xml
+
# SQLite data
App_Data/
*.db