146 lines
5.6 KiB
C#
146 lines
5.6 KiB
C#
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<IResult> 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 });
|
|
}
|
|
}
|