From 68ba8720319a36e7646d045c16727774c89ac2e8 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 28 Jan 2026 14:46:59 +0100 Subject: [PATCH] Add phase-gated API, cookie identity, and initial migration --- .../20260128134624_InitialCreate.Designer.cs | 178 +++++++++++ .../20260128134624_InitialCreate.cs | 132 ++++++++ Data/Migrations/AppDbContextModelSnapshot.cs | 175 +++++++++++ Program.cs | 294 ++++++++++++++++++ TASKS.md | 24 +- dotnet-tools.json | 13 + 6 files changed, 804 insertions(+), 12 deletions(-) create mode 100644 Data/Migrations/20260128134624_InitialCreate.Designer.cs create mode 100644 Data/Migrations/20260128134624_InitialCreate.cs create mode 100644 Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 dotnet-tools.json diff --git a/Data/Migrations/20260128134624_InitialCreate.Designer.cs b/Data/Migrations/20260128134624_InitialCreate.Designer.cs new file mode 100644 index 0000000..29f9097 --- /dev/null +++ b/Data/Migrations/20260128134624_InitialCreate.Designer.cs @@ -0,0 +1,178 @@ +// +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("20260128134624_InitialCreate")] + partial class InitialCreate + { + /// + 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("CurrentPhase") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = 0, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 28, 13, 46, 23, 267, DateTimeKind.Unspecified).AddTicks(1749), 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("DisplayName") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Votes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GameList.Domain.Suggestion", "Suggestion") + .WithMany("Votes") + .HasForeignKey("SuggestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Suggestion"); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Navigation("Suggestions"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260128134624_InitialCreate.cs b/Data/Migrations/20260128134624_InitialCreate.cs new file mode 100644 index 0000000..862f130 --- /dev/null +++ b/Data/Migrations/20260128134624_InitialCreate.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppState", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CurrentPhase = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppState", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Players", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Players", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Suggestions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Genre = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + ScreenshotUrl = table.Column(type: "TEXT", maxLength: 2048, nullable: true), + YoutubeUrl = table.Column(type: "TEXT", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Suggestions", x => x.Id); + table.ForeignKey( + name: "FK_Suggestions_Players_PlayerId", + column: x => x.PlayerId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Votes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "TEXT", nullable: false), + SuggestionId = table.Column(type: "INTEGER", nullable: false), + Score = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Votes", x => x.Id); + table.ForeignKey( + name: "FK_Votes_Players_PlayerId", + column: x => x.PlayerId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Votes_Suggestions_SuggestionId", + column: x => x.SuggestionId, + principalTable: "Suggestions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "AppState", + columns: new[] { "Id", "CurrentPhase", "UpdatedAt" }, + values: new object[] { 1, 0, new DateTimeOffset(new DateTime(2026, 1, 28, 13, 46, 23, 267, DateTimeKind.Unspecified).AddTicks(1749), new TimeSpan(0, 0, 0, 0, 0)) }); + + migrationBuilder.CreateIndex( + name: "IX_Suggestions_PlayerId", + table: "Suggestions", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Votes_PlayerId_SuggestionId", + table: "Votes", + columns: new[] { "PlayerId", "SuggestionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Votes_SuggestionId", + table: "Votes", + column: "SuggestionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppState"); + + migrationBuilder.DropTable( + name: "Votes"); + + migrationBuilder.DropTable( + name: "Suggestions"); + + migrationBuilder.DropTable( + name: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..22b6ab7 --- /dev/null +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,175 @@ +// +using System; +using GameList.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameList.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("CurrentPhase") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = 0, + UpdatedAt = new DateTimeOffset(new DateTime(2026, 1, 28, 13, 46, 23, 267, DateTimeKind.Unspecified).AddTicks(1749), 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("DisplayName") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Votes") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GameList.Domain.Suggestion", "Suggestion") + .WithMany("Votes") + .HasForeignKey("SuggestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Suggestion"); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Navigation("Suggestions"); + + b.Navigation("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Program.cs b/Program.cs index 7cbdc20..fd41880 100644 --- a/Program.cs +++ b/Program.cs @@ -1,6 +1,10 @@ using GameList.Data; +using GameList.Domain; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; var builder = WebApplication.CreateBuilder(args); @@ -37,9 +41,299 @@ builder.Services.AddDbContext(options => var app = builder.Build(); +// Ensure database and migrations are applied on startup +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); +} + app.UseDefaultFiles(); app.UseStaticFiles(); +const string PlayerCookieName = "player"; + +// Issue/refresh anonymous player cookie and stash the Guid in Items +app.Use(async (ctx, next) => +{ + var cookieOptions = new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Secure = !app.Environment.IsDevelopment(), + IsEssential = true, + Expires = DateTimeOffset.UtcNow.AddYears(1) + }; + + Guid playerId; + if (!ctx.Request.Cookies.TryGetValue(PlayerCookieName, out var value) || !Guid.TryParse(value, out playerId)) + { + playerId = Guid.NewGuid(); + } + + ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), cookieOptions); + ctx.Items[PlayerCookieName] = playerId; + + await next(); +}); + app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +var api = app.MapGroup("/api"); + +api.MapGet("/state", async (AppDbContext db) => +{ + var state = await db.AppState.AsNoTracking().FirstAsync(); + var summary = new + { + state.CurrentPhase, + state.UpdatedAt, + Players = await db.Players.CountAsync(), + Suggestions = await db.Suggestions.CountAsync(), + Votes = await db.Votes.CountAsync() + }; + return Results.Ok(summary); +}); + +api.MapGet("/me", async (HttpContext ctx, AppDbContext db) => +{ + var player = await GetOrCreatePlayer(ctx, db); + return Results.Ok(new { player.Id, player.DisplayName }); +}); + +api.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) => +{ + if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 64) + { + return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." }); + } + + var player = await GetOrCreatePlayer(ctx, db); + player.DisplayName = request.Name.Trim(); + await db.SaveChangesAsync(); + return Results.Ok(new { player.Id, player.DisplayName }); +}); + +api.MapGet("/suggestions/mine", async (HttpContext ctx, AppDbContext db) => +{ + var phase = await GetPhase(db); + if (phase != Phase.Suggest) + return PhaseMismatch(Phase.Suggest, phase); + + var player = await GetOrCreatePlayer(ctx, db); + var mine = await db.Suggestions.AsNoTracking() + .Where(s => s.PlayerId == player.Id) + .OrderBy(s => s.CreatedAt) + .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl)) + .ToListAsync(); + return Results.Ok(mine); +}); + +api.MapPost("/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db) => +{ + var phase = await GetPhase(db); + if (phase != Phase.Suggest) + return PhaseMismatch(Phase.Suggest, phase); + + if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) + { + return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." }); + } + + var player = await GetOrCreatePlayer(ctx, db); + + var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id); + if (existingCount >= 3) + { + return Results.BadRequest(new { error = "You have reached the 3 suggestion limit." }); + } + + var suggestion = new Suggestion + { + PlayerId = player.Id, + Name = request.Name.Trim(), + Genre = TrimTo(request.Genre, 50), + Description = TrimTo(request.Description, 500), + ScreenshotUrl = TrimTo(request.ScreenshotUrl, 2048), + YoutubeUrl = TrimTo(request.YoutubeUrl, 2048) + }; + + db.Suggestions.Add(suggestion); + await db.SaveChangesAsync(); + + return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); +}); + +api.MapGet("/suggestions/all", async (AppDbContext db) => +{ + var phase = await GetPhase(db); + if (phase < Phase.Reveal) + return PhaseMismatch(Phase.Reveal, phase); + + var all = await db.Suggestions.AsNoTracking() + .Include(s => s.Player) + .OrderBy(s => s.CreatedAt) + .Select(s => new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + Author = s.Player!.DisplayName + }) + .ToListAsync(); + + return Results.Ok(all); +}); + +api.MapGet("/votes/mine", async (HttpContext ctx, AppDbContext db) => +{ + var phase = await GetPhase(db); + if (phase != Phase.Vote) + return PhaseMismatch(Phase.Vote, phase); + + var player = await GetOrCreatePlayer(ctx, db); + var votes = await db.Votes.AsNoTracking() + .Where(v => v.PlayerId == player.Id) + .Select(v => new { v.SuggestionId, v.Score }) + .ToListAsync(); + + return Results.Ok(votes); +}); + +api.MapPost("/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) => +{ + var phase = await GetPhase(db); + if (phase != Phase.Vote) + return PhaseMismatch(Phase.Vote, phase); + + if (request.Score is < 0 or > 10) + return Results.BadRequest(new { error = "Score must be between 0 and 10." }); + + var player = await GetOrCreatePlayer(ctx, db); + + var suggestionExists = await db.Suggestions.AnyAsync(s => s.Id == request.SuggestionId); + if (!suggestionExists) + return Results.BadRequest(new { error = "Suggestion not found." }); + + var vote = await db.Votes.FirstOrDefaultAsync(v => v.PlayerId == player.Id && v.SuggestionId == request.SuggestionId); + if (vote == null) + { + vote = new Vote + { + PlayerId = player.Id, + SuggestionId = request.SuggestionId, + Score = request.Score + }; + db.Votes.Add(vote); + } + else + { + vote.Score = request.Score; + } + + await db.SaveChangesAsync(); + return Results.Ok(new { vote.Id, vote.Score }); +}); + +api.MapGet("/results", async (AppDbContext db) => +{ + var phase = await GetPhase(db); + if (phase != Phase.Results) + return PhaseMismatch(Phase.Results, phase); + + var results = await db.Suggestions.AsNoTracking() + .Include(s => s.Player) + .Include(s => s.Votes) + .Select(s => new + { + s.Id, + s.Name, + Author = s.Player!.DisplayName, + Total = s.Votes.Sum(v => v.Score), + Count = s.Votes.Count, + Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score) + }) + .OrderByDescending(r => r.Total) + .ToListAsync(); + + return Results.Ok(results); +}); + +var admin = api.MapGroup("/admin"); + +admin.MapPost("/phase", async ([FromBody] PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => +{ + if (!IsAuthorized(ctx, config)) return Results.Unauthorized(); + + var state = await db.AppState.FirstAsync(); + state.CurrentPhase = request.Phase; + state.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return Results.Ok(new { state.CurrentPhase, state.UpdatedAt }); +}); + +admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => +{ + if (!IsAuthorized(ctx, config)) return Results.Unauthorized(); + + await db.Votes.ExecuteDeleteAsync(); + await db.Suggestions.ExecuteDeleteAsync(); + + var state = await db.AppState.FirstAsync(); + state.CurrentPhase = Phase.Suggest; + state.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(new { state.CurrentPhase, state.UpdatedAt }); +}); + app.Run(); + +static async Task GetOrCreatePlayer(HttpContext ctx, AppDbContext db) +{ + if (!ctx.Items.TryGetValue("player", out var value) || value is not Guid playerId) + { + throw new InvalidOperationException("Player cookie missing."); + } + + var existing = await db.Players.FindAsync(playerId); + if (existing != null) return existing; + + var player = new Player { Id = playerId }; + db.Players.Add(player); + await db.SaveChangesAsync(); + return player; +} + +static async Task GetPhase(AppDbContext db) +{ + var state = await db.AppState.AsNoTracking().FirstAsync(); + return state.CurrentPhase; +} + +static IResult PhaseMismatch(Phase required, Phase current) => + Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." }); + +static string? TrimTo(string? input, int max) => + string.IsNullOrWhiteSpace(input) + ? null + : input.Trim() is var t && t.Length > 0 + ? t[..Math.Min(t.Length, max)] + : null; + +static bool IsAuthorized(HttpContext ctx, IConfiguration config) +{ + var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault() + ?? ctx.Request.Query["key"].FirstOrDefault(); + var expected = config["ADMIN_PASSWORD"]; + return !string.IsNullOrWhiteSpace(expected) && provided == expected; +} + +public record SetNameRequest(string Name); +public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl); +public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl); +public record VoteRequest(int SuggestionId, int Score); +public record PhaseRequest(Phase Phase); diff --git a/TASKS.md b/TASKS.md index 6bfb556..3db0cfb 100644 --- a/TASKS.md +++ b/TASKS.md @@ -10,21 +10,21 @@ - [x] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`. ## Identity & Middleware -- [ ] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production. -- [ ] Minimal API filters/helpers to resolve current player and ensure existence in DB. +- [x] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production. +- [x] Minimal API helpers to resolve current player and ensure existence in DB. - [ ] Global exception/validation handling and basic logging. ## Phase Enforcement -- [ ] Store current phase in `AppState`; default to Suggest. -- [ ] Central guard ensuring endpoints respect allowed phase (server-side blindness, no client trust). +- [x] Store current phase in `AppState`; default to Suggest. +- [x] Central guard ensuring endpoints respect allowed phase (server-side blindness, no client trust). ## API Endpoints (see API.md) -- [ ] `GET /api/state` returns phase and counts. -- [ ] `GET /api/me` and `POST /api/me/name` to set display name. -- [ ] Suggestion endpoints: mine/create/all with per-player visibility rules. -- [ ] Vote endpoints: mine/create with per-player visibility and phase gating. -- [ ] Results endpoint aggregates totals and vote counts (optionally averages) sorted desc. -- [ ] Admin endpoints: switch phase, reset data; protect via env password. +- [x] `GET /api/state` returns phase and counts. +- [x] `GET /api/me` and `POST /api/me/name` to set display name. +- [x] Suggestion endpoints: mine/create/all with per-player visibility rules. +- [x] Vote endpoints: mine/create with per-player visibility and phase gating. +- [x] Results endpoint aggregates totals and vote counts (optionally averages) sorted desc. +- [x] Admin endpoints: switch phase, reset data; protect via env password. ## Frontend (wwwroot) - [ ] `index.html` shell with phase-driven sections. @@ -32,8 +32,8 @@ - [ ] `styles.css` basic responsive layout (desktop + mobile). ## Persistence & Migrations -- [ ] Create initial EF Core migration for SQLite schema. -- [ ] Add startup migration application. +- [x] Create initial EF Core migration for SQLite schema. +- [x] Add startup migration application. ## Testing & Quality - [ ] Happy-path smoke test script (manual or minimal automated) for phase flow. diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..a74bf0b --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.2", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file