6b3b0360e7
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>
455 lines
19 KiB
C#
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;
|
|
}
|
|
}
|