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:
AzuTear
2026-06-28 23:32:21 +02:00
parent 4b2c5fa15d
commit b53c7fb736
178 changed files with 28933 additions and 1908 deletions
+283 -12
View File
@@ -16,6 +16,7 @@ public static partial class SeedDataBootstrapper
.ToArrayAsync();
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
{
@@ -23,15 +24,15 @@ public static partial class SeedDataBootstrapper
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"),
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"),
SubmittedByTwitchId = "local_user_3",
ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment",
Title = "Starlight Debut Moment",
Creator = "Hoshimi Miyu",
Platform = "Twitch",
Status = "approved",
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
@@ -40,15 +41,15 @@ public static partial class SeedDataBootstrapper
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"),
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"),
SubmittedByTwitchId = "local_user_4",
ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype",
Title = "Finale-Hype mit Chat-Chaos",
Creator = "Kurainu",
Platform = "Twitch",
Status = "approved",
ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.",
ReviewNote = "Geprüfter Clip für Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
@@ -57,8 +58,8 @@ public static partial class SeedDataBootstrapper
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "best-gaming"),
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"),
CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"),
SubmittedByTwitchId = "local_user",
ClipUrl = "https://clips.twitch.tv/EpicGamingMoment",
Title = "Epischer Clutch im Finale",
@@ -71,15 +72,15 @@ public static partial class SeedDataBootstrapper
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "gesang-musik"),
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"),
CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"),
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"),
SubmittedByTwitchId = "local_user_2",
ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment",
Title = "Live-Cover mit Gänsehaut",
Creator = "Melo Diva",
Platform = "YouTube",
Status = "approved",
ReviewNote = "Geprüfter Clip fuer Review-Workflow.",
ReviewNote = "Geprüfter Clip für Review-Workflow.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
@@ -114,7 +115,7 @@ public static partial class SeedDataBootstrapper
EntityType = "database",
EntityId = season.Year.ToString(),
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }),
MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }),
CreatedFromIp = "seed",
UserAgent = "seed-bootstrap",
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero),
@@ -279,5 +280,275 @@ public static partial class SeedDataBootstrapper
.Where(char.IsLetterOrDigit)
.ToArray());
private static async Task EnsureSeedReviewNominationsAsync(
AwardsDbContext db,
Season season,
IReadOnlyDictionary<string, Category> categories,
Candidate[] candidates)
{
var staleSeedNominations = await db.Nominations
.Where(item =>
item.SeasonId == season.Id
&& (
item.SubmittedByTwitchId.StartsWith("seed_review_")
|| item.SubmittedByTwitchId == "twitch_hoshi"
|| item.SubmittedByTwitchId == "twitch_kurainu"
|| item.SubmittedByTwitchId.StartsWith("demo_user")
|| item.SubmittedByTwitchId.StartsWith("local_user")
))
.ToArrayAsync();
if (staleSeedNominations.Length > 0)
{
db.Nominations.RemoveRange(staleSeedNominations);
await db.SaveChangesAsync();
}
var orderedCategories = categories.Values
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToArray();
var candidatesByCategoryId = candidates
.GroupBy(item => item.CategoryId)
.ToDictionary(
grouping => grouping.Key,
grouping => grouping.OrderBy(item => item.DisplayName).ToArray());
var seedNominations = new List<Nomination>();
var createdAt = new DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
foreach (var category in orderedCategories)
{
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
var existingCandidate = categoryCandidates?.FirstOrDefault();
seedNominations.AddRange(BuildPendingSeedGroup(
category,
existingCandidate,
groupKey: "existing",
firstSubmitter: $"seed_review_{category.Slug}_existing_a",
secondSubmitter: $"seed_review_{category.Slug}_existing_b",
createdAt,
useSuggestedCategory: true,
trackerStatus: "resolved"));
createdAt = createdAt.AddMinutes(8);
seedNominations.AddRange(BuildPendingSeedGroup(
category,
existingCandidate: null,
groupKey: "fresh",
firstSubmitter: $"seed_review_{category.Slug}_fresh_a",
secondSubmitter: $"seed_review_{category.Slug}_fresh_b",
createdAt,
useSuggestedCategory: false,
trackerStatus: "unsupported_platform"));
createdAt = createdAt.AddMinutes(8);
}
foreach (var category in orderedCategories.Take(2))
{
seedNominations.AddRange(BuildReviewedSeedGroup(
category,
status: "rejected",
displayName: $"{category.GroupName} Review Return",
submittedByPrefix: $"seed_review_{category.Slug}_rejected",
createdAt,
reviewedByTwitchId: "jayuhime_admin",
candidateId: null,
candidateDisplayName: null));
createdAt = createdAt.AddMinutes(10);
}
foreach (var category in orderedCategories.Skip(2).Take(2))
{
candidatesByCategoryId.TryGetValue(category.Id, out var categoryCandidates);
var candidate = categoryCandidates?.FirstOrDefault();
seedNominations.AddRange(BuildReviewedSeedGroup(
category,
status: "approved",
displayName: candidate?.DisplayName ?? $"{category.GroupName} Approved Pick",
submittedByPrefix: $"seed_review_{category.Slug}_approved",
createdAt,
reviewedByTwitchId: "jayuhime_admin",
candidateId: candidate?.Id,
candidateDisplayName: candidate?.DisplayName));
createdAt = createdAt.AddMinutes(10);
}
db.Nominations.AddRange(seedNominations);
await db.SaveChangesAsync();
}
private static IEnumerable<Nomination> BuildPendingSeedGroup(
Category category,
Candidate? existingCandidate,
string groupKey,
string firstSubmitter,
string secondSubmitter,
DateTimeOffset createdAt,
bool useSuggestedCategory,
string trackerStatus)
{
var displayName = existingCandidate?.DisplayName ?? BuildFreshSeedName(category, groupKey);
var platform = existingCandidate?.Platform ?? "YouTube";
var channelSlug = existingCandidate?.ChannelSlug ?? BuildSeedChannelSlug(category, groupKey);
var streamUrl = BuildSeedStreamUrl(platform, channelSlug);
var avgViewers = ResolveSeedViewerValue(category);
int? suggestedCategoryId = useSuggestedCategory ? category.Id : null;
yield return new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
CategoryGroupName = category.GroupName,
SubmittedByTwitchId = firstSubmitter,
CandidateText = displayName,
StreamUrl = streamUrl,
ResolvedChannel = channelSlug.TrimStart('@'),
ResolvedPlatform = platform,
AvgViewers = avgViewers,
SuggestedCategoryId = suggestedCategoryId,
TrackerStatus = trackerStatus,
TrackerCheckedAt = createdAt.AddMinutes(2),
TrackingReviewStatus = "clear",
Status = "pending",
CreatedAt = createdAt,
};
yield return new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
CategoryGroupName = category.GroupName,
SubmittedByTwitchId = secondSubmitter,
CandidateText = displayName,
StreamUrl = streamUrl,
ResolvedChannel = channelSlug.TrimStart('@'),
ResolvedPlatform = platform,
AvgViewers = avgViewers,
SuggestedCategoryId = suggestedCategoryId,
TrackerStatus = trackerStatus,
TrackerCheckedAt = createdAt.AddMinutes(3),
TrackingReviewStatus = "clear",
Status = "pending",
CreatedAt = createdAt.AddMinutes(1),
};
}
private static IEnumerable<Nomination> BuildReviewedSeedGroup(
Category category,
string status,
string displayName,
string submittedByPrefix,
DateTimeOffset createdAt,
string reviewedByTwitchId,
int? candidateId,
string? candidateDisplayName)
{
var channelSlug = BuildSeedChannelSlug(category, $"{status}_{displayName}");
var streamUrl = BuildSeedStreamUrl("Twitch", channelSlug);
var reviewNote = status == "approved"
? "Seed-Datensatz: bereits als Kandidat übernommen."
: "Seed-Datensatz: bewusst verworfen für Undo-Tests.";
yield return new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
CategoryGroupName = category.GroupName,
SubmittedByTwitchId = $"{submittedByPrefix}_a",
CandidateId = candidateId,
CandidateText = displayName,
StreamUrl = streamUrl,
ResolvedChannel = channelSlug.TrimStart('@'),
ResolvedPlatform = "Twitch",
AvgViewers = ResolveSeedViewerValue(category),
SuggestedCategoryId = category.Id,
TrackerStatus = "resolved",
TrackerCheckedAt = createdAt.AddMinutes(2),
TrackingReviewStatus = "reviewed",
TrackingReviewNote = reviewNote,
TrackingReviewedByTwitchId = reviewedByTwitchId,
TrackingReviewedAt = createdAt.AddMinutes(4),
Status = status,
ReviewNote = reviewNote,
ReviewedByTwitchId = reviewedByTwitchId,
CreatedAt = createdAt,
ReviewedAt = createdAt.AddMinutes(4),
};
yield return new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
CategoryGroupName = category.GroupName,
SubmittedByTwitchId = $"{submittedByPrefix}_b",
CandidateId = candidateId,
CandidateText = displayName,
StreamUrl = streamUrl,
ResolvedChannel = channelSlug.TrimStart('@'),
ResolvedPlatform = "Twitch",
AvgViewers = ResolveSeedViewerValue(category),
SuggestedCategoryId = category.Id,
TrackerStatus = "resolved",
TrackerCheckedAt = createdAt.AddMinutes(3),
TrackingReviewStatus = "reviewed",
TrackingReviewNote = reviewNote,
TrackingReviewedByTwitchId = reviewedByTwitchId,
TrackingReviewedAt = createdAt.AddMinutes(5),
Status = status,
ReviewNote = reviewNote,
ReviewedByTwitchId = reviewedByTwitchId,
CreatedAt = createdAt.AddMinutes(1),
ReviewedAt = createdAt.AddMinutes(5),
};
}
private static string BuildFreshSeedName(Category category, string groupKey) =>
groupKey switch
{
"fresh" => $"{category.GroupName} Spotlight {category.Name}",
_ => $"{category.GroupName} {category.Name} Pick",
};
private static string BuildSeedChannelSlug(Category category, string suffix)
{
var raw = $"{category.Slug}-{suffix}"
.Trim()
.TrimStart('@')
.ToLowerInvariant();
return new string(raw.Where(char.IsLetterOrDigit).ToArray());
}
private static string BuildSeedStreamUrl(string platform, string channelSlug) =>
platform.Trim().ToLowerInvariant() switch
{
"youtube" => $"https://www.youtube.com/@{channelSlug}",
"kick" => $"https://kick.com/{channelSlug}",
"cake" => $"https://cake.gg/{channelSlug}",
_ => $"https://www.twitch.tv/{channelSlug}",
};
private static int ResolveSeedViewerValue(Category category)
{
if (category.ViewerRangeMin is int min && category.ViewerRangeMax is int max)
{
return min + ((max - min) / 2);
}
if (category.ViewerRangeMin is int lowerBound)
{
return lowerBound + 12;
}
if (category.ViewerRangeMax is int upperBound)
{
return Math.Max(1, upperBound - 5);
}
return 25;
}
private sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
}