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 player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var mine = await db.Suggestions.AsNoTracking() .Where(s => s.PlayerId == player.Id) .Select(s => new { s.Id, s.PlayerId, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.CreatedAt, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId }) .ToListAsync(); var ordered = mine .OrderBy(s => s.CreatedAt) .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId)); return Results.Ok(ordered); }); app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) => { if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) { return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." }); } if (!EndpointHelpers.IsValidImageUrl(request.ScreenshotUrl)) { return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." }); } if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http)) { return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." }); } if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError)) return Results.BadRequest(new { error = playersError }); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var phase = await EndpointHelpers.GetPhase(db, player.Id); if (phase != Phase.Suggest) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); 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 >= 5) { return Results.BadRequest(new { error = "You have reached the 5 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), GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048), MinPlayers = request.MinPlayers, MaxPlayers = request.MaxPlayers }; 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, IConfiguration config) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config); var phase = await EndpointHelpers.GetPhase(db, player.Id); if (!isAdmin && phase != Phase.Suggest) return Results.BadRequest(new { error = "Suggestions are frozen; you can no longer delete them." }); var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : 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.MapPut("/api/suggestions/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, IHttpClientFactory http) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config); if (!isAdmin) { if (player is null) return Results.Unauthorized(); var phase = await EndpointHelpers.GetPhase(db, player.Id); // Non-admins can edit optional fields after Suggest, but not the name } if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) { return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." }); } if (!EndpointHelpers.IsValidImageUrl(request.ScreenshotUrl)) { return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." }); } if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http)) { return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." }); } if (!ValidatePlayers(request.MinPlayers, request.MaxPlayers, out var playersError)) return Results.BadRequest(new { error = playersError }); var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id); if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); if (!isAdmin) { if (suggestion.PlayerId != player!.Id) return Results.Unauthorized(); } var isSuggestPhase = isAdmin ? true : await EndpointHelpers.GetPhase(db, player?.Id ?? Guid.Empty) == Phase.Suggest; if (isSuggestPhase || isAdmin) { suggestion.Name = request.Name.Trim(); } suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50); suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500); suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048); suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048); suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048); suggestion.MinPlayers = request.MinPlayers; suggestion.MaxPlayers = request.MaxPlayers; await db.SaveChangesAsync(); return Results.Ok(new { suggestion.Id, suggestion.Name, suggestion.Genre, suggestion.Description, suggestion.ScreenshotUrl, suggestion.YoutubeUrl, suggestion.GameUrl, suggestion.MinPlayers, suggestion.MaxPlayers }); }); app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) => { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); var phase = await EndpointHelpers.GetPhase(db, player.Id); if (phase < Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); var all = await db.Suggestions.AsNoTracking() .Include(s => s.Player) .Select(s => new { s.Id, s.PlayerId, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, Author = s.Player!.DisplayName, s.CreatedAt, s.ParentSuggestionId }) .ToListAsync(); var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); var ordered = all .OrderBy(s => s.CreatedAt) .Select(s => { var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex) .Where(id => id != s.Id) .ToList(); return new { s.Id, s.PlayerId, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.Author, s.ParentSuggestionId, LinkedIds = linkedIds, LinkedTitles = linkedIds .Where(id => nameLookup.ContainsKey(id)) .Select(id => nameLookup[id]) .ToList() }; }); return Results.Ok(ordered); }); } private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error) { error = null; if (minPlayers is null && maxPlayers is null) return true; if (minPlayers is not null && (minPlayers < 1 || minPlayers > 32)) { error = "Min players must be between 1 and 32."; return false; } if (maxPlayers is not null && (maxPlayers < 1 || maxPlayers > 32)) { error = "Max players must be between 1 and 32."; return false; } if (minPlayers is null || maxPlayers is null) { error = "Provide both min and max players."; return false; } if (minPlayers > maxPlayers) { error = "Min players cannot exceed max players."; return false; } return true; } }