Files
vtuber-awards/Backend/Endpoints/AdminSeasonDetailEndpoints.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

820 lines
34 KiB
C#

using Backend.Contracts;
using Backend.Data;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminSeasonManagementEndpoints
{
private static async Task<IResult> GetSeasonDetail(int seasonId, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
var trackingRules = TrackingRulesSettings.Read(settings);
var candidateRows = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.DisplayName)
.Select(item => new AdminCandidateRow(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.Platform,
item.NominationTally,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
item.ClipCompilationTitle,
item.ClipCompilationPlatform,
item.ClipEmbedStatus))
.ToArrayAsync();
var candidateCounts = candidateRows
.GroupBy(item => item.CategoryId)
.ToDictionary(grouping => grouping.Key, grouping => grouping.Count());
var categoryRows = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(category => new
{
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
})
.ToArrayAsync();
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
season,
categoryRows.Select(category => new Backend.Domain.Category
{
GroupName = category.GroupName,
Name = category.Name,
Slug = category.Slug,
SortOrder = category.SortOrder,
ViewerRangeMin = category.ViewerRangeMin,
ViewerRangeMax = category.ViewerRangeMax,
}));
var visibleCategoryRows = categoryRows
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
new Backend.Domain.Category
{
GroupName = category.GroupName,
Name = category.Name,
Slug = category.Slug,
SortOrder = category.SortOrder,
ViewerRangeMin = category.ViewerRangeMin,
ViewerRangeMax = category.ViewerRangeMax,
},
subcategoryTemplateSettings))
.ToArray();
var categories = visibleCategoryRows
.Select(category => new AdminCategoryItemDto(
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
category.ViewerRangeMin,
category.ViewerRangeMax,
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
.ToArray();
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
var pendingNominationRows = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
.OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminNominationRow(
item.Id,
item.CategoryId,
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
item.CategoryId != null ? item.Category!.Name : null,
item.SubmittedByTwitchId,
item.CandidateText ?? string.Empty,
item.StreamUrl,
item.ResolvedChannel,
item.ResolvedPlatform,
item.AvgViewers,
item.HoursStreamed,
item.HoursWatched,
item.PeakViewers,
item.FollowersGained,
item.SuggestedCategoryId,
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
item.StreamerIdentityId,
item.TrackerStatus,
item.TrackerCheckedAt,
item.TrackingReviewStatus,
item.TrackingFlagsJson,
item.TrackingReviewNote,
item.TrackingReviewedByTwitchId,
item.TrackingReviewedAt,
item.Status,
item.CreatedAt,
item.CandidateId,
item.CandidateId != null ? item.Candidate!.DisplayName : null,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt))
.ToArrayAsync();
var pendingNominations = pendingNominationRows
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
.ToArray();
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
var reviewedNominationRows = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
.Select(item => new AdminNominationRow(
item.Id,
item.CategoryId,
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
item.CategoryId != null ? item.Category!.Name : null,
item.SubmittedByTwitchId,
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
item.StreamUrl,
item.ResolvedChannel,
item.ResolvedPlatform,
item.AvgViewers,
item.HoursStreamed,
item.HoursWatched,
item.PeakViewers,
item.FollowersGained,
item.SuggestedCategoryId,
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
item.StreamerIdentityId,
item.TrackerStatus,
item.TrackerCheckedAt,
item.TrackingReviewStatus,
item.TrackingFlagsJson,
item.TrackingReviewNote,
item.TrackingReviewedByTwitchId,
item.TrackingReviewedAt,
item.Status,
item.CreatedAt,
item.CandidateId,
item.CandidateId != null ? item.Candidate!.DisplayName : null,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt))
.ToArrayAsync();
var reviewedNominations = reviewedNominationRows
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
.ToArray();
var resultItems = await db.Results
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.Category.SortOrder)
.ThenBy(item => item.Category.Name)
.Select(item => new AdminAwardResultItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.CandidateId,
item.Candidate.StreamerIdentityId,
item.Candidate.DisplayName,
item.Candidate.ChannelSlug,
item.Candidate.Platform))
.ToArrayAsync();
var workflowRules = WorkflowRuleSettings.Read(season, settings);
var votingEntryRows = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Category.SeasonId == seasonId)
.Select(item => new AdminVotingEntryRow(
item.BallotId,
item.CategoryId,
item.CandidateId))
.ToArrayAsync();
var candidates = BuildCandidateItems(
candidateRows,
pendingNominationRows,
reviewedNominationRows,
votingEntryRows);
var votingWorkspace = BuildVotingWorkspace(
categories,
candidates,
pendingNominations,
resultItems,
votingEntryRows,
workflowRules);
var clipSubmissions = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminClipSubmissionItemDto(
item.Id,
item.CategoryId,
item.CandidateId,
item.SubmittedByTwitchId,
item.ClipUrl,
item.Title,
item.Creator,
item.Platform,
item.Status,
item.CreatedAt,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse(
season.Id,
season.Year,
season.Name,
season.IsDemo,
season.CurrentPhase,
season.IsCurrent,
season.IsCommunityOnly,
season.NominationStartsAt,
season.NominationEndsAt,
season.VotingStartsAt,
season.VotingEndsAt,
season.ReviewStartsAt,
season.ReviewEndsAt,
season.ShowDate,
season.ShowStartsAt,
season.WinnersPublishedAt,
season.WinnersPublishedByTwitchId,
subcategoryTemplates,
categories,
candidates,
pendingNominations,
pendingNominationGroups,
reviewedNominations,
settings?.TrackingReviewNotes ?? string.Empty,
trackingRules.Source.ShowManualReviewNotesInReview,
resultItems,
votingWorkspace,
clipSubmissions));
}
private sealed record AdminVotingEntryRow(
int BallotId,
int CategoryId,
int CandidateId);
private sealed record AdminCandidateRow(
int Id,
int CategoryId,
int? StreamerIdentityId,
string DisplayName,
string ChannelSlug,
string Platform,
int NominationTally,
string AcceptanceStatus,
string? AcceptanceNote,
string? ClipCompilationUrl,
string? ClipCompilationTitle,
string? ClipCompilationPlatform,
string ClipEmbedStatus);
private sealed record AdminNominationRow(
int Id,
int? CategoryId,
string? CategoryGroupName,
string? CategoryName,
string SubmittedByTwitchId,
string CandidateText,
string? StreamUrl,
string? ResolvedChannel,
string? ResolvedPlatform,
int? AvgViewers,
int? HoursStreamed,
int? HoursWatched,
int? PeakViewers,
int? FollowersGained,
int? SuggestedCategoryId,
string? SuggestedCategoryName,
int? StreamerIdentityId,
string TrackerStatus,
DateTimeOffset? TrackerCheckedAt,
string TrackingReviewStatus,
string TrackingFlagsJson,
string? TrackingReviewNote,
string? TrackingReviewedByTwitchId,
DateTimeOffset? TrackingReviewedAt,
string Status,
DateTimeOffset CreatedAt,
int? CandidateId,
string? CandidateDisplayName,
string? ReviewNote,
string? ReviewedByTwitchId,
DateTimeOffset? ReviewedAt);
private static AdminCandidateItemDto[] BuildCandidateItems(
AdminCandidateRow[] candidateRows,
AdminNominationRow[] pendingNominationRows,
AdminNominationRow[] reviewedNominationRows,
AdminVotingEntryRow[] votingEntryRows)
{
var allNominationRows = pendingNominationRows
.Concat(reviewedNominationRows)
.ToArray();
var voteCountByCandidate = votingEntryRows
.GroupBy(item => item.CandidateId)
.ToDictionary(group => group.Key, group => group.Count());
return candidateRows
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.StreamerIdentityId,
item.DisplayName,
item.ChannelSlug,
item.Platform,
ResolveCandidateAvgViewers(item, allNominationRows),
voteCountByCandidate.GetValueOrDefault(item.Id, 0),
item.NominationTally,
item.AcceptanceStatus,
item.AcceptanceNote,
item.ClipCompilationUrl,
item.ClipCompilationTitle,
item.ClipCompilationPlatform,
item.ClipEmbedStatus))
.ToArray();
}
private static int? ResolveCandidateAvgViewers(AdminCandidateRow candidate, AdminNominationRow[] nominationRows)
{
var directMatch = nominationRows
.Where(item => item.CandidateId == candidate.Id && item.AvgViewers.HasValue)
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
if (directMatch.HasValue)
{
return directMatch.Value;
}
if (candidate.StreamerIdentityId.HasValue)
{
var identityMatch = nominationRows
.Where(item => item.StreamerIdentityId == candidate.StreamerIdentityId && item.AvgViewers.HasValue)
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
if (identityMatch.HasValue)
{
return identityMatch.Value;
}
}
var normalizedChannel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalizedChannel))
{
return null;
}
return nominationRows
.Where(item =>
item.AvgViewers.HasValue
&& string.Equals(item.ResolvedChannel?.Trim().TrimStart('@'), normalizedChannel, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.TrackerCheckedAt ?? item.ReviewedAt ?? item.CreatedAt)
.Select(item => item.AvgViewers)
.FirstOrDefault();
}
private static AdminNominationReviewItemDto ToNominationReviewItem(
AdminNominationRow item,
IEnumerable<dynamic> categoryRows,
TrackingRulesConfiguration trackingRules)
{
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
.Select(ToTrackingFlagHitDto)
.ToArray();
return new(
item.Id,
item.CategoryId,
ResolveCategoryGroupName(item, categoryRows),
ResolveCategoryName(item, categoryRows),
item.SubmittedByTwitchId,
item.CandidateText,
item.StreamUrl,
item.ResolvedChannel,
item.ResolvedPlatform,
item.AvgViewers,
item.SuggestedCategoryId,
item.SuggestedCategoryName,
item.StreamerIdentityId,
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
item.TrackerCheckedAt,
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
trackingFlags.Any(flag => flag.RequiresManualReview),
trackingFlags,
BuildTrackingMetricStateDtos(item, trackingRules),
item.TrackingReviewNote,
item.TrackingReviewedByTwitchId,
item.TrackingReviewedAt,
item.Status,
item.CreatedAt,
item.CandidateId,
item.CandidateDisplayName,
item.ReviewNote,
item.ReviewedByTwitchId,
item.ReviewedAt);
}
private static AdminVotingWorkspaceDto BuildVotingWorkspace(
AdminCategoryItemDto[] categories,
AdminCandidateItemDto[] candidates,
AdminNominationReviewItemDto[] pendingNominations,
AdminAwardResultItemDto[] results,
AdminVotingEntryRow[] voteEntries,
WorkflowRuleSetting[] workflowRules)
{
var resultMap = results.ToDictionary(item => item.CategoryId);
var totalBallots = voteEntries.Select(item => item.BallotId).Distinct().Count();
var voteCountByCategory = voteEntries
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Count());
var ballotCountByCategory = voteEntries
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Select(item => item.BallotId).Distinct().Count());
var voteCountByCandidate = voteEntries
.GroupBy(item => (item.CategoryId, item.CandidateId))
.ToDictionary(group => group.Key, group => group.Count());
var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements);
var recommendedNominatorsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.RecommendedNominatorsPerSubcategory);
var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip);
var workspaceItems = categories
.Select(category =>
{
var categoryCandidates = candidates
.Where(candidate => candidate.CategoryId == category.Id)
.ToArray();
var categoryVoteCount = voteCountByCategory.TryGetValue(category.Id, out var voteCount) ? voteCount : 0;
var categoryBallotCount = ballotCountByCategory.TryGetValue(category.Id, out var ballotCount) ? ballotCount : 0;
var nominationCount = categoryCandidates.Sum(candidate => Math.Max(candidate.NominationTally, 0));
var openReviewCount = pendingNominations.Count(item =>
item.CategoryId == category.Id
|| item.SuggestedCategoryId == category.Id);
var existingResult = resultMap.GetValueOrDefault(category.Id);
var maxVotes = categoryCandidates.Length == 0
? 0
: categoryCandidates.Max(candidate => voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0));
var topVoteTieCount = maxVotes <= 0
? 0
: categoryCandidates.Count(candidate => voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0) == maxVotes);
var leaderboard = categoryCandidates
.Select(candidate =>
{
var candidateVotes = voteCountByCandidate.GetValueOrDefault((category.Id, candidate.Id), 0);
var hasWinnerConflict = CandidateHasWinnerConflict(candidate, category.Id, results, winnerPlacementsRule);
return new AdminVotingCandidateRankDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
candidate.Platform,
candidateVotes,
categoryVoteCount > 0 ? (int)Math.Round(candidateVotes * 100d / categoryVoteCount) : 0,
Math.Max(candidate.NominationTally, 0),
!string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl),
string.IsNullOrWhiteSpace(candidate.ClipEmbedStatus) ? "unchecked" : candidate.ClipEmbedStatus,
hasWinnerConflict,
existingResult?.CandidateId == candidate.Id,
!string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase),
maxVotes > 0 && candidateVotes == maxVotes && topVoteTieCount > 1);
})
.OrderByDescending(item => item.Votes)
.ThenByDescending(item => item.NominationTally)
.ThenBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var readyCandidates = leaderboard.Count(item => item.IsAccepted);
var leadingCandidate = leaderboard.FirstOrDefault();
var hasMissingClip = winnerRequiresClipRule.Enabled
&& leadingCandidate is not null
&& !leadingCandidate.HasClip;
var hasRuleConflict = leadingCandidate?.HasWinnerConflict ?? false;
var hasOpenReviews = openReviewCount > 0;
var hasSoftNominatorWarning = recommendedNominatorsRule.Enabled
&& nominationCount < Math.Max(1, recommendedNominatorsRule.Limit);
var winnerReady = existingResult is not null
|| leadingCandidate is not null
&& leadingCandidate.IsAccepted
&& !hasMissingClip
&& !hasRuleConflict
&& !hasOpenReviews;
return new AdminVotingCategoryWorkspaceItemDto(
category.Id,
category.GroupName,
category.Name,
category.SortOrder,
category.ViewerRangeMin,
category.ViewerRangeMax,
categoryVoteCount,
categoryBallotCount,
categoryCandidates.Length,
readyCandidates,
nominationCount,
openReviewCount,
existingResult is not null,
winnerReady,
topVoteTieCount > 1,
hasMissingClip,
hasRuleConflict,
hasOpenReviews,
hasSoftNominatorWarning,
leaderboard);
})
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.CategoryName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var summary = new AdminVotingWorkspaceSummaryDto(
voteEntries.Length,
totalBallots,
workspaceItems.Length,
workspaceItems.Count(item => item.VoteCount > 0),
workspaceItems.Count(item => item.WinnerReady),
workspaceItems.Count(item =>
item.HasMissingClip
|| item.HasRuleConflict
|| item.HasOpenReviews
|| item.HasTopVoteTie),
workspaceItems.Count(item => item.HasWinner));
return new AdminVotingWorkspaceDto(summary, workspaceItems);
}
private static bool CandidateHasWinnerConflict(
AdminCandidateItemDto candidate,
int categoryId,
AdminAwardResultItemDto[] results,
WorkflowRuleSetting winnerPlacementsRule)
{
if (!winnerPlacementsRule.Enabled)
{
return false;
}
var identityKey = candidate.StreamerIdentityId.HasValue
? $"identity:{candidate.StreamerIdentityId.Value}"
: WorkflowRuleSettings.CandidateIdentityKey(candidate.DisplayName, candidate.ChannelSlug);
var existingWinnerCount = results.Count(result =>
{
if (result.CategoryId == categoryId)
{
return false;
}
var resultIdentityKey = result.StreamerIdentityId.HasValue
? $"identity:{result.StreamerIdentityId.Value}"
: WorkflowRuleSettings.CandidateIdentityKey(result.CandidateDisplayName, result.CandidateChannelSlug);
return string.Equals(resultIdentityKey, identityKey, StringComparison.Ordinal);
});
return existingWinnerCount >= winnerPlacementsRule.Limit;
}
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
IEnumerable<AdminNominationRow> rows,
IEnumerable<dynamic> categoryRows,
TrackingRulesConfiguration trackingRules) =>
rows
.GroupBy(item => new
{
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
IdentityKey = item.StreamerIdentityId.HasValue
? $"identity:{item.StreamerIdentityId.Value}"
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
})
.Select(group =>
{
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
var representative = ordered
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
.ThenByDescending(item => item.AvgViewers.HasValue)
.First();
var trackerStatus = ResolveGroupTrackerStatus(ordered);
var trackingFlags = ordered
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
.GroupBy(item => item.Key)
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
.ToArray();
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
return new AdminNominationReviewGroupDto(
representative.Id,
ordered.Select(item => item.Id).ToArray(),
ResolveCategoryGroupName(representative, categoryRows),
ResolveNominationDisplayName(representative),
representative.StreamUrl,
representative.ResolvedChannel,
representative.ResolvedPlatform,
representative.AvgViewers,
representative.SuggestedCategoryId,
representative.SuggestedCategoryName,
representative.StreamerIdentityId,
trackerStatus,
representative.TrackerCheckedAt,
ResolveGroupTrackingReviewStatus(ordered),
requiresManualReview,
trackingFlags,
BuildTrackingMetricStateDtos(representative, trackingRules),
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
ordered.Max(item => item.TrackingReviewedAt),
ordered.Length,
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
ordered.First().CreatedAt,
ordered.Last().CreatedAt);
})
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
.ThenByDescending(item => item.RequiresManualReview)
.ThenByDescending(item => item.NominationTally)
.ThenByDescending(item => item.UniqueSubmitterCount)
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
.ThenByDescending(item => item.AvgViewers ?? -1)
.ThenByDescending(item => item.LastSubmittedAt)
.ToArray();
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
item.ResolvedChannel
?? item.CandidateText
?? item.StreamUrl
?? "Name im Review festlegen";
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
{
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
?? rows.FirstOrDefault()?.TrackerStatus
?? "pending";
}
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
{
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
?? rows.FirstOrDefault()?.TrackingReviewStatus
?? "clear";
}
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
{
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
{
return item.CategoryGroupName.Trim();
}
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
return category?.GroupName ?? "Unbekannte Hauptkategorie";
}
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
{
if (!string.IsNullOrWhiteSpace(item.CategoryName))
{
return item.CategoryName.Trim();
}
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
}
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
new(
flag.Key,
flag.Label,
flag.Severity,
flag.Description,
flag.RequiresManualReview,
flag.BlocksApproval,
flag.AdminNoteRequiredOnOverride);
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
AdminNominationRow row,
TrackingRulesConfiguration trackingRules) =>
trackingRules.ImportantMetrics
.Concat(trackingRules.OptionalMetrics)
.Where(metric => metric.Enabled && metric.ShowInReview)
.Select(metric => new AdminTrackingMetricStateDto(
metric.Key,
metric.Label,
metric.RequiredForAutoClassification,
metric.SourceSupport,
MetricPresent(metric, row),
MetricValue(metric, row),
metric.Description,
metric.WindowKey,
TrackingRulesSettings.WindowLabel(metric.WindowKey),
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
.ToArray();
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
{
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
{
return false;
}
return metric.Key switch
{
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
_ => false,
};
}
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
{
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
{
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
}
return metric.Key switch
{
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
_ => "manuell",
};
}
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
{
var parts = new List<string>();
if (metric.TopCount.HasValue)
{
parts.Add($"Top {metric.TopCount.Value}");
}
if (metric.MinPrimaryCategorySharePercent.HasValue)
{
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
}
if (metric.MinPrimaryCategoryHours.HasValue)
{
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
}
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
{
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
}
if (metric.IgnoredCategories.Length > 0)
{
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
}
return parts.Count > 0
? string.Join(" · ", parts)
: "Top-Kategorien manuell pruefen";
}
}