diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs new file mode 100644 index 0000000..ea83a13 --- /dev/null +++ b/Contracts/Dtos.cs @@ -0,0 +1,7 @@ +namespace GameList.Contracts; + +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(GameList.Domain.Phase Phase); diff --git a/Endpoints/ApiRoutes.cs b/Endpoints/ApiRoutes.cs new file mode 100644 index 0000000..a58c007 --- /dev/null +++ b/Endpoints/ApiRoutes.cs @@ -0,0 +1,345 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +public static class ApiRoutes +{ + public static void MapApi(this IEndpointRouteBuilder app) + { + 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) + .Select(s => new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.CreatedAt + }) + .ToListAsync(); + + var ordered = mine + .OrderBy(s => s.CreatedAt) + .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl)); + + return Results.Ok(ordered); + }); + + 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); + + if (string.IsNullOrWhiteSpace(player.DisplayName)) + { + return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); + } + + 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.MapDelete("/suggestions/{id:int}", async (int id, 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 suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id); + if (suggestion == null) + return Results.NotFound(new { error = "Suggestion not found." }); + + db.Suggestions.Remove(suggestion); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + 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) + .Select(s => new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + Author = s.Player!.DisplayName, + s.CreatedAt + }) + .ToListAsync(); + + var ordered = all + .OrderBy(s => s.CreatedAt) + .Select(s => new + { + s.Id, + s.Name, + s.Genre, + s.Description, + s.ScreenshotUrl, + s.YoutubeUrl, + s.Author + }); + + return Results.Ok(ordered); + }); + + 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); + + if (string.IsNullOrWhiteSpace(player.DisplayName)) + return Results.BadRequest(new { error = "Set a display name before voting." }); + + 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), + s.ScreenshotUrl, + s.YoutubeUrl, + s.Description, + s.Genre + }) + .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 }); + }); + + admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => + { + if (!IsAuthorized(ctx, config)) return Results.Unauthorized(); + + await using var tx = await db.Database.BeginTransactionAsync(); + + await db.Votes.ExecuteDeleteAsync(); + await db.Suggestions.ExecuteDeleteAsync(); + await db.Players.ExecuteDeleteAsync(); + await db.AppState.ExecuteDeleteAsync(); + + var fresh = NewAppState(); + db.AppState.Add(fresh); + await db.SaveChangesAsync(); + + await tx.CommitAsync(); + + return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt }); + }); + } + + private static async Task GetOrCreatePlayer(HttpContext ctx, AppDbContext db) + { + if (!ctx.Items.TryGetValue(Infrastructure.PlayerIdentityExtensions.PlayerCookieName, 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; + } + + private static async Task GetPhase(AppDbContext db) + { + var state = await db.AppState.AsNoTracking().FirstAsync(); + return state.CurrentPhase; + } + + private static IResult PhaseMismatch(Phase required, Phase current) => + Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." }); + + private 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; + + private 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; + } + + private static AppState NewAppState() => new() + { + Id = 1, + CurrentPhase = Phase.Suggest, + UpdatedAt = DateTimeOffset.UnixEpoch + }; +} diff --git a/Infrastructure/PlayerIdentityExtensions.cs b/Infrastructure/PlayerIdentityExtensions.cs new file mode 100644 index 0000000..f0444a7 --- /dev/null +++ b/Infrastructure/PlayerIdentityExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Diagnostics; + +namespace GameList.Infrastructure; + +public static class PlayerIdentityExtensions +{ + public const string PlayerCookieName = "player"; + + public static IApplicationBuilder UsePlayerIdentity(this IApplicationBuilder app) + { + app.Use(async (ctx, next) => + { + var cookieOptions = new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Secure = !app.ApplicationServices.GetRequiredService().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(); + }); + + return app; + } + + public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app) + { + app.UseExceptionHandler(handler => + { + handler.Run(async context => + { + var feature = context.Features.Get(); + var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); + if (feature?.Error != null) + { + logger.LogError(feature.Error, "Unhandled exception"); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" }); + }); + }); + return app; + } + + public static IEndpointRouteBuilder MapHealthChecks(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/health", () => Results.Ok(new { status = "ok" })); + return endpoints; + } +} diff --git a/Program.cs b/Program.cs index 52430e6..f44307e 100644 --- a/Program.cs +++ b/Program.cs @@ -1,11 +1,8 @@ using GameList.Data; -using GameList.Domain; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; +using GameList.Endpoints; +using GameList.Infrastructure; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); @@ -15,31 +12,25 @@ Directory.CreateDirectory(dataDirectory); var configuredConnection = builder.Configuration.GetConnectionString("Default"); var dbPath = Path.Combine(dataDirectory, "gamelist.db"); -var connectionBuilder = new SqliteConnectionStringBuilder(); +var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) + ? $"Data Source={dbPath}" + : configuredConnection); -if (string.IsNullOrWhiteSpace(configuredConnection)) +if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) { - connectionBuilder.DataSource = dbPath; + var fileName = Path.GetFileName(connectionBuilder.DataSource); + connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName); } -else +else if (!Path.IsPathRooted(connectionBuilder.DataSource)) { - connectionBuilder = new SqliteConnectionStringBuilder(configuredConnection); - - if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) - { - var fileName = Path.GetFileName(connectionBuilder.DataSource); - connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName); - } - else if (!Path.IsPathRooted(connectionBuilder.DataSource)) - { - connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory); - } + connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory); } var connectionString = connectionBuilder.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); + builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); @@ -47,22 +38,7 @@ builder.Services.ConfigureHttpJsonOptions(options => var app = builder.Build(); -app.UseExceptionHandler(handler => -{ - handler.Run(async context => - { - var feature = context.Features.Get(); - var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); - if (feature?.Error != null) - { - logger.LogError(feature.Error, "Unhandled exception"); - } - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" }); - }); -}); +app.UseGlobalExceptionLogging(); // Ensure database and migrations are applied on startup using (var scope = app.Services.CreateScope()) @@ -73,371 +49,9 @@ using (var scope = app.Services.CreateScope()) app.UseDefaultFiles(); app.UseStaticFiles(); +app.UsePlayerIdentity(); -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) - .Select(s => new - { - s.Id, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.CreatedAt - }) - .ToListAsync(); - - var ordered = mine - .OrderBy(s => s.CreatedAt) - .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl)); - - return Results.Ok(ordered); -}); - -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); - - if (string.IsNullOrWhiteSpace(player.DisplayName)) - { - return Results.BadRequest(new { error = "Set a display name before submitting suggestions." }); - } - - 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.MapDelete("/suggestions/{id:int}", async (int id, 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 suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id); - if (suggestion == null) - return Results.NotFound(new { error = "Suggestion not found." }); - - db.Suggestions.Remove(suggestion); - await db.SaveChangesAsync(); - return Results.NoContent(); -}); - -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) - .Select(s => new - { - s.Id, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - Author = s.Player!.DisplayName, - s.CreatedAt - }) - .ToListAsync(); - - var ordered = all - .OrderBy(s => s.CreatedAt) - .Select(s => new - { - s.Id, - s.Name, - s.Genre, - s.Description, - s.ScreenshotUrl, - s.YoutubeUrl, - s.Author - }); - - return Results.Ok(ordered); -}); - -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); - - if (string.IsNullOrWhiteSpace(player.DisplayName)) - return Results.BadRequest(new { error = "Set a display name before voting." }); - - 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), - s.ScreenshotUrl, - s.YoutubeUrl, - s.Description, - s.Genre - }) - .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 }); -}); - -admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => -{ - if (!IsAuthorized(ctx, config)) return Results.Unauthorized(); - - await using var tx = await db.Database.BeginTransactionAsync(); - - await db.Votes.ExecuteDeleteAsync(); - await db.Suggestions.ExecuteDeleteAsync(); - await db.Players.ExecuteDeleteAsync(); - await db.AppState.ExecuteDeleteAsync(); - - var fresh = NewAppState(); - db.AppState.Add(fresh); - await db.SaveChangesAsync(); - - await tx.CommitAsync(); - - return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt }); -}); +app.MapHealthChecks(); +app.MapApi(); 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; -} - -static AppState NewAppState() => new() -{ - Id = 1, - CurrentPhase = Phase.Suggest, - UpdatedAt = DateTimeOffset.UnixEpoch -}; - -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);