Add phase-gated API, cookie identity, and initial migration
This commit is contained in:
178
Data/Migrations/20260128134624_InitialCreate.Designer.cs
generated
Normal file
178
Data/Migrations/20260128134624_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// <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("20260128134624_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <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<int>("CurrentPhase")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
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>("Genre")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
Data/Migrations/20260128134624_InitialCreate.cs
Normal file
132
Data/Migrations/20260128134624_InitialCreate.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppState",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
CurrentPhase = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppState", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Players",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
DisplayName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Players", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Suggestions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
PlayerId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
Genre = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||||
|
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||||
|
ScreenshotUrl = table.Column<string>(type: "TEXT", maxLength: 2048, nullable: true),
|
||||||
|
YoutubeUrl = table.Column<string>(type: "TEXT", maxLength: 2048, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
PlayerId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
SuggestionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Score = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppState");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Votes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Suggestions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Players");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
Data/Migrations/AppDbContextModelSnapshot.cs
Normal file
175
Data/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentPhase")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
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>("Genre")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
Program.cs
294
Program.cs
@@ -1,6 +1,10 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -37,9 +41,299 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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" }));
|
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();
|
app.Run();
|
||||||
|
|
||||||
|
static async Task<Player> 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<Phase> 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);
|
||||||
|
|||||||
24
TASKS.md
24
TASKS.md
@@ -10,21 +10,21 @@
|
|||||||
- [x] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`.
|
- [x] Implement `AppDbContext` in `Data/` with DbSets and simple seeding of `AppState`.
|
||||||
|
|
||||||
## Identity & Middleware
|
## Identity & Middleware
|
||||||
- [ ] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production.
|
- [x] 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] Minimal API helpers to resolve current player and ensure existence in DB.
|
||||||
- [ ] Global exception/validation handling and basic logging.
|
- [ ] Global exception/validation handling and basic logging.
|
||||||
|
|
||||||
## Phase Enforcement
|
## Phase Enforcement
|
||||||
- [ ] Store current phase in `AppState`; default to Suggest.
|
- [x] Store current phase in `AppState`; default to Suggest.
|
||||||
- [ ] Central guard ensuring endpoints respect allowed phase (server-side blindness, no client trust).
|
- [x] Central guard ensuring endpoints respect allowed phase (server-side blindness, no client trust).
|
||||||
|
|
||||||
## API Endpoints (see API.md)
|
## API Endpoints (see API.md)
|
||||||
- [ ] `GET /api/state` returns phase and counts.
|
- [x] `GET /api/state` returns phase and counts.
|
||||||
- [ ] `GET /api/me` and `POST /api/me/name` to set display name.
|
- [x] `GET /api/me` and `POST /api/me/name` to set display name.
|
||||||
- [ ] Suggestion endpoints: mine/create/all with per-player visibility rules.
|
- [x] Suggestion endpoints: mine/create/all with per-player visibility rules.
|
||||||
- [ ] Vote endpoints: mine/create with per-player visibility and phase gating.
|
- [x] Vote endpoints: mine/create with per-player visibility and phase gating.
|
||||||
- [ ] Results endpoint aggregates totals and vote counts (optionally averages) sorted desc.
|
- [x] Results endpoint aggregates totals and vote counts (optionally averages) sorted desc.
|
||||||
- [ ] Admin endpoints: switch phase, reset data; protect via env password.
|
- [x] Admin endpoints: switch phase, reset data; protect via env password.
|
||||||
|
|
||||||
## Frontend (wwwroot)
|
## Frontend (wwwroot)
|
||||||
- [ ] `index.html` shell with phase-driven sections.
|
- [ ] `index.html` shell with phase-driven sections.
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
- [ ] `styles.css` basic responsive layout (desktop + mobile).
|
- [ ] `styles.css` basic responsive layout (desktop + mobile).
|
||||||
|
|
||||||
## Persistence & Migrations
|
## Persistence & Migrations
|
||||||
- [ ] Create initial EF Core migration for SQLite schema.
|
- [x] Create initial EF Core migration for SQLite schema.
|
||||||
- [ ] Add startup migration application.
|
- [x] Add startup migration application.
|
||||||
|
|
||||||
## Testing & Quality
|
## Testing & Quality
|
||||||
- [ ] Happy-path smoke test script (manual or minimal automated) for phase flow.
|
- [ ] Happy-path smoke test script (manual or minimal automated) for phase flow.
|
||||||
|
|||||||
13
dotnet-tools.json
Normal file
13
dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.2",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user