Refactor endpoint services to accept narrow inputs

This commit is contained in:
2026-02-07 02:17:01 +01:00
parent 5b06e279f3
commit c765dd322b
10 changed files with 179 additions and 102 deletions

View File

@@ -14,7 +14,7 @@ public static class AdminEndpoints
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
{ {
return await service.SetResultsOpenAsync(request); return await service.SetResultsOpenAsync(request.ResultsOpen);
}); });
admin.MapGet("/vote-status", async (AdminWorkflowService service) => admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
@@ -24,7 +24,7 @@ public static class AdminEndpoints
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
{ {
return await service.GrantJokerAsync(request); return await service.GrantJokerAsync(request.PlayerId);
}); });
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) =>
@@ -38,7 +38,7 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.LinkSuggestionsAsync(player, request); return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
}); });
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -47,7 +47,7 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.UnlinkSuggestionsAsync(player, request); return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
}); });
admin.MapPost("/reset", async (AdminWorkflowService service) => admin.MapPost("/reset", async (AdminWorkflowService service) =>

View File

@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
internal sealed class AdminWorkflowService(AppDbContext db) internal sealed class AdminWorkflowService(AppDbContext db)
{ {
public async Task<IResult> SetResultsOpenAsync(ResultsOpenRequest request) public async Task<IResult> SetResultsOpenAsync(bool resultsOpen)
{ {
var state = await db.AppState.FirstAsync(); var state = await db.AppState.FirstAsync();
state.ResultsOpen = request.ResultsOpen; state.ResultsOpen = resultsOpen;
state.UpdatedAt = DateTimeOffset.UtcNow; state.UpdatedAt = DateTimeOffset.UtcNow;
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
if (request.ResultsOpen) if (resultsOpen)
{ {
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results)); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results));
} }
@@ -44,9 +44,9 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new VoteStatusResponse(voters, ready, waiting)); return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
} }
public async Task<IResult> GrantJokerAsync(GrantJokerRequest request) public async Task<IResult> GrantJokerAsync(Guid playerId)
{ {
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) if (player is null)
return EndpointHelpers.NotFoundError("Player not found."); return EndpointHelpers.NotFoundError("Player not found.");
@@ -88,18 +88,18 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminDeletePlayerResponse(playerId)); return Results.Ok(new AdminDeletePlayerResponse(playerId));
} }
public async Task<IResult> LinkSuggestionsAsync(Player adminPlayer, LinkSuggestionsRequest request) public async Task<IResult> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayer.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (request.SourceSuggestionId == request.TargetSuggestionId) if (sourceSuggestionId == targetSuggestionId)
return EndpointHelpers.BadRequestError("Pick two different games to link."); return EndpointHelpers.BadRequestError("Pick two different games to link.");
var suggestions = await db.Suggestions.ToListAsync(); var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == request.SourceSuggestionId); var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == request.TargetSuggestionId); var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
if (source is null || target is null) if (source is null || target is null)
return EndpointHelpers.NotFoundError("Suggestion not found."); return EndpointHelpers.NotFoundError("Suggestion not found.");
@@ -143,14 +143,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
} }
public async Task<IResult> UnlinkSuggestionsAsync(Player adminPlayer, UnlinkSuggestionsRequest request) public async Task<IResult> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayer.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var suggestions = await db.Suggestions.ToListAsync(); var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId); var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
if (target is null) if (target is null)
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0)); return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));

View File

@@ -18,7 +18,7 @@ public static class ResultsEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetResultsAsync(player); return await service.GetResultsAsync(player.Id);
}); });
} }
} }

View File

