18b61bed52
- 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>
175 lines
7.0 KiB
C#
175 lines
7.0 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> CreateClip(
|
|
HttpContext context,
|
|
CreateClipRequest request,
|
|
AwardsDbContext db,
|
|
IUserSessionService userSessionService,
|
|
IRiskFlagService riskFlagService,
|
|
IRiskRuleService riskRuleService)
|
|
{
|
|
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl))
|
|
{
|
|
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
|
|
}
|
|
|
|
var platform = ResolveClipPlatform(clipUrl);
|
|
if (platform is null)
|
|
{
|
|
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
|
|
}
|
|
|
|
var siteSettings = await db.SiteSettings
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted);
|
|
if (siteSettings is null)
|
|
{
|
|
return Results.Problem("Site settings are missing.");
|
|
}
|
|
|
|
if (!siteSettings.ClipSubmissionsEnabled)
|
|
{
|
|
var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage)
|
|
? "Clip-Einreichungen sind aktuell geschlossen."
|
|
: siteSettings.ClipSubmissionDisabledMessage;
|
|
return Results.BadRequest(new { message });
|
|
}
|
|
|
|
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
|
|
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
|
|
if (clipSeasonResolution.Result is not null)
|
|
{
|
|
return clipSeasonResolution.Result;
|
|
}
|
|
|
|
season = clipSeasonResolution.Season!;
|
|
|
|
var selectedCandidate = request.CandidateId is int candidateId
|
|
? await db.Candidates
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
|
|
: null;
|
|
if (request.CandidateId is not null && selectedCandidate is null)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
|
|
}
|
|
|
|
if (request.CategoryId is int requestedCategoryId
|
|
&& selectedCandidate is not null
|
|
&& selectedCandidate.CategoryId != requestedCategoryId)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
|
|
}
|
|
|
|
var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId;
|
|
var normalizedTitle = request.Title?.Trim() ?? string.Empty;
|
|
var submittedCreator = request.Creator?.Trim();
|
|
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
|
|
? selectedCandidate?.DisplayName ?? string.Empty
|
|
: submittedCreator;
|
|
if (normalizedTitle.Length > 160)
|
|
{
|
|
return Results.BadRequest(new { message = "Clip titles must stay below 160 characters." });
|
|
}
|
|
|
|
if (normalizedCreator.Length > 160)
|
|
{
|
|
return Results.BadRequest(new { message = "Creator names must stay below 160 characters." });
|
|
}
|
|
|
|
if (resolvedCategoryId is int categoryId)
|
|
{
|
|
var categoryExists = selectedCandidate?.CategoryId == categoryId
|
|
|| await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
|
|
if (!categoryExists)
|
|
{
|
|
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
|
|
}
|
|
}
|
|
|
|
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 duplicateClipRule = await riskRuleService.GetRuleAsync("duplicate_clip_submission", context.RequestAborted);
|
|
var rapidClipBurstRule = await riskRuleService.GetRuleAsync("rapid_clip_burst", context.RequestAborted);
|
|
var recentClipSubmissions = await db.ClipSubmissions.CountAsync(item =>
|
|
item.SubmittedByTwitchId == submitterId
|
|
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidClipBurstRule.WindowMinutes));
|
|
var alreadySubmittedClip = await db.ClipSubmissions.AnyAsync(item =>
|
|
item.SeasonId == season.Id
|
|
&& item.SubmittedByTwitchId == submitterId
|
|
&& item.ClipUrl == clipUrl);
|
|
|
|
var clip = new ClipSubmission
|
|
{
|
|
SeasonId = season.Id,
|
|
CategoryId = resolvedCategoryId,
|
|
CandidateId = selectedCandidate?.Id,
|
|
SubmittedByTwitchId = submitterId,
|
|
ClipUrl = clipUrl,
|
|
Title = normalizedTitle,
|
|
Creator = normalizedCreator,
|
|
Platform = platform,
|
|
Status = "pending",
|
|
CreatedFromIp = requestMetadata.ClientIp,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
|
|
db.ClipSubmissions.Add(clip);
|
|
await db.SaveChangesAsync(context.RequestAborted);
|
|
|
|
var clipLink = new
|
|
{
|
|
label = "Clip öffnen",
|
|
entityType = "clip",
|
|
entityId = clip.Id.ToString(),
|
|
to = $"/admin/clips?query={Uri.EscapeDataString(clip.Id.ToString())}",
|
|
};
|
|
|
|
if (alreadySubmittedClip && duplicateClipRule.Enabled)
|
|
{
|
|
await riskFlagService.AddIfMissingAsync(
|
|
season.Id,
|
|
submitterId,
|
|
"clip",
|
|
"duplicate_clip_submission",
|
|
duplicateClipRule.Severity,
|
|
"Ein User hat denselben Clip erneut eingereicht.",
|
|
requestMetadata,
|
|
new { clipId = clip.Id, clipUrl, CategoryId = resolvedCategoryId, CandidateId = selectedCandidate?.Id, entityLinks = new[] { clipLink } },
|
|
context.RequestAborted);
|
|
}
|
|
|
|
if (rapidClipBurstRule.Enabled && recentClipSubmissions >= rapidClipBurstRule.Threshold)
|
|
{
|
|
await riskFlagService.AddIfMissingAsync(
|
|
season.Id,
|
|
submitterId,
|
|
"clip",
|
|
"rapid_clip_burst",
|
|
rapidClipBurstRule.Severity,
|
|
"Ungewoehnlich viele Clip-Einreichungen in kurzer Zeit erkannt.",
|
|
requestMetadata,
|
|
new { clipId = clip.Id, recentClipSubmissions, threshold = rapidClipBurstRule.Threshold, windowMinutes = rapidClipBurstRule.WindowMinutes, entityLinks = new[] { clipLink } },
|
|
context.RequestAborted);
|
|
}
|
|
|
|
await db.SaveChangesAsync(context.RequestAborted);
|
|
return Results.Ok(new { saved = true, clipId = clip.Id });
|
|
}
|
|
}
|