270 lines
11 KiB
C#
270 lines
11 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> CreateNomination(
|
|
HttpContext context,
|
|
CreateNominationRequest? request,
|
|
AwardsDbContext db,
|
|
IUserSessionService userSessionService,
|
|
IRiskFlagService riskFlagService,
|
|
IRiskRuleService riskRuleService,
|
|
NominationEnrichmentService nominationEnrichmentService)
|
|
{
|
|
var validationError = ValidateCreateNominationRequest(request);
|
|
if (validationError is not null)
|
|
{
|
|
return validationError;
|
|
}
|
|
|
|
var validatedRequest = request!;
|
|
var submittedNominations = NormalizeSubmittedNominations(validatedRequest);
|
|
|
|
if (submittedNominations.Length == 0)
|
|
{
|
|
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
|
|
}
|
|
|
|
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
|
{
|
|
return Results.BadRequest(new { message = "Legacy 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." });
|
|
}
|
|
|
|
var distinctStreamUrls = submittedNominations
|
|
.Select(item => NormalizeNominationUrlForCompare(item.StreamUrl))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
if (distinctStreamUrls.Length != submittedNominations.Length)
|
|
{
|
|
return Results.BadRequest(new { message = "Duplicate stream links 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 settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
|
var linkBlacklist = NominationLinkBlacklistSettings.Read(settings);
|
|
var blacklistedStreamUrl = submittedNominations
|
|
.Select(item => item.StreamUrl)
|
|
.FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist));
|
|
|
|
if (blacklistedStreamUrl is not null)
|
|
{
|
|
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
|
|
}
|
|
|
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == validatedRequest.Year);
|
|
|
|
if (season is null)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected season does not exist." });
|
|
}
|
|
|
|
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, validatedRequest, context.RequestAborted);
|
|
if (string.IsNullOrWhiteSpace(categoryGroupName))
|
|
{
|
|
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
|
}
|
|
|
|
var groupCategories = await db.Categories
|
|
.Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName)
|
|
.OrderBy(item => item.SortOrder)
|
|
.ThenBy(item => item.Name)
|
|
.ToArrayAsync(context.RequestAborted);
|
|
|
|
if (groupCategories.Length == 0)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
|
|
}
|
|
|
|
var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories);
|
|
if (submittedNominations.Length > maxNomineesPerUser)
|
|
{
|
|
return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." });
|
|
}
|
|
|
|
var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
|
if (nominationSeasonResolution.Result is not null)
|
|
{
|
|
return nominationSeasonResolution.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 existingNominationCount = await db.Nominations.CountAsync(item =>
|
|
item.SeasonId == season.Id
|
|
&& item.CategoryGroupName == categoryGroupName
|
|
&& item.SubmittedByTwitchId == submitterId
|
|
&& item.Status == "pending");
|
|
|
|
var records = submittedNominations.Select(nomination => new Nomination
|
|
{
|
|
SeasonId = season.Id,
|
|
CategoryId = null,
|
|
CategoryGroupName = categoryGroupName,
|
|
SubmittedByTwitchId = submitterId,
|
|
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
|
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
|
Status = "pending",
|
|
ReviewNote = $"Stream-Link: {nomination.StreamUrl}",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
}).ToArray();
|
|
|
|
foreach (var record in records)
|
|
{
|
|
await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted);
|
|
}
|
|
|
|
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(
|
|
season.Id,
|
|
submitterId,
|
|
"nomination",
|
|
"resubmitted_nomination",
|
|
resubmittedNominationRule.Severity,
|
|
"Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.",
|
|
requestMetadata,
|
|
new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } },
|
|
context.RequestAborted);
|
|
}
|
|
|
|
if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold)
|
|
{
|
|
await riskFlagService.AddIfMissingAsync(
|
|
season.Id,
|
|
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, categoryGroupName, 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(string.IsNullOrWhiteSpace(name) ? null : name, streamUrl);
|
|
})
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
|
.ToArray();
|
|
}
|
|
|
|
return (request.Nominees ?? [])
|
|
.Select(item =>
|
|
{
|
|
var streamUrl = item.Trim();
|
|
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
|
|
{
|
|
streamUrl = normalizedUrl;
|
|
}
|
|
|
|
return new SubmittedNomination(null, streamUrl);
|
|
})
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
|
.ToArray();
|
|
}
|
|
|
|
private static string NormalizeNominationUrlForCompare(string value) =>
|
|
value.Trim().TrimEnd('/').ToLowerInvariant();
|
|
|
|
private static int ResolveMaxNomineesPerUser(IEnumerable<Category> groupCategories)
|
|
{
|
|
var configuredLimit = groupCategories
|
|
.Select(item => item.MaxNomineesPerUser)
|
|
.Where(value => value > 0)
|
|
.DefaultIfEmpty(3)
|
|
.Max();
|
|
|
|
return Math.Clamp(configuredLimit, 1, 10);
|
|
}
|
|
|
|
private static async Task<string?> ResolveCategoryGroupNameAsync(
|
|
AwardsDbContext db,
|
|
int seasonId,
|
|
CreateNominationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var categoryGroupName = request.CategoryGroupName?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(categoryGroupName))
|
|
{
|
|
return await db.Categories
|
|
.Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName)
|
|
.Select(item => item.GroupName)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
if (!request.CategoryId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return await db.Categories
|
|
.Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value)
|
|
.Select(item => item.GroupName)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
}
|