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