Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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 static IResult? ValidateSeasonRequest(CreateSeasonRequest request)
|
||||
{
|
||||
if (request.Year < 2020 || request.Year > 2100)
|
||||
@@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRuleSetting[]> LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken)
|
||||
private static async Task<WorkflowRuleSetting[]> 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(settings);
|
||||
return WorkflowRuleSettings.Read(season, settings);
|
||||
}
|
||||
|
||||
private static IResult CreateWorkflowRuleError(string message) =>
|
||||
@@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
int seasonId,
|
||||
int categoryId,
|
||||
int? existingCandidateId,
|
||||
int? streamerIdentityId,
|
||||
string displayName,
|
||||
string channelSlug,
|
||||
string acceptanceStatus,
|
||||
@@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
var rules = await LoadWorkflowRulesAsync(db, cancellationToken);
|
||||
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))
|
||||
@@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new CandidateRuleSnapshot(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.AcceptanceStatus))
|
||||
@@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug);
|
||||
var appearanceCount = existingCandidates.Count(item =>
|
||||
string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId
|
||||
|| string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal));
|
||||
if (appearanceCount >= appearancesRule.Limit)
|
||||
{
|
||||
return CreateWorkflowRuleError(
|
||||
@@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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);
|
||||
@@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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)
|
||||
@@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (needsCandidateReadiness && categoryIds.Length > 0)
|
||||
{
|
||||
var categoriesWithCandidates = await db.Candidates
|
||||
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()
|
||||
.CountAsync(cancellationToken);
|
||||
.Count();
|
||||
var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates);
|
||||
if (emptyCategories > 0)
|
||||
{
|
||||
issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten.");
|
||||
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)
|
||||
@@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
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 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))
|
||||
@@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user