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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user