Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user