using GameList.Contracts; using GameList.Data; using GameList.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace GameList.Endpoints; internal sealed class VoteWorkflowService(AppDbContext db) { public async Task GetMineAsync(Guid playerId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); var votes = await db.Votes .AsNoTracking() .Where(v => v.PlayerId == playerId) .Select(v => new { v.SuggestionId, v.Score }) .ToListAsync(); return Results.Ok(votes); } public async Task UpsertAsync(Guid playerId, int suggestionId, int score) { if (score is < 0 or > 10) return EndpointHelpers.BadRequestError("Score must be between 0 and 10."); 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."); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) return EndpointHelpers.BadRequestError("Set a display name before voting."); 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(suggestionId)) return EndpointHelpers.BadRequestError("Suggestion not found."); var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex); if (linkedIds.Count == 0) linkedIds.Add(suggestionId); var existingVotes = await db.Votes .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .ToListAsync(); for (var attempt = 0; attempt < 2; attempt++) { foreach (var linkedSuggestionId in linkedIds) { var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); if (vote == null) { db.Votes.Add(new Vote { PlayerId = playerId, SuggestionId = linkedSuggestionId, Score = score }); } else { vote.Score = score; } } try { await db.SaveChangesAsync(); return Results.Ok(new VoteUpsertResponse(linkedIds, score)); } catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex)) { DetachAddedVotes(db.ChangeTracker.Entries()); await db.Votes .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score)); existingVotes = await db.Votes .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .ToListAsync(); } } return EndpointHelpers.ConflictError("Vote update conflict. Please retry."); } public async Task SetFinalizeAsync(Guid playerId, bool final) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); var player = await db.Players.FirstAsync(p => p.Id == playerId); player.VotesFinal = final; await db.SaveChangesAsync(); return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); } private static void DetachAddedVotes(IEnumerable> voteEntries) { foreach (var entry in voteEntries) { if (entry.State == EntityState.Added) entry.State = EntityState.Detached; } } }