using Backend.Contracts; using Backend.Data; using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static partial class AdminSeasonManagementEndpoints { private static async Task 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 candidates = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == seasonId) .OrderBy(item => item.DisplayName) .Select(item => new AdminCandidateItemDto( 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 = candidates .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 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, NormalizeSeasonStreamUrl(season.ShowStreamUrl), season.CurrentPhase, season.IsCurrent, season.IsCommunityOnly, season.NominationStartsAt, season.NominationEndsAt, season.VotingStartsAt, season.VotingEndsAt, season.ReviewStartsAt, season.ReviewEndsAt, season.ShowDate, season.ShowStartsAt, subcategoryTemplates, categories, candidates, pendingNominations, pendingNominationGroups, reviewedNominations, settings?.TrackingReviewNotes ?? string.Empty, trackingRules.Source.ShowManualReviewNotesInReview, resultItems, clipSubmissions)); } 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 AdminNominationReviewItemDto ToNominationReviewItem( AdminNominationRow item, IEnumerable 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 AdminNominationReviewGroupDto[] BuildNominationReviewGroups( IEnumerable rows, IEnumerable 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 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 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 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 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(); 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"; } }