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