using Backend.Common; using Backend.Contracts; using Backend.Data; using Backend.Domain; using Backend.Services; using Microsoft.EntityFrameworkCore; 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) { if (request.Entries.Length == 0) { return Results.BadRequest(new { message = "At least one vote entry is required." }); } var distinctCategoryCount = request.Entries .Select(item => item.CategoryId) .Distinct() .Count(); if (distinctCategoryCount != request.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 == request.SeasonId); var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting"); if (voteSeasonResolution.Result is not null) { return voteSeasonResolution.Result; } var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); if (submitterIdResult.Result is not null) { return submitterIdResult.Result; } var submitterId = submitterIdResult.SubmitterId!; var requestMetadata = RequestMetadataReader.Read(context); var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray(); var validCandidates = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == request.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 (request.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 == request.SeasonId && item.SubmittedByTwitchId == submitterId); var isResubmission = ballot is not null; if (ballot is null) { ballot = new VoteBallot { SeasonId = request.SeasonId, SubmittedByTwitchId = submitterId, }; await db.VoteBallots.AddAsync(ballot); } else { db.VoteEntries.RemoveRange(ballot.Entries); ballot.Entries.Clear(); } ballot.SubmittedAt = DateTimeOffset.UtcNow; ballot.Status = "submitted"; ballot.Entries = request.Entries.Select(entry => new VoteEntry { CategoryId = entry.CategoryId, CandidateId = entry.CandidateId, }).ToList(); 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)); 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( request.SeasonId, submitterId, "vote", "resubmitted_ballot", resubmittedBallotRule.Severity, "Ein User hat sein Ballot erneut gespeichert oder aktualisiert.", requestMetadata, new { ballotId = ballot.Id, entryCount = request.Entries.Length, entityLinks = new[] { ballotLink } }, context.RequestAborted); } if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold) { await riskFlagService.AddIfMissingAsync( request.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 }); } }