using Backend.Common; using Backend.Contracts; using Backend.Data; using Backend.Domain; using Backend.Services; using Microsoft.EntityFrameworkCore; using Npgsql; namespace Backend.Endpoints; public static partial class PublicEndpoints { private static async Task CreateVote( HttpContext context, CreateVoteRequest? request, AwardsDbContext db, IUserSessionService userSessionService, IRiskFlagService riskFlagService, IRiskRuleService riskRuleService) { var validationError = ValidateCreateVoteRequest(request); if (validationError is not null) { return validationError; } var validatedRequest = request!; var distinctCategoryCount = validatedRequest.Entries .Select(item => item.CategoryId) .Distinct() .Count(); if (distinctCategoryCount != validatedRequest.Entries.Length) { return Results.BadRequest(new { message = "Only one vote entry per category is allowed." }); } var season = await db.Seasons .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == validatedRequest.SeasonId); var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting"); if (voteSeasonResolution.Result is not null) { return voteSeasonResolution.Result; } var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService); if (submitterIdResult.Result is not null) { return submitterIdResult.Result; } var submitterId = submitterIdResult.SubmitterId!; var requestMetadata = RequestMetadataReader.Read(context); var candidateIds = validatedRequest.Entries.Select(item => item.CandidateId).Distinct().ToArray(); var validCandidates = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == validatedRequest.SeasonId && candidateIds.Contains(item.Id)) .Select(item => new { item.Id, item.CategoryId }) .ToArrayAsync(); if (validCandidates.Length != candidateIds.Length) { return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." }); } var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId); if (validatedRequest.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId)) { return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." }); } var ballot = await db.VoteBallots .Include(item => item.Entries) .FirstOrDefaultAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId); var isResubmission = ballot is not null; ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted); var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted); var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted); var recentVoteSubmissions = await db.VoteBallots.CountAsync(item => item.SubmittedByTwitchId == submitterId && item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes)); try { await db.SaveChangesAsync(context.RequestAborted); } catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error)) { db.ChangeTracker.Clear(); ballot = await db.VoteBallots .Include(item => item.Entries) .FirstAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted); isResubmission = true; ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted); await db.SaveChangesAsync(context.RequestAborted); } var ballotLink = new { label = "Voting-Analytics öffnen", entityType = "vote", entityId = ballot.Id.ToString(), to = $"/admin/analytics?query={Uri.EscapeDataString(submitterId)}", }; if (isResubmission && resubmittedBallotRule.Enabled) { await riskFlagService.AddIfMissingAsync( validatedRequest.SeasonId, submitterId, "vote", "resubmitted_ballot", resubmittedBallotRule.Severity, "Ein User hat sein Ballot erneut gespeichert oder aktualisiert.", requestMetadata, new { ballotId = ballot.Id, entryCount = validatedRequest.Entries.Length, entityLinks = new[] { ballotLink } }, context.RequestAborted); } if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold) { await riskFlagService.AddIfMissingAsync( validatedRequest.SeasonId, submitterId, "vote", "rapid_vote_updates", rapidVoteUpdatesRule.Severity, "Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.", requestMetadata, new { ballotId = ballot.Id, recentVoteSubmissions, threshold = rapidVoteUpdatesRule.Threshold, windowMinutes = rapidVoteUpdatesRule.WindowMinutes, entityLinks = new[] { ballotLink } }, context.RequestAborted); } await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission }); } private static async Task ApplyVoteEntriesAsync( AwardsDbContext db, VoteBallot? ballot, int seasonId, string submitterId, VoteEntryRequest[] entries, CancellationToken cancellationToken) { if (ballot is null) { ballot = new VoteBallot { SeasonId = seasonId, SubmittedByTwitchId = submitterId, }; await db.VoteBallots.AddAsync(ballot, cancellationToken); } else { db.VoteEntries.RemoveRange(ballot.Entries); ballot.Entries.Clear(); } ballot.SubmittedAt = DateTimeOffset.UtcNow; ballot.Status = "submitted"; ballot.Entries = entries.Select(entry => new VoteEntry { CategoryId = entry.CategoryId, CandidateId = entry.CandidateId, }).ToList(); return ballot; } private static bool IsUniqueVoteBallotViolation(DbUpdateException error) => error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; }