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
+44
View File
@@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<Season> Seasons => Set<Season>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Candidate> Candidates => Set<Candidate>();
public DbSet<StreamerIdentity> StreamerIdentities => Set<StreamerIdentity>();
public DbSet<AwardResult> Results => Set<AwardResult>();
public DbSet<Nomination> Nominations => Set<Nomination>();
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
@@ -30,6 +31,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Name).HasMaxLength(160);
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
});
modelBuilder.Entity<SiteSettings>(entity =>
@@ -53,14 +56,19 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
entity.Property(item => item.TwitchScope).HasMaxLength(300);
entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3);
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]");
entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400);
entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]");
entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false);
entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true);
entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240);
entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false);
entity.Property(item => item.ShowactApplicationStartsAt);
entity.Property(item => item.ShowactApplicationEndsAt);
entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240);
entity.Property(item => item.SponsorsVisible).HasDefaultValue(true);
});
@@ -93,13 +101,17 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.GroupName).HasMaxLength(80);
entity.Property(item => item.Name).HasMaxLength(120);
entity.Property(item => item.Description).HasMaxLength(400);
entity.Property(item => item.ViewerRangeMin);
entity.Property(item => item.ViewerRangeMax);
});
modelBuilder.Entity<Candidate>(entity =>
{
entity.HasIndex(item => item.StreamerIdentityId);
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.NominationTally).HasDefaultValue(0);
entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open");
entity.Property(item => item.AcceptanceNote).HasMaxLength(500);
entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500);
@@ -108,15 +120,47 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked");
});
modelBuilder.Entity<StreamerIdentity>(entity =>
{
entity.HasIndex(item => item.NormalizedKey).IsUnique();
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.Login).HasMaxLength(120);
entity.Property(item => item.NormalizedKey).HasMaxLength(180);
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.ProfileUrl).HasMaxLength(500);
});
modelBuilder.Entity<Nomination>(entity =>
{
entity.Property(item => item.CategoryGroupName).HasMaxLength(80).HasDefaultValue(string.Empty);
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CandidateText).HasMaxLength(120);
entity.Property(item => item.StreamUrl).HasMaxLength(300);
entity.Property(item => item.ResolvedChannel).HasMaxLength(120);
entity.Property(item => item.ResolvedPlatform).HasMaxLength(40);
entity.Property(item => item.HoursStreamed);
entity.Property(item => item.HoursWatched);
entity.Property(item => item.PeakViewers);
entity.Property(item => item.FollowersGained);
entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending");
entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear");
entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]");
entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000);
entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.HasIndex(item => new { item.SeasonId, item.Status });
entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status });
entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName });
entity.HasOne(item => item.Category)
.WithMany()
.HasForeignKey(item => item.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(item => item.SuggestedCategory)
.WithMany()
.HasForeignKey(item => item.SuggestedCategoryId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<VoteBallot>(entity =>
@@ -19,6 +19,15 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TrackingRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ViewerStatsProviderBaseUrl" character varying(400) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TrackingReviewNotes" text NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]';
@@ -34,12 +43,30 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationStartsAt" date NULL;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationEndsAt" date NULL;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "HoursStreamed" integer NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "HoursWatched" integer NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "PeakViewers" integer NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "FollowersGained" integer NULL;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
@@ -55,6 +82,9 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SessionIdleTimeoutHours" integer NOT NULL DEFAULT 3;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '';
@@ -70,6 +100,9 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT '';
ALTER TABLE "Seasons"
ADD COLUMN IF NOT EXISTS "SubcategoryTemplatesJson" text NOT NULL DEFAULT '[]';
CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL,
@@ -254,6 +287,95 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked';
ALTER TABLE "Categories"
ADD COLUMN IF NOT EXISTS "ViewerRangeMin" integer NULL;
ALTER TABLE "Categories"
ADD COLUMN IF NOT EXISTS "ViewerRangeMax" integer NULL;
CREATE TABLE IF NOT EXISTS "StreamerIdentities" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Platform" character varying(40) NOT NULL,
"Login" character varying(120) NOT NULL,
"NormalizedKey" character varying(180) NOT NULL,
"DisplayName" character varying(120) NOT NULL,
"ProfileUrl" character varying(500) NULL,
"LastResolvedAt" timestamp with time zone NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_StreamerIdentities_NormalizedKey"
ON "StreamerIdentities" ("NormalizedKey");
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "NominationTally" integer NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS "IX_Candidates_StreamerIdentityId"
ON "Candidates" ("StreamerIdentityId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_Candidates_StreamerIdentities_StreamerIdentityId'
) THEN
ALTER TABLE "Candidates"
ADD CONSTRAINT "FK_Candidates_StreamerIdentities_StreamerIdentityId"
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
END IF;
END $$;
ALTER TABLE "Nominations"
ALTER COLUMN "CategoryId" DROP NOT NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "CategoryGroupName" character varying(80) NOT NULL DEFAULT '';
UPDATE "Nominations" n
SET "CategoryGroupName" = c."GroupName"
FROM "Categories" c
WHERE n."CategoryId" = c."Id"
AND COALESCE(n."CategoryGroupName", '') = '';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "SuggestedCategoryId" integer NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ResolvedChannel" character varying(120) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ResolvedPlatform" character varying(40) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "AvgViewers" integer NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackerStatus" character varying(40) NOT NULL DEFAULT 'pending';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackerCheckedAt" timestamp with time zone NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackingReviewStatus" character varying(30) NOT NULL DEFAULT 'clear';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackingFlagsJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackingReviewNote" character varying(1000) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackingReviewedByTwitchId" character varying(120) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "TrackingReviewedAt" timestamp with time zone NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending';
@@ -269,6 +391,36 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
ON "Nominations" ("SeasonId", "Status");
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_CategoryGroupName_Status"
ON "Nominations" ("SeasonId", "CategoryGroupName", "Status");
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName"
ON "Nominations" ("SeasonId", "StreamerIdentityId", "CategoryGroupName");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_Nominations_StreamerIdentities_StreamerIdentityId'
) THEN
ALTER TABLE "Nominations"
ADD CONSTRAINT "FK_Nominations_StreamerIdentities_StreamerIdentityId"
FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id");
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_Nominations_Categories_SuggestedCategoryId'
) THEN
ALTER TABLE "Nominations"
ADD CONSTRAINT "FK_Nominations_Categories_SuggestedCategoryId"
FOREIGN KEY ("SuggestedCategoryId") REFERENCES "Categories" ("Id")
ON DELETE SET NULL;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS "TeamMembers" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Login" character varying(80) NOT NULL,
+123 -36
View File
@@ -1,4 +1,5 @@
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
@@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper
{
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
{
var seasonCategories = await db.Categories
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
.ToListAsync();
var templates = SeedCatalog.DefaultSubcategoryTemplates
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
var usedCategories = new HashSet<Category>();
foreach (var category in seasonCategories)
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
var nextSortOrder = 1;
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
{
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
foreach (var template in templates)
{
continue;
}
var category = FindReusableCategory(categories, award, template, usedCategories)
?? new Category { SeasonId = season.Id };
usedCategories.Add(category);
var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug);
category.GroupName = target.GroupName;
category.Name = target.Name;
category.Slug = target.Slug;
category.Description = target.Description;
category.SortOrder = target.SortOrder;
category.MaxNomineesPerUser = 3;
category.GroupName = award.Name;
category.Name = template.Name;
category.Slug = BuildCategorySlug(award.Slug, template.Slug);
category.Description = award.Description;
category.SortOrder = nextSortOrder++;
category.MaxNomineesPerUser = 3;
category.ViewerRangeMin = template.ViewerRangeMin;
category.ViewerRangeMax = template.ViewerRangeMax;
if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category)))
{
db.Categories.Add(category);
categories.Add(category);
}
}
}
var staleCategories = categories
.Where(item => item.Id > 0 && !usedCategories.Contains(item))
.ToArray();
await RemoveStaleCategoryDataAsync(db, staleCategories);
await db.SaveChangesAsync();
}
var existing = await db.Categories
.Where(item => item.SeasonId == season.Id)
.Select(item => item.Slug)
.ToArrayAsync();
var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var seed in SeedCatalog.CategorySeeds)
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
{
if (staleCategories.Length == 0)
{
if (existingSlugs.Contains(seed.Slug))
{
continue;
}
db.Categories.Add(new Category
{
SeasonId = season.Id,
GroupName = seed.GroupName,
Name = seed.Name,
Slug = seed.Slug,
Description = seed.Description,
SortOrder = seed.SortOrder,
MaxNomineesPerUser = 3,
});
return;
}
await db.SaveChangesAsync();
var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray();
var staleCandidateIds = await db.Candidates
.Where(item => staleCategoryIds.Contains(item.CategoryId))
.Select(item => item.Id)
.ToArrayAsync();
var staleVoteEntries = await db.VoteEntries
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
.ToArrayAsync();
db.VoteEntries.RemoveRange(staleVoteEntries);
var staleResults = await db.Results
.Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId))
.ToArrayAsync();
db.Results.RemoveRange(staleResults);
var affectedClips = await db.ClipSubmissions
.Where(item =>
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
.ToArrayAsync();
foreach (var clip in affectedClips)
{
if (clip.CategoryId != null && staleCategoryIds.Contains(clip.CategoryId.Value))
{
clip.CategoryId = null;
}
if (clip.CandidateId != null && staleCandidateIds.Contains(clip.CandidateId.Value))
{
clip.CandidateId = null;
}
}
var affectedNominations = await db.Nominations
.Where(item =>
(item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value))
|| (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value))
|| (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value)))
.ToArrayAsync();
foreach (var nomination in affectedNominations)
{
if (nomination.CategoryId != null && staleCategoryIds.Contains(nomination.CategoryId.Value))
{
nomination.CategoryId = null;
}
if (nomination.SuggestedCategoryId != null && staleCategoryIds.Contains(nomination.SuggestedCategoryId.Value))
{
nomination.SuggestedCategoryId = null;
}
if (nomination.CandidateId != null && staleCandidateIds.Contains(nomination.CandidateId.Value))
{
nomination.CandidateId = null;
}
}
var staleCandidates = await db.Candidates
.Where(item => staleCandidateIds.Contains(item.Id))
.ToArrayAsync();
db.Candidates.RemoveRange(staleCandidates);
db.Categories.RemoveRange(staleCategories);
}
private static Category? FindReusableCategory(
List<Category> categories,
AwardCategorySeed award,
SeasonSubcategoryTemplateSetting template,
HashSet<Category> usedCategories)
{
var targetSlug = BuildCategorySlug(award.Slug, template.Slug);
return categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.GroupName, award.Name, StringComparison.OrdinalIgnoreCase)
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase))
?? categories.FirstOrDefault(item => !usedCategories.Contains(item)
&& string.Equals(item.GroupName, award.LegacyGroupName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase));
}
private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds)
@@ -142,4 +226,7 @@ public static partial class SeedDataBootstrapper
});
}
}
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
}
+64 -55
View File
@@ -1,6 +1,8 @@
namespace Backend.Data;
internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder);
using Backend.Services;
internal sealed record AwardCategorySeed(string LegacyGroupName, string Name, string Slug, string Description, int SortOrder);
internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform);
internal sealed record SiteFaqSeed(string Question, string Answer);
@@ -8,10 +10,17 @@ internal sealed record SiteSocialSeed(string Label, string Platform, string Url,
internal static class SeedCatalog
{
internal static readonly CategorySeed[] CategorySeeds =
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
[
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1),
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2),
new("Hidden Star", "hidden-star", 1, 1, 20),
new("Rising Star", "rising-star", 2, 21, 60),
new("Shining Star", "shining-star", 3, 61, null),
];
internal static readonly AwardCategorySeed[] AwardCategorySeeds =
[
new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die größte Auszeichnung des Jahres.", 1),
new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie für die Szene.", 2),
new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3),
new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4),
new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5),
@@ -34,16 +43,16 @@ internal static class SeedCatalog
"Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."),
new(
"Wie funktioniert das Voting?",
"Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."),
"Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase."),
new(
"Was kostet die Teilnahme?",
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."),
"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans."),
new(
"Wann und wo findet die Award-Show statt?",
"Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"),
"Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!"),
new(
"Ich wurde nominiert — was nun?",
"Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."),
"Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt."),
];
internal static readonly SiteSocialSeed[] SiteSocialSeeds =
@@ -60,18 +69,18 @@ Anbieter
VTuber Star Awards, vertreten durch Jayuhime.
Kontakt
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
Hinweis
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.
""";
internal const string DefaultContactContent = """
Kontakt zum Award-Team
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.
Datenschutzfragen
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
Community & Kooperationen
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
@@ -79,57 +88,57 @@ Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
internal const string DefaultSponsorsContent = """
Sponsoren & Partner
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
Hier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.
Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
""";
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
[
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"),
new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"),
new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
new("best-newcomer", "Nox Live", "@noxlive", "Twitch"),
new("model-design", "Velvet Rei", "@velvetrei", "Twitch"),
new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
new("best-gaming", "Kurainu", "@kurainu", "Twitch"),
new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"),
new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
new("community-liebling", "Lumi", "@lumi_vt", "Cake"),
new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"),
new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"),
new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"),
new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"),
new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"),
new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"),
new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"),
];
internal static readonly WinnerSeed[] WinnerSeeds =
[
new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"),
new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"),
new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"),
new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"),
new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"),
new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"),
new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"),
new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"),
new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"),
new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"),
new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"),
new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"),
new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"),
new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"),
new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"),
new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"),
new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"),
new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"),
new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"),
new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"),
new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"),
new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"),
new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"),
new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"),
new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"),
new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"),
new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"),
new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"),
new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"),
new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"),
new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"),
new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"),
new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"),
new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"),
new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"),
new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"),
new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"),
new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"),
new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"),
new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"),
new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"),
new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"),
];
}
+10 -2
View File
@@ -49,15 +49,23 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
ShowactsUrl = "https://vtuber-star-awards.de/showacts",
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.",
ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.",
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults),
TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
TrackingRulesSettings.DefaultSource,
TrackingRulesSettings.DefaultImportantMetrics,
TrackingRulesSettings.DefaultOptionalMetrics,
TrackingRulesSettings.DefaultFlags)),
ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl,
TrackingReviewNotes = "Fallback-Quellen für manuelle Reviews:\\n- SullyGnome\\n- Twitch-Kanal direkt\\n\\nNutze diese Notizen für Edge Cases und manuelle Tier-Entscheidungen.",
NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults),
ClipSubmissionsEnabled = false,
ClipReviewEnabled = true,
ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.",
ShowactApplicationsEnabled = false,
ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.",
SessionIdleTimeoutHours = 3,
SponsorsVisible = true,
SocialLinksJson = JsonSerializer.Serialize(new[]
{
@@ -152,7 +160,7 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
});
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die größte Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
+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);
}
+42 -1
View File
@@ -46,6 +46,34 @@ public static partial class SeedDataBootstrapper
settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults);
}
if (!HasValidTrackingRules(settings.TrackingRulesJson))
{
settings.TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration(
TrackingRulesSettings.DefaultSource,
TrackingRulesSettings.DefaultImportantMetrics,
TrackingRulesSettings.DefaultOptionalMetrics,
TrackingRulesSettings.DefaultFlags));
}
if (string.IsNullOrWhiteSpace(settings.ViewerStatsProviderBaseUrl))
{
settings.ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl;
}
if (string.IsNullOrWhiteSpace(settings.TrackingReviewNotes))
{
settings.TrackingReviewNotes = """
Fallback-Quellen für manuelle Reviews:
- SullyGnome Channel Summary
- Offizieller Twitch-Kanal
Prüfe bei Edge Cases:
- passt der Kanal wirklich zur Unterkategorie?
- fehlen TwitchTracker-Daten nur temporär?
- braucht der Fall eine manuelle Team-Notiz?
""";
}
if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson))
{
settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults);
@@ -83,7 +111,12 @@ public static partial class SeedDataBootstrapper
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
{
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.";
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.";
}
if (settings.SessionIdleTimeoutHours < 3)
{
settings.SessionIdleTimeoutHours = 3;
}
}
@@ -121,6 +154,14 @@ public static partial class SeedDataBootstrapper
private static bool HasValidWorkflowRules(string? json) =>
WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length;
private static bool HasValidTrackingRules(string? json)
{
var rules = TrackingRulesSettings.Read(new Backend.Domain.SiteSettings { TrackingRulesJson = json ?? string.Empty });
return rules.ImportantMetrics.Length == TrackingRulesSettings.DefaultImportantMetrics.Length
&& rules.OptionalMetrics.Length == TrackingRulesSettings.DefaultOptionalMetrics.Length
&& rules.Flags.Length == TrackingRulesSettings.DefaultFlags.Length;
}
private static bool HasValidNominationLinkBlacklist(string? json)
{
if (string.IsNullOrWhiteSpace(json))