Add linked suggestions with synced voting
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user