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 ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); IReadOnlyList votes = await db.Votes .AsNoTracking() .Where(v => v.PlayerId == playerId) .Select(v => new VoteRecordDto(v.SuggestionId, v.Score)) .ToListAsync(); return ServiceResult>.Success(votes); } public async Task> UpsertAsync(Guid playerId, int suggestionId, int score) { if (score is < 0 or > 10) return ServiceResult.Failure(ServiceError.BadRequest("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 ServiceResult.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores.")); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) return ServiceResult.Failure(ServiceError.BadRequest("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 ServiceResult.Failure(ServiceError.BadRequest("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 ServiceResult.Success(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 ServiceResult.Failure(ServiceError.Conflict("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 ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); var player = await db.Players.FirstAsync(p => p.Id == playerId); player.VotesFinal = final; await db.SaveChangesAsync(); return ServiceResult.Success(new VoteFinalizeResponse(player.VotesFinal)); } private static void DetachAddedVotes(IEnumerable> voteEntries) { foreach (var entry in voteEntries) { if (entry.State == EntityState.Added) entry.State = EntityState.Detached; } } }