Update release notes and deploy workspace
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 53s

This commit is contained in:
AzuTear
2026-06-29 17:49:54 +02:00
parent c466348d18
commit 441ef2b850
196 changed files with 9279 additions and 39292 deletions
+15 -3
View File
@@ -29,8 +29,9 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
{
entity.HasIndex(item => item.Year).IsUnique();
entity.Property(item => item.Name).HasMaxLength(160);
entity.Property(item => item.ShowStreamUrl).HasMaxLength(400);
entity.Property(item => item.IsDemo).HasDefaultValue(false);
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
entity.Property(item => item.WinnersPublishedByTwitchId).HasMaxLength(120);
entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]");
entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]");
});
@@ -47,6 +48,15 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.SponsorsUrl).HasMaxLength(400);
entity.Property(item => item.ShowactsUrl).HasMaxLength(400);
entity.Property(item => item.ShowactsContent).HasDefaultValue(string.Empty);
entity.Property(item => item.StreamBannerEyebrow).HasMaxLength(120);
entity.Property(item => item.StreamBannerTitle).HasMaxLength(160);
entity.Property(item => item.StreamBannerLiveButtonLabel).HasMaxLength(120);
entity.Property(item => item.StreamBannerLiveButtonUrl).HasMaxLength(400);
entity.Property(item => item.StreamBannerLockedButtonLabel).HasMaxLength(120);
entity.Property(item => item.StreamBannerCompletedEyebrow).HasMaxLength(120);
entity.Property(item => item.StreamBannerCompletedTitle).HasMaxLength(160);
entity.Property(item => item.StreamBannerCompletedButtonLabel).HasMaxLength(120);
entity.Property(item => item.StreamBannerCompletedButtonUrl).HasMaxLength(400);
entity.Property(item => item.DemoLoginEmail).HasMaxLength(180);
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
@@ -225,6 +235,10 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.Status });
entity.HasIndex(item => item.CandidateId);
entity.HasOne<Season>()
.WithMany()
.HasForeignKey(item => item.SeasonId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(item => item.Candidate)
.WithMany()
.HasForeignKey(item => item.CandidateId)
@@ -258,7 +272,5 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Tier).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.IsVisible, item.SortOrder });
});
SeedData.Apply(modelBuilder);
}
}
@@ -1,471 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static class OperationalTablesBootstrapper
{
public static Task EnsureAsync(AwardsDbContext db) =>
db.Database.ExecuteSqlRawAsync(
"""
ALTER TABLE "UserSessions"
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
ALTER TABLE "UserSessions"
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
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 '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.';
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;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchClientId" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchClientSecret" character varying(180) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchRedirectUri" character varying(400) NOT NULL DEFAULT '';
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 '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ContactContent" text NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "ShowactsUrl" character varying(400) NOT NULL DEFAULT '';
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,
"TwitchUserId" character varying(120) NULL,
"Source" character varying(80) NOT NULL,
"Type" character varying(80) NOT NULL,
"Severity" character varying(20) NOT NULL,
"Status" character varying(20) NOT NULL,
"Summary" character varying(240) NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"UserAgent" character varying(400) NOT NULL,
"MetadataJson" text NOT NULL,
"ReviewNote" character varying(500) NULL,
"ReviewedByTwitchId" character varying(120) NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone NULL
);
ALTER TABLE "RiskFlags"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
ON "RiskFlags" ("Status", "CreatedAt" DESC);
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_SeasonId"
ON "RiskFlags" ("SeasonId");
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"AdminTwitchUserId" character varying(120) NOT NULL,
"ActionType" character varying(80) NOT NULL,
"EntityType" character varying(80) NOT NULL,
"EntityId" character varying(120) NOT NULL,
"Summary" character varying(240) NOT NULL,
"MetadataJson" text NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL DEFAULT '',
"UserAgent" character varying(400) NOT NULL DEFAULT '',
"CreatedAt" timestamp with time zone NOT NULL
);
ALTER TABLE "AdminAuditEntries"
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
ALTER TABLE "AdminAuditEntries"
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt" DESC);
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"CategoryId" integer NULL,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"ClipUrl" character varying(500) NOT NULL,
"Title" character varying(200) NOT NULL,
"Creator" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
"Status" character varying(20) NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL
);
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "CandidateId" integer NULL;
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
ALTER TABLE "ClipSubmissions"
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status");
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId"
ON "ClipSubmissions" ("CandidateId");
CREATE TABLE IF NOT EXISTS "ShowactApplications" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"ArtistName" character varying(120) NOT NULL,
"ContactEmail" character varying(180) NOT NULL,
"ContactDiscord" character varying(120) NOT NULL,
"PlatformUrl" character varying(500) NOT NULL,
"PerformanceType" character varying(80) NOT NULL,
"Description" character varying(1000) NOT NULL,
"TechnicalNotes" character varying(1000) NOT NULL,
"ReferenceUrl" character varying(500) NOT NULL,
"Status" character varying(20) NOT NULL,
"ReviewNote" character varying(500) NULL,
"ReviewedByTwitchId" character varying(120) NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"UserAgent" character varying(400) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"ReviewedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_ShowactApplications_SeasonId_Status"
ON "ShowactApplications" ("SeasonId", "Status");
CREATE TABLE IF NOT EXISTS "Sponsors" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"Name" character varying(120) NOT NULL,
"WebsiteUrl" character varying(500) NOT NULL,
"LogoUrl" character varying(500) NOT NULL,
"Description" character varying(500) NOT NULL,
"Tier" character varying(80) NOT NULL,
"SortOrder" integer NOT NULL,
"IsVisible" boolean NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NULL
);
CREATE INDEX IF NOT EXISTS "IX_Sponsors_SeasonId_IsVisible_SortOrder"
ON "Sponsors" ("SeasonId", "IsVisible", "SortOrder");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ShowactApplications_Seasons_SeasonId'
) THEN
ALTER TABLE "ShowactApplications"
ADD CONSTRAINT "FK_ShowactApplications_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
ON DELETE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_Sponsors_Seasons_SeasonId'
) THEN
ALTER TABLE "Sponsors"
ADD CONSTRAINT "FK_Sponsors_Seasons_SeasonId"
FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id")
ON DELETE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'FK_ClipSubmissions_Candidates_CandidateId'
) THEN
ALTER TABLE "ClipSubmissions"
ADD CONSTRAINT "FK_ClipSubmissions_Candidates_CandidateId"
FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id")
ON DELETE SET NULL;
END IF;
END $$;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "AcceptanceStatus" character varying(30) NOT NULL DEFAULT 'open';
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "AcceptanceNote" character varying(500) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationUrl" character varying(500) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationTitle" character varying(200) NULL;
ALTER TABLE "Candidates"
ADD COLUMN IF NOT EXISTS "ClipCompilationPlatform" character varying(40) NULL;
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';
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ReviewNote" character varying(500) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ReviewedByTwitchId" character varying(120) NULL;
ALTER TABLE "Nominations"
ADD COLUMN IF NOT EXISTS "ReviewedAt" timestamp with time zone NULL;
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,
"DisplayName" character varying(120) NOT NULL,
"Role" character varying(40) NOT NULL,
"PasswordHash" character varying(120) NOT NULL,
"PasswordSalt" character varying(80) NOT NULL,
"BoundTwitchUserId" character varying(120) NULL,
"BoundTwitchDisplayName" character varying(120) NULL,
"MustChangePassword" boolean NOT NULL,
"IsActive" boolean NOT NULL,
"CreatedByTwitchId" character varying(120) NOT NULL,
"UpdatedByTwitchId" character varying(120) NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NULL,
"LastLoginAt" timestamp with time zone NULL,
"TwitchBoundAt" timestamp with time zone NULL,
"PasswordResetAt" timestamp with time zone NULL
);
ALTER TABLE "TeamMembers"
ADD COLUMN IF NOT EXISTS "BoundTwitchUserId" character varying(120) NULL;
ALTER TABLE "TeamMembers"
ADD COLUMN IF NOT EXISTS "BoundTwitchDisplayName" character varying(120) NULL;
ALTER TABLE "TeamMembers"
ADD COLUMN IF NOT EXISTS "TwitchBoundAt" timestamp with time zone NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamMembers_Login"
ON "TeamMembers" ("Login");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamMembers_BoundTwitchUserId"
ON "TeamMembers" ("BoundTwitchUserId")
WHERE "BoundTwitchUserId" IS NOT NULL;
CREATE TABLE IF NOT EXISTS "TeamRolePermissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Role" character varying(40) NOT NULL,
"PermissionsJson" text NOT NULL,
"UpdatedByTwitchId" character varying(120) NOT NULL,
"UpdatedAt" timestamp with time zone NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamRolePermissions_Role"
ON "TeamRolePermissions" ("Role");
""");
}
@@ -1,232 +0,0 @@
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToListAsync();
var templates = SeedCatalog.DefaultSubcategoryTemplates
.Select((item, index) => item with { SortOrder = index + 1 })
.ToArray();
var usedCategories = new HashSet<Category>();
season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates);
var nextSortOrder = 1;
foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder))
{
foreach (var template in templates)
{
var category = FindReusableCategory(categories, award, template, usedCategories)
?? new Category { SeasonId = season.Id };
usedCategories.Add(category);
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();
}
private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories)
{
if (staleCategories.Length == 0)
{
return;
}
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)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var existing = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.Select(item => new { item.CategoryId, item.DisplayName, item.ChannelSlug })
.ToArrayAsync();
var existingKeys = existing
.Select(item => $"{item.CategoryId}|{item.DisplayName}|{item.ChannelSlug}".ToLowerInvariant())
.ToHashSet();
foreach (var seed in seeds)
{
if (!categories.TryGetValue(seed.CategorySlug, out var category))
{
continue;
}
var key = $"{category.Id}|{seed.DisplayName}|{seed.ChannelSlug}".ToLowerInvariant();
if (existingKeys.Contains(key))
{
continue;
}
db.Candidates.Add(new Candidate
{
SeasonId = season.Id,
CategoryId = category.Id,
DisplayName = seed.DisplayName,
ChannelSlug = seed.ChannelSlug,
Platform = seed.Platform,
});
}
await db.SaveChangesAsync();
}
private static async Task EnsureWinnersAsync(AwardsDbContext db, Season season, WinnerSeed[] seeds)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var candidates = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
var existingResults = await db.Results
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
foreach (var result in existingResults)
{
if (categories.Values.FirstOrDefault(item => item.Id == result.CategoryId) is { } category
&& result.CategoryName != category.Name)
{
result.CategoryName = category.Name;
}
}
var existingResultCategoryIds = existingResults.Select(item => item.CategoryId).ToHashSet();
foreach (var seed in seeds)
{
if (!categories.TryGetValue(seed.CategorySlug, out var category) || existingResultCategoryIds.Contains(category.Id))
{
continue;
}
var candidate = candidates.FirstOrDefault(item =>
item.CategoryId == category.Id
&& string.Equals(item.DisplayName, seed.DisplayName, StringComparison.OrdinalIgnoreCase));
if (candidate is null)
{
continue;
}
db.Results.Add(new AwardResult
{
SeasonId = season.Id,
CategoryId = category.Id,
CandidateId = candidate.Id,
CategoryName = category.Name,
});
}
}
private static string BuildCategorySlug(string awardSlug, string templateSlug) =>
$"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}";
}
-184
View File
@@ -1,184 +0,0 @@
namespace Backend.Data;
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);
internal sealed record SiteSocialSeed(string Label, string Platform, string Url, string Icon);
internal sealed record SponsorSeed(string Name, string WebsiteUrl, string LogoUrl, string Description, string Tier, int SortOrder);
internal static class SeedCatalog
{
internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates =
[
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),
new("Entertainment", "Best Variety", "best-variety", "Talk, Comedy, Watchalongs und kreative Streamformate.", 6),
new("Community", "Community Liebling", "community-liebling", "Creator:innen, die ihre Community besonders stark verbinden.", 7),
new("Collab", "Best Collab & Duo", "best-collab-duo", "Gemeinsame Streams, Projekte und Duo-Dynamik.", 8),
];
internal static readonly Dictionary<string, string> LegacyCategorySlugMap = new(StringComparer.OrdinalIgnoreCase)
{
["bestes-live-event"] = "best-newcomer",
["clip-des-jahres"] = "model-design",
["beste-community"] = "gesang-musik",
};
internal static readonly SiteFaqSeed[] SiteFaqSeeds =
[
new(
"Wer darf nominiert werden?",
"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 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 für Fans."),
new(
"Wann und wo findet die Award-Show statt?",
"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?",
"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 =
[
new("Twitch", "twitch", "https://twitch.tv/jayuhime", "twitch"),
new("YouTube", "youtube", "https://youtube.com/c/Jayuhime", "youtube"),
new("X", "x", "https://x.com/jayuhime", "x"),
new("Instagram", "instagram", "https://instagram.com/jayuhime", "instagram"),
new("Discord", "discord", "https://discord.gg/jayuhime", "discord"),
];
internal const string DefaultImprintContent = """
Anbieter
VTuber Star Awards, vertreten durch Jayuhime.
Kontakt
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 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 über die hinterlegte Kontaktseite.
Datenschutzfragen
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.
""";
internal const string DefaultSponsorsContent = """
Sponsoren & Partner
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 öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.
""";
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
[
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-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"),
];
internal static readonly SponsorSeed[] DemoSponsorSeeds =
[
new(
"HoshiForge Studio",
"https://hoshiforge.example",
"/demo/sponsors/hoshiforge-studio.svg",
"Branding-, Overlay- und Debuet-Visuals fuer VTuber-Projekte und Community-Events.",
"Presenting Sponsor",
10),
new(
"NekoPixel Energy",
"https://nekopixel.example",
"/demo/sponsors/nekopixel-energy.svg",
"Community-fokussierter Drink-Partner fuer lange Showabende, Watchpartys und Creator-Collabs.",
"Gold Partner",
20),
new(
"PrismLoop Audio",
"https://prismloop.example",
"/demo/sponsors/prismloop-audio.svg",
"Audio-Tools, Intro-Packs und Stream-Sounddesign fuer Live-Shows und Highlight-Clips.",
"Gold Partner",
30),
new(
"CloudBeacon Hosting",
"https://cloudbeacon.example",
"/demo/sponsors/cloudbeacon-hosting.svg",
"Skalierbares Hosting fuer Voting, Landingpages und Event-Traffic rund um Showtage.",
"Tech Partner",
40),
new(
"ChibiCanvas Market",
"https://chibicanvas.example",
"/demo/sponsors/chibicanvas-market.svg",
"Merch-, Sticker- und Artist-Marketplace mit Fokus auf VTuber, Emotes und Fanartikel.",
"Community Partner",
50),
];
}
-211
View File
@@ -1,211 +0,0 @@
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Backend.Data;
public static class SeedData
{
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<SiteSettings>().HasData(
new SiteSettings
{
Id = 1,
HostDisplayName = "Jayuhime",
HostTagline = "VTuber & Award Host",
NewsletterUrl = "https://vtuber-star-awards.de/newsletter",
PrivacyEmail = "datenschutz@vtuber-star-awards.de",
PrivacyPolicyContent = """
Verantwortliche:r
VTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de
Welche Daten wir verarbeiten
Bei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.
Für Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.
Rechtsgrundlage
Verarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.
Zweck der Verarbeitung
Durchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.
Löschfristen
Alle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.
Deine Rechte
Du hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.
Weitergabe an Dritte
Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.
""",
PrivacyPolicyUpdatedBy = "seed",
PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero),
ImprintUrl = "https://vtuber-star-awards.de/impressum",
ImprintContent = SeedCatalog.DefaultImprintContent,
ContactUrl = "https://vtuber-star-awards.de/kontakt",
ContactContent = SeedCatalog.DefaultContactContent,
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 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[]
{
new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true },
new { label = "YouTube", platform = "youtube", url = "https://youtube.com/c/Jayuhime", icon = "youtube", showOnHost = true, showOnCommunity = true },
new { label = "X", platform = "x", url = "https://x.com/jayuhime", icon = "x", showOnHost = true, showOnCommunity = true },
new { label = "Instagram", platform = "instagram", url = "https://instagram.com/jayuhime", icon = "instagram", showOnHost = true, showOnCommunity = true },
new { label = "Discord", platform = "discord", url = "https://discord.gg/jayuhime", icon = "discord", showOnHost = true, showOnCommunity = true },
}),
FaqJson = JsonSerializer.Serialize(new[]
{
new { question = "Wer darf nominiert werden?", answer = "Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhängig von Follower-Zahl oder Plattform. Die Community schlägt in der Nominierungsphase ihre Favorit:innen vor." },
new { question = "Wie funktioniert das Voting?", answer = "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 { question = "Was kostet die Teilnahme?", answer = "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 { question = "Wann und wo findet die Award-Show statt?", answer = "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 { question = "Ich wurde nominiert — was nun?", answer = "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." },
}),
});
modelBuilder.Entity<Season>().HasData(
new Season
{
Id = 1,
Year = 2026,
Name = "VTuber Star Awards 2026",
ShowStreamUrl = "https://twitch.tv/jayuhime",
IsCurrent = true,
IsCommunityOnly = true,
CurrentPhase = "Community Voting",
NominationStartsAt = new DateOnly(2026, 5, 1),
NominationEndsAt = new DateOnly(2026, 5, 31),
VotingStartsAt = new DateOnly(2026, 6, 1),
VotingEndsAt = new DateOnly(2026, 6, 30),
ReviewStartsAt = new DateOnly(2026, 7, 1),
ReviewEndsAt = new DateOnly(2026, 7, 10),
ShowDate = new DateOnly(2026, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
},
new Season
{
Id = 2,
Year = 2025,
Name = "VTuber Star Awards 2025",
ShowStreamUrl = "https://twitch.tv/jayuhime",
IsCurrent = false,
IsCommunityOnly = true,
CurrentPhase = "Archived",
NominationStartsAt = new DateOnly(2025, 5, 1),
NominationEndsAt = new DateOnly(2025, 5, 31),
VotingStartsAt = new DateOnly(2025, 6, 1),
VotingEndsAt = new DateOnly(2025, 6, 30),
ReviewStartsAt = new DateOnly(2025, 7, 1),
ReviewEndsAt = new DateOnly(2025, 7, 10),
ShowDate = new DateOnly(2025, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
},
new Season
{
Id = 3,
Year = 2024,
Name = "VTuber Star Awards 2024",
ShowStreamUrl = "https://youtube.com/c/Jayuhime",
IsCurrent = false,
IsCommunityOnly = true,
CurrentPhase = "Archived",
NominationStartsAt = new DateOnly(2024, 5, 1),
NominationEndsAt = new DateOnly(2024, 5, 31),
VotingStartsAt = new DateOnly(2024, 6, 1),
VotingEndsAt = new DateOnly(2024, 6, 30),
ReviewStartsAt = new DateOnly(2024, 7, 1),
ReviewEndsAt = new DateOnly(2024, 7, 10),
ShowDate = new DateOnly(2024, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
},
new Season
{
Id = 4,
Year = 2023,
Name = "VTuber Star Awards 2023",
ShowStreamUrl = "https://twitch.tv/jayuhime",
IsCurrent = false,
IsCommunityOnly = true,
CurrentPhase = "Archived",
NominationStartsAt = new DateOnly(2023, 5, 1),
NominationEndsAt = new DateOnly(2023, 5, 31),
VotingStartsAt = new DateOnly(2023, 6, 1),
VotingEndsAt = new DateOnly(2023, 6, 30),
ReviewStartsAt = new DateOnly(2023, 7, 1),
ReviewEndsAt = new DateOnly(2023, 7, 10),
ShowDate = new DateOnly(2023, 7, 20),
ShowStartsAt = new TimeOnly(20, 0),
});
modelBuilder.Entity<Category>().HasData(
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 },
new Category { Id = 5, SeasonId = 2, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2025.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 6, SeasonId = 2, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Archivkategorie 2025.", SortOrder = 2, MaxNomineesPerUser = 3 },
new Category { Id = 7, SeasonId = 2, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Archivkategorie 2025.", SortOrder = 3, MaxNomineesPerUser = 3 },
new Category { Id = 8, SeasonId = 3, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2024.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 9, SeasonId = 3, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Archivkategorie 2024.", SortOrder = 2, MaxNomineesPerUser = 3 },
new Category { Id = 10, SeasonId = 4, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2023.", SortOrder = 1, MaxNomineesPerUser = 3 });
modelBuilder.Entity<Candidate>().HasData(
new Candidate { Id = 1, SeasonId = 1, CategoryId = 1, DisplayName = "Hoshimi Miyu", ChannelSlug = "@hoshimimiyu", Platform = "Twitch" },
new Candidate { Id = 2, SeasonId = 1, CategoryId = 1, DisplayName = "Kurainu", ChannelSlug = "@kurainu", Platform = "Twitch" },
new Candidate { Id = 3, SeasonId = 1, CategoryId = 1, DisplayName = "Shiro Ch.", ChannelSlug = "@shiroch", Platform = "Twitch" },
new Candidate { Id = 4, SeasonId = 1, CategoryId = 2, DisplayName = "Kurainu 3D Live", ChannelSlug = "@kurainu", Platform = "Twitch" },
new Candidate { Id = 5, SeasonId = 1, CategoryId = 2, DisplayName = "Aoi Sakura Showcase", ChannelSlug = "@aoisakura", Platform = "YouTube" },
new Candidate { Id = 6, SeasonId = 1, CategoryId = 3, DisplayName = "Pyonkichi Kingdom", ChannelSlug = "@pyonkichikingdom", Platform = "Twitch" },
new Candidate { Id = 7, SeasonId = 1, CategoryId = 4, DisplayName = "Moonrelay", ChannelSlug = "@moonrelay", Platform = "Twitch" },
new Candidate { Id = 8, SeasonId = 2, CategoryId = 5, DisplayName = "Hoshimi Miyu", ChannelSlug = "@hoshimimiyu", Platform = "Twitch" },
new Candidate { Id = 9, SeasonId = 2, CategoryId = 6, DisplayName = "Kurainu 3D Live", ChannelSlug = "@kurainu", Platform = "Twitch" },
new Candidate { Id = 10, SeasonId = 2, CategoryId = 7, DisplayName = "Pyonkichi Kingdom", ChannelSlug = "@pyonkichikingdom", Platform = "Twitch" },
new Candidate { Id = 11, SeasonId = 3, CategoryId = 8, DisplayName = "Aoi Sakura", ChannelSlug = "@aoisakura", Platform = "YouTube" },
new Candidate { Id = 12, SeasonId = 3, CategoryId = 9, DisplayName = "Starbyte", ChannelSlug = "@starbyte", Platform = "Twitch" },
new Candidate { Id = 13, SeasonId = 4, CategoryId = 10, DisplayName = "Tenshi Vox", ChannelSlug = "@tenshivox", Platform = "Twitch" });
modelBuilder.Entity<AwardResult>().HasData(
new AwardResult { Id = 1, SeasonId = 2, CategoryId = 5, CandidateId = 8, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 2, SeasonId = 2, CategoryId = 6, CandidateId = 9, CategoryName = "Bestes Live Event" },
new AwardResult { Id = 3, SeasonId = 2, CategoryId = 7, CandidateId = 10, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 4, SeasonId = 3, CategoryId = 8, CandidateId = 11, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 5, SeasonId = 3, CategoryId = 9, CandidateId = 12, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 6, SeasonId = 4, CategoryId = 10, CandidateId = 13, CategoryName = "VTuber des Jahres" });
modelBuilder.Entity<Nomination>().HasData(
new Nomination { Id = 1, SeasonId = 1, CategoryId = 1, SubmittedByTwitchId = "twitch_hoshi", CandidateText = "Hoshimi Miyu", CreatedAt = new DateTimeOffset(2026, 6, 10, 13, 0, 0, TimeSpan.Zero) },
new Nomination { Id = 2, SeasonId = 1, CategoryId = 2, SubmittedByTwitchId = "twitch_kurainu", CandidateText = "Kurainu 3D Live", CreatedAt = new DateTimeOffset(2026, 6, 10, 14, 0, 0, TimeSpan.Zero) });
modelBuilder.Entity<VoteBallot>().HasData(
new VoteBallot { Id = 1, SeasonId = 1, SubmittedByTwitchId = "twitch_vote_1", Status = "submitted", SubmittedAt = new DateTimeOffset(2026, 6, 11, 12, 0, 0, TimeSpan.Zero) },
new VoteBallot { Id = 2, SeasonId = 1, SubmittedByTwitchId = "twitch_vote_2", Status = "submitted", SubmittedAt = new DateTimeOffset(2026, 6, 11, 12, 5, 0, TimeSpan.Zero) });
modelBuilder.Entity<VoteEntry>().HasData(
new VoteEntry { Id = 1, BallotId = 1, CategoryId = 1, CandidateId = 1 },
new VoteEntry { Id = 2, BallotId = 1, CategoryId = 2, CandidateId = 4 },
new VoteEntry { Id = 3, BallotId = 2, CategoryId = 1, CandidateId = 2 },
new VoteEntry { Id = 4, BallotId = 2, CategoryId = 3, CandidateId = 6 });
}
}
-43
View File
@@ -1,43 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
public static async Task EnsureAsync(AwardsDbContext db)
{
await EnsureSiteSettingsAsync(db);
var seasons = await db.Seasons.ToDictionaryAsync(item => item.Year);
if (seasons.Count == 0)
{
return;
}
foreach (var season in seasons.Values)
{
await EnsureCategoriesAsync(db, season);
}
if (seasons.TryGetValue(2026, out var currentSeason))
{
await EnsureCandidatesAsync(db, currentSeason, SeedCatalog.CurrentCandidateSeeds);
await EnsureSponsorsAsync(db, currentSeason);
await EnsureSeedOperationalDataAsync(db, currentSeason);
}
foreach (var year in new[] { 2025, 2024, 2023 })
{
if (!seasons.TryGetValue(year, out var season))
{
continue;
}
var winners = SeedCatalog.WinnerSeeds.Where(item => item.Year == year).ToArray();
await EnsureCandidatesAsync(db, season, winners.Select(item => new CandidateSeed(item.CategorySlug, item.DisplayName, item.ChannelSlug, item.Platform)).ToArray());
await EnsureWinnersAsync(db, season, winners);
}
await db.SaveChangesAsync();
}
}
@@ -1,554 +0,0 @@
using System.Text.Json;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureSeedOperationalDataAsync(AwardsDbContext db, Season season)
{
var categories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToDictionaryAsync(item => item.Slug, StringComparer.OrdinalIgnoreCase);
var candidates = await db.Candidates
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db);
await EnsureSeedReviewNominationsAsync(db, season, categories, candidates);
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
{
db.ClipSubmissions.AddRange(
new ClipSubmission
{
SeasonId = season.Id,
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 für Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero),
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 5, 0, TimeSpan.Zero),
},
new ClipSubmission
{
SeasonId = season.Id,
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 für Voting-Vorschau.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero),
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 10, 0, TimeSpan.Zero),
},
new ClipSubmission
{
SeasonId = season.Id,
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",
Creator = "Kurainu",
Platform = "Twitch",
Status = "pending",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 17, 9, 10, 0, TimeSpan.Zero),
},
new ClipSubmission
{
SeasonId = season.Id,
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 für Review-Workflow.",
ReviewedByTwitchId = "jayuhime_admin",
CreatedFromIp = "127.0.0.1",
CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero),
ReviewedAt = new DateTimeOffset(2026, 6, 18, 12, 0, 0, TimeSpan.Zero),
});
}
if (!normalizedLegacyState.HasRiskSeed && !await db.RiskFlags.AnyAsync(item => item.Source == "seed"))
{
db.RiskFlags.Add(new RiskFlag
{
SeasonId = season.Id,
TwitchUserId = "sample_user",
Source = "seed",
Type = "rapid_vote_updates",
Severity = "medium",
Status = "open",
Summary = "Mehrere Voting-Aenderungen in kurzer Zeit erkannt.",
CreatedFromIp = "127.0.0.1",
UserAgent = "seed-bootstrap",
MetadataJson = JsonSerializer.Serialize(new { recentVoteSubmissions = 3 }),
CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 40, 0, TimeSpan.Zero),
});
}
if (!normalizedLegacyState.HasAuditSeed && !await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"))
{
db.AdminAuditEntries.Add(new AdminAuditEntry
{
AdminTwitchUserId = "system",
ActionType = "seed.initialize",
EntityType = "database",
EntityId = season.Year.ToString(),
Summary = "Startinhalte wurden in der Datenbank bereitgestellt.",
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),
});
}
}
private static int? ResolveCategoryId(IReadOnlyDictionary<string, Category> categories, string slug) =>
categories.TryGetValue(slug, out var category) ? category.Id : null;
private static int? ResolveCandidateId(
IReadOnlyDictionary<string, Category> categories,
IEnumerable<Candidate> candidates,
string categorySlug,
string displayName)
{
var categoryId = ResolveCategoryId(categories, categorySlug);
return categoryId is int resolvedCategoryId
? candidates.FirstOrDefault(item =>
item.CategoryId == resolvedCategoryId
&& string.Equals(item.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))?.Id
: null;
}
private static async Task<LegacySeedState> NormalizeLegacyDemoLabelsAsync(AwardsDbContext db)
{
var legacySessions = await db.UserSessions
.Where(item => item.TwitchUserId == "admin_demo" || item.TwitchUserId == "jayuhime_demo" || item.TwitchUserId == "demo_user")
.ToArrayAsync();
foreach (var session in legacySessions)
{
session.TwitchUserId = session.TwitchUserId switch
{
"admin_demo" => "jayuhime_admin",
"jayuhime_demo" => "jayuhime_viewer",
"demo_user" => "local_user",
_ => session.TwitchUserId,
};
session.DisplayName = session.DisplayName switch
{
"Admin Demo" => "Jayuhime Admin",
"Demo User" => "Local User",
_ => session.DisplayName,
};
}
var legacyClipSubmissions = await db.ClipSubmissions
.Where(item =>
item.SubmittedByTwitchId == "demo_user" ||
item.SubmittedByTwitchId == "demo_user_2" ||
item.ClipUrl.Contains("Demo") ||
item.ClipUrl.Contains("demo") ||
(item.ReviewNote != null && item.ReviewNote.Contains("Demo-Clip")))
.ToArrayAsync();
foreach (var clip in legacyClipSubmissions)
{
clip.SubmittedByTwitchId = clip.SubmittedByTwitchId switch
{
"demo_user" => "local_user",
"demo_user_2" => "local_user_2",
_ => clip.SubmittedByTwitchId,
};
clip.ClipUrl = clip.ClipUrl
.Replace("DemoGamingMoment", "EpicGamingMoment")
.Replace("demoSong", "liveCoverMoment");
clip.ReviewNote = clip.ReviewNote?.Replace("Demo-Clip", "Geprüfter Clip");
}
await LinkExistingClipsToCandidatesAsync(db);
var legacyRiskFlags = await db.RiskFlags
.Where(item =>
item.Source == "demo" ||
item.Summary.StartsWith("Demo:") ||
item.TwitchUserId == "jayuhime_demo" ||
item.TwitchUserId == "demo_user")
.ToArrayAsync();
foreach (var flag in legacyRiskFlags)
{
flag.Source = "seed";
flag.TwitchUserId = flag.TwitchUserId switch
{
"demo_user" => "local_user",
"jayuhime_demo" => "jayuhime_viewer",
_ => flag.TwitchUserId,
};
flag.Summary = flag.Summary.Replace("Demo: ", string.Empty);
flag.UserAgent = flag.UserAgent == "demo-seed" ? "seed-bootstrap" : flag.UserAgent;
}
var legacyAuditEntries = await db.AdminAuditEntries
.Where(item =>
item.ActionType == "demo.seed" ||
item.Summary.Contains("Demo-Inhalte") ||
item.AdminTwitchUserId == "admin_demo" ||
item.AdminTwitchUserId == "jayuhime_demo")
.ToArrayAsync();
foreach (var entry in legacyAuditEntries)
{
entry.AdminTwitchUserId = entry.AdminTwitchUserId switch
{
"admin_demo" => "jayuhime_admin",
"jayuhime_demo" => "jayuhime_viewer",
_ => entry.AdminTwitchUserId,
};
if (entry.ActionType == "demo.seed")
{
entry.ActionType = "seed.initialize";
}
if (entry.Summary.Contains("Demo-Inhalte"))
{
entry.Summary = "Startinhalte wurden in der Datenbank bereitgestellt.";
}
}
return new LegacySeedState(
legacyRiskFlags.Length > 0 || await db.RiskFlags.AnyAsync(item => item.Source == "seed"),
legacyAuditEntries.Length > 0 || await db.AdminAuditEntries.AnyAsync(item => item.ActionType == "seed.initialize"));
}
private static async Task LinkExistingClipsToCandidatesAsync(AwardsDbContext db)
{
var clips = await db.ClipSubmissions
.Where(item => item.CandidateId == null && item.CategoryId != null && item.Creator != string.Empty)
.ToArrayAsync();
if (clips.Length == 0)
{
return;
}
var seasonIds = clips.Select(item => item.SeasonId).Distinct().ToArray();
var categoryIds = clips.Select(item => item.CategoryId!.Value).Distinct().ToArray();
var candidates = await db.Candidates
.Where(item => seasonIds.Contains(item.SeasonId) && categoryIds.Contains(item.CategoryId))
.ToArrayAsync();
foreach (var clip in clips)
{
var creatorKey = NormalizeSeedCandidateKey(clip.Creator);
var candidate = candidates.FirstOrDefault(item =>
item.SeasonId == clip.SeasonId
&& item.CategoryId == clip.CategoryId
&& (NormalizeSeedCandidateKey(item.DisplayName) == creatorKey
|| NormalizeSeedCandidateKey(item.ChannelSlug) == creatorKey));
if (candidate is not null)
{
clip.CandidateId = candidate.Id;
}
}
}
private static string NormalizeSeedCandidateKey(string value) =>
new(
value
.Trim()
.TrimStart('@')
.ToLowerInvariant()
.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);
}
@@ -1,187 +0,0 @@
using System.Text.Json;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureSiteSettingsAsync(AwardsDbContext db)
{
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return;
}
if (!HasValidSiteArray(settings.FaqJson, "question", "answer"))
{
settings.FaqJson = JsonSerializer.Serialize(SeedCatalog.SiteFaqSeeds.Select(item => new
{
question = item.Question,
answer = item.Answer,
}));
}
if (!HasValidSiteArray(settings.SocialLinksJson, "label", "platform", "url"))
{
settings.SocialLinksJson = JsonSerializer.Serialize(SeedCatalog.SiteSocialSeeds.Select(item => new
{
label = item.Label,
platform = item.Platform,
url = item.Url,
icon = item.Icon,
showOnHost = true,
showOnCommunity = true,
}));
}
if (!HasValidRiskRules(settings.RiskRulesJson))
{
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
}
if (!HasValidWorkflowRules(settings.WorkflowRulesJson))
{
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);
}
if (string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage))
{
settings.ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
}
if (string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage))
{
settings.ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.";
}
if (string.IsNullOrWhiteSpace(settings.ImprintContent))
{
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
}
if (string.IsNullOrWhiteSpace(settings.ContactContent))
{
settings.ContactContent = SeedCatalog.DefaultContactContent;
}
if (string.IsNullOrWhiteSpace(settings.SponsorsContent))
{
settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent;
}
if (string.IsNullOrWhiteSpace(settings.ShowactsUrl))
{
settings.ShowactsUrl = "https://vtuber-star-awards.de/showacts";
}
if (string.IsNullOrWhiteSpace(settings.ShowactsContent))
{
settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.";
}
if (settings.SessionIdleTimeoutHours < 3)
{
settings.SessionIdleTimeoutHours = 3;
}
}
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
{
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return false;
}
return document.RootElement.EnumerateArray().Any(item =>
item.ValueKind == JsonValueKind.Object
&& requiredKeys.All(key =>
item.TryGetProperty(key, out var value)
&& value.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(value.GetString())));
}
catch (JsonException)
{
return false;
}
}
private static bool HasValidRiskRules(string? json) =>
RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length;
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))
{
return false;
}
try
{
using var document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Array
&& document.RootElement.EnumerateArray().Any(item =>
item.ValueKind == JsonValueKind.Object
&& item.TryGetProperty("Url", out var url)
&& url.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(url.GetString()));
}
catch (JsonException)
{
return false;
}
}
}
-40
View File
@@ -1,40 +0,0 @@
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureSponsorsAsync(AwardsDbContext db, Season season)
{
var existingSponsors = await db.Sponsors
.Where(item => item.SeasonId == season.Id)
.ToListAsync();
foreach (var seed in SeedCatalog.DemoSponsorSeeds)
{
var sponsor = existingSponsors.FirstOrDefault(item =>
string.Equals(item.Name, seed.Name, StringComparison.OrdinalIgnoreCase))
?? new Sponsor
{
SeasonId = season.Id,
CreatedAt = DateTimeOffset.UtcNow,
};
sponsor.Name = seed.Name;
sponsor.WebsiteUrl = seed.WebsiteUrl;
sponsor.LogoUrl = seed.LogoUrl;
sponsor.Description = seed.Description;
sponsor.Tier = seed.Tier;
sponsor.SortOrder = seed.SortOrder;
sponsor.IsVisible = true;
sponsor.UpdatedAt = DateTimeOffset.UtcNow;
if (sponsor.Id == 0)
{
db.Sponsors.Add(sponsor);
existingSponsors.Add(sponsor);
}
}
}
}
-24
View File
@@ -1,24 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static class SessionBootstrapper
{
public static Task EnsureAsync(AwardsDbContext db) =>
db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" uuid NOT NULL PRIMARY KEY,
"SessionToken" character varying(120) NOT NULL,
"TwitchUserId" character varying(120) NOT NULL,
"DisplayName" character varying(120) NOT NULL,
"Role" character varying(40) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"LastSeenAt" timestamp with time zone NOT NULL,
"IsActive" boolean NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_UserSessions_SessionToken"
ON "UserSessions" ("SessionToken");
""");
}