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 }; }