Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
+37
View File
@@ -16,6 +16,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -23,9 +24,29 @@ 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.CurrentPhase).HasMaxLength(60);
});
modelBuilder.Entity<SiteSettings>(entity =>
{
entity.Property(item => item.HostDisplayName).HasMaxLength(120);
entity.Property(item => item.HostTagline).HasMaxLength(160);
entity.Property(item => item.NewsletterUrl).HasMaxLength(400);
entity.Property(item => item.PrivacyEmail).HasMaxLength(160);
entity.Property(item => item.PrivacyPolicyUpdatedBy).HasMaxLength(120);
entity.Property(item => item.ImprintUrl).HasMaxLength(400);
entity.Property(item => item.ContactUrl).HasMaxLength(400);
entity.Property(item => item.SponsorsUrl).HasMaxLength(400);
entity.Property(item => item.DemoLoginEmail).HasMaxLength(180);
entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120);
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120);
entity.Property(item => item.DemoLoginDisplayName).HasMaxLength(120);
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
});
modelBuilder.Entity<Category>(entity =>
{
entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique();
@@ -45,6 +66,11 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
{
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.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 });
});
modelBuilder.Entity<VoteBallot>(entity =>
@@ -55,6 +81,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
modelBuilder.Entity<AwardResult>(entity =>
{
entity.HasIndex(item => new { item.SeasonId, item.CategoryId }).IsUnique();
entity.Property(item => item.CategoryName).HasMaxLength(120);
});
@@ -79,6 +106,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Summary).HasMaxLength(240);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
});
@@ -89,6 +117,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.EntityType).HasMaxLength(80);
entity.Property(item => item.EntityId).HasMaxLength(120);
entity.Property(item => item.Summary).HasMaxLength(240);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.Property(item => item.UserAgent).HasMaxLength(400);
});
modelBuilder.Entity<ClipSubmission>(entity =>
@@ -99,8 +129,15 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Creator).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.ReviewNote).HasMaxLength(500);
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.Status });
entity.HasIndex(item => item.CandidateId);
entity.HasOne(item => item.Candidate)
.WithMany()
.HasForeignKey(item => item.CandidateId)
.OnDelete(DeleteBehavior.SetNull);
});
SeedData.Apply(modelBuilder);
+18 -2
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace Backend.Data;
@@ -7,9 +8,24 @@ public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<Awa
{
public AwardsDbContext CreateDbContext(string[] args)
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AwardsDbContext>();
var connectionString = Environment.GetEnvironmentVariable("VTSA_POSTGRES")
?? "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres";
var connectionString = configuration["VTSA_POSTGRES"]
?? configuration.GetConnectionString("Postgres");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"No PostgreSQL connection string configured for design-time EF operations. " +
"Set VTSA_POSTGRES or ConnectionStrings__Postgres before running dotnet ef.");
}
optionsBuilder.UseNpgsql(connectionString);
return new AwardsDbContext(optionsBuilder.Options);
@@ -13,6 +13,9 @@ public static class OperationalTablesBootstrapper
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 '[]';
CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL,
@@ -25,11 +28,15 @@ public static class OperationalTablesBootstrapper
"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);
@@ -44,9 +51,17 @@ public static class OperationalTablesBootstrapper
"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);
@@ -64,7 +79,54 @@ public static class OperationalTablesBootstrapper
"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");
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 "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");
""");
}
@@ -0,0 +1,145 @@
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static partial class SeedDataBootstrapper
{
private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season)
{
var seasonCategories = await db.Categories
.Where(item => item.SeasonId == season.Id)
.ToArrayAsync();
foreach (var category in seasonCategories)
{
if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug))
{
continue;
}
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;
}
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)
{
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,
});
}
await db.SaveChangesAsync();
}
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,
});
}
}
}
+106
View File
@@ -0,0 +1,106 @@
namespace Backend.Data;
internal sealed record CategorySeed(string GroupName, 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 static class SeedCatalog
{
internal static readonly CategorySeed[] CategorySeeds =
[
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("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 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."),
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."),
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!"),
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."),
];
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 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"),
];
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"),
];
}
+71 -6
View File
@@ -1,5 +1,7 @@
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Backend.Data;
@@ -7,12 +9,68 @@ 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",
ContactUrl = "https://vtuber-star-awards.de/kontakt",
SponsorsUrl = "https://vtuber-star-awards.de/partner",
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
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",
@@ -23,12 +81,14 @@ public static class SeedData
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",
@@ -39,12 +99,14 @@ public static class SeedData
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",
@@ -55,12 +117,14 @@ public static class SeedData
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",
@@ -71,6 +135,7 @@ public static class SeedData
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(
@@ -101,12 +166,12 @@ public static class SeedData
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, CandidateId = 8, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 2, SeasonId = 2, CandidateId = 9, CategoryName = "Bestes Live Event" },
new AwardResult { Id = 3, SeasonId = 2, CandidateId = 10, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 4, SeasonId = 3, CandidateId = 11, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 5, SeasonId = 3, CandidateId = 12, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 6, SeasonId = 4, CandidateId = 13, CategoryName = "VTuber des Jahres" });
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) },
+42
View File
@@ -0,0 +1,42 @@
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 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();
}
}
@@ -0,0 +1,283 @@
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);
if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id))
{
db.ClipSubmissions.AddRange(
new ClipSubmission
{
SeasonId = season.Id,
CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "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.",
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"),
CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "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.",
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"),
CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "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"),
CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "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.",
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 { categories = SeedCatalog.CategorySeeds.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 sealed record LegacySeedState(bool HasRiskSeed, bool HasAuditSeed);
}
@@ -0,0 +1,75 @@
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);
}
}
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;
}