diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs new file mode 100644 index 0000000..d9698e2 --- /dev/null +++ b/Endpoints/AdminEndpoints.cs @@ -0,0 +1,60 @@ +using GameList.Data; +using GameList.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +public static class AdminEndpoints +{ + public static void MapAdminEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin"); + + admin.MapPost("/phase", async ([FromBody] Contracts.PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => + { + if (!EndpointHelpers.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 (!EndpointHelpers.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 (!EndpointHelpers.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 = EndpointHelpers.NewAppState(); + db.AppState.Add(fresh); + await db.SaveChangesAsync(); + + await tx.CommitAsync(); + + return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt }); + }); + } +} diff --git a/Endpoints/ApiRoutes.cs b/Endpoints/ApiRoutes.cs deleted file mode 100644 index a58c007..0000000 --- a/Endpoints/ApiRoutes.cs +++ /dev/null @@ -1,345 +0,0 @@ -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/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs new file mode 100644 index 0000000..207a63e --- /dev/null +++ b/Endpoints/EndpointHelpers.cs @@ -0,0 +1,56 @@ +using GameList.Data; +using GameList.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +internal static class EndpointHelpers +{ + public 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; + } + + public static async Task GetPhase(AppDbContext db) + { + var state = await db.AppState.AsNoTracking().FirstAsync(); + return state.CurrentPhase; + } + + public static IResult PhaseMismatch(Phase required, Phase current) => + Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." }); + + public 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; + + public 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 static AppState NewAppState() => new() + { + Id = 1, + CurrentPhase = Phase.Suggest, + UpdatedAt = DateTimeOffset.UnixEpoch + }; +} diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs new file mode 100644 index 0000000..7caed52 --- /dev/null +++ b/Endpoints/ResultsEndpoints.cs @@ -0,0 +1,39 @@ +using GameList.Data; +using GameList.Domain; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +public static class ResultsEndpoints +{ + public static void MapResultsEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/results", async (AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase != Phase.Results) + return EndpointHelpers.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); + }); + } +} diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs new file mode 100644 index 0000000..189a067 --- /dev/null +++ b/Endpoints/StateEndpoints.cs @@ -0,0 +1,45 @@ +using GameList.Contracts; +using GameList.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; + +namespace GameList.Endpoints; + +public static class StateEndpoints +{ + public static void MapStateEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/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); + }); + + app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) => + { + var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db); + return Results.Ok(new { player.Id, player.DisplayName }); + }); + + app.MapPost("/api/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 EndpointHelpers.GetOrCreatePlayer(ctx, db); + player.DisplayName = request.Name.Trim(); + await db.SaveChangesAsync(); + return Results.Ok(new { player.Id, player.DisplayName }); + }); + } +} diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs new file mode 100644 index 0000000..2d977ab --- /dev/null +++ b/Endpoints/SuggestEndpoints.cs @@ -0,0 +1,134 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +public static class SuggestEndpoints +{ + public static void MapSuggestEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase != Phase.Suggest) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + + var player = await EndpointHelpers.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); + }); + + app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase != Phase.Suggest) + return EndpointHelpers.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 EndpointHelpers.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 = EndpointHelpers.TrimTo(request.Genre, 50), + Description = EndpointHelpers.TrimTo(request.Description, 500), + ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048), + YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048) + }; + + db.Suggestions.Add(suggestion); + await db.SaveChangesAsync(); + + return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id }); + }); + + app.MapDelete("/api/suggestions/{id:int}", async (int id, HttpContext ctx, AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase != Phase.Suggest) + return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + + var player = await EndpointHelpers.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(); + }); + + app.MapGet("/api/suggestions/all", async (AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase < Phase.Reveal) + return EndpointHelpers.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); + }); + } +} diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs new file mode 100644 index 0000000..df26c85 --- /dev/null +++ b/Endpoints/VoteEndpoints.cs @@ -0,0 +1,66 @@ +using GameList.Contracts; +using GameList.Data; +using GameList.Domain; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GameList.Endpoints; + +public static class VoteEndpoints +{ + public static void MapVoteEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase != Phase.Vote) + return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + + var player = await EndpointHelpers.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); + }); + + app.MapPost("/api/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) => + { + var phase = await EndpointHelpers.GetPhase(db); + if (phase != Phase.Vote) + return EndpointHelpers.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 EndpointHelpers.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 }); + }); + } +} diff --git a/Program.cs b/Program.cs index f44307e..2d3ef8e 100644 --- a/Program.cs +++ b/Program.cs @@ -52,6 +52,10 @@ app.UseStaticFiles(); app.UsePlayerIdentity(); app.MapHealthChecks(); -app.MapApi(); +app.MapStateEndpoints(); +app.MapSuggestEndpoints(); +app.MapVoteEndpoints(); +app.MapResultsEndpoints(); +app.MapAdminEndpoints(); app.Run();