Files
vtuber-awards/Backend/Endpoints/PublicNominationEndpoints.cs
T
AzuTear 18b61bed52 Improve admin candidate modal UX and add clip menu visibility toggle
- Widen AdminCandidateEditorModal to size lg for better readability
- Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link"
- Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport
- Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 18:39:35 +02:00

195 lines
8.2 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)
{
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 stream links." });
}
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 => 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 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 = 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();
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(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();
}
}