diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 3a3a95c..0092679 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -6,6 +6,7 @@ public record SuggestionDto(int Id, string Name, string? Genre, string? Descript public record VoteRequest(int SuggestionId, int Score); public record ResultsOpenRequest(bool ResultsOpen); public record VoteFinalizeRequest(bool Final); -public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized); +public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized, bool HasJoker); public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId); public record UnlinkSuggestionsRequest(int SuggestionId); +public record GrantJokerRequest(Guid PlayerId); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index e3c884f..eced9c0 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -26,6 +26,7 @@ public class AppDbContext : DbContext builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired(); builder.Property(p => p.IsAdmin).HasDefaultValue(false); + builder.Property(p => p.HasJoker).HasDefaultValue(false); builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.VotesFinal).HasDefaultValue(false); builder.HasMany(p => p.Suggestions) diff --git a/Data/Migrations/20260205120525_AddPlayerJoker.Designer.cs b/Data/Migrations/20260205120525_AddPlayerJoker.Designer.cs new file mode 100644 index 0000000..28a4770 --- /dev/null +++ b/Data/Migrations/20260205120525_AddPlayerJoker.Designer.cs @@ -0,0 +1,246 @@ +// +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("20260205120525_AddPlayerJoker")] + partial class AddPlayerJoker + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("GameList.Domain.AppState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + ResultsOpen = false, + UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("HasJoker") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("VotesFinal") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentSuggestionId"); + + b.HasIndex("PlayerId"); + + b.ToTable("Suggestions"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SuggestionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SuggestionId"); + + b.HasIndex("PlayerId", "SuggestionId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion") + .WithMany("LinkedSuggestions") + .HasForeignKey("ParentSuggestionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ParentSuggestion"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Votes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GameList.Domain.Suggestion", "Suggestion") + .WithMany("Votes") + .HasForeignKey("SuggestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Suggestion"); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Navigation("Suggestions"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Navigation("LinkedSuggestions"); + + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260205120525_AddPlayerJoker.cs b/Data/Migrations/20260205120525_AddPlayerJoker.cs new file mode 100644 index 0000000..9963a8c --- /dev/null +++ b/Data/Migrations/20260205120525_AddPlayerJoker.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class AddPlayerJoker : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HasJoker", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HasJoker", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index ba9c16b..cd37628 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -60,6 +60,11 @@ namespace GameList.Data.Migrations .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("HasJoker") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("IsAdmin") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/Domain/Player.cs b/Domain/Player.cs index 33a209b..34069d2 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -22,6 +22,7 @@ public class Player public bool IsAdmin { get; set; } public Phase CurrentPhase { get; set; } = Phase.Suggest; public bool VotesFinal { get; set; } + public bool HasJoker { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 53edd68..738fcb8 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -44,7 +44,7 @@ public static class AdminEndpoints .AsNoTracking() .Where(p => p.CurrentPhase == Phase.Vote || p.Suggestions.Any()) .OrderBy(p => p.DisplayName ?? p.Username) - .Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.VotesFinal)) + .Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.VotesFinal, p.HasJoker)) .ToListAsync(); var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); @@ -52,6 +52,23 @@ public static class AdminEndpoints return Results.Ok(new { voters, ready, waiting }); }); + admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => + { + if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId); + if (player is null) return Results.NotFound(new { error = "Player not found." }); + + var phase = await EndpointHelpers.GetPhase(db, player.Id); + if (phase != Phase.Vote) + return Results.BadRequest(new { error = "Player must be in the Vote phase to receive a joker." }); + + player.HasJoker = true; + player.VotesFinal = false; + await db.SaveChangesAsync(); + + return Results.Ok(new { player.Id, player.HasJoker }); + }); + admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); @@ -187,7 +204,8 @@ public static class AdminEndpoints await db.Suggestions.ExecuteDeleteAsync(); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest) - .SetProperty(x => x.VotesFinal, false)); + .SetProperty(x => x.VotesFinal, false) + .SetProperty(x => x.HasJoker, false)); var state = await db.AppState.FirstAsync(); state.ResultsOpen = false; state.UpdatedAt = DateTimeOffset.UtcNow; diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 3b0331a..9ee5ee3 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -21,6 +21,7 @@ public static class StateEndpoints { CurrentPhase = phase, player.VotesFinal, + player.HasJoker, state.ResultsOpen, state.UpdatedAt, Players = await db.Players.CountAsync(), @@ -35,7 +36,7 @@ public static class StateEndpoints var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var phase = await EndpointHelpers.GetPhase(db, player.Id); - return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal }); + return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal, player.HasJoker }); }); app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) => diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index db14d6e..f8a86a4 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -62,7 +62,8 @@ public static class SuggestEndpoints var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var phase = await EndpointHelpers.GetPhase(db, player.Id); - if (phase != Phase.Suggest) + var usingJoker = phase == Phase.Vote && player.HasJoker; + if (phase != Phase.Suggest && !usingJoker) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); if (string.IsNullOrWhiteSpace(player.DisplayName)) @@ -90,6 +91,13 @@ public static class SuggestEndpoints }; db.Suggestions.Add(suggestion); + + if (usingJoker) + { + player.HasJoker = false; + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + } + await db.SaveChangesAsync(); return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); diff --git a/wwwroot/app.js b/wwwroot/app.js index 8efb488..0b3bd37 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -124,6 +124,14 @@ function setupHandlers() { openNewSuggestionModal(); }); } + const openJokerBtn = $("open-joker-modal"); + if (openJokerBtn) { + openJokerBtn.addEventListener("click", (e) => { + e.preventDefault(); + if (state.phase !== "Vote" || !state.hasJoker) return; + openNewSuggestionModal(); + }); + } bindNavButtons(); @@ -195,6 +203,21 @@ function setupHandlers() { } }); } + + const grantJokerBtn = $("grant-joker"); + if (grantJokerBtn) { + grantJokerBtn.addEventListener("click", async () => { + const playerId = $("joker-player")?.value; + if (!playerId) return toast(t("admin.jokerSelectFirst"), true); + try { + await adminApi.grantJoker(playerId); + toast(t("admin.jokerGranted")); + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } + }); + } } async function adminAction(fn, successMessage) { diff --git a/wwwroot/index.html b/wwwroot/index.html index 56970c2..02b2a8f 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -113,6 +113,7 @@