@@ -7,13 +7,13 @@ namespace GameList.Endpoints;
internal sealed class ResultsWorkflowService(AppDbContext db) internal sealed class ResultsWorkflowService(AppDbContext db)
{ {
public async Task<IResult> GetResultsAsync(Player player) public async Task<IResult> GetResultsAsync(Guid playerId)
{ {
var appState = await db.AppState.AsNoTracking().FirstAsync(); var appState = await db.AppState.AsNoTracking().FirstAsync();
if (!appState.ResultsOpen) if (!appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Results) if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase); return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
@@ -33,7 +33,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score), Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
Votes = s.Votes.Select(v => v.Score).ToList(), Votes = s.Votes.Select(v => v.Score).ToList(),
MyVote = s.Votes MyVote = s.Votes
.Where(v => v.PlayerId == player.Id) .Where(v => v.PlayerId == playerId)
.Select(v => (int?)v.Score) .Select(v => (int?)v.Score)
.FirstOrDefault(), .FirstOrDefault(),
s.ScreenshotUrl, s.ScreenshotUrl,

View File

@@ -17,7 +17,7 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player); return await service.GetMineAsync(player.Id);
}); });
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -26,7 +26,19 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.CreateAsync(player, request); return await service.CreateAsync(
player.Id,
new SuggestionInput(
request.Name,
request.Genre,
request.Description,
request.ScreenshotUrl,
request.YoutubeUrl,
request.GameUrl,
request.MinPlayers,
request.MaxPlayers
)
);
}).AddEndpointFilter(new PhaseOrJokerFilter()); }).AddEndpointFilter(new PhaseOrJokerFilter());
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -35,8 +47,7 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db); return await service.DeleteAsync(player.Id, id);
return await service.DeleteAsync(player, isAdmin, id);
}); });
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -45,8 +56,20 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
var isAdmin = player.IsAdmin; return await service.UpdateAsync(
return await service.UpdateAsync(player, isAdmin, id, request); player.Id,
id,
new SuggestionInput(
request.Name,
request.Genre,
request.Description,
request.ScreenshotUrl,
request.YoutubeUrl,
request.GameUrl,
request.MinPlayers,
request.MaxPlayers
)
);
}); });
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -55,7 +78,7 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetAllAsync(player); return await service.GetAllAsync(player.Id);
}); });
} }
} }

View File

@@ -0,0 +1,12 @@
namespace GameList.Endpoints;
internal readonly record struct SuggestionInput(
string Name,
string? Genre,
string? Description,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
int? MinPlayers,
int? MaxPlayers
);

View File

@@ -1,27 +1,25 @@
using GameList.Contracts;
namespace GameList.Endpoints; namespace GameList.Endpoints;
internal static class SuggestionValidator internal static class SuggestionValidator
{ {
public static async Task<string?> ValidateAsync(SuggestionRequest request, IHttpClientFactory httpFactory) public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory)
{ {
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
return "Name is required and must be <= 100 characters."; return "Name is required and must be <= 100 characters.";
if (!EndpointHelpers.IsValidImageUrl(request.ScreenshotUrl)) if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
return "Screenshot URL must be http(s) and end with an image file extension."; return "Screenshot URL must be http(s) and end with an image file extension.";
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, httpFactory)) if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory))
return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)."; return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB).";
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl)) if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
return "Game URL must be http or https."; return "Game URL must be http or https.";
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl)) if (!EndpointHelpers.IsValidHttpUrl(input.YoutubeUrl))
return "YouTube URL must be http or https."; return "YouTube URL must be http or https.";
return ValidatePlayers(request.MinPlayers, request.MaxPlayers); return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
} }
private static string? ValidatePlayers(int? minPlayers, int? maxPlayers) private static string? ValidatePlayers(int? minPlayers, int? maxPlayers)

View File

