Merge branch 'chore/review-remediation-2026-02-08'
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -40,4 +40,7 @@ jobs:
|
|||||||
run: dotnet build GameList.sln --no-restore -warnaserror
|
run: dotnet build GameList.sln --no-restore -warnaserror
|
||||||
|
|
||||||
- name: Test
|
- 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
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,12 +11,17 @@ node_modules/
|
|||||||
|
|
||||||
# User secrets / configs
|
# User secrets / configs
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
|
scripts/deploy-ftp.profile.psd1
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Test results / coverage artifacts
|
||||||
|
TestResults/
|
||||||
|
coverage.cobertura.xml
|
||||||
|
|
||||||
# SQLite data
|
# SQLite data
|
||||||
App_Data/
|
App_Data/
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
3
API.md
3
API.md
@@ -11,6 +11,7 @@ POST /api/auth/logout
|
|||||||
Display names are set during registration and are immutable afterward.
|
Display names are set during registration and are immutable afterward.
|
||||||
Passwords must be 8-128 chars and contain uppercase, lowercase and number.
|
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`.
|
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)
|
## State (requires auth)
|
||||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
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)
|
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)
|
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
|
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)
|
## Votes (requires auth + Vote phase)
|
||||||
GET /api/votes/mine
|
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 — 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)
|
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)
|
## Results (requires auth + Results phase + resultsOpen)
|
||||||
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
namespace GameList.Contracts;
|
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);
|
||||||
|
|||||||
@@ -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<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
|
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<int>? LinkedIds = null, IReadOnlyList<string>? 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<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||||
|
|
||||||
public record VoteRequest(int SuggestionId, int Score);
|
public record VoteRequest(int SuggestionId, int Score);
|
||||||
|
|
||||||
|
public record VoteRecordDto(int SuggestionId, int Score);
|
||||||
|
|
||||||
public record ResultsOpenRequest(bool ResultsOpen);
|
public record ResultsOpenRequest(bool ResultsOpen);
|
||||||
|
|
||||||
public record VoteFinalizeRequest(bool Final);
|
public record VoteFinalizeRequest(bool Final);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||||
builder.Property(p => p.IsOwner).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.HasJoker).HasDefaultValue(false);
|
||||||
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||||
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
||||||
|
|||||||
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using GameList.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260208203323_HardenOwnerAndSuggestionInvariants")]
|
||||||
|
partial class HardenOwnerAndSuggestionInvariants
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("ResultsOpen")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AppState");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ResultsOpen = false,
|
||||||
|
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentPhase")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("HasJoker")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsOwner")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(24)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(24)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("GameUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Genre")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("MinPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentSuggestionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ScreenshotUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("YoutubeUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentSuggestionId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayerId");
|
||||||
|
|
||||||
|
b.ToTable("Suggestions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SuggestionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SuggestionId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayerId", "SuggestionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Votes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GameList.Domain.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class HardenOwnerAndSuggestionInvariants : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Players_IsOwner",
|
||||||
|
table: "Players");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,10 @@ namespace GameList.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsOwner")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("IsOwner = 1");
|
||||||
|
|
||||||
b.HasIndex("NormalizedUsername")
|
b.HasIndex("NormalizedUsername")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,34 @@ public static class AdminEndpoints
|
|||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
|
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
|
||||||
|
|
||||||
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-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) =>
|
||||||
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin));
|
{
|
||||||
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal sealed class AdminWorkflowService(AppDbContext db)
|
internal sealed class AdminWorkflowService(AppDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IResult> SetResultsOpenAsync(bool resultsOpen)
|
public async Task<ServiceResult<AdminResultsStateResponse>> SetResultsOpenAsync(bool resultsOpen)
|
||||||
{
|
{
|
||||||
var state = await db.AppState.SingleAsync();
|
var state = await db.AppState.SingleAsync();
|
||||||
state.ResultsOpen = resultsOpen;
|
state.ResultsOpen = resultsOpen;
|
||||||
@@ -29,81 +29,81 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
var currentState = await db.AppState.AsNoTracking().SingleAsync();
|
var currentState = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
|
return ServiceResult<AdminResultsStateResponse>.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> GetVoteStatusAsync()
|
public async Task<ServiceResult<VoteStatusResponse>> 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 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 waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||||
var ready = waiting.Count == 0;
|
var ready = waiting.Count == 0;
|
||||||
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
|
return ServiceResult<VoteStatusResponse>.Success(new VoteStatusResponse(voters, ready, waiting));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> GrantJokerAsync(Guid playerId)
|
public async Task<ServiceResult<AdminGrantJokerResponse>> GrantJokerAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.NotFoundError("Player not found.");
|
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||||
|
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker.");
|
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker."));
|
||||||
|
|
||||||
player.HasJoker = true;
|
player.HasJoker = true;
|
||||||
player.VotesFinal = false;
|
player.VotesFinal = false;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
return ServiceResult<AdminGrantJokerResponse>.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
public async Task<ServiceResult<AdminSetPlayerPhaseResponse>> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
||||||
{
|
{
|
||||||
if (phase != Phase.Suggest)
|
if (phase != Phase.Suggest)
|
||||||
return EndpointHelpers.BadRequestError("Only transition to Suggest is supported.");
|
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Only transition to Suggest is supported."));
|
||||||
|
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.NotFoundError("Player not found.");
|
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||||
|
|
||||||
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||||
if (currentPhase != Phase.Vote)
|
if (currentPhase != Phase.Vote)
|
||||||
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase.");
|
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase."));
|
||||||
|
|
||||||
player.CurrentPhase = Phase.Suggest;
|
player.CurrentPhase = Phase.Suggest;
|
||||||
player.VotesFinal = false;
|
player.VotesFinal = false;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
return ServiceResult<AdminSetPlayerPhaseResponse>.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
|
public async Task<ServiceResult<AdminSetPlayerAdminResponse>> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
|
||||||
{
|
{
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.NotFoundError("Player not found.");
|
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||||
|
|
||||||
if (player.IsOwner)
|
if (player.IsOwner)
|
||||||
return EndpointHelpers.BadRequestError("Owner permissions cannot be changed.");
|
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.BadRequest("Owner permissions cannot be changed."));
|
||||||
|
|
||||||
player.IsAdmin = isAdmin;
|
player.IsAdmin = isAdmin;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
|
return ServiceResult<AdminSetPlayerAdminResponse>.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
|
public async Task<ServiceResult<AdminDeletePlayerResponse>> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
|
||||||
{
|
{
|
||||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||||
if (passwordError is not null)
|
if (passwordError is not null)
|
||||||
return passwordError;
|
return ServiceResult<AdminDeletePlayerResponse>.Failure(passwordError);
|
||||||
|
|
||||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.NotFoundError("Player not found.");
|
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||||
if (player.IsOwner)
|
if (player.IsOwner)
|
||||||
return EndpointHelpers.BadRequestError("Owner account cannot be deleted.");
|
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.BadRequest("Owner account cannot be deleted."));
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -121,30 +121,30 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminDeletePlayerResponse(playerId));
|
return ServiceResult<AdminDeletePlayerResponse>.Success(new AdminDeletePlayerResponse(playerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
|
public async Task<ServiceResult<AdminLinkSuggestionsResponse>> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||||
|
|
||||||
if (sourceSuggestionId == targetSuggestionId)
|
if (sourceSuggestionId == targetSuggestionId)
|
||||||
return EndpointHelpers.BadRequestError("Pick two different games to link.");
|
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("Pick two different games to link."));
|
||||||
|
|
||||||
var suggestions = await db.Suggestions.ToListAsync();
|
var suggestions = await db.Suggestions.ToListAsync();
|
||||||
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
|
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
|
||||||
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
|
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
|
||||||
if (source is null || target is null)
|
if (source is null || target is null)
|
||||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||||
|
|
||||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
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))
|
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
|
||||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||||
|
|
||||||
if (sourceRoot == targetRoot)
|
if (sourceRoot == targetRoot)
|
||||||
return EndpointHelpers.BadRequestError("These games are already linked.");
|
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("These games are already linked."));
|
||||||
|
|
||||||
var affectedRootIds = new HashSet<int>
|
var affectedRootIds = new HashSet<int>
|
||||||
{
|
{
|
||||||
@@ -176,23 +176,23 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
|
return ServiceResult<AdminLinkSuggestionsResponse>.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
|
public async Task<ServiceResult<AdminUnlinkSuggestionsResponse>> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return ServiceResult<AdminUnlinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||||
|
|
||||||
var suggestions = await db.Suggestions.ToListAsync();
|
var suggestions = await db.Suggestions.ToListAsync();
|
||||||
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
|
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||||
|
|
||||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||||
|
|
||||||
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
|
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();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
public async Task<ServiceResult<AdminResetStateResponse>> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||||
{
|
{
|
||||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||||
if (passwordError is not null)
|
if (passwordError is not null)
|
||||||
return passwordError;
|
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -232,14 +232,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
public async Task<ServiceResult<AdminResetStateResponse>> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||||
{
|
{
|
||||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||||
if (passwordError is not null)
|
if (passwordError is not null)
|
||||||
return passwordError;
|
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -254,24 +254,24 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
|
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
private async Task<ServiceError?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
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);
|
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||||
if (admin is null)
|
if (admin is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return ServiceError.Unauthorized();
|
||||||
|
|
||||||
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
|
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
|
||||||
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
|
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
|
||||||
if (!verified)
|
if (!verified)
|
||||||
{
|
{
|
||||||
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
|
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);
|
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
|
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);
|
return EndpointHelpers.BadRequestError(registrationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ public static class AuthEndpoints
|
|||||||
if (exists)
|
if (exists)
|
||||||
return EndpointHelpers.ConflictError("Username already taken.");
|
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 expectedAdminKey = config["ADMIN_PASSWORD"];
|
||||||
var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey);
|
var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey);
|
||||||
if (wantsAdmin)
|
if (wantsAdmin)
|
||||||
@@ -68,7 +68,19 @@ public static class AuthEndpoints
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.Players.Add(player);
|
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)
|
if (isAdmin)
|
||||||
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
|
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
|
||||||
@@ -87,12 +99,12 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
|
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);
|
return EndpointHelpers.BadRequestError(loginError);
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
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");
|
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
|
||||||
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
|
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
|
||||||
@@ -123,4 +135,6 @@ public static class AuthEndpoints
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ internal static class AuthValidator
|
|||||||
|
|
||||||
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
|
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)
|
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
|
||||||
{
|
{
|
||||||
validated = default;
|
validated = default;
|
||||||
@@ -61,14 +61,14 @@ internal static class AuthValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength);
|
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;
|
error = string.Empty;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
|
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;
|
normalizedUsername = string.Empty;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
@@ -94,5 +94,5 @@ internal static class AuthValidator
|
|||||||
return true;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
@@ -9,6 +10,9 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal static class EndpointHelpers
|
internal static class EndpointHelpers
|
||||||
{
|
{
|
||||||
|
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
|
||||||
|
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
|
||||||
|
|
||||||
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
||||||
{
|
{
|
||||||
if (ctx.User.Identity?.IsAuthenticated != true)
|
if (ctx.User.Identity?.IsAuthenticated != true)
|
||||||
@@ -108,6 +112,36 @@ internal static class EndpointHelpers
|
|||||||
|
|
||||||
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
|
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
|
||||||
|
|
||||||
|
public static IResult ToHttpResult<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
return onSuccess(result.Value!);
|
||||||
|
|
||||||
|
return ToHttpError(result.Error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
|
||||||
|
{
|
||||||
|
if (result.IsSuccess)
|
||||||
|
return onSuccess();
|
||||||
|
|
||||||
|
return ToHttpError(result.Error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
private static IResult Problem(int statusCode, string title, string detail)
|
||||||
{
|
{
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
@@ -142,6 +176,18 @@ internal static class EndpointHelpers
|
|||||||
|| path.EndsWith(".avif", StringComparison.Ordinal);
|
|| 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()
|
public static HttpMessageHandler CreateImageValidationHandler()
|
||||||
{
|
{
|
||||||
return new SocketsHttpHandler
|
return new SocketsHttpHandler
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ public static class ResultsEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
return await service.GetResultsAsync(player.Id);
|
var result = await service.GetResultsAsync(player.Id);
|
||||||
|
return result.ToHttpResult(Results.Ok);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal sealed class ResultsWorkflowService(AppDbContext db)
|
internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IResult> GetResultsAsync(Guid playerId)
|
public async Task<ServiceResult<IReadOnlyList<ResultItemDto>>> GetResultsAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var appState = await db.AppState.AsNoTracking().SingleAsync();
|
var appState = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
if (!appState.ResultsOpen)
|
if (!appState.ResultsOpen)
|
||||||
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
|
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
|
||||||
|
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase != Phase.Results)
|
if (phase != Phase.Results)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
|
||||||
|
|
||||||
var results = await db
|
var results = await db
|
||||||
.Suggestions.AsNoTracking()
|
.Suggestions.AsNoTracking()
|
||||||
@@ -49,7 +49,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
|||||||
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
|
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
|
||||||
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
|
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
|
||||||
|
|
||||||
var shaped = results.Select(r =>
|
IReadOnlyList<ResultItemDto> shaped = results.Select(r =>
|
||||||
{
|
{
|
||||||
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
|
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
|
||||||
.Where(id => id != r.Id)
|
.Where(id => id != r.Id)
|
||||||
@@ -80,8 +80,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
|||||||
linkedIds,
|
linkedIds,
|
||||||
linkedTitles
|
linkedTitles
|
||||||
);
|
);
|
||||||
});
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(shaped);
|
return ServiceResult<IReadOnlyList<ResultItemDto>>.Success(shaped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
Endpoints/ServiceResult.cs
Normal file
36
Endpoints/ServiceResult.cs
Normal file
@@ -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>(T? Value, ServiceError? Error)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => Error is null;
|
||||||
|
|
||||||
|
public static ServiceResult<T> Success(T value) => new(value, null);
|
||||||
|
|
||||||
|
public static ServiceResult<T> Failure(ServiceError error) => new(default, error);
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ public static class StateEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||||
@@ -23,7 +24,8 @@ public static class StateEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||||
@@ -32,7 +34,8 @@ public static class StateEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||||
@@ -41,7 +44,8 @@ public static class StateEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
return await service.PrevPhaseAsync(player);
|
var result = await service.PrevPhaseAsync(player);
|
||||||
|
return result.ToHttpResult(Results.Ok);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal sealed class StateWorkflowService(AppDbContext db)
|
internal sealed class StateWorkflowService(AppDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IResult> GetStateAsync(Player player)
|
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
||||||
{
|
{
|
||||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
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());
|
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<StateSummaryResponse>.Success(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> GetMeAsync(Player player)
|
public async Task<ServiceResult<MeResponse>> GetMeAsync(Player player)
|
||||||
{
|
{
|
||||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
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<MeResponse>.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> NextPhaseAsync(Player player)
|
public async Task<ServiceResult<PhaseTransitionResponse>> NextPhaseAsync(Player player)
|
||||||
{
|
{
|
||||||
var appState = await db.AppState.SingleAsync();
|
var appState = await db.AppState.SingleAsync();
|
||||||
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
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);
|
var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id);
|
||||||
if (!hasSuggestions)
|
if (!hasSuggestions)
|
||||||
return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase.");
|
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Add at least one suggestion before entering the Vote phase."));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next == Phase.Results && !appState.ResultsOpen)
|
if (next == Phase.Results && !appState.ResultsOpen)
|
||||||
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
|
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
|
||||||
|
|
||||||
player.CurrentPhase = next;
|
player.CurrentPhase = next;
|
||||||
player.VotesFinal = false; // moving forward clears any prior finalize
|
player.VotesFinal = false; // moving forward clears any prior finalize
|
||||||
shouldSave = true;
|
shouldSave = true;
|
||||||
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -53,10 +53,10 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> PrevPhaseAsync(Player player)
|
public async Task<ServiceResult<PhaseTransitionResponse>> PrevPhaseAsync(Player player)
|
||||||
{
|
{
|
||||||
if (!player.IsAdmin)
|
if (!player.IsAdmin)
|
||||||
return EndpointHelpers.BadRequestError("Only admins can move backward.");
|
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Only admins can move backward."));
|
||||||
|
|
||||||
var appState = await db.AppState.SingleAsync();
|
var appState = await db.AppState.SingleAsync();
|
||||||
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||||
@@ -64,7 +64,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
||||||
player.VotesFinal = false;
|
player.VotesFinal = false;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Phase NextPhase(Phase current) => current switch
|
private static Phase NextPhase(Phase current) => current switch
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public static class SuggestEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||||
@@ -26,7 +27,7 @@ public static class SuggestEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
return await service.CreateAsync(
|
var result = await service.CreateAsync(
|
||||||
player.Id,
|
player.Id,
|
||||||
new SuggestionInput(
|
new SuggestionInput(
|
||||||
request.Name,
|
request.Name,
|
||||||
@@ -39,6 +40,8 @@ public static class SuggestEndpoints
|
|||||||
request.MaxPlayers
|
request.MaxPlayers
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
|
||||||
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
||||||
|
|
||||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
return await service.UpdateAsync(
|
var result = await service.UpdateAsync(
|
||||||
player.Id,
|
player.Id,
|
||||||
id,
|
id,
|
||||||
new SuggestionInput(
|
new SuggestionInput(
|
||||||
@@ -70,6 +74,8 @@ public static class SuggestEndpoints
|
|||||||
request.MaxPlayers
|
request.MaxPlayers
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return result.ToHttpResult(Results.Ok);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||||
@@ -78,7 +84,8 @@ public static class SuggestEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
return await service.GetAllAsync(player.Id);
|
var result = await service.GetAllAsync(player.Id);
|
||||||
|
return result.ToHttpResult(Results.Ok);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
internal static class SuggestionValidator
|
internal static class SuggestionValidator
|
||||||
{
|
{
|
||||||
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory)
|
private static readonly ConcurrentDictionary<string, (bool Reachable, DateTimeOffset ExpiresAt)> ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15);
|
||||||
|
private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
|
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
|
||||||
return "Name is required and must be <= 100 characters.";
|
return "Name is required and must be <= 100 characters.";
|
||||||
@@ -10,7 +16,7 @@ internal static class SuggestionValidator
|
|||||||
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
|
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
|
||||||
return "Screenshot URL must be http(s) and end with an image file extension.";
|
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).";
|
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))
|
if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
|
||||||
@@ -22,6 +28,21 @@ internal static class SuggestionValidator
|
|||||||
return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
|
return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> 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)
|
private static string? ValidatePlayers(int? minPlayers, int? maxPlayers)
|
||||||
{
|
{
|
||||||
if (minPlayers is null && maxPlayers is null)
|
if (minPlayers is null && maxPlayers is null)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
|
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
|
||||||
{
|
{
|
||||||
public async Task<IResult> GetMineAsync(Guid playerId)
|
public async Task<ServiceResult<IReadOnlyList<SuggestionDto>>> GetMineAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var mine = await db.Suggestions
|
var mine = await db.Suggestions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var ordered = mine
|
IReadOnlyList<SuggestionDto> ordered = mine
|
||||||
.OrderBy(s => s.CreatedAt)
|
.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<IReadOnlyList<SuggestionDto>>.Success(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input)
|
public async Task<ServiceResult<SuggestionCreatedResponse>> CreateAsync(Guid playerId, SuggestionInput input)
|
||||||
{
|
{
|
||||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
||||||
if (validationError is not null)
|
if (validationError is not null)
|
||||||
return EndpointHelpers.BadRequestError(validationError);
|
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||||
|
|
||||||
var playerState = await db.Players
|
var playerState = await db.Players
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
|
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
|
||||||
if (phase != Phase.Suggest && !usingJoker)
|
if (phase != Phase.Suggest && !usingJoker)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||||
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
|
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("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)
|
if (!usingJoker && existingCount >= 5)
|
||||||
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
|
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
|
||||||
|
|
||||||
var suggestion = new Suggestion
|
var suggestion = new Suggestion
|
||||||
{
|
{
|
||||||
@@ -81,21 +82,29 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
|
|
||||||
db.Suggestions.Add(suggestion);
|
db.Suggestions.Add(suggestion);
|
||||||
|
|
||||||
if (usingJoker)
|
try
|
||||||
{
|
{
|
||||||
await db.Players
|
await db.SaveChangesAsync();
|
||||||
.Where(p => p.Id == playerId)
|
|
||||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
|
if (usingJoker)
|
||||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
{
|
||||||
|
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 ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
return ServiceResult<SuggestionCreatedResponse>.Success(new SuggestionCreatedResponse(suggestion.Id));
|
||||||
await tx.CommitAsync();
|
|
||||||
|
|
||||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId)
|
public async Task<ServiceResult<Unit>> DeleteAsync(Guid playerId, int suggestionId)
|
||||||
{
|
{
|
||||||
var actor = await db.Players
|
var actor = await db.Players
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -111,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase != Phase.Suggest)
|
if (phase != Phase.Suggest)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return ServiceResult<Unit>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||||
}
|
}
|
||||||
|
|
||||||
var suggestion = isAdmin
|
var suggestion = isAdmin
|
||||||
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
|
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
|
||||||
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
|
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
|
||||||
if (suggestion == null)
|
if (suggestion == null)
|
||||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
return ServiceResult<Unit>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
@@ -131,15 +140,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
db.Suggestions.Remove(suggestion);
|
db.Suggestions.Remove(suggestion);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
return Results.NoContent();
|
return ServiceResult<Unit>.Success(default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
public async Task<ServiceResult<SuggestionUpdatedResponse>> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
||||||
{
|
{
|
||||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
|
||||||
if (validationError is not null)
|
|
||||||
return EndpointHelpers.BadRequestError(validationError);
|
|
||||||
|
|
||||||
var actor = await db.Players
|
var actor = await db.Players
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.Id == playerId)
|
.Where(p => p.Id == playerId)
|
||||||
@@ -151,17 +156,22 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
|
|
||||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
|
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
|
||||||
if (suggestion == null)
|
if (suggestion == null)
|
||||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
return ServiceResult<SuggestionUpdatedResponse>.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<SuggestionUpdatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||||
|
|
||||||
var isAdmin = actor.IsAdmin;
|
var isAdmin = actor.IsAdmin;
|
||||||
if (!isAdmin)
|
if (!isAdmin)
|
||||||
{
|
{
|
||||||
if (suggestion.PlayerId != playerId)
|
if (suggestion.PlayerId != playerId)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.Unauthorized());
|
||||||
|
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase == Phase.Results)
|
if (phase == Phase.Results)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||||
|
|
||||||
if (phase == Phase.Suggest)
|
if (phase == Phase.Suggest)
|
||||||
{
|
{
|
||||||
@@ -169,7 +179,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
}
|
}
|
||||||
else if (phase != Phase.Vote)
|
else if (phase != Phase.Vote)
|
||||||
{
|
{
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyEditableFields(suggestion, input);
|
ApplyEditableFields(suggestion, input);
|
||||||
@@ -182,7 +192,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new SuggestionUpdatedResponse(
|
return ServiceResult<SuggestionUpdatedResponse>.Success(new SuggestionUpdatedResponse(
|
||||||
suggestion.Id,
|
suggestion.Id,
|
||||||
suggestion.Name,
|
suggestion.Name,
|
||||||
suggestion.Genre,
|
suggestion.Genre,
|
||||||
@@ -195,11 +205,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> GetAllAsync(Guid playerId)
|
public async Task<ServiceResult<IReadOnlyList<SuggestionAllDto>>> GetAllAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase < Phase.Vote)
|
if (phase < Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||||
|
|
||||||
var all = await db.Suggestions
|
var all = await db.Suggestions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -225,12 +235,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||||
|
|
||||||
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
IReadOnlyList<SuggestionAllDto> ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||||
{
|
{
|
||||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||||
|
|
||||||
return new
|
return new SuggestionAllDto(
|
||||||
{
|
|
||||||
s.Id,
|
s.Id,
|
||||||
s.Name,
|
s.Name,
|
||||||
s.Genre,
|
s.Genre,
|
||||||
@@ -243,12 +252,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
s.Author,
|
s.Author,
|
||||||
s.ParentSuggestionId,
|
s.ParentSuggestionId,
|
||||||
s.IsOwner,
|
s.IsOwner,
|
||||||
LinkedIds = linkedIds,
|
linkedIds,
|
||||||
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||||
};
|
);
|
||||||
});
|
}).ToList();
|
||||||
|
|
||||||
return Results.Ok(ordered);
|
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Success(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
|
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
|
||||||
@@ -261,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
|||||||
suggestion.MinPlayers = input.MinPlayers;
|
suggestion.MinPlayers = input.MinPlayers;
|
||||||
suggestion.MaxPlayers = input.MaxPlayers;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public static class VoteEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
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);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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) =>
|
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||||
@@ -34,7 +37,8 @@ public static class VoteEndpoints
|
|||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,34 +2,31 @@ using GameList.Contracts;
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
internal sealed class VoteWorkflowService(AppDbContext db)
|
internal sealed class VoteWorkflowService(AppDbContext db)
|
||||||
{
|
{
|
||||||
public async Task<IResult> GetMineAsync(Guid playerId)
|
public async Task<ServiceResult<IReadOnlyList<VoteRecordDto>>> GetMineAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||||
|
|
||||||
var votes = await db.Votes
|
IReadOnlyList<VoteRecordDto> votes = await db.Votes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(v => v.PlayerId == playerId)
|
.Where(v => v.PlayerId == playerId)
|
||||||
.Select(v => new
|
.Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
|
||||||
{
|
|
||||||
v.SuggestionId,
|
|
||||||
v.Score
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Results.Ok(votes);
|
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Success(votes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score)
|
public async Task<ServiceResult<VoteUpsertResponse>> UpsertAsync(Guid playerId, int suggestionId, int score)
|
||||||
{
|
{
|
||||||
if (score is < 0 or > 10)
|
if (score is < 0 or > 10)
|
||||||
return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
|
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
|
||||||
|
|
||||||
var playerState = await db.Players
|
var playerState = await db.Players
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -42,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
|||||||
.FirstAsync();
|
.FirstAsync();
|
||||||
|
|
||||||
if (playerState.VotesFinal)
|
if (playerState.VotesFinal)
|
||||||
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
|
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
|
||||||
|
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||||
return EndpointHelpers.BadRequestError("Set a display name before voting.");
|
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Set a display name before voting."));
|
||||||
|
|
||||||
var linkMap = await db.Suggestions
|
var linkMap = await db.Suggestions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -61,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||||
if (!rootIndex.ContainsKey(suggestionId))
|
if (!rootIndex.ContainsKey(suggestionId))
|
||||||
return EndpointHelpers.BadRequestError("Suggestion not found.");
|
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Suggestion not found."));
|
||||||
|
|
||||||
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
|
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
|
||||||
if (linkedIds.Count == 0)
|
if (linkedIds.Count == 0)
|
||||||
@@ -71,38 +68,67 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
|||||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
foreach (var linkedSuggestionId in linkedIds)
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
{
|
{
|
||||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
foreach (var linkedSuggestionId in linkedIds)
|
||||||
if (vote == null)
|
|
||||||
{
|
{
|
||||||
db.Votes.Add(new Vote
|
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||||
|
if (vote == null)
|
||||||
{
|
{
|
||||||
PlayerId = playerId,
|
db.Votes.Add(new Vote
|
||||||
SuggestionId = linkedSuggestionId,
|
{
|
||||||
Score = score
|
PlayerId = playerId,
|
||||||
});
|
SuggestionId = linkedSuggestionId,
|
||||||
|
Score = score
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
vote.Score = score;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
vote.Score = score;
|
await db.SaveChangesAsync();
|
||||||
|
return ServiceResult<VoteUpsertResponse>.Success(new VoteUpsertResponse(linkedIds, score));
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
|
||||||
|
{
|
||||||
|
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
|
||||||
|
|
||||||
|
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 ServiceResult<VoteUpsertResponse>.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
|
||||||
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
|
public async Task<ServiceResult<VoteFinalizeResponse>> SetFinalizeAsync(Guid playerId, bool final)
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return ServiceResult<VoteFinalizeResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||||
|
|
||||||
var player = await db.Players.FirstAsync(p => p.Id == playerId);
|
var player = await db.Players.FirstAsync(p => p.Id == playerId);
|
||||||
|
|
||||||
player.VotesFinal = final;
|
player.VotesFinal = final;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
|
return ServiceResult<VoteFinalizeResponse>.Success(new VoteFinalizeResponse(player.VotesFinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
|
||||||
|
{
|
||||||
|
foreach (var entry in voteEntries)
|
||||||
|
{
|
||||||
|
if (entry.State == EntityState.Added)
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -210,6 +212,29 @@ public class AuthTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Non_admin_cannot_access_admin_routes()
|
public async Task Non_admin_cannot_access_admin_routes()
|
||||||
{
|
{
|
||||||
@@ -247,4 +272,32 @@ public class AuthTests
|
|||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
|
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<DbUpdateException>(() => 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides;
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.FileProviders;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
@@ -28,34 +27,10 @@ public class HelperTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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());
|
var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
|
||||||
Directory.CreateDirectory(webRoot);
|
Assert.False(hasRewriteMethod);
|
||||||
var index = Path.Combine(webRoot, "index.html");
|
|
||||||
File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">");
|
|
||||||
|
|
||||||
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, "<html></html>");
|
|
||||||
|
|
||||||
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("<html></html>", File.ReadAllText(index));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -349,16 +324,6 @@ public class HelperTests
|
|||||||
Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal);
|
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)
|
private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config)
|
||||||
{
|
{
|
||||||
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions"));
|
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions"));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using GameList.Domain;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -347,6 +348,45 @@ public class SuggestionTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
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<JsonElement>();
|
||||||
|
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]
|
[Fact]
|
||||||
public async Task Get_all_requires_vote_phase()
|
public async Task Get_all_requires_vote_phase()
|
||||||
{
|
{
|
||||||
@@ -626,4 +666,41 @@ public class SuggestionTests
|
|||||||
Assert.False(db.Votes.Any(v => v.SuggestionId == id));
|
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<DbUpdateException>(() => 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
services.Remove(descriptor);
|
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();
|
_connection.Open();
|
||||||
|
|
||||||
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
|
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
|
||||||
@@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
using var scope = host.Services.CreateScope();
|
using var scope = host.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
db.Database.EnsureCreated();
|
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
|
||||||
return host;
|
return host;
|
||||||
|
|||||||
4
IIS.md
4
IIS.md
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
## Publish
|
## Publish
|
||||||
- From repo root: `dotnet publish -c Release -o 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).
|
- Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user).
|
||||||
- Set environment variables in web.config or IIS config:
|
- Set environment variables in web.config or IIS config:
|
||||||
- `ASPNETCORE_ENVIRONMENT=Production`
|
- `ASPNETCORE_ENVIRONMENT=Production`
|
||||||
@@ -21,6 +22,9 @@
|
|||||||
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward.
|
- 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.
|
- 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 `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).
|
- Frontend base path: set `<meta name="app-base" content="/picknplay">` 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
|
## Permissions
|
||||||
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
|
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
|
||||||
|
|||||||
46
Program.cs
46
Program.cs
@@ -146,7 +146,6 @@ var basePath = builder.Configuration["BasePath"];
|
|||||||
if (!string.IsNullOrWhiteSpace(basePath))
|
if (!string.IsNullOrWhiteSpace(basePath))
|
||||||
{
|
{
|
||||||
app.UsePathBase(basePath);
|
app.UsePathBase(basePath);
|
||||||
UpdateIndexMetaBase(app.Environment, basePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseGlobalExceptionLogging();
|
app.UseGlobalExceptionLogging();
|
||||||
@@ -154,13 +153,6 @@ app.UseAuthentication();
|
|||||||
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
// Ensure database and migrations are applied on startup
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
db.Database.Migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
@@ -274,42 +266,4 @@ static Task WriteUnauthorizedChallengeAsync(HttpContext context)
|
|||||||
return context.Response.WriteAsJsonAsync(problem);
|
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;
|
public partial class Program;
|
||||||
|
|||||||
18
README.md
18
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:
|
1. Restore and build:
|
||||||
`dotnet build GameList.sln`
|
`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`
|
`dotnet test GameList.Tests/GameList.Tests.csproj`
|
||||||
3. Run locally:
|
4. Run locally:
|
||||||
`dotnet run --project GameList.csproj`
|
`dotnet run --project GameList.csproj`
|
||||||
4. Open:
|
5. Open:
|
||||||
`http://localhost:5000` (or the URL shown by `dotnet run`)
|
`http://localhost:5000` (or the URL shown by `dotnet run`)
|
||||||
|
|
||||||
## Frontend Tooling
|
## Frontend Tooling
|
||||||
@@ -25,21 +27,24 @@ 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.
|
- Authentication: username/password with HttpOnly `player` cookie.
|
||||||
- Admin authorization: authenticated account with `IsAdmin=true`.
|
- 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.
|
- 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`.
|
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
- 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.
|
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
||||||
|
|
||||||
## Module Ownership
|
## Module Ownership
|
||||||
|
|
||||||
- `Program.cs`: startup wiring, middleware order, route registration.
|
- `Program.cs`: startup wiring, middleware order, route registration.
|
||||||
- `Endpoints/`: HTTP endpoint transport + request orchestration.
|
- `Endpoints/`: endpoint adapters plus application workflow services (`ServiceResult<T>` outputs mapped to HTTP at the edge).
|
||||||
- `Infrastructure/`: filters, middleware, identity helpers.
|
- `Infrastructure/`: filters, middleware, identity helpers.
|
||||||
- `Data/`: EF Core `DbContext` and migrations.
|
- `Data/`: EF Core `DbContext` and migrations.
|
||||||
- `Domain/`: entities and enums.
|
- `Domain/`: entities and enums.
|
||||||
- `Contracts/`: request/response DTOs.
|
- `Contracts/`: request/response DTOs.
|
||||||
- `wwwroot/`: static frontend assets.
|
- `wwwroot/`: static frontend assets.
|
||||||
- `GameList.Tests/`: integration and helper tests.
|
- `GameList.Tests/`: integration and helper tests.
|
||||||
- `scripts/`: deployment scripts.
|
- `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
|
## Operations
|
||||||
|
|
||||||
@@ -55,4 +60,5 @@ GitHub Actions workflow: `.github/workflows/ci.yml`
|
|||||||
- Restores dependencies
|
- Restores dependencies
|
||||||
- Runs frontend lint and format checks
|
- Runs frontend lint and format checks
|
||||||
- Builds with warnings treated as errors
|
- Builds with warnings treated as errors
|
||||||
- Runs `GameList.Tests`
|
- Runs `GameList.Tests` with coverage collection
|
||||||
|
- Enforces minimum coverage thresholds (line 90%, branch 70%)
|
||||||
|
|||||||
15
TESTS.md
15
TESTS.md
@@ -33,7 +33,9 @@ stateDiagram-v2
|
|||||||
### 1) Authentication & Identity
|
### 1) Authentication & Identity
|
||||||
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
|
- 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 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.
|
- 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.
|
- `/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.
|
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
||||||
- Logout clears cookie.
|
- Logout clears cookie.
|
||||||
@@ -50,9 +52,10 @@ stateDiagram-v2
|
|||||||
### 3) Suggestions
|
### 3) Suggestions
|
||||||
- GET /mine returns only caller’s suggestions ordered by CreatedAt.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ stateDiagram-v2
|
|||||||
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
|
- 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.
|
- 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.
|
- 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.
|
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
|
||||||
|
|
||||||
### 5) Results
|
### 5) Results
|
||||||
@@ -84,13 +88,18 @@ stateDiagram-v2
|
|||||||
- EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext.
|
- 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).
|
- 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.
|
- 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.
|
- Global exception handler returns 500 with JSON body and logs error.
|
||||||
- /health returns {status:"ok"}.
|
- /health returns {status:"ok"}.
|
||||||
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes.
|
- 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.
|
- 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
|
## Execution Notes
|
||||||
- Use named test data builders for players/suggestions to keep cases small and isolated.
|
- 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.
|
- 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.
|
||||||
|
|||||||
17
deploy.ps1
Normal file
17
deploy.ps1
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
param(
|
||||||
|
[string]$Password,
|
||||||
|
[switch]$SkipRecycle,
|
||||||
|
[switch]$SkipMigrations
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
|
||||||
|
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
|
||||||
|
|
||||||
|
& $scriptPath `
|
||||||
|
-ProfilePath $profilePath `
|
||||||
|
-Password $Password `
|
||||||
|
-SkipRecycle:$SkipRecycle `
|
||||||
|
-SkipMigrations:$SkipMigrations
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint \"wwwroot/**/*.js\"",
|
"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": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.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:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.21.0",
|
"@eslint/js": "9.21.0",
|
||||||
|
|||||||
43
scripts/check-coverage.ps1
Normal file
43
scripts/check-coverage.ps1
Normal file
@@ -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."
|
||||||
|
}
|
||||||
@@ -53,13 +53,17 @@ try {
|
|||||||
|
|
||||||
Invoke-Step -Name "Run tests" -Action {
|
Invoke-Step -Name "Run tests" -Action {
|
||||||
if ($SkipBuild) {
|
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 {
|
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."
|
Write-Host "CI checks passed."
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
31
scripts/deploy-ftp.profile.sample.psd1
Normal file
31
scripts/deploy-ftp.profile.sample.psd1
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@{
|
||||||
|
# Required publish settings
|
||||||
|
ProjectPath = "..\GameList.csproj"
|
||||||
|
Configuration = "Release"
|
||||||
|
Runtime = "win-x64"
|
||||||
|
PublishDir = "%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"
|
||||||
|
}
|
||||||
@@ -1,157 +1,251 @@
|
|||||||
# Hard-coded deploy settings. Fill these in before running.
|
param(
|
||||||
$FtpHost = "xTr1m.com"
|
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
||||||
$FtpUser = "xTr1m"
|
[string]$Password,
|
||||||
$Password = $null # prompted at runtime
|
[switch]$SkipRecycle,
|
||||||
$RemoteDir = "/httpdocs/picknplay"
|
[switch]$SkipMigrations
|
||||||
$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
|
|
||||||
|
|
||||||
<#!
|
<#
|
||||||
.SYNOPSIS
|
.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
|
.DESCRIPTION
|
||||||
|
- Reads environment-specific settings from a PowerShell data file profile.
|
||||||
- Builds with dotnet publish.
|
- Builds with dotnet publish.
|
||||||
- Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files).
|
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
|
||||||
- Optionally recycles the IIS app pool remotely via WinRM (no RDP needed).
|
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
|
||||||
|
|
||||||
.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).
|
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
pwsh ./scripts/deploy-ftp.ps1
|
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
|
||||||
#>
|
#>
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
function Assert-Tool {
|
function Assert-Tool {
|
||||||
param([string]$Name)
|
param([Parameter(Mandatory = $true)][string]$Name)
|
||||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
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"
|
function Require-ConfigValue {
|
||||||
Assert-Tool $WinScpPath
|
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) {
|
if ($Secure) {
|
||||||
$pwd = Read-Host -Prompt $Prompt -AsSecureString
|
$pwd = Read-Host -Prompt $Prompt -AsSecureString
|
||||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
|
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
|
||||||
try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) }
|
try {
|
||||||
|
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
|
||||||
|
}
|
||||||
finally {
|
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
|
return Read-Host -Prompt $Prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
$Password = Read-PlainOrPrompt $Password "Password" $true
|
function Invoke-WinRmScript {
|
||||||
$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain
|
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
|
||||||
|
$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
|
||||||
|
|
||||||
Write-Host "1) Publishing..." -ForegroundColor Cyan
|
Write-Host "1) Publishing..." -ForegroundColor Cyan
|
||||||
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue }
|
if (Test-Path $publishDir) {
|
||||||
New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null
|
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir)
|
}
|
||||||
if (-not $SelfContained) { $publishArgs += "--self-contained=false" }
|
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
|
dotnet @publishArgs
|
||||||
|
|
||||||
if ($RecycleAppPool) {
|
if ($recycleAppPool) {
|
||||||
|
Require-ConfigValue $config "AppPoolName"
|
||||||
|
$appPoolName = [string]$config.AppPoolName
|
||||||
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
|
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 = {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
|
||||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
|
||||||
try {
|
try {
|
||||||
Invoke-Command @invokeParams
|
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||||
} catch {
|
param($poolName)
|
||||||
|
Import-Module WebAdministration
|
||||||
|
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)."
|
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan
|
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
|
||||||
$tempScript = New-TemporaryFile
|
$openCommand = if ($useStoredSession) {
|
||||||
@"
|
"open `"$winScpSessionName`""
|
||||||
option batch continue
|
}
|
||||||
option confirm off
|
else {
|
||||||
open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost
|
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
|
||||||
lcd $PublishDir
|
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
|
||||||
cd $RemoteDir
|
$ftpHost = [string]$config.FtpHost
|
||||||
synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/"
|
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
|
||||||
exit
|
}
|
||||||
"@ | Set-Content -Path $tempScript -Encoding UTF8
|
|
||||||
|
|
||||||
& $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
|
Remove-Item $tempScript -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
if ($RecycleAppPool) {
|
if ($recycleAppPool) {
|
||||||
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
|
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 = {
|
|
||||||
Import-Module WebAdministration
|
|
||||||
Start-WebAppPool -Name $using:AppPoolName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
|
||||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
|
||||||
try {
|
try {
|
||||||
Invoke-Command @invokeParams
|
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||||
} catch {
|
param($poolName)
|
||||||
|
Import-Module WebAdministration
|
||||||
|
Start-WebAppPool -Name $poolName
|
||||||
|
} -ArgumentList @($appPoolName)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
Write-Warning "WinRM start failed: $($_.Exception.Message)."
|
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
|
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
|
||||||
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
|
try {
|
||||||
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
|
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||||
$invokeParams = @{
|
|
||||||
ComputerName = $WinRmComputer
|
|
||||||
Credential = $cred
|
|
||||||
ScriptBlock = {
|
|
||||||
param($sitePath)
|
param($sitePath)
|
||||||
Set-Location $sitePath
|
Set-Location $sitePath
|
||||||
if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
||||||
throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false."
|
throw "dotnet is not available on remote host."
|
||||||
}
|
}
|
||||||
|
|
||||||
dotnet ef database update --no-build
|
dotnet ef database update --no-build
|
||||||
}
|
} -ArgumentList @([string]$config.RemoteSitePath)
|
||||||
ArgumentList = @($RemoteSitePath)
|
|
||||||
}
|
}
|
||||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
catch {
|
||||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
|
||||||
try {
|
|
||||||
Invoke-Command @invokeParams
|
|
||||||
} catch {
|
|
||||||
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
|
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
scripts/deploy-ftp1.ps1
Normal file
14
scripts/deploy-ftp1.ps1
Normal file
@@ -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
|
||||||
418
wwwroot/app.js
418
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 { state, clearUserState } from "./js/state.js";
|
||||||
import { toast } from "./js/dom.js";
|
import { toast } from "./js/dom.js";
|
||||||
import {
|
import {
|
||||||
handleAuthError,
|
handleAuthError,
|
||||||
renderWelcome,
|
renderWelcome,
|
||||||
renderPhasePill,
|
renderPhasePill,
|
||||||
renderCounts,
|
renderCounts,
|
||||||
renderMySuggestions,
|
renderMySuggestions,
|
||||||
renderAllSuggestions,
|
renderAllSuggestions,
|
||||||
renderVotes,
|
renderVotes,
|
||||||
syncVoteScores,
|
syncVoteScores,
|
||||||
renderResults,
|
renderResults,
|
||||||
renderPhaseTitles,
|
renderPhaseTitles,
|
||||||
updatePhaseNav,
|
updatePhaseNav,
|
||||||
configureUiRuntime,
|
configureUiRuntime,
|
||||||
} from "./js/ui.js";
|
} from "./js/ui.js";
|
||||||
import {
|
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
|
||||||
loadSuggestData,
|
|
||||||
loadVoteData,
|
|
||||||
refreshPhaseData,
|
|
||||||
} from "./js/data.js";
|
|
||||||
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
||||||
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
||||||
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-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 refreshInFlight = null;
|
||||||
let refreshTimerId = null;
|
let refreshTimerId = null;
|
||||||
let refreshSchedulerStarted = false;
|
let refreshSchedulerStarted = false;
|
||||||
|
let unchangedRefreshCycles = 0;
|
||||||
|
let nextRefreshDelayMs = REFRESH_MIN_MS;
|
||||||
|
|
||||||
async function runSerializedRefresh() {
|
async function runSerializedRefresh() {
|
||||||
if (refreshInFlight) return refreshInFlight;
|
if (refreshInFlight) return refreshInFlight;
|
||||||
refreshInFlight = refreshPhaseData().finally(() => {
|
refreshInFlight = refreshPhaseData().finally(() => {
|
||||||
refreshInFlight = null;
|
refreshInFlight = null;
|
||||||
});
|
});
|
||||||
return refreshInFlight;
|
return refreshInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWithUiErrorHandling() {
|
async function refreshWithUiErrorHandling() {
|
||||||
try {
|
try {
|
||||||
await runSerializedRefresh();
|
const changed = await runSerializedRefresh();
|
||||||
} catch (err) {
|
updateRefreshCadence(changed === true);
|
||||||
if (!handleAuthError(err, clearUserState)) toast(err.message, 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() {
|
function scheduleNextRefresh() {
|
||||||
refreshTimerId = window.setTimeout(async () => {
|
refreshTimerId = window.setTimeout(async () => {
|
||||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||||
await refreshWithUiErrorHandling();
|
await refreshWithUiErrorHandling();
|
||||||
}
|
}
|
||||||
scheduleNextRefresh();
|
scheduleNextRefresh();
|
||||||
}, REFRESH_INTERVAL_MS);
|
}, nextRefreshDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRefreshScheduler() {
|
function startRefreshScheduler() {
|
||||||
if (refreshSchedulerStarted) return;
|
if (refreshSchedulerStarted) return;
|
||||||
refreshSchedulerStarted = true;
|
refreshSchedulerStarted = true;
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||||
refreshWithUiErrorHandling();
|
unchangedRefreshCycles = 0;
|
||||||
|
nextRefreshDelayMs = baseRefreshDelayForPhase();
|
||||||
|
refreshWithUiErrorHandling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshTimerId !== null) {
|
||||||
|
window.clearTimeout(refreshTimerId);
|
||||||
}
|
}
|
||||||
});
|
scheduleNextRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshTimerId !== null) {
|
function updateRefreshCadence(changed) {
|
||||||
window.clearTimeout(refreshTimerId);
|
const base = baseRefreshDelayForPhase();
|
||||||
}
|
if (changed) {
|
||||||
scheduleNextRefresh();
|
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({
|
configureUiRuntime({
|
||||||
refreshPhaseData: runSerializedRefresh,
|
refreshPhaseData: runSerializedRefresh,
|
||||||
loadSuggestData,
|
loadSuggestData,
|
||||||
loadVoteData,
|
loadVoteData,
|
||||||
handleAuthError: (err) => handleAuthError(err, clearUserState),
|
handleAuthError: (err) => handleAuthError(err, clearUserState),
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupHandlers() {
|
function setupHandlers() {
|
||||||
setupAuthHandlers({ runSerializedRefresh });
|
setupAuthHandlers({ runSerializedRefresh });
|
||||||
setupAdminHandlers({ runSerializedRefresh });
|
setupAdminHandlers({ runSerializedRefresh });
|
||||||
setupVoteNavigationHandlers({ runSerializedRefresh });
|
setupVoteNavigationHandlers({ runSerializedRefresh });
|
||||||
setupLanguageSwitchers();
|
setupLanguageSwitchers();
|
||||||
|
|
||||||
onLanguageChange(() => {
|
onLanguageChange(() => {
|
||||||
updateLanguageButtons();
|
updateLanguageButtons();
|
||||||
renderWelcome();
|
renderWelcome();
|
||||||
renderPhasePill();
|
renderPhasePill();
|
||||||
renderCounts();
|
renderCounts();
|
||||||
renderPhaseTitles();
|
renderPhaseTitles();
|
||||||
renderMySuggestions();
|
renderMySuggestions();
|
||||||
renderAllSuggestions();
|
renderAllSuggestions();
|
||||||
if (state.phase === "Vote") {
|
if (state.phase === "Vote") {
|
||||||
renderVotes();
|
renderVotes();
|
||||||
state.votesRendered = true;
|
state.votesRendered = true;
|
||||||
syncVoteScores();
|
syncVoteScores();
|
||||||
}
|
}
|
||||||
if (state.phase === "Results") {
|
if (state.phase === "Results") {
|
||||||
renderResults();
|
renderResults();
|
||||||
}
|
}
|
||||||
updatePhaseNav();
|
updatePhaseNav();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll(".help-chip").forEach((chip) => {
|
document.querySelectorAll(".help-chip").forEach((chip) => {
|
||||||
chip.addEventListener("click", () => openFaqModal());
|
chip.addEventListener("click", () => openFaqModal());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await initI18n();
|
await initI18n();
|
||||||
setupHandlers();
|
setupHandlers();
|
||||||
await refreshWithUiErrorHandling();
|
await refreshWithUiErrorHandling();
|
||||||
startRefreshScheduler();
|
startRefreshScheduler();
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
||||||
function updateLanguageButtons() {
|
function updateLanguageButtons() {
|
||||||
document.querySelectorAll(".lang-button").forEach((btn) => {
|
document.querySelectorAll(".lang-button").forEach((btn) => {
|
||||||
btn.textContent = "🌐";
|
btn.textContent = "🌐";
|
||||||
btn.title = t("lang.label");
|
btn.title = t("lang.label");
|
||||||
btn.setAttribute("aria-label", t("lang.label"));
|
btn.setAttribute("aria-label", t("lang.label"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupLanguageSwitchers() {
|
function setupLanguageSwitchers() {
|
||||||
const switches = document.querySelectorAll(".lang-switch");
|
const switches = document.querySelectorAll(".lang-switch");
|
||||||
const closeAll = () =>
|
const closeAll = () =>
|
||||||
switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden"));
|
switches.forEach((wrap) =>
|
||||||
|
wrap.querySelector(".lang-menu")?.classList.add("hidden"),
|
||||||
|
);
|
||||||
|
|
||||||
switches.forEach((wrap) => {
|
switches.forEach((wrap) => {
|
||||||
const btn = wrap.querySelector(".lang-button");
|
const btn = wrap.querySelector(".lang-button");
|
||||||
const menu = wrap.querySelector(".lang-menu");
|
const menu = wrap.querySelector(".lang-menu");
|
||||||
if (!btn || !menu) return;
|
if (!btn || !menu) return;
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isHidden = menu.classList.contains("hidden");
|
const isHidden = menu.classList.contains("hidden");
|
||||||
closeAll();
|
closeAll();
|
||||||
if (isHidden) menu.classList.remove("hidden");
|
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) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (!e.target.closest(".lang-switch")) closeAll();
|
if (!e.target.closest(".lang-switch")) closeAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
updateLanguageButtons();
|
updateLanguageButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function markdownToHtml(md) {
|
function markdownToHtml(md) {
|
||||||
const lines = md.trim().split(/\r?\n/);
|
const lines = md.trim().split(/\r?\n/);
|
||||||
const html = [];
|
const html = [];
|
||||||
let inList = false;
|
let inList = false;
|
||||||
let inParagraph = false;
|
let inParagraph = false;
|
||||||
|
|
||||||
const escapeHtml = (text) =>
|
const escapeHtml = (text) =>
|
||||||
text
|
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
|
|
||||||
const formatInline = (text) =>
|
const formatInline = (text) =>
|
||||||
escapeHtml(text)
|
escapeHtml(text)
|
||||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||||
|
|
||||||
const closeParagraph = () => {
|
const closeParagraph = () => {
|
||||||
if (inParagraph) {
|
if (inParagraph) {
|
||||||
html.push("</p>");
|
html.push("</p>");
|
||||||
inParagraph = false;
|
inParagraph = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeList = () => {
|
const closeList = () => {
|
||||||
if (inList) {
|
if (inList) {
|
||||||
html.push("</ul>");
|
html.push("</ul>");
|
||||||
inList = false;
|
inList = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
lines.forEach((rawLine) => {
|
lines.forEach((rawLine) => {
|
||||||
const line = rawLine.trimEnd();
|
const line = rawLine.trimEnd();
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
closeList();
|
closeList();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^-{5,}$/.test(trimmed)) {
|
if (/^-{5,}$/.test(trimmed)) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
closeList();
|
closeList();
|
||||||
html.push('<hr class="faq-divider" />');
|
html.push('<hr class="faq-divider" />');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
|
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
|
||||||
if (heading) {
|
if (heading) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
closeList();
|
closeList();
|
||||||
const level = heading[1].length;
|
const level = heading[1].length;
|
||||||
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
|
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
|
||||||
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
|
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[*-]\s+/.test(trimmed)) {
|
if (/^[*-]\s+/.test(trimmed)) {
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
if (!inList) {
|
if (!inList) {
|
||||||
html.push("<ul>");
|
html.push("<ul>");
|
||||||
inList = true;
|
inList = true;
|
||||||
}
|
}
|
||||||
const text = trimmed.replace(/^[*-]\s+/, "");
|
const text = trimmed.replace(/^[*-]\s+/, "");
|
||||||
html.push(`<li>${formatInline(text)}</li>`);
|
html.push(`<li>${formatInline(text)}</li>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inParagraph) {
|
if (!inParagraph) {
|
||||||
html.push("<p>");
|
html.push("<p>");
|
||||||
inParagraph = true;
|
inParagraph = true;
|
||||||
}
|
}
|
||||||
html.push(formatInline(trimmed));
|
html.push(formatInline(trimmed));
|
||||||
});
|
});
|
||||||
|
|
||||||
closeParagraph();
|
closeParagraph();
|
||||||
closeList();
|
closeList();
|
||||||
return html.join("\n");
|
return html.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFaqModal() {
|
function openFaqModal() {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "edit-modal";
|
overlay.className = "edit-modal";
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
panel.className = "edit-panel faq-panel";
|
panel.className = "edit-panel faq-panel";
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="edit-header">
|
<div class="edit-header">
|
||||||
<h3>${t("help.title")}</h3>
|
<h3>${t("help.title")}</h3>
|
||||||
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
||||||
@@ -250,16 +286,20 @@ function openFaqModal() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const list = panel.querySelector(".faq-list");
|
const list = panel.querySelector(".faq-list");
|
||||||
const lang = getLanguage();
|
const lang = getLanguage();
|
||||||
const md = faqMarkdown[lang] ?? faqMarkdown.en;
|
const md = faqMarkdown[lang] ?? faqMarkdown.en;
|
||||||
list.innerHTML = markdownToHtml(md);
|
list.innerHTML = markdownToHtml(md);
|
||||||
|
|
||||||
const close = () => overlay.remove();
|
const close = () => overlay.remove();
|
||||||
overlay.addEventListener("click", (e) => {
|
overlay.addEventListener("click", (e) => {
|
||||||
if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close();
|
if (
|
||||||
});
|
e.target.classList.contains("edit-modal") ||
|
||||||
|
e.target.classList.contains("lightbox-close")
|
||||||
|
)
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
overlay.appendChild(panel);
|
overlay.appendChild(panel);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="grid">
|
<main class="grid">
|
||||||
|
|||||||
@@ -5,77 +5,107 @@ const basePath = normalizeBase(rawBase);
|
|||||||
const withBase = (path) => `${basePath}${path}`;
|
const withBase = (path) => `${basePath}${path}`;
|
||||||
|
|
||||||
function normalizeBase(value) {
|
function normalizeBase(value) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
if (!value.startsWith("/")) return `/${value}`;
|
if (!value.startsWith("/")) return `/${value}`;
|
||||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, { method = "GET", body } = {}) {
|
async function request(path, { method = "GET", body } = {}) {
|
||||||
const res = await fetch(withBase(path), {
|
const res = await fetch(withBase(path), {
|
||||||
method,
|
method,
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: defaultHeaders,
|
headers: defaultHeaders,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `${res.status}`;
|
let msg = `${res.status}`;
|
||||||
try {
|
try {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
msg = data.error || data.detail || data.title || JSON.stringify(data);
|
msg =
|
||||||
} catch { /* ignore */ }
|
data.error || data.detail || data.title || JSON.stringify(data);
|
||||||
const err = new Error(msg);
|
} catch {
|
||||||
err.status = res.status;
|
/* ignore */
|
||||||
throw err;
|
}
|
||||||
}
|
const err = new Error(msg);
|
||||||
return res.status === 204 ? null : res.json();
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return res.status === 204 ? null : res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
state: () => request("/api/state"),
|
state: () => request("/api/state"),
|
||||||
me: () => request("/api/me"),
|
me: () => request("/api/me"),
|
||||||
authOptions: () => request("/api/auth/options"),
|
authOptions: () => request("/api/auth/options"),
|
||||||
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
|
register: (payload) =>
|
||||||
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
|
request("/api/auth/register", { method: "POST", body: payload }),
|
||||||
logout: () => request("/api/auth/logout", { method: "POST" }),
|
login: (payload) =>
|
||||||
|
request("/api/auth/login", { method: "POST", body: payload }),
|
||||||
|
logout: () => request("/api/auth/logout", { method: "POST" }),
|
||||||
|
|
||||||
mySuggestions: () => request("/api/suggestions/mine"),
|
mySuggestions: () => request("/api/suggestions/mine"),
|
||||||
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
|
createSuggestion: (payload) =>
|
||||||
deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }),
|
request("/api/suggestions", { method: "POST", body: payload }),
|
||||||
updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }),
|
deleteSuggestion: (id) =>
|
||||||
allSuggestions: () => request("/api/suggestions/all"),
|
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"),
|
myVotes: () => request("/api/votes/mine"),
|
||||||
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
|
vote: (suggestionId, score) =>
|
||||||
finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }),
|
request("/api/votes", {
|
||||||
|
method: "POST",
|
||||||
|
body: { suggestionId, score },
|
||||||
|
}),
|
||||||
|
finalizeVotes: (final) =>
|
||||||
|
request("/api/votes/finalize", { method: "POST", body: { final } }),
|
||||||
|
|
||||||
results: () => request("/api/results"),
|
results: () => request("/api/results"),
|
||||||
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
|
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
|
||||||
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
|
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
|
setResultsOpen: (resultsOpen) =>
|
||||||
voteStatus: () => request("/api/admin/vote-status"),
|
request("/api/admin/results", {
|
||||||
reset: (password) =>
|
method: "POST",
|
||||||
request("/api/admin/reset", { method: "POST", body: { password } }),
|
body: { resultsOpen },
|
||||||
factoryReset: (password) =>
|
}),
|
||||||
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
|
voteStatus: () => request("/api/admin/vote-status"),
|
||||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
reset: (password) =>
|
||||||
setPlayerAdmin: (playerId, isAdmin) =>
|
request("/api/admin/reset", { method: "POST", body: { password } }),
|
||||||
request("/api/admin/player-admin", {
|
factoryReset: (password) =>
|
||||||
method: "POST",
|
request("/api/admin/factory-reset", {
|
||||||
body: { playerId, isAdmin },
|
method: "POST",
|
||||||
}),
|
body: { password },
|
||||||
setPlayerPhase: (playerId, phase) =>
|
}),
|
||||||
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
grantJoker: (playerId) =>
|
||||||
deletePlayer: (playerId, password) =>
|
request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||||
request(`/api/admin/players/${playerId}`, {
|
setPlayerAdmin: (playerId, isAdmin) =>
|
||||||
method: "DELETE",
|
request("/api/admin/player-admin", {
|
||||||
body: { password },
|
method: "POST",
|
||||||
}),
|
body: { playerId, isAdmin },
|
||||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
}),
|
||||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
setPlayerPhase: (playerId, phase) =>
|
||||||
unlinkSuggestions: (suggestionId) =>
|
request("/api/admin/player-phase", {
|
||||||
request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }),
|
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 },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ function setupLoginFormHandlers({
|
|||||||
if (err?.status === 401)
|
if (err?.status === 401)
|
||||||
return toast(t("auth.invalidCredentials"), true);
|
return toast(t("auth.invalidCredentials"), true);
|
||||||
if (handleAuthError(err, clearUserState)) return;
|
if (handleAuthError(err, clearUserState)) return;
|
||||||
|
toast(err?.message || t("toast.unexpected"), true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import { api, adminApi } from "./api.js";
|
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";
|
import { state, clearUserState } from "./state.js";
|
||||||
|
|
||||||
export async function loadState() {
|
export async function loadState() {
|
||||||
@@ -86,18 +101,26 @@ export async function loadResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshPhaseData() {
|
export async function refreshPhaseData() {
|
||||||
|
const before = buildRefreshSnapshot();
|
||||||
try {
|
try {
|
||||||
const prevPhase = state.phase;
|
const prevPhase = state.phase;
|
||||||
const prevResultsOpen = state.resultsOpen;
|
const prevResultsOpen = state.resultsOpen;
|
||||||
await loadState();
|
await loadState();
|
||||||
await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]);
|
await Promise.all([
|
||||||
|
loadSuggestData(),
|
||||||
|
loadSuggestionsData(),
|
||||||
|
loadResults(),
|
||||||
|
]);
|
||||||
if (state.phase === "Vote") {
|
if (state.phase === "Vote") {
|
||||||
if (!state.votesRendered) await loadVoteData();
|
if (!state.votesRendered) await loadVoteData();
|
||||||
} else {
|
} else {
|
||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
await loadVoteData();
|
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();
|
state.adminVoteStatus = await adminApi.voteStatus();
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -109,12 +132,34 @@ export async function refreshPhaseData() {
|
|||||||
openResultsRelockModal();
|
openResultsRelockModal();
|
||||||
}
|
}
|
||||||
updatePhaseNav();
|
updatePhaseNav();
|
||||||
|
const after = buildRefreshSnapshot();
|
||||||
|
return before !== after;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (handleAuthError(err, clearUserState)) return;
|
if (handleAuthError(err, clearUserState)) return;
|
||||||
throw err;
|
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) {
|
export function signatureSuggestions(list) {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
list.map((s) => [
|
list.map((s) => [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const $ = (id) => document.getElementById(id);
|
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) {
|
export function toast(msg, isError = false) {
|
||||||
if (!toastEl) return;
|
if (!toastEl) return;
|
||||||
|
|||||||
@@ -49,16 +49,6 @@ export function renderMySuggestions() {
|
|||||||
|
|
||||||
export function renderAllSuggestions() {
|
export function renderAllSuggestions() {
|
||||||
renderAdminLinker();
|
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();
|
renderPhaseTitles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
const adminResultsToggle = $("results-open");
|
||||||
if (adminResultsToggle) {
|
if (adminResultsToggle) {
|
||||||
adminResultsToggle.textContent = state.resultsOpen
|
adminResultsToggle.textContent = state.resultsOpen
|
||||||
|
|||||||
Reference in New Issue
Block a user