806 lines
34 KiB
C#
806 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 categories = categoryRows
|
|
.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";
|
|
}
|
|
}
|