Add linked suggestions with synced voting

This commit is contained in:
2026-02-05 09:07:46 +01:00
parent 431370ceb9
commit 5d432c9d17
19 changed files with 725 additions and 34 deletions

View File

@@ -3,6 +3,7 @@ using GameList.Domain;
using GameList.Contracts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
namespace GameList.Endpoints;
@@ -51,6 +52,78 @@ public static class AdminEndpoints
return Results.Ok(new { voters, ready, waiting });
});
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null || !await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
if (request.SourceSuggestionId == request.TargetSuggestionId)
return Results.BadRequest(new { error = "Pick two different games to link." });
var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == request.SourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == request.TargetSuggestionId);
if (source is null || target is null)
return Results.NotFound(new { error = "Suggestion not found." });
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
return Results.NotFound(new { error = "Suggestion not found." });
if (sourceRoot == targetRoot)
return Results.BadRequest(new { error = "These games are already linked." });
var affectedRootIds = new HashSet<int> { sourceRoot, targetRoot };
var affectedIds = rootIndex
.Where(kv => affectedRootIds.Contains(kv.Value))
.Select(kv => kv.Key)
.ToList();
await using var tx = await db.Database.BeginTransactionAsync();
foreach (var suggestion in suggestions)
{
var root = rootIndex.GetValueOrDefault(suggestion.Id);
if (root == targetRoot)
{
suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot;
}
else if (root == sourceRoot)
{
suggestion.ParentSuggestionId = targetRoot;
}
}
await db.SaveChangesAsync();
var affectedPlayerIds = await db.Votes
.Where(v => affectedIds.Contains(v.SuggestionId))
.Select(v => v.PlayerId)
.Distinct()
.ToListAsync();
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
if (affectedPlayerIds.Count > 0)
{
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
}
await tx.CommitAsync();
return Results.Ok(new
{
RootId = targetRoot,
LinkedSuggestionIds = affectedIds,
UnfinalizedPlayers = affectedPlayerIds.Count
});
});
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Mvc;
@@ -163,4 +164,35 @@ internal static class EndpointHelpers
ResultsOpen = false,
UpdatedAt = DateTimeOffset.UnixEpoch
};
public static Dictionary<int, int> BuildLinkRoots(IEnumerable<(int Id, int? ParentId)> items)
{
var parentMap = items.ToDictionary(x => x.Id, x => x.ParentId);
var roots = new Dictionary<int, int>();
foreach (var id in parentMap.Keys)
{
roots[id] = FindRootId(id, parentMap);
}
return roots;
}
public static int FindRootId(int suggestionId, IReadOnlyDictionary<int, int?> parentMap)
{
var current = suggestionId;
var visited = new HashSet<int>();
while (parentMap.TryGetValue(current, out var parent) && parent is int p && !visited.Contains(p))
{
visited.Add(current);
current = p;
}
return current;
}
public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex)
{
if (!rootIndex.TryGetValue(suggestionId, out var root)) return new();
return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList();
}
}

View File

@@ -46,11 +46,47 @@ public static class ResultsEndpoints
s.GameUrl,
s.Description,
s.Genre,
s.ParentSuggestionId
})
.OrderByDescending(r => r.Average)
.ToListAsync();
return Results.Ok(results);
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
var shaped = results.Select(r =>
{
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id)
.ToList();
return new
{
r.Id,
r.Name,
r.Author,
r.MinPlayers,
r.MaxPlayers,
r.Total,
r.Count,
r.Average,
r.Votes,
r.MyVote,
r.ScreenshotUrl,
r.YoutubeUrl,
r.GameUrl,
r.Description,
r.Genre,
r.ParentSuggestionId,
LinkedIds = linkedIds,
LinkedTitles = linkedIds
.Where(id => nameLookup.ContainsKey(id))
.Select(id => nameLookup[id])
.ToList()
};
});
return Results.Ok(shaped);
}
);
}

View File

@@ -28,13 +28,14 @@ public static class SuggestEndpoints
s.GameUrl,
s.CreatedAt,
s.MinPlayers,
s.MaxPlayers
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));
.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);
});
@@ -206,25 +207,42 @@ public static class SuggestEndpoints
s.MinPlayers,
s.MaxPlayers,
Author = s.Player!.DisplayName,
s.CreatedAt
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 => new
.Select(s =>
{
s.Id,
s.PlayerId,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.GameUrl,
s.MinPlayers,
s.MaxPlayers,
s.Author
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);

View File

@@ -41,28 +41,40 @@ public static class VoteEndpoints
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)
var linkMap = await db.Suggestions.AsNoTracking()
.Select(s => new { s.Id, s.ParentSuggestionId })
.ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(request.SuggestionId))
return Results.BadRequest(new { error = "Suggestion not found." });
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
if (linkedIds.Count == 0)
linkedIds.Add(request.SuggestionId);
var vote = await db.Votes.FirstOrDefaultAsync(v => v.PlayerId == player.Id && v.SuggestionId == request.SuggestionId);
if (vote == null)
var existingVotes = await db.Votes
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
foreach (var suggestionId in linkedIds)
{
vote = new Vote
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId);
if (vote == null)
{
PlayerId = player.Id,
SuggestionId = request.SuggestionId,
Score = request.Score
};
db.Votes.Add(vote);
}
else
{
vote.Score = request.Score;
db.Votes.Add(new Vote
{
PlayerId = player.Id,
SuggestionId = suggestionId,
Score = request.Score
});
}
else
{
vote.Score = request.Score;
}
}
await db.SaveChangesAsync();
return Results.Ok(new { vote.Id, vote.Score });
return Results.Ok(new { SuggestionIds = linkedIds, request.Score });
});
app.MapPost("/api/votes/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>