Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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> CreateNomination(
|
||||
HttpContext context,
|
||||
CreateNominationRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService,
|
||||
IRiskFlagService riskFlagService,
|
||||
IRiskRuleService riskRuleService)
|
||||
{
|
||||
var submittedNominations = NormalizeSubmittedNominations(request);
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name.Length > 120))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nominee names must stay below 120 characters." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.StreamUrl.Length > 300))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Stream links must stay below 300 characters." });
|
||||
}
|
||||
|
||||
if (request.Nominations is { Length: > 0 } && submittedNominations.Any(item => string.IsNullOrWhiteSpace(item.StreamUrl)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A stream link is required for every nomination." });
|
||||
}
|
||||
|
||||
var distinctNomineeNames = submittedNominations
|
||||
.Select(item => item.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (distinctNomineeNames.Length != submittedNominations.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
|
||||
}
|
||||
|
||||
var invalidStreamUrl = submittedNominations
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.Select(item => item.StreamUrl)
|
||||
.FirstOrDefault(item => !TryNormalizeExternalUrl(item, out _));
|
||||
|
||||
if (invalidStreamUrl is not null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A valid http(s) stream link is required." });
|
||||
}
|
||||
|
||||
var category = await db.Categories
|
||||
.Include(item => item.Season)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
|
||||
|
||||
if (category is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
||||
}
|
||||
|
||||
var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination");
|
||||
if (nominationSeasonResolution.Result is not null)
|
||||
{
|
||||
return nominationSeasonResolution.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 existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
&& item.SubmittedByTwitchId == submitterId
|
||||
&& item.Status == "pending");
|
||||
|
||||
var records = submittedNominations.Select(nomination => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
Status = "pending",
|
||||
ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl)
|
||||
? null
|
||||
: $"Stream-Link: {nomination.StreamUrl}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted);
|
||||
var rapidNominationBurstRule = await riskRuleService.GetRuleAsync("rapid_nomination_burst", context.RequestAborted);
|
||||
var recentNominationVolume = await db.Nominations.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidNominationBurstRule.WindowMinutes));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
var reviewLink = new
|
||||
{
|
||||
label = "Review-Fälle öffnen",
|
||||
entityType = "nomination",
|
||||
entityId = string.Join(",", records.Select(item => item.Id)),
|
||||
to = $"/admin/reviews?query={Uri.EscapeDataString(submitterId)}",
|
||||
};
|
||||
|
||||
if (existingNominationCount > 0 && resubmittedNominationRule.Enabled)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
resubmittedNominationRule.Severity,
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
requestMetadata,
|
||||
new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
||||
{
|
||||
await riskFlagService.AddIfMissingAsync(
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
rapidNominationBurstRule.Severity,
|
||||
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
|
||||
requestMetadata,
|
||||
new { recentNominationVolume, threshold = rapidNominationBurstRule.Threshold, windowMinutes = rapidNominationBurstRule.WindowMinutes, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string Name, string StreamUrl);
|
||||
|
||||
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
|
||||
{
|
||||
if (request.Nominations is { Length: > 0 })
|
||||
{
|
||||
return request.Nominations
|
||||
.Select(item =>
|
||||
{
|
||||
var name = item.Name?.Trim() ?? string.Empty;
|
||||
var streamUrl = item.StreamUrl?.Trim() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
|
||||
{
|
||||
streamUrl = normalizedUrl;
|
||||
}
|
||||
|
||||
return new SubmittedNomination(name, streamUrl);
|
||||
})
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return (request.Nominees ?? [])
|
||||
.Select(item => new SubmittedNomination(item.Trim(), string.Empty))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user