using Backend.Common; using Backend.Contracts; using Backend.Data; using Backend.Domain; using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static partial class AdminSeasonManagementEndpoints { private const int MaxCategoryGroupNameLength = 80; private const int MaxCategoryNameLength = 120; private const int MaxCategorySlugLength = 120; private const int MaxCategoryDescriptionLength = 600; private const int MaxCandidateDisplayNameLength = 120; private const int MaxCandidateChannelSlugLength = 120; private const int MaxCandidatePlatformLength = 60; private const int MaxCandidateAcceptanceNoteLength = 500; private const int MaxCandidateClipUrlLength = 500; private const int MaxCandidateClipTitleLength = 200; private const int MaxCandidateClipPlatformLength = 40; private sealed record CandidateRuleSnapshot( int Id, int CategoryId, int? StreamerIdentityId, string DisplayName, string ChannelSlug, string AcceptanceStatus); private sealed record CandidateReadinessSnapshot( int CategoryId, int? StreamerIdentityId, string DisplayName, string ChannelSlug, string AcceptanceStatus, string? ClipCompilationUrl); private sealed record WinnerReadinessSnapshot( int CategoryId, int? StreamerIdentityId, string DisplayName, string ChannelSlug, string? ClipCompilationUrl); private sealed record WinnerPublicationSnapshot( int CategoryId, int? StreamerIdentityId, string DisplayName, string ChannelSlug, string? ClipCompilationUrl); private static IResult? ValidateSeasonRequest(CreateSeasonRequest request) { if (request.Year < 2020 || request.Year > 2100) { return Results.BadRequest(new { message = "Please provide a valid award year." }); } if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase)) { return Results.BadRequest(new { message = "Season name and current phase are required." }); } if (!IsKnownSeasonPhase(request.CurrentPhase)) { return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." }); } if (!SeasonMappings.IsSeasonScheduleValid( request.NominationStartsAt, request.NominationEndsAt, request.VotingStartsAt, request.VotingEndsAt, request.ReviewStartsAt, request.ReviewEndsAt, request.ShowDate)) { return Results.BadRequest(new { message = "The season schedule is not in chronological order." }); } return null; } private static IResult? ValidateSeasonRequest(UpdateSeasonRequest request) { if (request.Year < 2020 || request.Year > 2100) { return Results.BadRequest(new { message = "Please provide a valid award year." }); } if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CurrentPhase)) { return Results.BadRequest(new { message = "Season name and current phase are required." }); } if (!IsKnownSeasonPhase(request.CurrentPhase)) { return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." }); } if (!SeasonMappings.IsSeasonScheduleValid( request.NominationStartsAt, request.NominationEndsAt, request.VotingStartsAt, request.VotingEndsAt, request.ReviewStartsAt, request.ReviewEndsAt, request.ShowDate)) { return Results.BadRequest(new { message = "The season schedule is not in chronological order." }); } return null; } private static bool IsKnownSeasonPhase(string? currentPhase) { var value = currentPhase?.Trim().ToLowerInvariant() ?? string.Empty; return value.Contains("show") || value.Contains("abgeschlossen") || value.Contains("archiv") || value.Contains("complete") || value.Contains("ended") || value.Contains("aufbereit") || value.Contains("vorbereit") || value.Contains("pause") || value.Contains("review") || value.Contains("auswert") || value.Contains("vot") || value.Contains("nomin"); } private static async Task UnsetOtherCurrentSeasonsAsync( AwardsDbContext db, bool shouldUnsetOthers, int? seasonIdToKeep, CancellationToken cancellationToken) { if (!shouldUnsetOthers) { return; } var activeSeasons = await db.Seasons .Where(item => item.IsCurrent && (!seasonIdToKeep.HasValue || item.Id != seasonIdToKeep.Value)) .ToListAsync(cancellationToken); foreach (var activeSeason in activeSeasons) { activeSeason.IsCurrent = false; } } private static IResult CreateReadinessError(IEnumerable issues) { var issueList = issues.ToArray(); return Results.BadRequest(new { message = $"Landingpage-Freigabe blockiert: {string.Join(" ", issueList)}", issues = issueList, }); } private static IResult CreateWinnerPublicationError(IEnumerable issues) { var issueList = issues.ToArray(); return Results.BadRequest(new { message = $"Gewinner-Freigabe blockiert: {string.Join(" ", issueList)}", issues = issueList, }); } private static async Task LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken) { var season = await db.Seasons .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken); var settings = await db.SiteSettings .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); return WorkflowRuleSettings.Read(season, settings); } private static IResult CreateWorkflowRuleError(string message) => Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" }); private static async Task BuildCandidateWorkflowRuleBlockAsync( 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 rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken); 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 CandidateRuleSnapshot( item.Id, item.CategoryId, item.StreamerIdentityId, item.DisplayName, item.ChannelSlug, item.AcceptanceStatus)) .ToArrayAsync(cancellationToken); if (WorkflowRuleSettings.ShouldBlock(finalistsRule)) { var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId); if (categoryCount >= finalistsRule.Limit) { return CreateWorkflowRuleError( $"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 CreateWorkflowRuleError( $"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}."); } } return null; } private static string[] BuildNewSeasonReadinessIssues( string currentPhase, bool isCurrent, int copiedCategoryCount) { var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase); var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent); var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey); if (!isCurrent && !needsWinnerReadiness) { return []; } var issues = new List(); if (copiedCategoryCount <= 0) { issues.Add("Mindestens eine Kategorie ist erforderlich."); } if (needsCandidateReadiness) { issues.Add("Kandidaten muessen vor dieser Phase fuer alle Kategorien gepflegt sein."); } if (needsWinnerReadiness) { issues.Add("Abgeschlossen ist erst moeglich, wenn jede Kategorie einen Gewinner hat."); } return issues.ToArray(); } private static async Task BuildSeasonReadinessIssuesAsync( AwardsDbContext db, int seasonId, string currentPhase, bool isCurrent, CancellationToken cancellationToken) { var season = await db.Seasons .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken); if (season is null) { return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."]; } var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase); var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent); var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey); if (!isCurrent && !needsWinnerReadiness) { return []; } var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken); var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances); var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements); var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip); var categoryIds = await db.Categories .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => item.Id) .ToArrayAsync(cancellationToken); var issues = new List(); if (categoryIds.Length == 0) { issues.Add("Mindestens eine Kategorie ist erforderlich."); } if (needsCandidateReadiness && categoryIds.Length > 0) { var candidateSnapshots = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => new CandidateReadinessSnapshot( item.CategoryId, item.StreamerIdentityId, item.DisplayName, item.ChannelSlug, item.AcceptanceStatus, item.ClipCompilationUrl)) .ToArrayAsync(cancellationToken); var activeCandidates = candidateSnapshots .Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) .ToArray(); var categoriesWithCandidates = activeCandidates .Select(item => item.CategoryId) .Distinct() .Count(); var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates); if (emptyCategories > 0) { issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten."); } if (WorkflowRuleSettings.ShouldBlock(appearancesRule)) { var identityOverflow = activeCandidates .GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug)) .Select(group => new { Count = group.Count(), DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt", }) .Where(item => item.Count > appearancesRule.Limit) .OrderByDescending(item => item.Count) .FirstOrDefault(); if (identityOverflow is not null) { issues.Add( $"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}."); } } } if (needsWinnerReadiness && categoryIds.Length > 0) { if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now)) { issues.Add("Die Award Show liegt noch nicht in der Vergangenheit."); } var categoriesWithResults = await db.Results .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => item.CategoryId) .Distinct() .CountAsync(cancellationToken); var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults); if (missingResults > 0) { issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner."); } var resultSnapshots = await db.Results .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => new WinnerReadinessSnapshot( item.CategoryId, item.Candidate.StreamerIdentityId, item.Candidate.DisplayName, item.Candidate.ChannelSlug, item.Candidate.ClipCompilationUrl)) .ToArrayAsync(cancellationToken); if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)) { var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl)); if (missingWinnerClipCount > 0) { issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link."); } } if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule)) { var winnerOverflow = resultSnapshots .GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug)) .Select(group => new { Count = group.Count(), DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt", }) .Where(item => item.Count > winnerPlacementsRule.Limit) .OrderByDescending(item => item.Count) .FirstOrDefault(); if (winnerOverflow is not null) { issues.Add( $"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}."); } } } return issues.ToArray(); } private static async Task BuildWinnerPublicationIssuesAsync( AwardsDbContext db, int seasonId, CancellationToken cancellationToken) { var categoryIds = await db.Categories .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => item.Id) .ToArrayAsync(cancellationToken); var issues = new List(); if (categoryIds.Length == 0) { issues.Add("Mindestens eine Kategorie ist erforderlich."); return issues.ToArray(); } var resultSnapshots = await db.Results .AsNoTracking() .Where(item => item.SeasonId == seasonId) .Select(item => new WinnerPublicationSnapshot( item.CategoryId, item.Candidate.StreamerIdentityId, item.Candidate.DisplayName, item.Candidate.ChannelSlug, item.Candidate.ClipCompilationUrl)) .ToArrayAsync(cancellationToken); var categoriesWithResults = resultSnapshots .Select(item => item.CategoryId) .Distinct() .Count(); var missingResults = Math.Max(0, categoryIds.Length - categoriesWithResults); if (missingResults > 0) { issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner."); } var openReviewCount = await db.Nominations .AsNoTracking() .CountAsync(item => item.SeasonId == seasonId && item.Status == "pending", cancellationToken); if (openReviewCount > 0) { issues.Add($"{openReviewCount} Nominierungs-Review(s) sind noch offen."); } var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken); var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip); if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)) { var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl)); if (missingWinnerClipCount > 0) { issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link."); } } var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements); if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule)) { var winnerOverflow = resultSnapshots .GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug)) .Select(group => new { Count = group.Count(), DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt", }) .Where(item => item.Count > winnerPlacementsRule.Limit) .OrderByDescending(item => item.Count) .FirstOrDefault(); if (winnerOverflow is not null) { issues.Add( $"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}."); } } return issues.ToArray(); } private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug) { if (streamerIdentityId.HasValue) { return $"identity:{streamerIdentityId.Value}"; } return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug); } private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent) { return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal)) || string.Equals(phaseKey, "completed", StringComparison.Ordinal); } private static bool RequiresWinnerReadiness(string phaseKey) { return string.Equals(phaseKey, "completed", StringComparison.Ordinal); } private static IResult? ValidateCategoryRequest(UpsertCategoryRequest request) { var groupName = request.GroupName.Trim(); var name = request.Name.Trim(); var slug = request.Slug.Trim(); var description = request.Description.Trim(); if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > MaxCategoryGroupNameLength) { return Results.BadRequest(new { message = $"Category group name is required and must stay below {MaxCategoryGroupNameLength} characters." }); } if (string.IsNullOrWhiteSpace(name) || name.Length > MaxCategoryNameLength) { return Results.BadRequest(new { message = $"Category name is required and must stay below {MaxCategoryNameLength} characters." }); } if (string.IsNullOrWhiteSpace(slug) || slug.Length > MaxCategorySlugLength) { return Results.BadRequest(new { message = $"Category slug is required and must stay below {MaxCategorySlugLength} characters." }); } if (!slug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_')) { return Results.BadRequest(new { message = "Category slug contains unsupported characters." }); } if (description.Length > MaxCategoryDescriptionLength) { return Results.BadRequest(new { message = $"Category description must stay below {MaxCategoryDescriptionLength} characters." }); } if (request.SortOrder is < 0 or > 500) { return Results.BadRequest(new { message = "Category sort order must be between 0 and 500." }); } if (request.MaxNomineesPerUser is < 1 or > 10) { return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." }); } if (request.ViewerRangeMin is < 0 or > 100000) { return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." }); } if (request.ViewerRangeMax is < 0 or > 100000) { return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." }); } if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin) { return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." }); } return null; } private static IResult? ValidateCandidateRequest(UpsertCandidateRequest request) { var displayName = request.DisplayName.Trim(); var channelSlug = request.ChannelSlug.Trim(); var platform = request.Platform.Trim(); if (request.CategoryId <= 0) { return Results.BadRequest(new { message = "A valid category is required." }); } if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > MaxCandidateDisplayNameLength) { return Results.BadRequest(new { message = $"Display name is required and must stay below {MaxCandidateDisplayNameLength} characters." }); } if (string.IsNullOrWhiteSpace(channelSlug) || channelSlug.Length > MaxCandidateChannelSlugLength) { return Results.BadRequest(new { message = $"Channel slug is required and must stay below {MaxCandidateChannelSlugLength} characters." }); } if (!channelSlug.All(value => char.IsLetterOrDigit(value) || value is '-' or '_' or '.')) { return Results.BadRequest(new { message = "Channel slug contains unsupported characters." }); } if (string.IsNullOrWhiteSpace(platform) || platform.Length > MaxCandidatePlatformLength) { return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." }); } if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses)) { return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." }); } if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses)) { return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." }); } if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength) { return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." }); } var clipUrl = request.ClipCompilationUrl?.Trim(); if (!string.IsNullOrWhiteSpace(clipUrl)) { if (clipUrl.Length > MaxCandidateClipUrlLength) { return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." }); } if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) { return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." }); } } if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength) { return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." }); } if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength) { return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." }); } return null; } private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection allowedValues) { var normalized = value?.Trim(); return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase); } }