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 CreateNomination( HttpContext context, CreateNominationRequest request, AwardsDbContext db, IUserSessionService userSessionService, IRiskFlagService riskFlagService, IRiskRuleService riskRuleService, NominationEnrichmentService nominationEnrichmentService) { var submittedNominations = NormalizeSubmittedNominations(request); 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 == request.Year); if (season is null) { return Results.BadRequest(new { message = "The selected season does not exist." }); } var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, 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, 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 == 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 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 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); } }