@@ -7,11 +7,11 @@ namespace GameList.Endpoints;
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
{ {
public async Task<IResult> GetMineAsync(Player player) public async Task<IResult> GetMineAsync(Guid playerId)
{ {
var mine = await db.Suggestions var mine = await db.Suggestions
.AsNoTracking() .AsNoTracking()
.Where(s => s.PlayerId == player.Id) .Where(s => s.PlayerId == playerId)
.Select(s => new .Select(s => new
{ {
s.Id, s.Id,
@@ -36,35 +36,45 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
return Results.Ok(ordered); return Results.Ok(ordered);
} }
public async Task<IResult> CreateAsync(Player player, SuggestionRequest request) public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input)
{ {
var validationError = await SuggestionValidator.ValidateAsync(request, httpFactory); var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null) if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError); return EndpointHelpers.BadRequestError(validationError);
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var playerState = await db.Players
var usingJoker = phase == Phase.Vote && player.HasJoker; .AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.DisplayName,
p.HasJoker
})
.FirstAsync();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
if (phase != Phase.Suggest && !usingJoker) if (phase != Phase.Suggest && !usingJoker)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
if (string.IsNullOrWhiteSpace(player.DisplayName)) if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id); var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5) if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
var suggestion = new Suggestion var suggestion = new Suggestion
{ {
PlayerId = player.Id, PlayerId = playerId,
Name = request.Name.Trim(), Name = input.Name.Trim(),
Genre = EndpointHelpers.TrimTo(request.Genre, 50), Genre = EndpointHelpers.TrimTo(input.Genre, 50),
Description = EndpointHelpers.TrimTo(request.Description, 500), Description = EndpointHelpers.TrimTo(input.Description, 500),
ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048), ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048),
YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048), YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048),
GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048), GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048),
MinPlayers = request.MinPlayers, MinPlayers = input.MinPlayers,
MaxPlayers = request.MaxPlayers MaxPlayers = input.MaxPlayers
}; };
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -73,7 +83,9 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
if (usingJoker) if (usingJoker)
{ {
player.HasJoker = false; await db.Players
.Where(p => p.Id == playerId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
} }
@@ -83,18 +95,28 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id)); return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
} }
public async Task<IResult> DeleteAsync(Player player, bool isAdmin, int suggestionId) public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId)
{ {
var actor = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.IsAdmin
})
.FirstAsync();
var isAdmin = actor.IsAdmin;
if (!isAdmin) if (!isAdmin)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Suggest) if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
} }
var suggestion = isAdmin var suggestion = isAdmin
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId) ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == player.Id); : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
if (suggestion == null) if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found."); return EndpointHelpers.NotFoundError("Suggestion not found.");
@@ -112,40 +134,50 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
return Results.NoContent(); return Results.NoContent();
} }
public async Task<IResult> UpdateAsync(Player player, bool isAdmin, int suggestionId, SuggestionRequest request) public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
{ {
var validationError = await SuggestionValidator.ValidateAsync(request, httpFactory); var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null) if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError); return EndpointHelpers.BadRequestError(validationError);
var actor = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.IsAdmin
})
.FirstAsync();
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId); var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
if (suggestion == null) if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found."); return EndpointHelpers.NotFoundError("Suggestion not found.");
var isAdmin = actor.IsAdmin;
if (!isAdmin) if (!isAdmin)
{ {
if (suggestion.PlayerId != player.Id) if (suggestion.PlayerId != playerId)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase == Phase.Results) if (phase == Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
if (phase == Phase.Suggest) if (phase == Phase.Suggest)
{ {
suggestion.Name = request.Name.Trim(); suggestion.Name = input.Name.Trim();
} }
else if (phase != Phase.Vote) else if (phase != Phase.Vote)
{ {
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
} }
ApplyEditableFields(suggestion, request); ApplyEditableFields(suggestion, input);
} }
else else
{ {
suggestion.Name = request.Name.Trim(); suggestion.Name = input.Name.Trim();
ApplyEditableFields(suggestion, request); ApplyEditableFields(suggestion, input);
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -163,9 +195,9 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
)); ));
} }
public async Task<IResult> GetAllAsync(Player player) public async Task<IResult> GetAllAsync(Guid playerId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase < Phase.Vote) if (phase < Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
@@ -186,7 +218,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
Author = s.Player!.DisplayName, Author = s.Player!.DisplayName,
s.CreatedAt, s.CreatedAt,
s.ParentSuggestionId, s.ParentSuggestionId,
IsOwner = s.PlayerId == player.Id IsOwner = s.PlayerId == playerId
}) })
.ToListAsync(); .ToListAsync();
@@ -219,14 +251,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
return Results.Ok(ordered); return Results.Ok(ordered);
} }
private static void ApplyEditableFields(Suggestion suggestion, SuggestionRequest request) private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
{ {
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50); suggestion.Genre = EndpointHelpers.TrimTo(input.Genre, 50);
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500); suggestion.Description = EndpointHelpers.TrimTo(input.Description, 500);
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048); suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048);
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048); suggestion.YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048);
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048); suggestion.GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048);
suggestion.MinPlayers = request.MinPlayers; suggestion.MinPlayers = input.MinPlayers;
suggestion.MaxPlayers = request.MaxPlayers; suggestion.MaxPlayers = input.MaxPlayers;
} }
} }

