Files
vtuber-awards/Backend/Endpoints/AdminNominationModerationEndpoints.cs
T
AzuTear 6b3b0360e7
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s
Add winner archive, host image upload and live demo data
Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:47:52 +02:00

455 lines
19 KiB
C#

using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> ApproveNomination(
HttpContext context,
int nominationId,
ApproveNominationRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations
.Include(item => item.Category)
.Include(item => item.SuggestedCategory)
.Include(item => item.StreamerIdentity)
.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel);
if (string.IsNullOrWhiteSpace(rawDisplayName))
{
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
}
var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId;
if (!categoryId.HasValue)
{
return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." });
}
var targetCategory = await db.Categories.FirstOrDefaultAsync(item =>
item.Id == categoryId.Value
&& item.SeasonId == nomination.SeasonId
&& item.GroupName == nomination.CategoryGroupName);
if (targetCategory is null)
{
return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." });
}
var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel);
var platform = string.IsNullOrWhiteSpace(request.Platform)
? nomination.ResolvedPlatform?.Trim() ?? "Twitch"
: request.Platform.Trim();
var normalizedDisplayName = rawDisplayName.ToLower();
var normalizedChannelSlug = channelSlug.ToLower();
var normalizedPlatform = platform.ToLower();
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryId == targetCategory.Id
&& (
(nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId)
||
item.DisplayName.ToLower() == normalizedDisplayName
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
&& item.Platform.ToLower() == normalizedPlatform)
));
var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync(
db,
nomination.SeasonId,
targetCategory.Id,
existingCandidate?.Id,
nomination.StreamerIdentityId,
rawDisplayName,
channelSlug,
existingCandidate?.AcceptanceStatus ?? "open",
context.RequestAborted);
if (workflowRuleBlock is not null)
{
return workflowRuleBlock;
}
var candidate = existingCandidate;
if (candidate is null)
{
candidate = new Candidate
{
SeasonId = nomination.SeasonId,
CategoryId = targetCategory.Id,
StreamerIdentityId = nomination.StreamerIdentityId,
DisplayName = rawDisplayName,
ChannelSlug = channelSlug,
Platform = platform,
};
db.Candidates.Add(candidate);
await db.SaveChangesAsync(context.RequestAborted);
}
else
{
candidate.StreamerIdentityId ??= nomination.StreamerIdentityId;
candidate.DisplayName = rawDisplayName;
if (!string.IsNullOrWhiteSpace(channelSlug))
{
candidate.ChannelSlug = channelSlug;
}
if (!string.IsNullOrWhiteSpace(platform))
{
candidate.Platform = platform;
}
}
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
var uniqueViewerCount = relatedNominations
.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.Count();
candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount);
foreach (var relatedNomination in relatedNominations)
{
relatedNomination.CandidateId = candidate.Id;
relatedNomination.SuggestedCategoryId ??= targetCategory.Id;
relatedNomination.Status = "approved";
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
}
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.approve",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.",
new
{
candidateId = candidate.Id,
created = existingCandidate is null,
targetCategoryId = targetCategory.Id,
targetCategoryName = targetCategory.Name,
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
uniqueViewerCount,
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new
{
saved = true,
nominationId = nomination.Id,
candidateId = candidate.Id,
created = existingCandidate is null,
uniqueViewerCount,
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
});
}
private static async Task<IResult> RejectNomination(
HttpContext context,
int nominationId,
RejectNominationRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
foreach (var relatedNomination in relatedNominations)
{
relatedNomination.CandidateId = null;
relatedNomination.Status = "rejected";
relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
relatedNomination.ReviewedAt = DateTimeOffset.UtcNow;
relatedNomination.ReviewedByTwitchId = session.TwitchUserId;
ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId);
}
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.reject",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde verworfen.",
new
{
reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(),
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
}
private static async Task<IResult> ReopenRejectedNomination(
HttpContext context,
int nominationId,
ReopenRejectedNominationRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
if (nomination is null)
{
return Results.NotFound();
}
if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." });
}
var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted);
foreach (var relatedNomination in relatedNominations)
{
relatedNomination.CandidateId = null;
relatedNomination.Status = "pending";
relatedNomination.ReviewNote = null;
relatedNomination.ReviewedAt = null;
relatedNomination.ReviewedByTwitchId = null;
}
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.reopen",
"nomination",
nomination.Id.ToString(),
$"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.",
new
{
reviewNote,
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() });
}
private static async Task<IResult> UpdateNominationTrackingReview(
HttpContext context,
int nominationId,
UpdateNominationTrackingReviewRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted);
if (nomination is null)
{
return Results.NotFound();
}
var normalizedStatus = request.Status?.Trim().ToLowerInvariant();
if (normalizedStatus is not ("reviewed" or "overridden"))
{
return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." });
}
var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted);
var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim();
var requiresOverrideNote = relatedNominations
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
.Any(flag => flag.AdminNoteRequiredOnOverride);
if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote))
{
return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." });
}
foreach (var item in relatedNominations)
{
item.TrackingReviewStatus = normalizedStatus;
item.TrackingReviewNote = reviewNote;
item.TrackingReviewedByTwitchId = session.TwitchUserId;
item.TrackingReviewedAt = DateTimeOffset.UtcNow;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"nomination.tracking-review.update",
"nomination",
nomination.Id.ToString(),
$"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.",
new
{
nominationIds = relatedNominations.Select(item => item.Id).ToArray(),
status = normalizedStatus,
reviewNote,
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, nominationId, status = normalizedStatus });
}
private static async Task<Nomination[]> FindRelatedPendingNominationsAsync(
AwardsDbContext db,
Nomination nomination,
CancellationToken cancellationToken)
=> await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken);
private static async Task<Nomination[]> FindRelatedNominationsByStatusAsync(
AwardsDbContext db,
Nomination nomination,
string status,
CancellationToken cancellationToken)
{
var query = db.Nominations
.Where(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryGroupName == nomination.CategoryGroupName
&& item.Status == status);
if (nomination.StreamerIdentityId.HasValue)
{
return await query
.Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId)
.ToArrayAsync(cancellationToken);
}
var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl);
if (!string.IsNullOrWhiteSpace(normalizedStreamUrl))
{
var rows = await query.ToArrayAsync(cancellationToken);
return rows
.Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
return [nomination];
}
private static string NormalizeModerationStreamUrl(string? value) =>
(value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
private static string FirstNonEmpty(params string?[] values) =>
values
.Select(value => value?.Trim() ?? string.Empty)
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
?? string.Empty;
private static async Task<IResult?> BuildModerationCandidateWorkflowRuleBlockAsync(
AwardsDbContext db,
int seasonId,
int categoryId,
int? existingCandidateId,
int? streamerIdentityId,
string displayName,
string channelSlug,
string acceptanceStatus,
CancellationToken cancellationToken)
{
if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken);
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
var rules = WorkflowRuleSettings.Read(season, settings);
var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory);
var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances);
if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
return null;
}
var existingCandidates = await db.Candidates
.AsNoTracking()
.Where(item =>
item.SeasonId == seasonId
&& (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value)
&& item.AcceptanceStatus != "declined")
.Select(item => new
{
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
})
.ToArrayAsync(cancellationToken);
if (WorkflowRuleSettings.ShouldBlock(finalistsRule))
{
var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId);
if (categoryCount >= finalistsRule.Limit)
{
return CreateModerationWorkflowRuleError(
$"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt.");
}
}
if (WorkflowRuleSettings.ShouldBlock(appearancesRule))
{
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
var appearanceCount = existingCandidates.Count(item =>
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
if (appearanceCount >= appearancesRule.Limit)
{
return CreateModerationWorkflowRuleError(
$"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}.");
}
}
return null;
}
private static IResult CreateModerationWorkflowRuleError(string message) =>
Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" });
private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId)
{
var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson);
if (trackingFlags.Length == 0)
{
nomination.TrackingReviewStatus = "clear";
nomination.TrackingReviewNote = null;
nomination.TrackingReviewedByTwitchId = null;
nomination.TrackingReviewedAt = null;
return;
}
var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim();
nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note))
? "overridden"
: "reviewed";
nomination.TrackingReviewNote = note;
nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId;
nomination.TrackingReviewedAt = DateTimeOffset.UtcNow;
}
}