View File

@@ -17,7 +17,7 @@ public static class VoteEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player); return await service.GetMineAsync(player.Id);
}); });
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -25,7 +25,7 @@ public static class VoteEndpoints
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.UpsertAsync(player, request); return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
}); });
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -34,7 +34,7 @@ public static class VoteEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.SetFinalizeAsync(player, request); return await service.SetFinalizeAsync(player.Id, request.Final);
}); });
} }
} }

View File

@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
internal sealed class VoteWorkflowService(AppDbContext db) internal sealed class VoteWorkflowService(AppDbContext db)
{ {
public async Task<IResult> GetMineAsync(Player player) public async Task<IResult> GetMineAsync(Guid playerId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
var votes = await db.Votes var votes = await db.Votes
.AsNoTracking() .AsNoTracking()
.Where(v => v.PlayerId == player.Id) .Where(v => v.PlayerId == playerId)
.Select(v => new .Select(v => new
{ {
v.SuggestionId, v.SuggestionId,
@@ -26,19 +26,29 @@ internal sealed class VoteWorkflowService(AppDbContext db)
return Results.Ok(votes); return Results.Ok(votes);
} }
public async Task<IResult> UpsertAsync(Player player, VoteRequest request) public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score)
{ {
if (request.Score is < 0 or > 10) if (score is < 0 or > 10)
return EndpointHelpers.BadRequestError("Score must be between 0 and 10."); return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
if (player.VotesFinal) var playerState = await db.Players
.AsNoTracking()
.Where(p => p.Id == playerId)
.Select(p => new
{
p.VotesFinal,
p.DisplayName
})
.FirstAsync();
if (playerState.VotesFinal)
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores."); return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (string.IsNullOrWhiteSpace(player.DisplayName)) if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before voting."); return EndpointHelpers.BadRequestError("Set a display name before voting.");
var linkMap = await db.Suggestions var linkMap = await db.Suggestions
@@ -50,46 +60,48 @@ internal sealed class VoteWorkflowService(AppDbContext db)
}) })
.ToListAsync(); .ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(request.SuggestionId)) if (!rootIndex.ContainsKey(suggestionId))
return EndpointHelpers.BadRequestError("Suggestion not found."); return EndpointHelpers.BadRequestError("Suggestion not found.");
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex); var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
if (linkedIds.Count == 0) if (linkedIds.Count == 0)
linkedIds.Add(request.SuggestionId); linkedIds.Add(suggestionId);
var existingVotes = await db.Votes var existingVotes = await db.Votes
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)) .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync(); .ToListAsync();
foreach (var suggestionId in linkedIds) foreach (var linkedSuggestionId in linkedIds)
{ {
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId); var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
if (vote == null) if (vote == null)
{ {
db.Votes.Add(new Vote db.Votes.Add(new Vote
{ {
PlayerId = player.Id, PlayerId = playerId,
SuggestionId = suggestionId, SuggestionId = linkedSuggestionId,
Score = request.Score Score = score
}); });
} }
else else
{ {
vote.Score = request.Score; vote.Score = score;
} }
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new VoteUpsertResponse(linkedIds, request.Score)); return Results.Ok(new VoteUpsertResponse(linkedIds, score));
} }
public async Task<IResult> SetFinalizeAsync(Player player, VoteFinalizeRequest request) public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
player.VotesFinal = request.Final; var player = await db.Players.FirstAsync(p => p.Id == playerId);
player.VotesFinal = final;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
} }