diff --git a/Backend/Common/ShowactApplicationSchedule.cs b/Backend/Common/ShowactApplicationSchedule.cs new file mode 100644 index 0000000..19fe448 --- /dev/null +++ b/Backend/Common/ShowactApplicationSchedule.cs @@ -0,0 +1,36 @@ +using Backend.Domain; + +namespace Backend.Common; + +public static class ShowactApplicationSchedule +{ + public static string? Validate(DateOnly? startsAt, DateOnly? endsAt) + { + if (startsAt.HasValue && endsAt.HasValue && startsAt.Value > endsAt.Value) + { + return "Der Showact-Zeitraum ist ungueltig. Der Start darf nicht nach der Deadline liegen."; + } + + return null; + } + + public static bool IsOpenNow(SiteSettings settings, DateOnly today) + { + if (!settings.ShowactApplicationsEnabled) + { + return false; + } + + if (settings.ShowactApplicationStartsAt.HasValue && today < settings.ShowactApplicationStartsAt.Value) + { + return false; + } + + if (settings.ShowactApplicationEndsAt.HasValue && today > settings.ShowactApplicationEndsAt.Value) + { + return false; + } + + return true; + } +} diff --git a/Backend/Contracts/AdminModerationContracts.cs b/Backend/Contracts/AdminModerationContracts.cs index b8b0b50..642b344 100644 --- a/Backend/Contracts/AdminModerationContracts.cs +++ b/Backend/Contracts/AdminModerationContracts.cs @@ -55,11 +55,27 @@ public sealed record AdminAuditEntriesResponse( public sealed record AdminNominationReviewItemDto( int Id, - int CategoryId, + int? CategoryId, + string CategoryGroupName, string CategoryName, string SubmittedByTwitchId, string CandidateText, string? StreamUrl, + string? ResolvedChannel, + string? ResolvedPlatform, + int? AvgViewers, + int? SuggestedCategoryId, + string? SuggestedCategoryName, + int? StreamerIdentityId, + string TrackerStatus, + DateTimeOffset? TrackerCheckedAt, + string TrackingReviewStatus, + bool RequiresManualReview, + AdminTrackingFlagHitDto[] TrackingFlags, + AdminTrackingMetricStateDto[] TrackingMetrics, + string? TrackingReviewNote, + string? TrackingReviewedByTwitchId, + DateTimeOffset? TrackingReviewedAt, string Status, DateTimeOffset CreatedAt, int? CandidateId, @@ -68,6 +84,32 @@ public sealed record AdminNominationReviewItemDto( string? ReviewedByTwitchId, DateTimeOffset? ReviewedAt); +public sealed record AdminNominationReviewGroupDto( + int Id, + int[] NominationIds, + string CategoryGroupName, + string DisplayName, + string? StreamUrl, + string? ResolvedChannel, + string? ResolvedPlatform, + int? AvgViewers, + int? SuggestedCategoryId, + string? SuggestedCategoryName, + int? StreamerIdentityId, + string TrackerStatus, + DateTimeOffset? TrackerCheckedAt, + string TrackingReviewStatus, + bool RequiresManualReview, + AdminTrackingFlagHitDto[] TrackingFlags, + AdminTrackingMetricStateDto[] TrackingMetrics, + string? TrackingReviewNote, + string? TrackingReviewedByTwitchId, + DateTimeOffset? TrackingReviewedAt, + int NominationTally, + int UniqueSubmitterCount, + DateTimeOffset FirstSubmittedAt, + DateTimeOffset LastSubmittedAt); + public sealed record AdminClipSubmissionItemDto( int Id, int? CategoryId, @@ -87,10 +129,17 @@ public sealed record ApproveNominationRequest( string? DisplayName, string? ChannelSlug, string? Platform, + int? CategoryId, string? ReviewNote); public sealed record RejectNominationRequest(string? ReviewNote); +public sealed record ReopenRejectedNominationRequest(string? ReviewNote); + +public sealed record UpdateNominationTrackingReviewRequest( + string Status, + string? ReviewNote); + public sealed record AdminNominationLinkBlacklistEntryDto(string Url); public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries); @@ -136,3 +185,24 @@ public sealed record AdminWorkflowRuleDto( public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules); public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules); + +public sealed record AdminTrackingFlagHitDto( + string Key, + string Label, + string Severity, + string Description, + bool RequiresManualReview, + bool BlocksApproval, + bool AdminNoteRequiredOnOverride); + +public sealed record AdminTrackingMetricStateDto( + string Key, + string Label, + bool Required, + string SourceSupport, + bool Present, + string Value, + string Description, + string WindowKey, + string WindowLabel, + bool AutoWindowSupported); diff --git a/Backend/Contracts/AdminSeasonContracts.cs b/Backend/Contracts/AdminSeasonContracts.cs index 9a2fdae..ce6cef0 100644 --- a/Backend/Contracts/AdminSeasonContracts.cs +++ b/Backend/Contracts/AdminSeasonContracts.cs @@ -16,14 +16,25 @@ public sealed record AdminCategoryItemDto( string Description, int SortOrder, int MaxNomineesPerUser, + int? ViewerRangeMin, + int? ViewerRangeMax, int CandidateCount); +public sealed record AdminSubcategoryTemplateDto( + string Name, + string Slug, + int SortOrder, + int? ViewerRangeMin, + int? ViewerRangeMax); + public sealed record AdminCandidateItemDto( int Id, int CategoryId, + int? StreamerIdentityId, string DisplayName, string ChannelSlug, string Platform, + int NominationTally, string AcceptanceStatus, string? AcceptanceNote, string? ClipCompilationUrl, @@ -36,6 +47,7 @@ public sealed record AdminAwardResultItemDto( int CategoryId, string CategoryName, int CandidateId, + int? StreamerIdentityId, string CandidateDisplayName, string CandidateChannelSlug, string CandidatePlatform); @@ -56,10 +68,14 @@ public sealed record AdminSeasonDetailResponse( DateOnly ReviewEndsAt, DateOnly ShowDate, TimeOnly ShowStartsAt, + IEnumerable SubcategoryTemplates, IEnumerable Categories, IEnumerable Candidates, IEnumerable PendingNominations, + IEnumerable PendingNominationGroups, IEnumerable ReviewedNominations, + string TrackingReviewNotes, + bool ShowTrackingReviewNotes, IEnumerable Results, IEnumerable ClipSubmissions); @@ -102,6 +118,17 @@ public sealed record UpsertCategoryRequest( string Slug, string Description, int SortOrder, + int MaxNomineesPerUser, + int? ViewerRangeMin, + int? ViewerRangeMax); + +public sealed record UpdateSeasonSubcategoryTemplatesRequest( + AdminSubcategoryTemplateDto[] Templates); + +public sealed record UpsertCategoryGroupRequest( + string GroupName, + string Description, + int SortOrder, int MaxNomineesPerUser); public sealed record UpsertCandidateRequest( diff --git a/Backend/Contracts/AdminSiteSettingsContracts.cs b/Backend/Contracts/AdminSiteSettingsContracts.cs index bd943f1..971465e 100644 --- a/Backend/Contracts/AdminSiteSettingsContracts.cs +++ b/Backend/Contracts/AdminSiteSettingsContracts.cs @@ -4,6 +4,8 @@ public sealed record AdminSiteSettingsResponse( string HostDisplayName, string HostTagline, string NewsletterUrl, + string ShareXUrl, + string ShareDiscordUrl, string PrivacyEmail, string PrivacyPolicyContent, string? PrivacyPolicyUpdatedBy, @@ -17,12 +19,15 @@ public sealed record AdminSiteSettingsResponse( string ShowactsUrl, string ShowactsContent, IEnumerable SocialLinks, - IEnumerable Faq); + IEnumerable Faq, + string ShowactFormSchemaJson); public sealed record UpdateSiteSettingsRequest( string HostDisplayName, string HostTagline, string NewsletterUrl, + string ShareXUrl, + string ShareDiscordUrl, string PrivacyEmail, string PrivacyPolicyContent, string ImprintUrl, @@ -34,7 +39,8 @@ public sealed record UpdateSiteSettingsRequest( string ShowactsUrl, string ShowactsContent, PublicSocialLinkDto[] SocialLinks, - FaqItemDto[] Faq); + FaqItemDto[] Faq, + string? ShowactFormSchemaJson = null); public sealed record AdminOperationalSettingsResponse( bool DemoLoginManagedByDatabase, @@ -49,6 +55,7 @@ public sealed record AdminOperationalSettingsResponse( bool TwitchClientSecretSet, string TwitchRedirectUri, string TwitchScope, + int SessionIdleTimeoutHours, bool MaintenanceModeEnabled, string MaintenanceTitle, string MaintenanceMessage); @@ -59,6 +66,9 @@ public sealed record AdminOptionalFeatureSettingsResponse( bool ClipAdminMenuVisible, string ClipSubmissionDisabledMessage, bool ShowactApplicationsEnabled, + DateOnly? ShowactApplicationStartsAt, + DateOnly? ShowactApplicationEndsAt, + bool ShowactApplicationsOpenNow, string ShowactApplicationDisabledMessage, bool SponsorsVisible); @@ -68,6 +78,8 @@ public sealed record UpdateOptionalFeatureSettingsRequest( bool ClipAdminMenuVisible, string ClipSubmissionDisabledMessage, bool ShowactApplicationsEnabled, + DateOnly? ShowactApplicationStartsAt, + DateOnly? ShowactApplicationEndsAt, string ShowactApplicationDisabledMessage, bool SponsorsVisible); @@ -81,6 +93,71 @@ public sealed record UpdateOperationalSettingsRequest( string? TwitchClientSecret, string TwitchRedirectUri, string TwitchScope, + int SessionIdleTimeoutHours, bool MaintenanceModeEnabled, string MaintenanceTitle, string MaintenanceMessage); + +public sealed record AdminTrackingSourceDto( + string ProviderKey, + string ProviderLabel, + string BaseUrl, + string NotesSummary, + bool ShowManualReviewNotesInReview); + +public sealed record AdminTrackingMetricRuleDto( + string Key, + string Label, + bool Enabled, + string SourceSupport, + string Description, + bool RequiredForAutoClassification, + bool ShowInReview, + bool ShowInAdminSummary, + bool ManualOverrideAllowed, + string WindowKey, + string[] AutoSupportedWindowKeys, + string? ProviderFieldKey, + int? TopCount, + int? MinPrimaryCategorySharePercent, + int? MinPrimaryCategoryHours, + int? MaxDistinctCategoriesBeforeFlag, + string[] IgnoredCategories, + bool MatchAwardCategoryAgainstTopCategories, + bool FlagIfAwardCategoryNotInTopX, + bool FlagIfCategorySpreadTooWide, + bool FlagIfNoCategoryContextAvailable, + int? MinValue, + int? MaxValue); + +public sealed record AdminTrackingFlagRuleDto( + string Key, + string Label, + bool Enabled, + string Severity, + string Description, + bool AutoTriggerEnabled, + bool RequiresManualReview, + bool BlocksApproval, + bool AdminNoteRequiredOnOverride); + +public sealed record AdminTrackingRulesResponse( + AdminTrackingSourceDto Source, + AdminTrackingMetricRuleDto[] ImportantMetrics, + AdminTrackingMetricRuleDto[] OptionalMetrics, + AdminTrackingFlagRuleDto[] Flags, + string ManualReviewNotes); + +public sealed record UpdateTrackingRulesRequest( + AdminTrackingSourceDto Source, + AdminTrackingMetricRuleDto[] ImportantMetrics, + AdminTrackingMetricRuleDto[] OptionalMetrics, + AdminTrackingFlagRuleDto[] Flags, + string ManualReviewNotes); + +public sealed record UpdateTrackingSourceRequest( + AdminTrackingSourceDto Source); + +public sealed record UpdateTrackingReviewNotesRequest( + string ManualReviewNotes, + bool ShowManualReviewNotesInReview); diff --git a/Backend/Contracts/AuthContracts.cs b/Backend/Contracts/AuthContracts.cs index 4b49510..06a7fd1 100644 --- a/Backend/Contracts/AuthContracts.cs +++ b/Backend/Contracts/AuthContracts.cs @@ -32,6 +32,7 @@ public sealed record AuthSessionDto( string DisplayName, string Role, IEnumerable PermissionKeys, + int SessionIdleTimeoutHours, bool MustChangePassword = false, string? TeamLogin = null, string? BoundTwitchUserId = null, diff --git a/Backend/Contracts/ExtrasContracts.cs b/Backend/Contracts/ExtrasContracts.cs index 3289db8..cbb17f7 100644 --- a/Backend/Contracts/ExtrasContracts.cs +++ b/Backend/Contracts/ExtrasContracts.cs @@ -36,16 +36,18 @@ public sealed record ShowactApplicationDto( string Status, string? ReviewNote, DateTimeOffset CreatedAt, - DateTimeOffset? ReviewedAt); + DateTimeOffset? ReviewedAt, + string FieldResponsesJson); public sealed record CreateShowactApplicationRequest( - string ArtistName, - string ContactEmail, - string ContactDiscord, - string PlatformUrl, - string PerformanceType, - string Description, - string TechnicalNotes, - string ReferenceUrl); + string? ArtistName = null, + string? ContactEmail = null, + string? ContactDiscord = null, + string? PlatformUrl = null, + string? PerformanceType = null, + string? Description = null, + string? TechnicalNotes = null, + string? ReferenceUrl = null, + string? FieldResponsesJson = null); public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote); diff --git a/Backend/Contracts/PublicOverviewContracts.cs b/Backend/Contracts/PublicOverviewContracts.cs index c3072f1..362e6ba 100644 --- a/Backend/Contracts/PublicOverviewContracts.cs +++ b/Backend/Contracts/PublicOverviewContracts.cs @@ -50,6 +50,8 @@ public sealed record PublicSiteContentDto( string HostDisplayName, string HostTagline, string NewsletterUrl, + string ShareXUrl, + string ShareDiscordUrl, string PrivacyEmail, string PrivacyPolicyContent, IEnumerable SocialLinks, @@ -66,8 +68,11 @@ public sealed record PublicFeatureFlagsDto( bool ClipReviewEnabled, string ClipSubmissionDisabledMessage, bool ShowactApplicationsEnabled, + DateOnly? ShowactApplicationStartsAt, + DateOnly? ShowactApplicationEndsAt, string ShowactApplicationDisabledMessage, - bool SponsorsVisible); + bool SponsorsVisible, + string ShowactFormSchemaJson); public sealed record OverviewResponse( int SeasonId, diff --git a/Backend/Contracts/PublicSeasonCategoryContracts.cs b/Backend/Contracts/PublicSeasonCategoryContracts.cs index bd920ff..f0fe76e 100644 --- a/Backend/Contracts/PublicSeasonCategoryContracts.cs +++ b/Backend/Contracts/PublicSeasonCategoryContracts.cs @@ -17,6 +17,8 @@ public sealed record PublicCategoryDetailDto( string GroupName, string Description, int MaxNomineesPerUser, + int? ViewerRangeMin, + int? ViewerRangeMax, IEnumerable Candidates); public sealed record SeasonCategoriesResponse( diff --git a/Backend/Contracts/PublicUserParticipationContracts.cs b/Backend/Contracts/PublicUserParticipationContracts.cs index 31630b7..8e18b5c 100644 --- a/Backend/Contracts/PublicUserParticipationContracts.cs +++ b/Backend/Contracts/PublicUserParticipationContracts.cs @@ -1,7 +1,8 @@ namespace Backend.Contracts; public sealed record UserNominationStateDto( - int CategoryId, + int? CategoryId, + string CategoryGroupName, string[] Nominees); public sealed record UserVoteStateDto( diff --git a/Backend/Contracts/PublicWriteContracts.cs b/Backend/Contracts/PublicWriteContracts.cs index 921cb4d..d18ac34 100644 --- a/Backend/Contracts/PublicWriteContracts.cs +++ b/Backend/Contracts/PublicWriteContracts.cs @@ -6,7 +6,8 @@ public sealed record NominationEntryRequest( public sealed record CreateNominationRequest( int Year, - int CategoryId, + int? CategoryId, + string? CategoryGroupName, string TwitchUserId, string[]? Nominees, NominationEntryRequest[]? Nominations); diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index c851075..2eaeccc 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -8,6 +8,7 @@ public sealed class AwardsDbContext(DbContextOptions options) : public DbSet Seasons => Set(); public DbSet Categories => Set(); public DbSet Candidates => Set(); + public DbSet StreamerIdentities => Set(); public DbSet Results => Set(); public DbSet Nominations => Set(); public DbSet VoteBallots => Set(); @@ -30,6 +31,8 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.Name).HasMaxLength(160); entity.Property(item => item.ShowStreamUrl).HasMaxLength(400); entity.Property(item => item.CurrentPhase).HasMaxLength(60); + entity.Property(item => item.SubcategoryTemplatesJson).HasDefaultValue("[]"); + entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]"); }); modelBuilder.Entity(entity => @@ -53,14 +56,19 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.TwitchClientSecret).HasMaxLength(180); entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400); entity.Property(item => item.TwitchScope).HasMaxLength(300); + entity.Property(item => item.SessionIdleTimeoutHours).HasDefaultValue(3); entity.Property(item => item.MaintenanceTitle).HasMaxLength(120); entity.Property(item => item.MaintenanceMessage).HasMaxLength(600); entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]"); + entity.Property(item => item.TrackingRulesJson).HasDefaultValue("[]"); + entity.Property(item => item.ViewerStatsProviderBaseUrl).HasMaxLength(400); entity.Property(item => item.NominationLinkBlacklistJson).HasDefaultValue("[]"); entity.Property(item => item.ClipSubmissionsEnabled).HasDefaultValue(false); entity.Property(item => item.ClipReviewEnabled).HasDefaultValue(true); entity.Property(item => item.ClipSubmissionDisabledMessage).HasMaxLength(240); entity.Property(item => item.ShowactApplicationsEnabled).HasDefaultValue(false); + entity.Property(item => item.ShowactApplicationStartsAt); + entity.Property(item => item.ShowactApplicationEndsAt); entity.Property(item => item.ShowactApplicationDisabledMessage).HasMaxLength(240); entity.Property(item => item.SponsorsVisible).HasDefaultValue(true); }); @@ -93,13 +101,17 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.GroupName).HasMaxLength(80); entity.Property(item => item.Name).HasMaxLength(120); entity.Property(item => item.Description).HasMaxLength(400); + entity.Property(item => item.ViewerRangeMin); + entity.Property(item => item.ViewerRangeMax); }); modelBuilder.Entity(entity => { + entity.HasIndex(item => item.StreamerIdentityId); entity.Property(item => item.DisplayName).HasMaxLength(120); entity.Property(item => item.ChannelSlug).HasMaxLength(120); entity.Property(item => item.Platform).HasMaxLength(40); + entity.Property(item => item.NominationTally).HasDefaultValue(0); entity.Property(item => item.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open"); entity.Property(item => item.AcceptanceNote).HasMaxLength(500); entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500); @@ -108,15 +120,47 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked"); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(item => item.NormalizedKey).IsUnique(); + entity.Property(item => item.Platform).HasMaxLength(40); + entity.Property(item => item.Login).HasMaxLength(120); + entity.Property(item => item.NormalizedKey).HasMaxLength(180); + entity.Property(item => item.DisplayName).HasMaxLength(120); + entity.Property(item => item.ProfileUrl).HasMaxLength(500); + }); + modelBuilder.Entity(entity => { + entity.Property(item => item.CategoryGroupName).HasMaxLength(80).HasDefaultValue(string.Empty); entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120); entity.Property(item => item.CandidateText).HasMaxLength(120); entity.Property(item => item.StreamUrl).HasMaxLength(300); + entity.Property(item => item.ResolvedChannel).HasMaxLength(120); + entity.Property(item => item.ResolvedPlatform).HasMaxLength(40); + entity.Property(item => item.HoursStreamed); + entity.Property(item => item.HoursWatched); + entity.Property(item => item.PeakViewers); + entity.Property(item => item.FollowersGained); + entity.Property(item => item.TrackerStatus).HasMaxLength(40).HasDefaultValue("pending"); + entity.Property(item => item.TrackingReviewStatus).HasMaxLength(30).HasDefaultValue("clear"); + entity.Property(item => item.TrackingFlagsJson).HasDefaultValue("[]"); + entity.Property(item => item.TrackingReviewNote).HasMaxLength(1000); + entity.Property(item => item.TrackingReviewedByTwitchId).HasMaxLength(120); entity.Property(item => item.Status).HasMaxLength(20); entity.Property(item => item.ReviewNote).HasMaxLength(500); entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); entity.HasIndex(item => new { item.SeasonId, item.Status }); + entity.HasIndex(item => new { item.SeasonId, item.CategoryGroupName, item.Status }); + entity.HasIndex(item => new { item.SeasonId, item.StreamerIdentityId, item.CategoryGroupName }); + entity.HasOne(item => item.Category) + .WithMany() + .HasForeignKey(item => item.CategoryId) + .OnDelete(DeleteBehavior.SetNull); + entity.HasOne(item => item.SuggestedCategory) + .WithMany() + .HasForeignKey(item => item.SuggestedCategoryId) + .OnDelete(DeleteBehavior.SetNull); }); modelBuilder.Entity(entity => diff --git a/Backend/Data/OperationalTablesBootstrapper.cs b/Backend/Data/OperationalTablesBootstrapper.cs index f0eac28..03d2e0b 100644 --- a/Backend/Data/OperationalTablesBootstrapper.cs +++ b/Backend/Data/OperationalTablesBootstrapper.cs @@ -19,6 +19,15 @@ public static class OperationalTablesBootstrapper ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]'; + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "TrackingRulesJson" text NOT NULL DEFAULT '[]'; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ViewerStatsProviderBaseUrl" character varying(400) NOT NULL DEFAULT ''; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "TrackingReviewNotes" text NOT NULL DEFAULT ''; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]'; @@ -34,12 +43,30 @@ public static class OperationalTablesBootstrapper ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false; + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ShowactApplicationStartsAt" date NULL; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ShowactApplicationEndsAt" date NULL; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "ShowactApplicationDisabledMessage" character varying(240) NOT NULL DEFAULT 'Showact-Bewerbungen sind aktuell geschlossen.'; ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "SponsorsVisible" boolean NOT NULL DEFAULT true; + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "HoursStreamed" integer NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "HoursWatched" integer NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "PeakViewers" integer NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "FollowersGained" integer NULL; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false; @@ -55,6 +82,9 @@ public static class OperationalTablesBootstrapper ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT ''; + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "SessionIdleTimeoutHours" integer NOT NULL DEFAULT 3; + ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT ''; @@ -70,6 +100,9 @@ public static class OperationalTablesBootstrapper ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT ''; + ALTER TABLE "Seasons" + ADD COLUMN IF NOT EXISTS "SubcategoryTemplatesJson" text NOT NULL DEFAULT '[]'; + CREATE TABLE IF NOT EXISTS "RiskFlags" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "SeasonId" integer NULL, @@ -254,6 +287,95 @@ public static class OperationalTablesBootstrapper ALTER TABLE "Candidates" ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked'; + ALTER TABLE "Categories" + ADD COLUMN IF NOT EXISTS "ViewerRangeMin" integer NULL; + + ALTER TABLE "Categories" + ADD COLUMN IF NOT EXISTS "ViewerRangeMax" integer NULL; + + CREATE TABLE IF NOT EXISTS "StreamerIdentities" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "Platform" character varying(40) NOT NULL, + "Login" character varying(120) NOT NULL, + "NormalizedKey" character varying(180) NOT NULL, + "DisplayName" character varying(120) NOT NULL, + "ProfileUrl" character varying(500) NULL, + "LastResolvedAt" timestamp with time zone NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS "IX_StreamerIdentities_NormalizedKey" + ON "StreamerIdentities" ("NormalizedKey"); + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL; + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "NominationTally" integer NOT NULL DEFAULT 0; + + CREATE INDEX IF NOT EXISTS "IX_Candidates_StreamerIdentityId" + ON "Candidates" ("StreamerIdentityId"); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_Candidates_StreamerIdentities_StreamerIdentityId' + ) THEN + ALTER TABLE "Candidates" + ADD CONSTRAINT "FK_Candidates_StreamerIdentities_StreamerIdentityId" + FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id"); + END IF; + END $$; + + ALTER TABLE "Nominations" + ALTER COLUMN "CategoryId" DROP NOT NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "CategoryGroupName" character varying(80) NOT NULL DEFAULT ''; + + UPDATE "Nominations" n + SET "CategoryGroupName" = c."GroupName" + FROM "Categories" c + WHERE n."CategoryId" = c."Id" + AND COALESCE(n."CategoryGroupName", '') = ''; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "StreamerIdentityId" integer NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "SuggestedCategoryId" integer NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "ResolvedChannel" character varying(120) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "ResolvedPlatform" character varying(40) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "AvgViewers" integer NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackerStatus" character varying(40) NOT NULL DEFAULT 'pending'; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackerCheckedAt" timestamp with time zone NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackingReviewStatus" character varying(30) NOT NULL DEFAULT 'clear'; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackingFlagsJson" text NOT NULL DEFAULT '[]'; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackingReviewNote" character varying(1000) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackingReviewedByTwitchId" character varying(120) NULL; + + ALTER TABLE "Nominations" + ADD COLUMN IF NOT EXISTS "TrackingReviewedAt" timestamp with time zone NULL; + ALTER TABLE "Nominations" ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending'; @@ -269,6 +391,36 @@ public static class OperationalTablesBootstrapper CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status" ON "Nominations" ("SeasonId", "Status"); + CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_CategoryGroupName_Status" + ON "Nominations" ("SeasonId", "CategoryGroupName", "Status"); + + CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName" + ON "Nominations" ("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_Nominations_StreamerIdentities_StreamerIdentityId' + ) THEN + ALTER TABLE "Nominations" + ADD CONSTRAINT "FK_Nominations_StreamerIdentities_StreamerIdentityId" + FOREIGN KEY ("StreamerIdentityId") REFERENCES "StreamerIdentities" ("Id"); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_Nominations_Categories_SuggestedCategoryId' + ) THEN + ALTER TABLE "Nominations" + ADD CONSTRAINT "FK_Nominations_Categories_SuggestedCategoryId" + FOREIGN KEY ("SuggestedCategoryId") REFERENCES "Categories" ("Id") + ON DELETE SET NULL; + END IF; + END $$; + CREATE TABLE IF NOT EXISTS "TeamMembers" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "Login" character varying(80) NOT NULL, diff --git a/Backend/Data/SeedAwardCatalogBootstrapper.cs b/Backend/Data/SeedAwardCatalogBootstrapper.cs index b8fb807..5481b01 100644 --- a/Backend/Data/SeedAwardCatalogBootstrapper.cs +++ b/Backend/Data/SeedAwardCatalogBootstrapper.cs @@ -1,4 +1,5 @@ using Backend.Domain; +using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Data; @@ -7,54 +8,137 @@ public static partial class SeedDataBootstrapper { private static async Task EnsureCategoriesAsync(AwardsDbContext db, Season season) { - var seasonCategories = await db.Categories + var categories = await db.Categories .Where(item => item.SeasonId == season.Id) - .ToArrayAsync(); + .ToListAsync(); + var templates = SeedCatalog.DefaultSubcategoryTemplates + .Select((item, index) => item with { SortOrder = index + 1 }) + .ToArray(); + var usedCategories = new HashSet(); - foreach (var category in seasonCategories) + season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates); + + var nextSortOrder = 1; + foreach (var award in SeedCatalog.AwardCategorySeeds.OrderBy(item => item.SortOrder)) { - if (!SeedCatalog.LegacyCategorySlugMap.TryGetValue(category.Slug, out var targetSlug)) + foreach (var template in templates) { - continue; - } + var category = FindReusableCategory(categories, award, template, usedCategories) + ?? new Category { SeasonId = season.Id }; + usedCategories.Add(category); - var target = SeedCatalog.CategorySeeds.First(item => item.Slug == targetSlug); - category.GroupName = target.GroupName; - category.Name = target.Name; - category.Slug = target.Slug; - category.Description = target.Description; - category.SortOrder = target.SortOrder; - category.MaxNomineesPerUser = 3; + category.GroupName = award.Name; + category.Name = template.Name; + category.Slug = BuildCategorySlug(award.Slug, template.Slug); + category.Description = award.Description; + category.SortOrder = nextSortOrder++; + category.MaxNomineesPerUser = 3; + category.ViewerRangeMin = template.ViewerRangeMin; + category.ViewerRangeMax = template.ViewerRangeMax; + + if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category))) + { + db.Categories.Add(category); + categories.Add(category); + } + } } + var staleCategories = categories + .Where(item => item.Id > 0 && !usedCategories.Contains(item)) + .ToArray(); + await RemoveStaleCategoryDataAsync(db, staleCategories); await db.SaveChangesAsync(); + } - var existing = await db.Categories - .Where(item => item.SeasonId == season.Id) - .Select(item => item.Slug) - .ToArrayAsync(); - var existingSlugs = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var seed in SeedCatalog.CategorySeeds) + private static async Task RemoveStaleCategoryDataAsync(AwardsDbContext db, Category[] staleCategories) + { + if (staleCategories.Length == 0) { - if (existingSlugs.Contains(seed.Slug)) - { - continue; - } - - db.Categories.Add(new Category - { - SeasonId = season.Id, - GroupName = seed.GroupName, - Name = seed.Name, - Slug = seed.Slug, - Description = seed.Description, - SortOrder = seed.SortOrder, - MaxNomineesPerUser = 3, - }); + return; } - await db.SaveChangesAsync(); + var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray(); + var staleCandidateIds = await db.Candidates + .Where(item => staleCategoryIds.Contains(item.CategoryId)) + .Select(item => item.Id) + .ToArrayAsync(); + + var staleVoteEntries = await db.VoteEntries + .Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId)) + .ToArrayAsync(); + db.VoteEntries.RemoveRange(staleVoteEntries); + + var staleResults = await db.Results + .Where(item => staleCategoryIds.Contains(item.CategoryId) || staleCandidateIds.Contains(item.CandidateId)) + .ToArrayAsync(); + db.Results.RemoveRange(staleResults); + + var affectedClips = await db.ClipSubmissions + .Where(item => + (item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)) + || (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value))) + .ToArrayAsync(); + foreach (var clip in affectedClips) + { + if (clip.CategoryId != null && staleCategoryIds.Contains(clip.CategoryId.Value)) + { + clip.CategoryId = null; + } + + if (clip.CandidateId != null && staleCandidateIds.Contains(clip.CandidateId.Value)) + { + clip.CandidateId = null; + } + } + + var affectedNominations = await db.Nominations + .Where(item => + (item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)) + || (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value)) + || (item.CandidateId != null && staleCandidateIds.Contains(item.CandidateId.Value))) + .ToArrayAsync(); + foreach (var nomination in affectedNominations) + { + if (nomination.CategoryId != null && staleCategoryIds.Contains(nomination.CategoryId.Value)) + { + nomination.CategoryId = null; + } + + if (nomination.SuggestedCategoryId != null && staleCategoryIds.Contains(nomination.SuggestedCategoryId.Value)) + { + nomination.SuggestedCategoryId = null; + } + + if (nomination.CandidateId != null && staleCandidateIds.Contains(nomination.CandidateId.Value)) + { + nomination.CandidateId = null; + } + } + + var staleCandidates = await db.Candidates + .Where(item => staleCandidateIds.Contains(item.Id)) + .ToArrayAsync(); + db.Candidates.RemoveRange(staleCandidates); + db.Categories.RemoveRange(staleCategories); + } + + private static Category? FindReusableCategory( + List categories, + AwardCategorySeed award, + SeasonSubcategoryTemplateSetting template, + HashSet usedCategories) + { + var targetSlug = BuildCategorySlug(award.Slug, template.Slug); + + return categories.FirstOrDefault(item => !usedCategories.Contains(item) + && string.Equals(item.Slug, targetSlug, StringComparison.OrdinalIgnoreCase)) + ?? categories.FirstOrDefault(item => !usedCategories.Contains(item) + && string.Equals(item.GroupName, award.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase)) + ?? categories.FirstOrDefault(item => !usedCategories.Contains(item) + && string.Equals(item.GroupName, award.LegacyGroupName, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase)); } private static async Task EnsureCandidatesAsync(AwardsDbContext db, Season season, CandidateSeed[] seeds) @@ -142,4 +226,7 @@ public static partial class SeedDataBootstrapper }); } } + + private static string BuildCategorySlug(string awardSlug, string templateSlug) => + $"{SeasonSubcategoryTemplateSettings.Slugify(awardSlug)}-{SeasonSubcategoryTemplateSettings.Slugify(templateSlug)}"; } diff --git a/Backend/Data/SeedCatalog.cs b/Backend/Data/SeedCatalog.cs index 93c18e5..2ebc782 100644 --- a/Backend/Data/SeedCatalog.cs +++ b/Backend/Data/SeedCatalog.cs @@ -1,6 +1,8 @@ namespace Backend.Data; -internal sealed record CategorySeed(string GroupName, string Name, string Slug, string Description, int SortOrder); +using Backend.Services; + +internal sealed record AwardCategorySeed(string LegacyGroupName, string Name, string Slug, string Description, int SortOrder); internal sealed record CandidateSeed(string CategorySlug, string DisplayName, string ChannelSlug, string Platform); internal sealed record WinnerSeed(int Year, string CategorySlug, string DisplayName, string ChannelSlug, string Platform); internal sealed record SiteFaqSeed(string Question, string Answer); @@ -8,10 +10,17 @@ internal sealed record SiteSocialSeed(string Label, string Platform, string Url, internal static class SeedCatalog { - internal static readonly CategorySeed[] CategorySeeds = + internal static readonly SeasonSubcategoryTemplateSetting[] DefaultSubcategoryTemplates = [ - new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die groesste Auszeichnung des Jahres.", 1), - new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie fuer die Szene.", 2), + new("Hidden Star", "hidden-star", 1, 1, 20), + new("Rising Star", "rising-star", 2, 21, 60), + new("Shining Star", "shining-star", 3, 61, null), + ]; + + internal static readonly AwardCategorySeed[] AwardCategorySeeds = + [ + new("Main Awards", "VTuber des Jahres", "vtuber-des-jahres", "Die größte Auszeichnung des Jahres.", 1), + new("Discovery", "Best Newcomer", "best-newcomer", "Neue Stimmen, neue Welten und frische Energie für die Szene.", 2), new("Creative", "Model & Design", "model-design", "Live2D, 3D, Outfit, Rigging und visuelle Identitaet.", 3), new("Performance", "Gesang & Musik", "gesang-musik", "Songs, Covers, Konzerte und musikalische Highlights.", 4), new("Gaming", "Best Gaming", "best-gaming", "Gameplay, Skill, Chaos und legendaere Gaming-Momente.", 5), @@ -34,16 +43,16 @@ internal static class SeedCatalog "Jede:r aktive deutschsprachige VTuber kann nominiert werden — unabhaengig von Follower-Zahl oder Plattform. Die Community schlaegt in der Nominierungsphase ihre Favorit:innen vor."), new( "Wie funktioniert das Voting?", - "Du meldest dich ausschliesslich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das haelt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, aenderbar bis zum Ende der Phase."), + "Du meldest dich ausschließlich mit deinem Twitch-Account an — nur so kannst du teilnehmen. Das hält das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, änderbar bis zum Ende der Phase."), new( "Was kostet die Teilnahme?", - "Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans fuer Fans."), + "Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos — der VTuber Star Award ist ein Community-Event von Fans für Fans."), new( "Wann und wo findet die Award-Show statt?", - "Die grosse Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekuert werden!"), + "Die große Live-Show wird von Jayuhime gehostet und auf Twitch & YouTube gestreamt. Den genauen Termin findest du im Countdown oben — sei live dabei, wenn die Stars gekürt werden!"), new( "Ich wurde nominiert — was nun?", - "Glueckwunsch! Du erhaeltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zaehlt."), + "Glückwunsch! Du erhältst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf — jede Stimme zählt."), ]; internal static readonly SiteSocialSeed[] SiteSocialSeeds = @@ -60,18 +69,18 @@ Anbieter VTuber Star Awards, vertreten durch Jayuhime. Kontakt -Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse. +Nutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse. Hinweis -Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden. +Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden. """; internal const string DefaultContactContent = """ Kontakt zum Award-Team -Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite. +Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite. Datenschutzfragen -Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer. +Für Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer. Community & Kooperationen Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert. @@ -79,57 +88,57 @@ Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert. internal const string DefaultSponsorsContent = """ Sponsoren & Partner -Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden. +Hier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden. -Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind. +Partner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind. """; internal static readonly CandidateSeed[] CurrentCandidateSeeds = [ - new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), - new("vtuber-des-jahres", "Kurainu", "@kurainu", "Twitch"), - new("vtuber-des-jahres", "Shiro Ch.", "@shiroch", "Twitch"), - new("best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"), - new("best-newcomer", "Nox Live", "@noxlive", "Twitch"), - new("model-design", "Velvet Rei", "@velvetrei", "Twitch"), - new("model-design", "Mochi Atelier", "@mochiatelier", "Cake"), - new("gesang-musik", "Melo Diva", "@melodiva", "YouTube"), - new("gesang-musik", "Yuki Stern", "@yukistern", "Twitch"), - new("best-gaming", "Kurainu", "@kurainu", "Twitch"), - new("best-gaming", "PixelPunk", "@pixelpunk", "Twitch"), - new("best-variety", "Taro Chaos", "@tarochaos", "Twitch"), - new("best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"), - new("community-liebling", "Shiro Ch.", "@shiroch", "Twitch"), - new("community-liebling", "Lumi", "@lumi_vt", "Cake"), - new("best-collab-duo", "Akari & Nox", "@akari_vt", "Twitch"), - new("best-collab-duo", "Mochi & Hana", "@mochi_mochi", "YouTube"), + new("vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), + new("vtuber-des-jahres-shining-star", "Kurainu", "@kurainu", "Twitch"), + new("vtuber-des-jahres-shining-star", "Shiro Ch.", "@shiroch", "Twitch"), + new("best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"), + new("best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"), + new("model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"), + new("model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"), + new("gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"), + new("gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"), + new("best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"), + new("best-gaming-rising-star", "PixelPunk", "@pixelpunk", "Twitch"), + new("best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"), + new("best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"), + new("community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"), + new("community-liebling-hidden-star", "Lumi", "@lumi_vt", "Cake"), + new("best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Twitch"), + new("best-collab-duo-rising-star", "Mochi & Hana", "@mochi_mochi", "YouTube"), ]; internal static readonly WinnerSeed[] WinnerSeeds = [ - new(2025, "vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), - new(2025, "best-newcomer", "Aoi Sakura", "@aoisakura", "YouTube"), - new(2025, "model-design", "Velvet Rei", "@velvetrei", "Twitch"), - new(2025, "gesang-musik", "Melo Diva", "@melodiva", "YouTube"), - new(2025, "best-gaming", "Kurainu", "@kurainu", "Twitch"), - new(2025, "best-variety", "Taro Chaos", "@tarochaos", "Twitch"), - new(2025, "community-liebling", "Shiro Ch.", "@shiroch", "Twitch"), - new(2025, "best-collab-duo", "Akari & Nox", "@akari_vt", "Cake"), - new(2024, "vtuber-des-jahres", "Aoi Sakura", "@aoisakura", "YouTube"), - new(2024, "best-newcomer", "Lumi", "@lumi_vt", "Cake"), - new(2024, "model-design", "Mochi Atelier", "@mochiatelier", "Cake"), - new(2024, "gesang-musik", "Yuki Stern", "@yukistern", "Twitch"), - new(2024, "best-gaming", "Starbyte", "@starbyte", "Twitch"), - new(2024, "best-variety", "Kotaro Plays", "@kotaroplays", "YouTube"), - new(2024, "community-liebling", "Moonrelay", "@moonrelay", "Twitch"), - new(2024, "best-collab-duo", "Pixel & Kotaro", "@pixelpunk", "Twitch"), - new(2023, "vtuber-des-jahres", "Akari Nova", "@akarinova", "Twitch"), - new(2023, "best-newcomer", "Nox Live", "@noxlive", "Twitch"), - new(2023, "model-design", "Rei Velvet", "@reivelvet", "YouTube"), - new(2023, "gesang-musik", "Tenshi Vox", "@tenshivox", "Twitch"), - new(2023, "best-gaming", "Bit Knight", "@bitknight", "Twitch"), - new(2023, "best-variety", "Hana Hearts", "@hanahearts", "Cake"), - new(2023, "community-liebling", "Sora Blau", "@sorablau", "YouTube"), - new(2023, "best-collab-duo", "Yuki & Melo", "@yukistern", "Twitch"), + new(2025, "vtuber-des-jahres-shining-star", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"), + new(2025, "best-newcomer-hidden-star", "Aoi Sakura", "@aoisakura", "YouTube"), + new(2025, "model-design-shining-star", "Velvet Rei", "@velvetrei", "Twitch"), + new(2025, "gesang-musik-shining-star", "Melo Diva", "@melodiva", "YouTube"), + new(2025, "best-gaming-shining-star", "Kurainu", "@kurainu", "Twitch"), + new(2025, "best-variety-shining-star", "Taro Chaos", "@tarochaos", "Twitch"), + new(2025, "community-liebling-shining-star", "Shiro Ch.", "@shiroch", "Twitch"), + new(2025, "best-collab-duo-shining-star", "Akari & Nox", "@akari_vt", "Cake"), + new(2024, "vtuber-des-jahres-shining-star", "Aoi Sakura", "@aoisakura", "YouTube"), + new(2024, "best-newcomer-hidden-star", "Lumi", "@lumi_vt", "Cake"), + new(2024, "model-design-rising-star", "Mochi Atelier", "@mochiatelier", "Cake"), + new(2024, "gesang-musik-rising-star", "Yuki Stern", "@yukistern", "Twitch"), + new(2024, "best-gaming-shining-star", "Starbyte", "@starbyte", "Twitch"), + new(2024, "best-variety-rising-star", "Kotaro Plays", "@kotaroplays", "YouTube"), + new(2024, "community-liebling-rising-star", "Moonrelay", "@moonrelay", "Twitch"), + new(2024, "best-collab-duo-rising-star", "Pixel & Kotaro", "@pixelpunk", "Twitch"), + new(2023, "vtuber-des-jahres-shining-star", "Akari Nova", "@akarinova", "Twitch"), + new(2023, "best-newcomer-rising-star", "Nox Live", "@noxlive", "Twitch"), + new(2023, "model-design-shining-star", "Rei Velvet", "@reivelvet", "YouTube"), + new(2023, "gesang-musik-shining-star", "Tenshi Vox", "@tenshivox", "Twitch"), + new(2023, "best-gaming-rising-star", "Bit Knight", "@bitknight", "Twitch"), + new(2023, "best-variety-hidden-star", "Hana Hearts", "@hanahearts", "Cake"), + new(2023, "community-liebling-rising-star", "Sora Blau", "@sorablau", "YouTube"), + new(2023, "best-collab-duo-rising-star", "Yuki & Melo", "@yukistern", "Twitch"), ]; } diff --git a/Backend/Data/SeedData.cs b/Backend/Data/SeedData.cs index fe87a06..7447b0a 100644 --- a/Backend/Data/SeedData.cs +++ b/Backend/Data/SeedData.cs @@ -49,15 +49,23 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus SponsorsUrl = "https://vtuber-star-awards.de/partner", SponsorsContent = SeedCatalog.DefaultSponsorsContent, ShowactsUrl = "https://vtuber-star-awards.de/showacts", - ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.", RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults), WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults), + TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration( + TrackingRulesSettings.DefaultSource, + TrackingRulesSettings.DefaultImportantMetrics, + TrackingRulesSettings.DefaultOptionalMetrics, + TrackingRulesSettings.DefaultFlags)), + ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl, + TrackingReviewNotes = "Fallback-Quellen für manuelle Reviews:\\n- SullyGnome\\n- Twitch-Kanal direkt\\n\\nNutze diese Notizen für Edge Cases und manuelle Tier-Entscheidungen.", NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults), ClipSubmissionsEnabled = false, ClipReviewEnabled = true, ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", ShowactApplicationsEnabled = false, ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + SessionIdleTimeoutHours = 3, SponsorsVisible = true, SocialLinksJson = JsonSerializer.Serialize(new[] { @@ -152,7 +160,7 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus }); modelBuilder.Entity().HasData( - new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 }, + new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die größte Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 }, new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 }, new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 }, new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 }, diff --git a/Backend/Data/SeedOperationalDataBootstrapper.cs b/Backend/Data/SeedOperationalDataBootstrapper.cs index c19b564..3255758 100644 --- a/Backend/Data/SeedOperationalDataBootstrapper.cs +++ b/Backend/Data/SeedOperationalDataBootstrapper.cs @@ -16,6 +16,7 @@ public static partial class SeedDataBootstrapper .ToArrayAsync(); var normalizedLegacyState = await NormalizeLegacyDemoLabelsAsync(db); + await EnsureSeedReviewNominationsAsync(db, season, categories, candidates); if (!await db.ClipSubmissions.AnyAsync(item => item.SeasonId == season.Id)) { @@ -23,15 +24,15 @@ public static partial class SeedDataBootstrapper new ClipSubmission { SeasonId = season.Id, - CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"), - CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Hoshimi Miyu"), + CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"), + CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Hoshimi Miyu"), SubmittedByTwitchId = "local_user_3", ClipUrl = "https://clips.twitch.tv/StarlitDebutMoment", Title = "Starlight Debut Moment", Creator = "Hoshimi Miyu", Platform = "Twitch", Status = "approved", - ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.", + ReviewNote = "Geprüfter Clip für Voting-Vorschau.", ReviewedByTwitchId = "jayuhime_admin", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 15, 0, TimeSpan.Zero), @@ -40,15 +41,15 @@ public static partial class SeedDataBootstrapper new ClipSubmission { SeasonId = season.Id, - CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres"), - CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres", "Kurainu"), + CategoryId = ResolveCategoryId(categories, "vtuber-des-jahres-shining-star"), + CandidateId = ResolveCandidateId(categories, candidates, "vtuber-des-jahres-shining-star", "Kurainu"), SubmittedByTwitchId = "local_user_4", ClipUrl = "https://clips.twitch.tv/KurainuFinaleHype", Title = "Finale-Hype mit Chat-Chaos", Creator = "Kurainu", Platform = "Twitch", Status = "approved", - ReviewNote = "Geprüfter Clip fuer Voting-Vorschau.", + ReviewNote = "Geprüfter Clip für Voting-Vorschau.", ReviewedByTwitchId = "jayuhime_admin", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 18, 8, 35, 0, TimeSpan.Zero), @@ -57,8 +58,8 @@ public static partial class SeedDataBootstrapper new ClipSubmission { SeasonId = season.Id, - CategoryId = ResolveCategoryId(categories, "best-gaming"), - CandidateId = ResolveCandidateId(categories, candidates, "best-gaming", "Kurainu"), + CategoryId = ResolveCategoryId(categories, "best-gaming-shining-star"), + CandidateId = ResolveCandidateId(categories, candidates, "best-gaming-shining-star", "Kurainu"), SubmittedByTwitchId = "local_user", ClipUrl = "https://clips.twitch.tv/EpicGamingMoment", Title = "Epischer Clutch im Finale", @@ -71,15 +72,15 @@ public static partial class SeedDataBootstrapper new ClipSubmission { SeasonId = season.Id, - CategoryId = ResolveCategoryId(categories, "gesang-musik"), - CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik", "Melo Diva"), + CategoryId = ResolveCategoryId(categories, "gesang-musik-shining-star"), + CandidateId = ResolveCandidateId(categories, candidates, "gesang-musik-shining-star", "Melo Diva"), SubmittedByTwitchId = "local_user_2", ClipUrl = "https://www.youtube.com/watch?v=liveCoverMoment", Title = "Live-Cover mit Gänsehaut", Creator = "Melo Diva", Platform = "YouTube", Status = "approved", - ReviewNote = "Geprüfter Clip fuer Review-Workflow.", + ReviewNote = "Geprüfter Clip für Review-Workflow.", ReviewedByTwitchId = "jayuhime_admin", CreatedFromIp = "127.0.0.1", CreatedAt = new DateTimeOffset(2026, 6, 18, 10, 30, 0, TimeSpan.Zero), @@ -114,7 +115,7 @@ public static partial class SeedDataBootstrapper EntityType = "database", EntityId = season.Year.ToString(), Summary = "Startinhalte wurden in der Datenbank bereitgestellt.", - MetadataJson = JsonSerializer.Serialize(new { categories = SeedCatalog.CategorySeeds.Length }), + MetadataJson = JsonSerializer.Serialize(new { awardCategories = SeedCatalog.AwardCategorySeeds.Length, subcategories = SeedCatalog.DefaultSubcategoryTemplates.Length }), CreatedFromIp = "seed", UserAgent = "seed-bootstrap", CreatedAt = new DateTimeOffset(2026, 6, 17, 8, 32, 0, TimeSpan.Zero), @@ -279,5 +280,275 @@ public static partial class SeedDataBootstrapper .Where(char.IsLetterOrDigit) .ToArray()); + private static async Task EnsureSeedReviewNominationsAsync( + AwardsDbContext db, + Season season, + IReadOnlyDictionary 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(); + 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 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 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); } diff --git a/Backend/Data/SeedSiteSettingsBootstrapper.cs b/Backend/Data/SeedSiteSettingsBootstrapper.cs index e4b0e36..54f8e3e 100644 --- a/Backend/Data/SeedSiteSettingsBootstrapper.cs +++ b/Backend/Data/SeedSiteSettingsBootstrapper.cs @@ -46,6 +46,34 @@ public static partial class SeedDataBootstrapper settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults); } + if (!HasValidTrackingRules(settings.TrackingRulesJson)) + { + settings.TrackingRulesJson = TrackingRulesSettings.Serialize(new TrackingRulesConfiguration( + TrackingRulesSettings.DefaultSource, + TrackingRulesSettings.DefaultImportantMetrics, + TrackingRulesSettings.DefaultOptionalMetrics, + TrackingRulesSettings.DefaultFlags)); + } + + if (string.IsNullOrWhiteSpace(settings.ViewerStatsProviderBaseUrl)) + { + settings.ViewerStatsProviderBaseUrl = TrackingRulesSettings.DefaultBaseUrl; + } + + if (string.IsNullOrWhiteSpace(settings.TrackingReviewNotes)) + { + settings.TrackingReviewNotes = """ +Fallback-Quellen für manuelle Reviews: +- SullyGnome Channel Summary +- Offizieller Twitch-Kanal + +Prüfe bei Edge Cases: +- passt der Kanal wirklich zur Unterkategorie? +- fehlen TwitchTracker-Daten nur temporär? +- braucht der Fall eine manuelle Team-Notiz? +"""; + } + if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson)) { settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults); @@ -83,7 +111,12 @@ public static partial class SeedDataBootstrapper if (string.IsNullOrWhiteSpace(settings.ShowactsContent)) { - settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show."; + settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show."; + } + + if (settings.SessionIdleTimeoutHours < 3) + { + settings.SessionIdleTimeoutHours = 3; } } @@ -121,6 +154,14 @@ public static partial class SeedDataBootstrapper private static bool HasValidWorkflowRules(string? json) => WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length; + private static bool HasValidTrackingRules(string? json) + { + var rules = TrackingRulesSettings.Read(new Backend.Domain.SiteSettings { TrackingRulesJson = json ?? string.Empty }); + return rules.ImportantMetrics.Length == TrackingRulesSettings.DefaultImportantMetrics.Length + && rules.OptionalMetrics.Length == TrackingRulesSettings.DefaultOptionalMetrics.Length + && rules.Flags.Length == TrackingRulesSettings.DefaultFlags.Length; + } + private static bool HasValidNominationLinkBlacklist(string? json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/Backend/Domain/Candidate.cs b/Backend/Domain/Candidate.cs index 6ce2f09..1b9016e 100644 --- a/Backend/Domain/Candidate.cs +++ b/Backend/Domain/Candidate.cs @@ -7,9 +7,12 @@ public sealed class Candidate public Season Season { get; set; } = null!; public int CategoryId { get; set; } public Category Category { get; set; } = null!; + public int? StreamerIdentityId { get; set; } + public StreamerIdentity? StreamerIdentity { get; set; } public string DisplayName { get; set; } = string.Empty; public string ChannelSlug { get; set; } = string.Empty; public string Platform { get; set; } = "Twitch"; + public int NominationTally { get; set; } public string AcceptanceStatus { get; set; } = "open"; public string? AcceptanceNote { get; set; } public string? ClipCompilationUrl { get; set; } diff --git a/Backend/Domain/Category.cs b/Backend/Domain/Category.cs index 4a25b41..22efbd8 100644 --- a/Backend/Domain/Category.cs +++ b/Backend/Domain/Category.cs @@ -11,5 +11,7 @@ public sealed class Category public string Description { get; set; } = string.Empty; public int SortOrder { get; set; } public int MaxNomineesPerUser { get; set; } + public int? ViewerRangeMin { get; set; } + public int? ViewerRangeMax { get; set; } public ICollection Candidates { get; set; } = []; } diff --git a/Backend/Domain/Nomination.cs b/Backend/Domain/Nomination.cs index 61f3415..84990b3 100644 --- a/Backend/Domain/Nomination.cs +++ b/Backend/Domain/Nomination.cs @@ -5,13 +5,32 @@ public sealed class Nomination public int Id { get; set; } public int SeasonId { get; set; } public Season Season { get; set; } = null!; - public int CategoryId { get; set; } - public Category Category { get; set; } = null!; + public int? CategoryId { get; set; } + public Category? Category { get; set; } + public string CategoryGroupName { get; set; } = string.Empty; public string SubmittedByTwitchId { get; set; } = string.Empty; public int? CandidateId { get; set; } public Candidate? Candidate { get; set; } + public int? StreamerIdentityId { get; set; } + public StreamerIdentity? StreamerIdentity { get; set; } + public int? SuggestedCategoryId { get; set; } + public Category? SuggestedCategory { get; set; } public string? CandidateText { get; set; } public string? StreamUrl { get; set; } + public string? ResolvedChannel { get; set; } + public string? ResolvedPlatform { get; set; } + public int? AvgViewers { get; set; } + public int? HoursStreamed { get; set; } + public int? HoursWatched { get; set; } + public int? PeakViewers { get; set; } + public int? FollowersGained { get; set; } + public string TrackerStatus { get; set; } = "pending"; + public DateTimeOffset? TrackerCheckedAt { get; set; } + public string TrackingReviewStatus { get; set; } = "clear"; + public string TrackingFlagsJson { get; set; } = "[]"; + public string? TrackingReviewNote { get; set; } + public string? TrackingReviewedByTwitchId { get; set; } + public DateTimeOffset? TrackingReviewedAt { get; set; } public string Status { get; set; } = "pending"; public string? ReviewNote { get; set; } public string? ReviewedByTwitchId { get; set; } diff --git a/Backend/Domain/Season.cs b/Backend/Domain/Season.cs index ef8b461..23773e1 100644 --- a/Backend/Domain/Season.cs +++ b/Backend/Domain/Season.cs @@ -17,6 +17,8 @@ public sealed class Season public DateOnly ReviewEndsAt { get; set; } public DateOnly ShowDate { get; set; } public TimeOnly ShowStartsAt { get; set; } = new(20, 0); + public string SubcategoryTemplatesJson { get; set; } = "[]"; + public string WorkflowRulesJson { get; set; } = "[]"; public ICollection Categories { get; set; } = []; public ICollection Results { get; set; } = []; } diff --git a/Backend/Domain/ShowactApplication.cs b/Backend/Domain/ShowactApplication.cs index 821323c..26419af 100644 --- a/Backend/Domain/ShowactApplication.cs +++ b/Backend/Domain/ShowactApplication.cs @@ -13,6 +13,7 @@ public sealed class ShowactApplication public string Description { get; set; } = string.Empty; public string TechnicalNotes { get; set; } = string.Empty; public string ReferenceUrl { get; set; } = string.Empty; + public string FieldResponsesJson { get; set; } = "{}"; public string Status { get; set; } = "pending"; public string? ReviewNote { get; set; } public string? ReviewedByTwitchId { get; set; } diff --git a/Backend/Domain/SiteSettings.cs b/Backend/Domain/SiteSettings.cs index 72ac1a3..0035c26 100644 --- a/Backend/Domain/SiteSettings.cs +++ b/Backend/Domain/SiteSettings.cs @@ -6,6 +6,8 @@ public sealed class SiteSettings public string HostDisplayName { get; set; } = string.Empty; public string HostTagline { get; set; } = string.Empty; public string NewsletterUrl { get; set; } = string.Empty; + public string ShareXUrl { get; set; } = string.Empty; + public string ShareDiscordUrl { get; set; } = string.Empty; public string PrivacyEmail { get; set; } = string.Empty; public string PrivacyPolicyContent { get; set; } = string.Empty; public string? PrivacyPolicyUpdatedBy { get; set; } @@ -22,13 +24,19 @@ public sealed class SiteSettings public string FaqJson { get; set; } = "[]"; public string RiskRulesJson { get; set; } = "[]"; public string WorkflowRulesJson { get; set; } = "[]"; + public string TrackingRulesJson { get; set; } = "[]"; + public string ViewerStatsProviderBaseUrl { get; set; } = string.Empty; + public string TrackingReviewNotes { get; set; } = string.Empty; public string NominationLinkBlacklistJson { get; set; } = "[]"; public bool ClipSubmissionsEnabled { get; set; } public bool ClipReviewEnabled { get; set; } = true; public bool ClipAdminMenuVisible { get; set; } = true; public string ClipSubmissionDisabledMessage { get; set; } = "Clip-Einreichungen sind aktuell geschlossen."; public bool ShowactApplicationsEnabled { get; set; } + public DateOnly? ShowactApplicationStartsAt { get; set; } + public DateOnly? ShowactApplicationEndsAt { get; set; } public string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen."; + public string ShowactFormSchemaJson { get; set; } = "[]"; public bool SponsorsVisible { get; set; } = true; public bool DemoLoginManagedByDatabase { get; set; } public bool DemoLoginEnabled { get; set; } @@ -42,6 +50,7 @@ public sealed class SiteSettings public string TwitchClientSecret { get; set; } = string.Empty; public string TwitchRedirectUri { get; set; } = string.Empty; public string TwitchScope { get; set; } = string.Empty; + public int SessionIdleTimeoutHours { get; set; } = 3; public bool MaintenanceModeEnabled { get; set; } public string MaintenanceTitle { get; set; } = "Sternenpause"; public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; diff --git a/Backend/Domain/StreamerIdentity.cs b/Backend/Domain/StreamerIdentity.cs new file mode 100644 index 0000000..234a623 --- /dev/null +++ b/Backend/Domain/StreamerIdentity.cs @@ -0,0 +1,14 @@ +namespace Backend.Domain; + +public sealed class StreamerIdentity +{ + public int Id { get; set; } + public string Platform { get; set; } = string.Empty; + public string Login { get; set; } = string.Empty; + public string NormalizedKey { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? ProfileUrl { get; set; } + public DateTimeOffset? LastResolvedAt { get; set; } + public ICollection Nominations { get; set; } = []; + public ICollection Candidates { get; set; } = []; +} diff --git a/Backend/Endpoints/AdminExtrasEndpoints.cs b/Backend/Endpoints/AdminExtrasEndpoints.cs index 727138e..7ea8ccd 100644 --- a/Backend/Endpoints/AdminExtrasEndpoints.cs +++ b/Backend/Endpoints/AdminExtrasEndpoints.cs @@ -303,7 +303,8 @@ public static class AdminExtrasEndpoints application.Status, application.ReviewNote, application.CreatedAt, - application.ReviewedAt); + application.ReviewedAt, + application.FieldResponsesJson ?? "{}"); private static string NormalizeStatus(string? value) => string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant(); diff --git a/Backend/Endpoints/AdminModerationEndpoints.cs b/Backend/Endpoints/AdminModerationEndpoints.cs index 39dd636..8f2c80f 100644 --- a/Backend/Endpoints/AdminModerationEndpoints.cs +++ b/Backend/Endpoints/AdminModerationEndpoints.cs @@ -20,6 +20,14 @@ public static partial class AdminModerationEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) .WithName("RejectAdminNomination") .WithOpenApi(); + group.MapPost("/nominations/{nominationId:int}/reopen", ReopenRejectedNomination) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) + .WithName("ReopenRejectedAdminNomination") + .WithOpenApi(); + group.MapPost("/nominations/{nominationId:int}/tracking-review", UpdateNominationTrackingReview) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) + .WithName("UpdateNominationTrackingReview") + .WithOpenApi(); group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) .WithName("GetAdminNominationLinkBlacklist") diff --git a/Backend/Endpoints/AdminNominationModerationEndpoints.cs b/Backend/Endpoints/AdminNominationModerationEndpoints.cs index b83d65c..f930277 100644 --- a/Backend/Endpoints/AdminNominationModerationEndpoints.cs +++ b/Backend/Endpoints/AdminNominationModerationEndpoints.cs @@ -19,6 +19,8 @@ public static partial class AdminModerationEndpoints var session = AdminEndpointConventions.CurrentSession(context); var nomination = await db.Nominations .Include(item => item.Category) + .Include(item => item.SuggestedCategory) + .Include(item => item.StreamerIdentity) .FirstOrDefaultAsync(item => item.Id == nominationId); if (nomination is null) @@ -26,36 +28,78 @@ public static partial class AdminModerationEndpoints return Results.NotFound(); } - var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty; + var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson); + if (trackingFlags.Any(flag => flag.BlocksApproval) + && !string.Equals(nomination.TrackingReviewStatus, "overridden", StringComparison.OrdinalIgnoreCase)) + { + return Results.BadRequest(new { message = "Tracking Rules blockieren die Freigabe. Bitte setze zuerst einen manuellen Override im Review." }); + } + + var rawDisplayName = FirstNonEmpty(request.DisplayName, nomination.CandidateText, nomination.ResolvedChannel); if (string.IsNullOrWhiteSpace(rawDisplayName)) { return Results.BadRequest(new { message = "A display name is required to approve the nomination." }); } - var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty; - var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim(); + var categoryId = request.CategoryId ?? nomination.SuggestedCategoryId; + if (!categoryId.HasValue) + { + return Results.BadRequest(new { message = "Bitte waehle ein Tier aus. Fuer diesen Link konnte kein automatischer Vorschlag ermittelt werden." }); + } + + var targetCategory = await db.Categories.FirstOrDefaultAsync(item => + item.Id == categoryId.Value + && item.SeasonId == nomination.SeasonId + && item.GroupName == nomination.CategoryGroupName); + if (targetCategory is null) + { + return Results.BadRequest(new { message = "Das gewaehlte Tier gehoert nicht zur Hauptkategorie dieser Nominierung." }); + } + + var channelSlug = FirstNonEmpty(request.ChannelSlug, nomination.ResolvedChannel); + var platform = string.IsNullOrWhiteSpace(request.Platform) + ? nomination.ResolvedPlatform?.Trim() ?? "Twitch" + : request.Platform.Trim(); var normalizedDisplayName = rawDisplayName.ToLower(); var normalizedChannelSlug = channelSlug.ToLower(); var normalizedPlatform = platform.ToLower(); var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item => item.SeasonId == nomination.SeasonId - && item.CategoryId == nomination.CategoryId + && item.CategoryId == targetCategory.Id && ( + (nomination.StreamerIdentityId != null && item.StreamerIdentityId == nomination.StreamerIdentityId) + || item.DisplayName.ToLower() == normalizedDisplayName || (!string.IsNullOrWhiteSpace(normalizedChannelSlug) && item.ChannelSlug.ToLower() == normalizedChannelSlug && item.Platform.ToLower() == normalizedPlatform) )); + var workflowRuleBlock = await BuildModerationCandidateWorkflowRuleBlockAsync( + db, + nomination.SeasonId, + targetCategory.Id, + existingCandidate?.Id, + nomination.StreamerIdentityId, + rawDisplayName, + channelSlug, + existingCandidate?.AcceptanceStatus ?? "open", + context.RequestAborted); + if (workflowRuleBlock is not null) + { + return workflowRuleBlock; + } + var candidate = existingCandidate; if (candidate is null) { candidate = new Candidate { SeasonId = nomination.SeasonId, - CategoryId = nomination.CategoryId, + CategoryId = targetCategory.Id, + StreamerIdentityId = nomination.StreamerIdentityId, DisplayName = rawDisplayName, ChannelSlug = channelSlug, Platform = platform, @@ -66,6 +110,7 @@ public static partial class AdminModerationEndpoints } else { + candidate.StreamerIdentityId ??= nomination.StreamerIdentityId; candidate.DisplayName = rawDisplayName; if (!string.IsNullOrWhiteSpace(channelSlug)) @@ -79,23 +124,54 @@ public static partial class AdminModerationEndpoints } } - nomination.CandidateId = candidate.Id; - nomination.Status = "approved"; - nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); - nomination.ReviewedAt = DateTimeOffset.UtcNow; - nomination.ReviewedByTwitchId = session.TwitchUserId; + var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted); + var uniqueViewerCount = relatedNominations + .Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct() + .Count(); + + candidate.NominationTally = Math.Max(candidate.NominationTally, uniqueViewerCount); + + foreach (var relatedNomination in relatedNominations) + { + relatedNomination.CandidateId = candidate.Id; + relatedNomination.SuggestedCategoryId ??= targetCategory.Id; + relatedNomination.Status = "approved"; + relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + relatedNomination.ReviewedAt = DateTimeOffset.UtcNow; + relatedNomination.ReviewedByTwitchId = session.TwitchUserId; + ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId); + } adminAuditService.AddEntry( session.TwitchUserId, "nomination.approve", "nomination", nomination.Id.ToString(), - $"Nominierung {nomination.Id} wurde als Kandidat uebernommen.", - new { candidateId = candidate.Id, created = existingCandidate is null, nomination.ReviewNote }, + $"Nominierung {nomination.Id} wurde als Kandidat uebernommen. {uniqueViewerCount} Viewer haben diesen Streamer nominiert.", + new + { + candidateId = candidate.Id, + created = existingCandidate is null, + targetCategoryId = targetCategory.Id, + targetCategoryName = targetCategory.Name, + nominationIds = relatedNominations.Select(item => item.Id).ToArray(), + uniqueViewerCount, + reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(), + }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null }); + return Results.Ok(new + { + saved = true, + nominationId = nomination.Id, + candidateId = candidate.Id, + created = existingCandidate is null, + uniqueViewerCount, + nominationIds = relatedNominations.Select(item => item.Id).ToArray(), + }); } private static async Task RejectNomination( @@ -112,11 +188,16 @@ public static partial class AdminModerationEndpoints return Results.NotFound(); } - nomination.CandidateId = null; - nomination.Status = "rejected"; - nomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); - nomination.ReviewedAt = DateTimeOffset.UtcNow; - nomination.ReviewedByTwitchId = session.TwitchUserId; + var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted); + foreach (var relatedNomination in relatedNominations) + { + relatedNomination.CandidateId = null; + relatedNomination.Status = "rejected"; + relatedNomination.ReviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + relatedNomination.ReviewedAt = DateTimeOffset.UtcNow; + relatedNomination.ReviewedByTwitchId = session.TwitchUserId; + ApplyTrackingReviewDecision(relatedNomination, request.ReviewNote, session.TwitchUserId); + } adminAuditService.AddEntry( session.TwitchUserId, @@ -124,10 +205,257 @@ public static partial class AdminModerationEndpoints "nomination", nomination.Id.ToString(), $"Nominierung {nomination.Id} wurde verworfen.", - new { nomination.ReviewNote }, + new + { + reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(), + nominationIds = relatedNominations.Select(item => item.Id).ToArray(), + }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true }); + return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() }); + } + + private static async Task ReopenRejectedNomination( + HttpContext context, + int nominationId, + ReopenRejectedNominationRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted); + if (nomination is null) + { + return Results.NotFound(); + } + + if (!string.Equals(nomination.Status, "rejected", StringComparison.OrdinalIgnoreCase)) + { + return Results.BadRequest(new { message = "Nur verworfene Nominierungen koennen wieder geoeffnet werden." }); + } + + var relatedNominations = await FindRelatedNominationsByStatusAsync(db, nomination, "rejected", context.RequestAborted); + foreach (var relatedNomination in relatedNominations) + { + relatedNomination.CandidateId = null; + relatedNomination.Status = "pending"; + relatedNomination.ReviewNote = null; + relatedNomination.ReviewedAt = null; + relatedNomination.ReviewedByTwitchId = null; + } + + var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + adminAuditService.AddEntry( + session.TwitchUserId, + "nomination.reopen", + "nomination", + nomination.Id.ToString(), + $"Nominierung {nomination.Id} wurde wieder in die Review-Queue gelegt.", + new + { + reviewNote, + nominationIds = relatedNominations.Select(item => item.Id).ToArray(), + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, nominationId = nomination.Id, reopened = true, nominationIds = relatedNominations.Select(item => item.Id).ToArray() }); + } + + private static async Task UpdateNominationTrackingReview( + HttpContext context, + int nominationId, + UpdateNominationTrackingReviewRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId, context.RequestAborted); + if (nomination is null) + { + return Results.NotFound(); + } + + var normalizedStatus = request.Status?.Trim().ToLowerInvariant(); + if (normalizedStatus is not ("reviewed" or "overridden")) + { + return Results.BadRequest(new { message = "Tracking-Review-Status muss reviewed oder overridden sein." }); + } + + var relatedNominations = await FindRelatedPendingNominationsAsync(db, nomination, context.RequestAborted); + var reviewNote = string.IsNullOrWhiteSpace(request.ReviewNote) ? null : request.ReviewNote.Trim(); + var requiresOverrideNote = relatedNominations + .SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)) + .Any(flag => flag.AdminNoteRequiredOnOverride); + + if (normalizedStatus == "overridden" && requiresOverrideNote && string.IsNullOrWhiteSpace(reviewNote)) + { + return Results.BadRequest(new { message = "Fuer diesen Override ist eine Tracking-Review-Notiz Pflicht." }); + } + + foreach (var item in relatedNominations) + { + item.TrackingReviewStatus = normalizedStatus; + item.TrackingReviewNote = reviewNote; + item.TrackingReviewedByTwitchId = session.TwitchUserId; + item.TrackingReviewedAt = DateTimeOffset.UtcNow; + } + + adminAuditService.AddEntry( + session.TwitchUserId, + "nomination.tracking-review.update", + "nomination", + nomination.Id.ToString(), + $"Tracking-Review fuer Nominierung {nomination.Id} wurde auf {normalizedStatus} gesetzt.", + new + { + nominationIds = relatedNominations.Select(item => item.Id).ToArray(), + status = normalizedStatus, + reviewNote, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, nominationId, status = normalizedStatus }); + } + + private static async Task FindRelatedPendingNominationsAsync( + AwardsDbContext db, + Nomination nomination, + CancellationToken cancellationToken) + => await FindRelatedNominationsByStatusAsync(db, nomination, "pending", cancellationToken); + + private static async Task FindRelatedNominationsByStatusAsync( + AwardsDbContext db, + Nomination nomination, + string status, + CancellationToken cancellationToken) + { + var query = db.Nominations + .Where(item => + item.SeasonId == nomination.SeasonId + && item.CategoryGroupName == nomination.CategoryGroupName + && item.Status == status); + + if (nomination.StreamerIdentityId.HasValue) + { + return await query + .Where(item => item.StreamerIdentityId == nomination.StreamerIdentityId) + .ToArrayAsync(cancellationToken); + } + + var normalizedStreamUrl = NormalizeModerationStreamUrl(nomination.StreamUrl); + if (!string.IsNullOrWhiteSpace(normalizedStreamUrl)) + { + var rows = await query.ToArrayAsync(cancellationToken); + return rows + .Where(item => string.Equals(NormalizeModerationStreamUrl(item.StreamUrl), normalizedStreamUrl, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + return [nomination]; + } + + private static string NormalizeModerationStreamUrl(string? value) => + (value ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant(); + + private static string FirstNonEmpty(params string?[] values) => + values + .Select(value => value?.Trim() ?? string.Empty) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) + ?? string.Empty; + + private static async Task BuildModerationCandidateWorkflowRuleBlockAsync( + AwardsDbContext db, + int seasonId, + int categoryId, + int? existingCandidateId, + int? streamerIdentityId, + string displayName, + string channelSlug, + string acceptanceStatus, + CancellationToken cancellationToken) + { + if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken); + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); + var rules = WorkflowRuleSettings.Read(season, settings); + var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory); + var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances); + if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule)) + { + return null; + } + + var existingCandidates = await db.Candidates + .AsNoTracking() + .Where(item => + item.SeasonId == seasonId + && (!existingCandidateId.HasValue || item.Id != existingCandidateId.Value) + && item.AcceptanceStatus != "declined") + .Select(item => new + { + item.CategoryId, + item.StreamerIdentityId, + item.DisplayName, + item.ChannelSlug, + }) + .ToArrayAsync(cancellationToken); + + if (WorkflowRuleSettings.ShouldBlock(finalistsRule)) + { + var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId); + if (categoryCount >= finalistsRule.Limit) + { + return CreateModerationWorkflowRuleError( + $"In dieser Kategorie sind bereits {categoryCount} von {finalistsRule.Limit} finalen Kandidat:innen angelegt."); + } + } + + if (WorkflowRuleSettings.ShouldBlock(appearancesRule)) + { + var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug); + var appearanceCount = existingCandidates.Count(item => + streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId + || string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal)); + if (appearanceCount >= appearancesRule.Limit) + { + return CreateModerationWorkflowRuleError( + $"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}."); + } + } + + return null; + } + + private static IResult CreateModerationWorkflowRuleError(string message) => + Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" }); + + private static void ApplyTrackingReviewDecision(Nomination nomination, string? reviewNote, string reviewerTwitchUserId) + { + var trackingFlags = TrackingRulesSettings.ReadFlagHits(nomination.TrackingFlagsJson); + if (trackingFlags.Length == 0) + { + nomination.TrackingReviewStatus = "clear"; + nomination.TrackingReviewNote = null; + nomination.TrackingReviewedByTwitchId = null; + nomination.TrackingReviewedAt = null; + return; + } + + var note = string.IsNullOrWhiteSpace(reviewNote) ? null : reviewNote.Trim(); + nomination.TrackingReviewStatus = trackingFlags.Any(flag => flag.AdminNoteRequiredOnOverride && !string.IsNullOrWhiteSpace(note)) + ? "overridden" + : "reviewed"; + nomination.TrackingReviewNote = note; + nomination.TrackingReviewedByTwitchId = reviewerTwitchUserId; + nomination.TrackingReviewedAt = DateTimeOffset.UtcNow; } } diff --git a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs index f41b31b..f90d956 100644 --- a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs @@ -62,6 +62,7 @@ public static partial class AdminSeasonManagementEndpoints seasonId, request.CategoryId, null, + null, normalizedDisplayName, normalizedChannelSlug, normalizedAcceptanceStatus, @@ -158,6 +159,7 @@ public static partial class AdminSeasonManagementEndpoints candidate.SeasonId, request.CategoryId, candidateId, + candidate.StreamerIdentityId, normalizedDisplayName, normalizedChannelSlug, normalizedAcceptanceStatus, diff --git a/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs b/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs index a2df6c6..6ecfe00 100644 --- a/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonCategoryEndpoints.cs @@ -46,6 +46,8 @@ public static partial class AdminSeasonManagementEndpoints Description = request.Description.Trim(), SortOrder = request.SortOrder, MaxNomineesPerUser = request.MaxNomineesPerUser, + ViewerRangeMin = request.ViewerRangeMin, + ViewerRangeMax = request.ViewerRangeMax, }; db.Categories.Add(category); @@ -97,6 +99,8 @@ public static partial class AdminSeasonManagementEndpoints category.Description = request.Description.Trim(); category.SortOrder = request.SortOrder; category.MaxNomineesPerUser = request.MaxNomineesPerUser; + category.ViewerRangeMin = request.ViewerRangeMin; + category.ViewerRangeMax = request.ViewerRangeMax; adminAuditService.AddEntry( session.TwitchUserId, diff --git a/Backend/Endpoints/AdminSeasonCategoryGroupEndpoints.cs b/Backend/Endpoints/AdminSeasonCategoryGroupEndpoints.cs new file mode 100644 index 0000000..9e9765b --- /dev/null +++ b/Backend/Endpoints/AdminSeasonCategoryGroupEndpoints.cs @@ -0,0 +1,499 @@ +using Backend.Contracts; +using Backend.Common; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task UpdateSeasonSubcategoryTemplates( + HttpContext context, + int seasonId, + UpdateSeasonSubcategoryTemplatesRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var validationError = ValidateSubcategoryTemplatesRequest(request); + if (validationError is not null) + { + return validationError; + } + + var session = AdminEndpointConventions.CurrentSession(context); + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted); + if (season is null) + { + return Results.NotFound(); + } + + var categories = await db.Categories + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .ToListAsync(context.RequestAborted); + var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates); + var blockedRemovals = await FindBlockedSubcategoryRemovalsAsync(db, categories, templates, context.RequestAborted); + if (blockedRemovals.Length > 0) + { + var firstBlocked = blockedRemovals[0]; + return Results.BadRequest(new + { + message = $"Unterkategorie \"{firstBlocked.SubcategoryName}\" kann nicht entfernt werden, weil darunter noch {firstBlocked.CandidateCount} Kandidaten und {firstBlocked.NominationCount} Nominierungen haengen.", + blockedSubcategories = blockedRemovals, + }); + } + + season.SubcategoryTemplatesJson = SeasonSubcategoryTemplateSettings.Serialize(templates); + SyncCategoryGroupsToTemplates(db, season, categories, templates); + + adminAuditService.AddEntry( + session.TwitchUserId, + "category-templates.update", + "season", + seasonId.ToString(), + $"Unterkategorien für {season.Year} wurden aktualisiert.", + new { seasonId, templateCount = templates.Length }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, seasonId, templateCount = templates.Length }); + } + + private static async Task CreateCategoryGroup( + HttpContext context, + int seasonId, + UpsertCategoryGroupRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var validationError = ValidateCategoryGroupRequest(request); + if (validationError is not null) + { + return validationError; + } + + var session = AdminEndpointConventions.CurrentSession(context); + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted); + if (season is null) + { + return Results.NotFound(); + } + + var categories = await db.Categories + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .ToListAsync(context.RequestAborted); + var templates = SeasonSubcategoryTemplateSettings.Read(season, categories); + if (templates.Length == 0) + { + return Results.BadRequest(new { message = "Lege zuerst mindestens eine globale Unterkategorie an." }); + } + + var groupName = request.GroupName.Trim(); + if (categories.Any(item => string.Equals(item.GroupName, groupName, StringComparison.OrdinalIgnoreCase))) + { + return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." }); + } + + for (var index = 0; index < templates.Length; index += 1) + { + categories.Add(new Category + { + SeasonId = seasonId, + GroupName = groupName, + Name = templates[index].Name, + Slug = BuildCategorySlug(groupName, templates[index].Slug), + Description = request.Description.Trim(), + SortOrder = request.SortOrder, + MaxNomineesPerUser = request.MaxNomineesPerUser, + ViewerRangeMin = templates[index].ViewerRangeMin, + ViewerRangeMax = templates[index].ViewerRangeMax, + }); + } + + foreach (var category in categories.Where(item => item.Id == 0)) + { + db.Categories.Add(category); + } + + SyncCategoryGroupsToTemplates(db, season, categories, templates); + + adminAuditService.AddEntry( + session.TwitchUserId, + "category-group.create", + "season", + seasonId.ToString(), + $"Hauptkategorie {groupName} wurde angelegt.", + new { seasonId, groupName, request.SortOrder, request.MaxNomineesPerUser }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, seasonId, groupName }); + } + + private static async Task UpdateCategoryGroup( + HttpContext context, + int seasonId, + string groupName, + UpsertCategoryGroupRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var validationError = ValidateCategoryGroupRequest(request); + if (validationError is not null) + { + return validationError; + } + + var session = AdminEndpointConventions.CurrentSession(context); + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted); + if (season is null) + { + return Results.NotFound(); + } + + var categories = await db.Categories + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .ToListAsync(context.RequestAborted); + var templates = SeasonSubcategoryTemplateSettings.Read(season, categories); + var normalizedCurrentName = groupName.Trim(); + var groupCategories = categories + .Where(item => string.Equals(item.GroupName, normalizedCurrentName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (groupCategories.Length == 0) + { + return Results.NotFound(); + } + + var targetName = request.GroupName.Trim(); + if (!string.Equals(normalizedCurrentName, targetName, StringComparison.OrdinalIgnoreCase) + && categories.Any(item => string.Equals(item.GroupName, targetName, StringComparison.OrdinalIgnoreCase))) + { + return Results.BadRequest(new { message = "Eine Hauptkategorie mit diesem Namen existiert bereits in dieser Season." }); + } + + foreach (var category in groupCategories) + { + category.GroupName = targetName; + category.Description = request.Description.Trim(); + category.SortOrder = request.SortOrder; + category.MaxNomineesPerUser = request.MaxNomineesPerUser; + } + + SyncCategoryGroupsToTemplates(db, season, categories, templates); + + adminAuditService.AddEntry( + session.TwitchUserId, + "category-group.update", + "season", + seasonId.ToString(), + $"Hauptkategorie {normalizedCurrentName} wurde aktualisiert.", + new { seasonId, from = normalizedCurrentName, to = targetName, request.SortOrder, request.MaxNomineesPerUser }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, seasonId, groupName = targetName }); + } + + private static async Task DeleteCategoryGroup( + HttpContext context, + int seasonId, + string groupName, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var normalizedGroupName = groupName.Trim(); + var categories = await db.Categories + .Where(item => item.SeasonId == seasonId && item.GroupName.ToLower() == normalizedGroupName.ToLower()) + .ToListAsync(context.RequestAborted); + if (categories.Count == 0) + { + return Results.NotFound(); + } + + var categoryIds = categories.Select(item => item.Id).ToArray(); + var candidates = await db.Candidates + .Where(item => categoryIds.Contains(item.CategoryId)) + .ToArrayAsync(context.RequestAborted); + + if (candidates.Length > 0) + { + db.Candidates.RemoveRange(candidates); + } + + db.Categories.RemoveRange(categories); + + adminAuditService.AddEntry( + session.TwitchUserId, + "category-group.delete", + "season", + seasonId.ToString(), + $"Hauptkategorie {normalizedGroupName} wurde gelöscht.", + new { seasonId, removedCategories = categories.Count, removedCandidates = candidates.Length }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, seasonId, groupName = normalizedGroupName }); + } + + private static void SyncCategoryGroupsToTemplates( + AwardsDbContext db, + Season season, + List categories, + SeasonSubcategoryTemplateSetting[] templates) + { + var orderedGroups = categories + .Where(item => !string.IsNullOrWhiteSpace(item.GroupName)) + .GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var items = group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(); + var sample = items[0]; + return new + { + GroupName = sample.GroupName.Trim(), + Description = sample.Description.Trim(), + SortOrder = items.Min(item => item.SortOrder), + MaxNomineesPerUser = sample.MaxNomineesPerUser, + Items = items, + }; + }) + .OrderBy(group => group.SortOrder) + .ThenBy(group => group.GroupName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var nextSortOrder = 1; + foreach (var group in orderedGroups) + { + var usedCategories = new HashSet(); + for (var index = 0; index < templates.Length; index += 1) + { + var template = templates[index]; + var category = FindReusableCategoryForTemplate(group.Items, template, group.GroupName, usedCategories) + ?? new Category { SeasonId = season.Id }; + usedCategories.Add(category); + + category.GroupName = group.GroupName; + category.Name = template.Name; + category.Slug = BuildCategorySlug(group.GroupName, template.Slug); + category.Description = group.Description; + category.MaxNomineesPerUser = group.MaxNomineesPerUser; + category.ViewerRangeMin = template.ViewerRangeMin; + category.ViewerRangeMax = template.ViewerRangeMax; + category.SortOrder = nextSortOrder++; + + if (category.Id == 0 && !db.Categories.Local.Any(item => ReferenceEquals(item, category))) + { + db.Categories.Add(category); + categories.Add(category); + } + } + + foreach (var staleCategory in FindStaleCategoriesAfterTemplateSync(group.Items, templates, group.GroupName)) + { + RemoveCategoryWithCandidates(db, staleCategory); + categories.Remove(staleCategory); + } + } + } + + private static void RemoveCategoryWithCandidates(AwardsDbContext db, Category category) + { + if (category.Id > 0) + { + var candidates = db.Candidates.Where(item => item.CategoryId == category.Id).ToArray(); + if (candidates.Length > 0) + { + db.Candidates.RemoveRange(candidates); + } + } + + db.Categories.Remove(category); + } + + private static async Task FindBlockedSubcategoryRemovalsAsync( + AwardsDbContext db, + List categories, + SeasonSubcategoryTemplateSetting[] templates, + CancellationToken cancellationToken) + { + var staleCategories = categories + .Where(item => !string.IsNullOrWhiteSpace(item.GroupName)) + .GroupBy(item => item.GroupName.Trim(), StringComparer.OrdinalIgnoreCase) + .SelectMany(group => FindStaleCategoriesAfterTemplateSync( + group.OrderBy(item => item.SortOrder).ThenBy(item => item.Name).ToList(), + templates, + group.Key)) + .Where(item => item.Id > 0) + .ToArray(); + + if (staleCategories.Length == 0) + { + return []; + } + + var staleCategoryIds = staleCategories.Select(item => item.Id).ToArray(); + var candidateCounts = await db.Candidates + .Where(item => staleCategoryIds.Contains(item.CategoryId)) + .GroupBy(item => item.CategoryId) + .Select(group => new { CategoryId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken); + var nominationCounts = await db.Nominations + .Where(item => + (item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value)) + || (item.SuggestedCategoryId != null && staleCategoryIds.Contains(item.SuggestedCategoryId.Value))) + .GroupBy(item => item.CategoryId != null && staleCategoryIds.Contains(item.CategoryId.Value) + ? item.CategoryId!.Value + : item.SuggestedCategoryId!.Value) + .Select(group => new { CategoryId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.CategoryId, item => item.Count, cancellationToken); + + return staleCategories + .Select(category => new BlockedSubcategoryRemoval( + category.GroupName, + category.Name, + category.Slug, + candidateCounts.GetValueOrDefault(category.Id), + nominationCounts.GetValueOrDefault(category.Id))) + .Where(item => item.CandidateCount > 0 || item.NominationCount > 0) + .OrderBy(item => item.GroupName, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.SubcategoryName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static Category? FindReusableCategoryForTemplate( + List categories, + SeasonSubcategoryTemplateSetting template, + string groupName, + HashSet? usedCategories = null) + { + usedCategories ??= []; + var expectedSlug = BuildCategorySlug(groupName, template.Slug); + var normalizedTemplateSlug = SeasonSubcategoryTemplateSettings.Slugify(template.Slug); + + return categories.FirstOrDefault(item => !usedCategories.Contains(item) + && string.Equals(item.Slug, expectedSlug, StringComparison.OrdinalIgnoreCase)) + ?? categories.FirstOrDefault(item => !usedCategories.Contains(item) + && string.Equals(item.Slug, normalizedTemplateSlug, StringComparison.OrdinalIgnoreCase)) + ?? categories.FirstOrDefault(item => !usedCategories.Contains(item) + && item.Slug.EndsWith($"-{normalizedTemplateSlug}", StringComparison.OrdinalIgnoreCase)) + ?? categories.FirstOrDefault(item => !usedCategories.Contains(item) + && string.Equals(item.Name, template.Name, StringComparison.OrdinalIgnoreCase)); + } + + private static Category[] FindStaleCategoriesAfterTemplateSync( + List categories, + SeasonSubcategoryTemplateSetting[] templates, + string groupName) + { + var usedCategories = new HashSet(); + foreach (var template in templates) + { + var reusableCategory = FindReusableCategoryForTemplate(categories, template, groupName, usedCategories); + if (reusableCategory is not null) + { + usedCategories.Add(reusableCategory); + } + } + + return categories + .Where(item => !usedCategories.Contains(item)) + .ToArray(); + } + + private static IResult? ValidateSubcategoryTemplatesRequest(UpdateSeasonSubcategoryTemplatesRequest request) + { + var templates = SeasonSubcategoryTemplateSettings.Normalize(request.Templates); + if (templates.Length == 0) + { + return Results.BadRequest(new { message = "Mindestens eine Unterkategorie ist erforderlich." }); + } + + var duplicateNames = templates + .GroupBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Any(group => group.Count() > 1); + if (duplicateNames) + { + return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Namen mehrfach verwenden." }); + } + + var duplicateSlugs = templates + .GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase) + .Any(group => group.Count() > 1); + if (duplicateSlugs) + { + return Results.BadRequest(new { message = "Unterkategorien dürfen nicht denselben Slug mehrfach verwenden." }); + } + + foreach (var template in templates) + { + if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 120) + { + return Results.BadRequest(new { message = "Unterkategorie-Name ist erforderlich und muss unter 120 Zeichen bleiben." }); + } + + if (string.IsNullOrWhiteSpace(template.Slug) || template.Slug.Length > 120) + { + return Results.BadRequest(new { message = "Unterkategorie-Slug ist erforderlich und muss unter 120 Zeichen bleiben." }); + } + + if (template.ViewerRangeMax is not null + && template.ViewerRangeMin is not null + && template.ViewerRangeMax < template.ViewerRangeMin) + { + return Results.BadRequest(new { message = "Viewer-Range Ende muss groesser oder gleich dem Start sein." }); + } + } + + return null; + } + + private static IResult? ValidateCategoryGroupRequest(UpsertCategoryGroupRequest request) + { + var groupName = request.GroupName.Trim(); + var description = request.Description.Trim(); + + if (string.IsNullOrWhiteSpace(groupName) || groupName.Length > 80) + { + return Results.BadRequest(new { message = "Hauptkategorie ist erforderlich und muss unter 80 Zeichen bleiben." }); + } + + if (description.Length > 400) + { + return Results.BadRequest(new { message = "Beschreibung muss unter 400 Zeichen bleiben." }); + } + + if (request.SortOrder is < 1 or > 200) + { + return Results.BadRequest(new { message = "Die Reihenfolge muss zwischen 1 und 200 liegen." }); + } + + if (request.MaxNomineesPerUser is < 1 or > 10) + { + return Results.BadRequest(new { message = "Das Nominierungs-Limit muss zwischen 1 und 10 liegen." }); + } + + return null; + } + + private sealed record BlockedSubcategoryRemoval( + string GroupName, + string SubcategoryName, + string Slug, + int CandidateCount, + int NominationCount); + + private static string BuildCategorySlug(string groupName, string templateSlug) + { + var groupSlug = SeasonSubcategoryTemplateSettings.Slugify(groupName); + var detailSlug = SeasonSubcategoryTemplateSettings.Slugify(templateSlug); + return string.IsNullOrWhiteSpace(groupSlug) ? detailSlug : $"{groupSlug}-{detailSlug}"; + } +} diff --git a/Backend/Endpoints/AdminSeasonCreateEndpoints.cs b/Backend/Endpoints/AdminSeasonCreateEndpoints.cs index 1428e2e..6664606 100644 --- a/Backend/Endpoints/AdminSeasonCreateEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonCreateEndpoints.cs @@ -28,6 +28,10 @@ public static partial class AdminSeasonManagementEndpoints } var showStreamUrl = NormalizeSeasonStreamUrl(request.ShowStreamUrl); + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); + var initialWorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Read(settings)); var season = new Season { @@ -45,6 +49,8 @@ public static partial class AdminSeasonManagementEndpoints ReviewEndsAt = request.ReviewEndsAt, ShowDate = request.ShowDate, ShowStartsAt = request.ShowStartsAt, + SubcategoryTemplatesJson = "[]", + WorkflowRulesJson = initialWorkflowRulesJson, }; db.Seasons.Add(season); @@ -72,6 +78,13 @@ public static partial class AdminSeasonManagementEndpoints .ToArrayAsync(context.RequestAborted); copiedCategoryCount = sourceCategories.Length; + var sourceSeason = await db.Seasons + .AsNoTracking() + .FirstAsync(item => item.Id == sourceSeasonId, context.RequestAborted); + season.SubcategoryTemplatesJson = sourceSeason.SubcategoryTemplatesJson; + season.WorkflowRulesJson = string.IsNullOrWhiteSpace(sourceSeason.WorkflowRulesJson) + ? initialWorkflowRulesJson + : sourceSeason.WorkflowRulesJson; foreach (var category in sourceCategories) { db.Categories.Add(new Category @@ -83,6 +96,8 @@ public static partial class AdminSeasonManagementEndpoints Description = category.Description, SortOrder = category.SortOrder, MaxNomineesPerUser = category.MaxNomineesPerUser, + ViewerRangeMin = category.ViewerRangeMin, + ViewerRangeMax = category.ViewerRangeMax, }); } } diff --git a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs index cfd1c4b..2046181 100644 --- a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs @@ -1,5 +1,6 @@ using Backend.Contracts; using Backend.Data; +using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; @@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints return Results.NotFound(); } + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1); + var trackingRules = TrackingRulesSettings.Read(settings); + var candidates = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == seasonId) @@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints .Select(item => new AdminCandidateItemDto( item.Id, item.CategoryId, + item.StreamerIdentityId, item.DisplayName, item.ChannelSlug, item.Platform, + item.NominationTally, item.AcceptanceStatus, item.AcceptanceNote, item.ClipCompilationUrl, @@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints category.Description, category.SortOrder, category.MaxNomineesPerUser, + category.ViewerRangeMin, + category.ViewerRangeMax, }) .ToArrayAsync(); + var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read( + season, + categoryRows.Select(category => new Backend.Domain.Category + { + GroupName = category.GroupName, + Name = category.Name, + Slug = category.Slug, + SortOrder = category.SortOrder, + ViewerRangeMin = category.ViewerRangeMin, + ViewerRangeMax = category.ViewerRangeMax, + })); + var visibleCategoryRows = categoryRows + .Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate( + new Backend.Domain.Category + { + GroupName = category.GroupName, + Name = category.Name, + Slug = category.Slug, + SortOrder = category.SortOrder, + ViewerRangeMin = category.ViewerRangeMin, + ViewerRangeMax = category.ViewerRangeMax, + }, + subcategoryTemplateSettings)) + .ToArray(); - var categories = categoryRows + var categories = visibleCategoryRows .Select(category => new AdminCategoryItemDto( category.Id, category.GroupName, @@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints category.Description, category.SortOrder, category.MaxNomineesPerUser, + category.ViewerRangeMin, + category.ViewerRangeMax, candidateCounts.TryGetValue(category.Id, out var count) ? count : 0)) .ToArray(); - var pendingNominations = await db.Nominations + var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings); + + var pendingNominationRows = await db.Nominations .AsNoTracking() .Where(item => item.SeasonId == seasonId && item.Status == "pending") .OrderByDescending(item => item.CreatedAt) - .Select(item => new AdminNominationReviewItemDto( + .Select(item => new AdminNominationRow( item.Id, item.CategoryId, - item.Category.Name, + item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName, + item.CategoryId != null ? item.Category!.Name : null, item.SubmittedByTwitchId, item.CandidateText ?? string.Empty, item.StreamUrl, + item.ResolvedChannel, + item.ResolvedPlatform, + item.AvgViewers, + item.HoursStreamed, + item.HoursWatched, + item.PeakViewers, + item.FollowersGained, + item.SuggestedCategoryId, + item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null, + item.StreamerIdentityId, + item.TrackerStatus, + item.TrackerCheckedAt, + item.TrackingReviewStatus, + item.TrackingFlagsJson, + item.TrackingReviewNote, + item.TrackingReviewedByTwitchId, + item.TrackingReviewedAt, item.Status, item.CreatedAt, item.CandidateId, @@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints item.ReviewedAt)) .ToArrayAsync(); - var reviewedNominations = await db.Nominations + var pendingNominations = pendingNominationRows + .Select(item => ToNominationReviewItem(item, categoryRows, trackingRules)) + .ToArray(); + + var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules); + + var reviewedNominationRows = await db.Nominations .AsNoTracking() .Where(item => item.SeasonId == seasonId && item.Status != "pending") .OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt) - .Select(item => new AdminNominationReviewItemDto( + .Select(item => new AdminNominationRow( item.Id, item.CategoryId, - item.Category.Name, + item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName, + item.CategoryId != null ? item.Category!.Name : null, item.SubmittedByTwitchId, item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty), item.StreamUrl, + item.ResolvedChannel, + item.ResolvedPlatform, + item.AvgViewers, + item.HoursStreamed, + item.HoursWatched, + item.PeakViewers, + item.FollowersGained, + item.SuggestedCategoryId, + item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null, + item.StreamerIdentityId, + item.TrackerStatus, + item.TrackerCheckedAt, + item.TrackingReviewStatus, + item.TrackingFlagsJson, + item.TrackingReviewNote, + item.TrackingReviewedByTwitchId, + item.TrackingReviewedAt, item.Status, item.CreatedAt, item.CandidateId, @@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints item.ReviewedAt)) .ToArrayAsync(); + var reviewedNominations = reviewedNominationRows + .Select(item => ToNominationReviewItem(item, categoryRows, trackingRules)) + .ToArray(); + var resultItems = await db.Results .AsNoTracking() .Where(item => item.SeasonId == seasonId) @@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints item.CategoryId, item.Category.Name, item.CandidateId, + item.Candidate.StreamerIdentityId, item.Candidate.DisplayName, item.Candidate.ChannelSlug, item.Candidate.Platform)) @@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints season.ReviewEndsAt, season.ShowDate, season.ShowStartsAt, + subcategoryTemplates, categories, candidates, pendingNominations, + pendingNominationGroups, reviewedNominations, + settings?.TrackingReviewNotes ?? string.Empty, + trackingRules.Source.ShowManualReviewNotesInReview, resultItems, clipSubmissions)); } + + private sealed record AdminNominationRow( + int Id, + int? CategoryId, + string? CategoryGroupName, + string? CategoryName, + string SubmittedByTwitchId, + string CandidateText, + string? StreamUrl, + string? ResolvedChannel, + string? ResolvedPlatform, + int? AvgViewers, + int? HoursStreamed, + int? HoursWatched, + int? PeakViewers, + int? FollowersGained, + int? SuggestedCategoryId, + string? SuggestedCategoryName, + int? StreamerIdentityId, + string TrackerStatus, + DateTimeOffset? TrackerCheckedAt, + string TrackingReviewStatus, + string TrackingFlagsJson, + string? TrackingReviewNote, + string? TrackingReviewedByTwitchId, + DateTimeOffset? TrackingReviewedAt, + string Status, + DateTimeOffset CreatedAt, + int? CandidateId, + string? CandidateDisplayName, + string? ReviewNote, + string? ReviewedByTwitchId, + DateTimeOffset? ReviewedAt); + + private static AdminNominationReviewItemDto ToNominationReviewItem( + AdminNominationRow item, + IEnumerable categoryRows, + TrackingRulesConfiguration trackingRules) + { + var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson) + .Select(ToTrackingFlagHitDto) + .ToArray(); + + return new( + item.Id, + item.CategoryId, + ResolveCategoryGroupName(item, categoryRows), + ResolveCategoryName(item, categoryRows), + item.SubmittedByTwitchId, + item.CandidateText, + item.StreamUrl, + item.ResolvedChannel, + item.ResolvedPlatform, + item.AvgViewers, + item.SuggestedCategoryId, + item.SuggestedCategoryName, + item.StreamerIdentityId, + string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus, + item.TrackerCheckedAt, + string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus, + trackingFlags.Any(flag => flag.RequiresManualReview), + trackingFlags, + BuildTrackingMetricStateDtos(item, trackingRules), + item.TrackingReviewNote, + item.TrackingReviewedByTwitchId, + item.TrackingReviewedAt, + item.Status, + item.CreatedAt, + item.CandidateId, + item.CandidateDisplayName, + item.ReviewNote, + item.ReviewedByTwitchId, + item.ReviewedAt); + } + + private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups( + IEnumerable rows, + IEnumerable categoryRows, + TrackingRulesConfiguration trackingRules) => + rows + .GroupBy(item => new + { + CategoryGroupName = ResolveCategoryGroupName(item, categoryRows), + IdentityKey = item.StreamerIdentityId.HasValue + ? $"identity:{item.StreamerIdentityId.Value}" + : $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}", + }) + .Select(group => + { + var ordered = group.OrderBy(item => item.CreatedAt).ToArray(); + var representative = ordered + .OrderByDescending(item => item.StreamerIdentityId.HasValue) + .ThenByDescending(item => item.SuggestedCategoryId.HasValue) + .ThenByDescending(item => item.AvgViewers.HasValue) + .First(); + var trackerStatus = ResolveGroupTrackerStatus(ordered); + var trackingFlags = ordered + .SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)) + .GroupBy(item => item.Key) + .Select(grouping => ToTrackingFlagHitDto(grouping.First())) + .ToArray(); + var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview); + return new AdminNominationReviewGroupDto( + representative.Id, + ordered.Select(item => item.Id).ToArray(), + ResolveCategoryGroupName(representative, categoryRows), + ResolveNominationDisplayName(representative), + representative.StreamUrl, + representative.ResolvedChannel, + representative.ResolvedPlatform, + representative.AvgViewers, + representative.SuggestedCategoryId, + representative.SuggestedCategoryName, + representative.StreamerIdentityId, + trackerStatus, + representative.TrackerCheckedAt, + ResolveGroupTrackingReviewStatus(ordered), + requiresManualReview, + trackingFlags, + BuildTrackingMetricStateDtos(representative, trackingRules), + ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)), + ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)), + ordered.Max(item => item.TrackingReviewedAt), + ordered.Length, + ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(), + ordered.First().CreatedAt, + ordered.Last().CreatedAt); + }) + .OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval)) + .ThenByDescending(item => item.RequiresManualReview) + .ThenByDescending(item => item.NominationTally) + .ThenByDescending(item => item.UniqueSubmitterCount) + .ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1) + .ThenByDescending(item => item.AvgViewers ?? -1) + .ThenByDescending(item => item.LastSubmittedAt) + .ToArray(); + + private static string ResolveNominationDisplayName(AdminNominationRow item) => + item.ResolvedChannel + ?? item.CandidateText + ?? item.StreamUrl + ?? "Name im Review festlegen"; + + private static string ResolveGroupTrackerStatus(IReadOnlyCollection rows) + { + string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"]; + return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase))) + ?? rows.FirstOrDefault()?.TrackerStatus + ?? "pending"; + } + + private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection rows) + { + string[] priority = ["overridden", "reviewed", "flagged", "clear"]; + return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase))) + ?? rows.FirstOrDefault()?.TrackingReviewStatus + ?? "clear"; + } + + private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable categoryRows) + { + if (!string.IsNullOrWhiteSpace(item.CategoryGroupName)) + { + return item.CategoryGroupName.Trim(); + } + + var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value); + return category?.GroupName ?? "Unbekannte Hauptkategorie"; + } + + private static string ResolveCategoryName(AdminNominationRow item, IEnumerable categoryRows) + { + if (!string.IsNullOrWhiteSpace(item.CategoryName)) + { + return item.CategoryName.Trim(); + } + + var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value); + return category?.Name ?? ResolveCategoryGroupName(item, categoryRows); + } + + private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) => + new( + flag.Key, + flag.Label, + flag.Severity, + flag.Description, + flag.RequiresManualReview, + flag.BlocksApproval, + flag.AdminNoteRequiredOnOverride); + + private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos( + AdminNominationRow row, + TrackingRulesConfiguration trackingRules) => + trackingRules.ImportantMetrics + .Concat(trackingRules.OptionalMetrics) + .Where(metric => metric.Enabled && metric.ShowInReview) + .Select(metric => new AdminTrackingMetricStateDto( + metric.Key, + metric.Label, + metric.RequiredForAutoClassification, + metric.SourceSupport, + MetricPresent(metric, row), + MetricValue(metric, row), + metric.Description, + metric.WindowKey, + TrackingRulesSettings.WindowLabel(metric.WindowKey), + TrackingRulesSettings.SupportsAutomaticWindow(metric))) + .ToArray(); + + private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row) + { + if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric)) + { + return false; + } + + return metric.Key switch + { + TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue, + TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus), + TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue, + TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue, + TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue, + TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue, + TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue, + _ => false, + }; + } + + private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row) + { + if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric)) + { + return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}"; + } + + return metric.Key switch + { + TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen", + TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus, + TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen", + TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen", + TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen", + TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen", + TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen", + TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check", + TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric), + _ => "manuell", + }; + } + + private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric) + { + var parts = new List(); + if (metric.TopCount.HasValue) + { + parts.Add($"Top {metric.TopCount.Value}"); + } + + if (metric.MinPrimaryCategorySharePercent.HasValue) + { + parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie"); + } + + if (metric.MinPrimaryCategoryHours.HasValue) + { + parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie"); + } + + if (metric.MaxDistinctCategoriesBeforeFlag.HasValue) + { + parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien"); + } + + if (metric.IgnoredCategories.Length > 0) + { + parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}"); + } + + return parts.Count > 0 + ? string.Join(" · ", parts) + : "Top-Kategorien manuell pruefen"; + } } diff --git a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs index 983fc73..de8e7b7 100644 --- a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs @@ -36,6 +36,22 @@ public static partial class AdminSeasonManagementEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories)) .WithName("DeleteAdminCategory") .WithOpenApi(); + group.MapPut("/seasons/{seasonId:int}/subcategory-templates", UpdateSeasonSubcategoryTemplates) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories)) + .WithName("UpdateAdminSeasonSubcategoryTemplates") + .WithOpenApi(); + group.MapPost("/seasons/{seasonId:int}/category-groups", CreateCategoryGroup) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories)) + .WithName("CreateAdminCategoryGroup") + .WithOpenApi(); + group.MapPut("/seasons/{seasonId:int}/category-groups/{groupName}", UpdateCategoryGroup) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories)) + .WithName("UpdateAdminCategoryGroup") + .WithOpenApi(); + group.MapDelete("/seasons/{seasonId:int}/category-groups/{groupName}", DeleteCategoryGroup) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories)) + .WithName("DeleteAdminCategoryGroup") + .WithOpenApi(); group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates)) .WithName("CreateAdminCandidate") @@ -56,11 +72,11 @@ public static partial class AdminSeasonManagementEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners)) .WithName("DeleteAdminResult") .WithOpenApi(); - group.MapGet("/workflow-rules", GetWorkflowRules) + group.MapGet("/seasons/{seasonId:int}/workflow-rules", GetWorkflowRules) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings)) .WithName("GetAdminWorkflowRules") .WithOpenApi(); - group.MapPut("/workflow-rules", UpdateWorkflowRules) + group.MapPut("/seasons/{seasonId:int}/workflow-rules", UpdateWorkflowRules) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings)) .WithName("UpdateAdminWorkflowRules") .WithOpenApi(); diff --git a/Backend/Endpoints/AdminSeasonManagementSupport.cs b/Backend/Endpoints/AdminSeasonManagementSupport.cs index a22bfd1..9114085 100644 --- a/Backend/Endpoints/AdminSeasonManagementSupport.cs +++ b/Backend/Endpoints/AdminSeasonManagementSupport.cs @@ -24,10 +24,26 @@ public static partial class AdminSeasonManagementEndpoints private sealed record CandidateRuleSnapshot( int Id, int CategoryId, + int? StreamerIdentityId, string DisplayName, string ChannelSlug, string AcceptanceStatus); + private sealed record CandidateReadinessSnapshot( + int CategoryId, + int? StreamerIdentityId, + string DisplayName, + string ChannelSlug, + string AcceptanceStatus, + string? ClipCompilationUrl); + + private sealed record WinnerReadinessSnapshot( + int CategoryId, + int? StreamerIdentityId, + string DisplayName, + string ChannelSlug, + string? ClipCompilationUrl); + private static IResult? ValidateSeasonRequest(CreateSeasonRequest request) { if (request.Year < 2020 || request.Year > 2100) @@ -145,13 +161,16 @@ public static partial class AdminSeasonManagementEndpoints }); } - private static async Task LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken) + private static async Task LoadWorkflowRulesAsync(AwardsDbContext db, int seasonId, CancellationToken cancellationToken) { + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken); var settings = await db.SiteSettings .AsNoTracking() .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); - return WorkflowRuleSettings.Read(settings); + return WorkflowRuleSettings.Read(season, settings); } private static IResult CreateWorkflowRuleError(string message) => @@ -162,6 +181,7 @@ public static partial class AdminSeasonManagementEndpoints int seasonId, int categoryId, int? existingCandidateId, + int? streamerIdentityId, string displayName, string channelSlug, string acceptanceStatus, @@ -172,7 +192,7 @@ public static partial class AdminSeasonManagementEndpoints return null; } - var rules = await LoadWorkflowRulesAsync(db, cancellationToken); + var rules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken); var finalistsRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxFinalistsPerCategory); var appearancesRule = WorkflowRuleSettings.Find(rules, WorkflowRuleSettings.MaxCandidateAppearances); if (!WorkflowRuleSettings.ShouldBlock(finalistsRule) && !WorkflowRuleSettings.ShouldBlock(appearancesRule)) @@ -189,6 +209,7 @@ public static partial class AdminSeasonManagementEndpoints .Select(item => new CandidateRuleSnapshot( item.Id, item.CategoryId, + item.StreamerIdentityId, item.DisplayName, item.ChannelSlug, item.AcceptanceStatus)) @@ -208,7 +229,8 @@ public static partial class AdminSeasonManagementEndpoints { var identityKey = WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug); var appearanceCount = existingCandidates.Count(item => - string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal)); + streamerIdentityId.HasValue && item.StreamerIdentityId == streamerIdentityId + || string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal)); if (appearanceCount >= appearancesRule.Limit) { return CreateWorkflowRuleError( @@ -258,6 +280,14 @@ public static partial class AdminSeasonManagementEndpoints bool isCurrent, CancellationToken cancellationToken) { + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == seasonId, cancellationToken); + if (season is null) + { + return ["Das Award-Jahr konnte fuer die Readiness-Pruefung nicht gefunden werden."]; + } + var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase); var needsCandidateReadiness = RequiresCandidateReadiness(phaseKey, isCurrent); var needsWinnerReadiness = RequiresWinnerReadiness(phaseKey); @@ -266,6 +296,11 @@ public static partial class AdminSeasonManagementEndpoints return []; } + var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, cancellationToken); + var appearancesRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxCandidateAppearances); + var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements); + var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip); + var categoryIds = await db.Categories .AsNoTracking() .Where(item => item.SeasonId == seasonId) @@ -280,21 +315,57 @@ public static partial class AdminSeasonManagementEndpoints if (needsCandidateReadiness && categoryIds.Length > 0) { - var categoriesWithCandidates = await db.Candidates + var candidateSnapshots = await db.Candidates .AsNoTracking() .Where(item => item.SeasonId == seasonId) + .Select(item => new CandidateReadinessSnapshot( + item.CategoryId, + item.StreamerIdentityId, + item.DisplayName, + item.ChannelSlug, + item.AcceptanceStatus, + item.ClipCompilationUrl)) + .ToArrayAsync(cancellationToken); + var activeCandidates = candidateSnapshots + .Where(item => !string.Equals(item.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + var categoriesWithCandidates = activeCandidates .Select(item => item.CategoryId) .Distinct() - .CountAsync(cancellationToken); + .Count(); var emptyCategories = Math.Max(0, categoryIds.Length - categoriesWithCandidates); if (emptyCategories > 0) { - issues.Add($"{emptyCategories} Kategorien haben noch keine Kandidaten."); + issues.Add($"{emptyCategories} Kategorien haben noch keine voting-bereiten Kandidaten."); + } + + if (WorkflowRuleSettings.ShouldBlock(appearancesRule)) + { + var identityOverflow = activeCandidates + .GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug)) + .Select(group => new + { + Count = group.Count(), + DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt", + }) + .Where(item => item.Count > appearancesRule.Limit) + .OrderByDescending(item => item.Count) + .FirstOrDefault(); + if (identityOverflow is not null) + { + issues.Add( + $"Workflow-Regel blockiert: {identityOverflow.DisplayName} ist bereits {identityOverflow.Count}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}."); + } } } if (needsWinnerReadiness && categoryIds.Length > 0) { + if (season.ShowDate > DateOnly.FromDateTime(DateTime.Now)) + { + issues.Add("Die Award Show liegt noch nicht in der Vergangenheit."); + } + var categoriesWithResults = await db.Results .AsNoTracking() .Where(item => item.SeasonId == seasonId) @@ -306,11 +377,60 @@ public static partial class AdminSeasonManagementEndpoints { issues.Add($"{missingResults} Kategorien haben noch keinen Gewinner."); } + + var resultSnapshots = await db.Results + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .Select(item => new WinnerReadinessSnapshot( + item.CategoryId, + item.Candidate.StreamerIdentityId, + item.Candidate.DisplayName, + item.Candidate.ChannelSlug, + item.Candidate.ClipCompilationUrl)) + .ToArrayAsync(cancellationToken); + + if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule)) + { + var missingWinnerClipCount = resultSnapshots.Count(item => string.IsNullOrWhiteSpace(item.ClipCompilationUrl)); + if (missingWinnerClipCount > 0) + { + issues.Add($"{missingWinnerClipCount} Gewinner haben noch keinen gepflegten Clip-Link."); + } + } + + if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule)) + { + var winnerOverflow = resultSnapshots + .GroupBy(item => ResolveCandidateIdentityKey(item.StreamerIdentityId, item.DisplayName, item.ChannelSlug)) + .Select(group => new + { + Count = group.Count(), + DisplayName = group.Select(item => item.DisplayName).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? "Unbekannt", + }) + .Where(item => item.Count > winnerPlacementsRule.Limit) + .OrderByDescending(item => item.Count) + .FirstOrDefault(); + if (winnerOverflow is not null) + { + issues.Add( + $"Workflow-Regel blockiert: {winnerOverflow.DisplayName} hat bereits {winnerOverflow.Count} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}."); + } + } } return issues.ToArray(); } + private static string ResolveCandidateIdentityKey(int? streamerIdentityId, string displayName, string channelSlug) + { + if (streamerIdentityId.HasValue) + { + return $"identity:{streamerIdentityId.Value}"; + } + + return WorkflowRuleSettings.CandidateIdentityKey(displayName, channelSlug); + } + private static bool RequiresCandidateReadiness(string phaseKey, bool isCurrent) { return (isCurrent && !string.Equals(phaseKey, "nomination", StringComparison.Ordinal)) @@ -364,6 +484,21 @@ public static partial class AdminSeasonManagementEndpoints return Results.BadRequest(new { message = "Max nominees per user must be between 1 and 10." }); } + if (request.ViewerRangeMin is < 0 or > 100000) + { + return Results.BadRequest(new { message = "Viewer range start must be between 0 and 100000." }); + } + + if (request.ViewerRangeMax is < 0 or > 100000) + { + return Results.BadRequest(new { message = "Viewer range end must be between 0 and 100000." }); + } + + if (request.ViewerRangeMin is not null && request.ViewerRangeMax is not null && request.ViewerRangeMax < request.ViewerRangeMin) + { + return Results.BadRequest(new { message = "Viewer range end must be greater than or equal to the start." }); + } + return null; } diff --git a/Backend/Endpoints/AdminSeasonResultsEndpoints.cs b/Backend/Endpoints/AdminSeasonResultsEndpoints.cs index a02b1a0..c810972 100644 --- a/Backend/Endpoints/AdminSeasonResultsEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonResultsEndpoints.cs @@ -36,7 +36,7 @@ public static partial class AdminSeasonManagementEndpoints return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." }); } - var workflowRules = await LoadWorkflowRulesAsync(db, context.RequestAborted); + var workflowRules = await LoadWorkflowRulesAsync(db, seasonId, context.RequestAborted); var winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip); if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule) && string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)) @@ -56,11 +56,14 @@ public static partial class AdminSeasonManagementEndpoints .Select(item => new { item.CategoryId, + item.Candidate.StreamerIdentityId, item.Candidate.DisplayName, item.Candidate.ChannelSlug, }) .ToArrayAsync(context.RequestAborted); var existingWinnerCount = existingWinnerIdentities.Count(item => + candidate.StreamerIdentityId.HasValue && item.StreamerIdentityId == candidate.StreamerIdentityId + || string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal)); if (existingWinnerCount >= winnerPlacementsRule.Limit) diff --git a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs index 18270fb..ed0f6bb 100644 --- a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs +++ b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs @@ -41,6 +41,22 @@ public static class AdminSiteSettingsEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminOptionalFeatureSettings") .WithOpenApi(); + group.MapGet("/tracking-rules", GetTrackingRules) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) + .WithName("GetAdminTrackingRules") + .WithOpenApi(); + group.MapPut("/tracking-rules", UpdateTrackingRules) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) + .WithName("UpdateAdminTrackingRules") + .WithOpenApi(); + group.MapPut("/tracking-rules/source", UpdateTrackingSource) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) + .WithName("UpdateAdminTrackingSource") + .WithOpenApi(); + group.MapPut("/tracking-rules/notes", UpdateTrackingReviewNotes) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) + .WithName("UpdateAdminTrackingReviewNotes") + .WithOpenApi(); return group; } @@ -56,6 +72,8 @@ public static class AdminSiteSettingsEndpoints settings.HostDisplayName, settings.HostTagline, settings.NewsletterUrl, + settings.ShareXUrl, + settings.ShareDiscordUrl, settings.PrivacyEmail, settings.PrivacyPolicyContent, settings.PrivacyPolicyUpdatedBy, @@ -69,7 +87,8 @@ public static class AdminSiteSettingsEndpoints settings.ShowactsUrl, settings.ShowactsContent, SeasonMappings.ReadSocialLinks(settings), - SeasonMappings.ReadFaqItems(settings))); + SeasonMappings.ReadFaqItems(settings), + settings.ShowactFormSchemaJson ?? "[]")); } private static async Task UpdateSiteSettings( @@ -95,6 +114,8 @@ public static class AdminSiteSettingsEndpoints settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostTagline = request.HostTagline.Trim(); settings.NewsletterUrl = normalizedUrls.NewsletterUrl; + settings.ShareXUrl = normalizedUrls.ShareXUrl; + settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl; settings.PrivacyEmail = request.PrivacyEmail.Trim(); var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim(); var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal); @@ -115,6 +136,7 @@ public static class AdminSiteSettingsEndpoints settings.ShowactsContent = request.ShowactsContent.Trim(); settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks); settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); + settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]"; adminAuditService.AddEntry( session.TwitchUserId, @@ -144,6 +166,8 @@ public static class AdminSiteSettingsEndpoints socialLinks = []; if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage) + || !TryNormalizePublicUrl(request.ShareXUrl, "X-Teilen-Link", out var shareXUrl, out errorMessage) + || !TryNormalizePublicUrl(request.ShareDiscordUrl, "Discord-Teilen-Link", out var shareDiscordUrl, out errorMessage) || !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage) || !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage) || !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage) @@ -155,6 +179,8 @@ public static class AdminSiteSettingsEndpoints normalizedUrls = new PublicSiteUrlSettings { NewsletterUrl = newsletterUrl, + ShareXUrl = shareXUrl, + ShareDiscordUrl = shareDiscordUrl, ImprintUrl = imprintUrl, ContactUrl = contactUrl, SponsorsUrl = sponsorsUrl, @@ -199,6 +225,8 @@ public static class AdminSiteSettingsEndpoints private sealed class PublicSiteUrlSettings { public string NewsletterUrl { get; set; } = string.Empty; + public string ShareXUrl { get; set; } = string.Empty; + public string ShareDiscordUrl { get; set; } = string.Empty; public string ImprintUrl { get; set; } = string.Empty; public string ContactUrl { get; set; } = string.Empty; public string SponsorsUrl { get; set; } = string.Empty; @@ -216,6 +244,160 @@ public static class AdminSiteSettingsEndpoints return Results.Ok(ToOptionalFeatureSettingsResponse(settings)); } + private static async Task GetTrackingRules(AwardsDbContext db) + { + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + return Results.Ok(ToTrackingRulesResponse(settings)); + } + + private static async Task UpdateTrackingRules( + HttpContext context, + UpdateTrackingRulesRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService, + NominationTrackingReviewService trackingReviewService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + var before = ToTrackingRulesResponse(settings); + var applyResult = ApplyTrackingRulesRequest(settings, request); + if (applyResult is not null) + { + return applyResult; + } + + await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted); + + var after = ToTrackingRulesResponse(settings); + adminAuditService.AddEntry( + session.TwitchUserId, + "tracking-rules.update", + "site-settings", + settings.Id.ToString(), + "Tracking Rules wurden aktualisiert.", + new + { + sourceChanged = before.Source.BaseUrl != after.Source.BaseUrl, + manualReviewNotesChanged = before.ManualReviewNotes != after.ManualReviewNotes, + importantMetricCount = after.ImportantMetrics.Count(item => item.Enabled), + optionalMetricCount = after.OptionalMetrics.Count(item => item.Enabled), + flagCount = after.Flags.Count(item => item.Enabled), + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(after); + } + + private static async Task UpdateTrackingSource( + HttpContext context, + UpdateTrackingSourceRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService, + NominationTrackingReviewService trackingReviewService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage)) + { + return Results.BadRequest(new { message = errorMessage }); + } + + var before = ToTrackingRulesResponse(settings); + var rules = TrackingRulesSettings.Read(settings); + var updatedRules = rules with + { + Source = new TrackingSourceSetting( + TrackingRulesSettings.ProviderKey, + normalizedBaseUrl, + request.Source?.NotesSummary ?? rules.Source.NotesSummary, + request.Source?.ShowManualReviewNotesInReview ?? rules.Source.ShowManualReviewNotesInReview), + }; + + settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl; + settings.TrackingRulesJson = TrackingRulesSettings.Serialize(updatedRules); + + await trackingReviewService.ReevaluateAllAsync(TrackingRulesSettings.Read(settings), true, context.RequestAborted); + + var after = ToTrackingRulesResponse(settings); + adminAuditService.AddEntry( + session.TwitchUserId, + "tracking-source.update", + "site-settings", + settings.Id.ToString(), + "Tracking Source wurde aktualisiert.", + new + { + beforeBaseUrl = before.Source.BaseUrl, + afterBaseUrl = after.Source.BaseUrl, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(after); + } + + private static async Task UpdateTrackingReviewNotes( + HttpContext context, + UpdateTrackingReviewNotesRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var session = AdminEndpointConventions.CurrentSession(context); + var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + var beforeNotes = settings.TrackingReviewNotes ?? string.Empty; + var before = ToTrackingRulesResponse(settings); + var rules = TrackingRulesSettings.Read(settings); + settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim(); + settings.TrackingRulesJson = TrackingRulesSettings.Serialize(rules with + { + Source = rules.Source with + { + ShowManualReviewNotesInReview = request.ShowManualReviewNotesInReview, + }, + }); + + var after = ToTrackingRulesResponse(settings); + + adminAuditService.AddEntry( + session.TwitchUserId, + "tracking-review-notes.update", + "site-settings", + settings.Id.ToString(), + "Tracking Review Notes wurden aktualisiert.", + new + { + beforeLength = beforeNotes.Length, + afterLength = settings.TrackingReviewNotes.Length, + beforeShowInReview = before.Source.ShowManualReviewNotesInReview, + afterShowInReview = after.Source.ShowManualReviewNotesInReview, + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(after); + } + private static async Task UpdateOptionalFeatureSettings( HttpContext context, UpdateOptionalFeatureSettingsRequest request, @@ -230,6 +412,12 @@ public static class AdminSiteSettingsEndpoints } var before = ToOptionalFeatureSettingsResponse(settings); + var scheduleValidationError = ShowactApplicationSchedule.Validate(request.ShowactApplicationStartsAt, request.ShowactApplicationEndsAt); + if (scheduleValidationError is not null) + { + return Results.BadRequest(new { message = scheduleValidationError }); + } + var disabledMessage = NormalizeOptionalFeatureText( request.ClipSubmissionDisabledMessage, FallbackClipSubmissionDisabledMessage, @@ -244,6 +432,8 @@ public static class AdminSiteSettingsEndpoints settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible; settings.ClipSubmissionDisabledMessage = disabledMessage; settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled; + settings.ShowactApplicationStartsAt = request.ShowactApplicationStartsAt; + settings.ShowactApplicationEndsAt = request.ShowactApplicationEndsAt; settings.ShowactApplicationDisabledMessage = showactDisabledMessage; settings.SponsorsVisible = request.SponsorsVisible; @@ -275,6 +465,9 @@ public static class AdminSiteSettingsEndpoints ? FallbackClipSubmissionDisabledMessage : settings.ClipSubmissionDisabledMessage, settings.ShowactApplicationsEnabled, + settings.ShowactApplicationStartsAt, + settings.ShowactApplicationEndsAt, + ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow)), string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage) ? "Showact-Bewerbungen sind aktuell geschlossen." : settings.ShowactApplicationDisabledMessage, @@ -301,6 +494,8 @@ public static class AdminSiteSettingsEndpoints AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible); AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage); AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled); + AddOperationalChange(changes, "showactApplicationStartsAt", "Showact Start", before.ShowactApplicationStartsAt, after.ShowactApplicationStartsAt); + AddOperationalChange(changes, "showactApplicationEndsAt", "Showact Deadline", before.ShowactApplicationEndsAt, after.ShowactApplicationEndsAt); AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage); AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible); return changes.ToArray(); @@ -314,7 +509,7 @@ public static class AdminSiteSettingsEndpoints return Results.NotFound(); } - var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); + var usesDatabaseDemo = settings.DemoLoginManagedByDatabase; var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration); return Results.Ok(new AdminOperationalSettingsResponse( usesDatabaseDemo, @@ -329,6 +524,7 @@ public static class AdminSiteSettingsEndpoints twitchSettings.ClientSecretSet, twitchSettings.RedirectUri, twitchSettings.Scope, + UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours), settings.MaintenanceModeEnabled, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceMessage) @@ -365,6 +561,12 @@ public static class AdminSiteSettingsEndpoints var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty; var twitchRedirectUri = request.TwitchRedirectUri.Trim(); var twitchScope = request.TwitchScope.Trim(); + if (request.SessionIdleTimeoutHours < UserSessionService.MinimumIdleTimeoutHours) + { + return Results.BadRequest(new { message = $"Session-Timeout muss mindestens {UserSessionService.MinimumIdleTimeoutHours} Stunden betragen." }); + } + + var sessionIdleTimeoutHours = UserSessionService.NormalizeIdleTimeoutHours(request.SessionIdleTimeoutHours); var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret) || !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET")); @@ -424,6 +626,7 @@ public static class AdminSiteSettingsEndpoints settings.TwitchClientSecret, settings.TwitchRedirectUri, settings.TwitchScope); + settings.SessionIdleTimeoutHours = sessionIdleTimeoutHours; var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword) ? newPassword @@ -456,11 +659,12 @@ public static class AdminSiteSettingsEndpoints "operational-settings.update", "site-settings", settings.Id.ToString(), - "Demo-Zugang und Wartungsmodus wurden aktualisiert.", + "Demo-Zugang, Session-Timeout und Wartungsmodus wurden aktualisiert.", new { settings.DemoLoginEnabled, passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist), + settings.SessionIdleTimeoutHours, settings.MaintenanceModeEnabled, changes, }, @@ -555,6 +759,7 @@ public static class AdminSiteSettingsEndpoints HasEffectiveTwitchClientSecret(settings, configuration), settings.TwitchRedirectUri, settings.TwitchScope, + UserSessionService.NormalizeIdleTimeoutHours(settings.SessionIdleTimeoutHours), settings.MaintenanceModeEnabled, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage); @@ -576,6 +781,7 @@ public static class AdminSiteSettingsEndpoints AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId); AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri); AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope); + AddOperationalChange(changes, "sessionIdleTimeoutHours", "Session Inaktivitaet", before.SessionIdleTimeoutHours, after.SessionIdleTimeoutHours); if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged) { @@ -653,6 +859,7 @@ public static class AdminSiteSettingsEndpoints bool TwitchClientSecretSet, string TwitchRedirectUri, string TwitchScope, + int SessionIdleTimeoutHours, bool MaintenanceModeEnabled, string MaintenanceTitle, string MaintenanceMessage); @@ -663,4 +870,136 @@ public static class AdminSiteSettingsEndpoints string RedirectUri, string Scope, bool Configured); + + private static IResult? ApplyTrackingRulesRequest(SiteSettings settings, UpdateTrackingRulesRequest request) + { + if (!TryNormalizeTrackingSourceUrl(request.Source?.BaseUrl, out var normalizedBaseUrl, out var errorMessage)) + { + return Results.BadRequest(new { message = errorMessage }); + } + + var currentRules = TrackingRulesSettings.Read(settings); + var configuration = new TrackingRulesConfiguration( + new TrackingSourceSetting( + TrackingRulesSettings.ProviderKey, + normalizedBaseUrl, + request.Source?.NotesSummary ?? currentRules.Source.NotesSummary, + request.Source?.ShowManualReviewNotesInReview ?? currentRules.Source.ShowManualReviewNotesInReview), + (request.ImportantMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(), + (request.OptionalMetrics ?? []).Select(ToTrackingMetricRuleSetting).ToArray(), + (request.Flags ?? []).Select(ToTrackingFlagRuleSetting).ToArray()); + + settings.TrackingRulesJson = TrackingRulesSettings.Serialize(configuration); + settings.ViewerStatsProviderBaseUrl = normalizedBaseUrl; + settings.TrackingReviewNotes = (request.ManualReviewNotes ?? string.Empty).Trim(); + return null; + } + + private static AdminTrackingRulesResponse ToTrackingRulesResponse(SiteSettings settings) + { + var rules = TrackingRulesSettings.Read(settings); + return new AdminTrackingRulesResponse( + new AdminTrackingSourceDto( + rules.Source.ProviderKey, + "TwitchTracker Basic API", + TrackingRulesSettings.NormalizeBaseUrl(settings.ViewerStatsProviderBaseUrl), + rules.Source.NotesSummary, + rules.Source.ShowManualReviewNotesInReview), + rules.ImportantMetrics.Select(ToTrackingMetricRuleDto).ToArray(), + rules.OptionalMetrics.Select(ToTrackingMetricRuleDto).ToArray(), + rules.Flags.Select(ToTrackingFlagRuleDto).ToArray(), + settings.TrackingReviewNotes ?? string.Empty); + } + + private static AdminTrackingMetricRuleDto ToTrackingMetricRuleDto(TrackingMetricRuleSetting rule) => + new( + rule.Key, + rule.Label, + rule.Enabled, + rule.SourceSupport, + rule.Description, + rule.RequiredForAutoClassification, + rule.ShowInReview, + rule.ShowInAdminSummary, + rule.ManualOverrideAllowed, + rule.WindowKey, + rule.AutoSupportedWindowKeys, + rule.ProviderFieldKey, + rule.TopCount, + rule.MinPrimaryCategorySharePercent, + rule.MinPrimaryCategoryHours, + rule.MaxDistinctCategoriesBeforeFlag, + rule.IgnoredCategories, + rule.MatchAwardCategoryAgainstTopCategories, + rule.FlagIfAwardCategoryNotInTopX, + rule.FlagIfCategorySpreadTooWide, + rule.FlagIfNoCategoryContextAvailable, + rule.MinValue, + rule.MaxValue); + + private static TrackingMetricRuleSetting ToTrackingMetricRuleSetting(AdminTrackingMetricRuleDto rule) => + new( + rule.Key, + rule.Label, + rule.Enabled, + rule.SourceSupport, + rule.Description, + rule.RequiredForAutoClassification, + rule.ShowInReview, + rule.ShowInAdminSummary, + rule.ManualOverrideAllowed, + TrackingRulesSettings.NormalizeWindowKey(rule.WindowKey, TrackingRulesSettings.Window30d), + (rule.AutoSupportedWindowKeys ?? []).Select(item => TrackingRulesSettings.NormalizeWindowKey(item, TrackingRulesSettings.Window30d)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + string.IsNullOrWhiteSpace(rule.ProviderFieldKey) ? null : rule.ProviderFieldKey.Trim(), + rule.TopCount, + rule.MinPrimaryCategorySharePercent, + rule.MinPrimaryCategoryHours, + rule.MaxDistinctCategoriesBeforeFlag, + (rule.IgnoredCategories ?? []).Select(item => item.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + rule.MatchAwardCategoryAgainstTopCategories, + rule.FlagIfAwardCategoryNotInTopX, + rule.FlagIfCategorySpreadTooWide, + rule.FlagIfNoCategoryContextAvailable, + rule.MinValue, + rule.MaxValue); + + private static AdminTrackingFlagRuleDto ToTrackingFlagRuleDto(TrackingFlagRuleSetting rule) => + new( + rule.Key, + rule.Label, + rule.Enabled, + rule.Severity, + rule.Description, + rule.AutoTriggerEnabled, + rule.RequiresManualReview, + rule.BlocksApproval, + rule.AdminNoteRequiredOnOverride); + + private static TrackingFlagRuleSetting ToTrackingFlagRuleSetting(AdminTrackingFlagRuleDto rule) => + new( + rule.Key, + rule.Label, + rule.Enabled, + rule.Severity, + rule.Description, + rule.AutoTriggerEnabled, + rule.RequiresManualReview, + rule.BlocksApproval, + rule.AdminNoteRequiredOnOverride); + + private static bool TryNormalizeTrackingSourceUrl(string? rawValue, out string normalizedValue, out string errorMessage) + { + normalizedValue = string.Empty; + errorMessage = string.Empty; + var trimmed = (rawValue ?? string.Empty).Trim(); + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + errorMessage = "Tracking Source URL muss eine absolute http/https-URL sein."; + return false; + } + + normalizedValue = uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); + return true; + } } diff --git a/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs b/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs index 9a4c86f..b9401fb 100644 --- a/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs +++ b/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs @@ -8,33 +8,41 @@ namespace Backend.Endpoints; public static partial class AdminSeasonManagementEndpoints { - private static async Task GetWorkflowRules(AwardsDbContext db) + private static async Task GetWorkflowRules(int seasonId, AwardsDbContext db) { - var settings = await db.SiteSettings + var season = await db.Seasons .AsNoTracking() - .FirstOrDefaultAsync(item => item.Id == 1); - if (settings is null) + .FirstOrDefaultAsync(item => item.Id == seasonId); + if (season is null) { return Results.NotFound(); } - return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray())); + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1); + + return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(season, settings).Select(ToWorkflowRuleDto).ToArray())); } private static async Task UpdateWorkflowRules( HttpContext context, + int seasonId, UpdateWorkflowRulesRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); - var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); - if (settings is null) + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId, context.RequestAborted); + if (season is null) { return Results.NotFound(); } - var before = WorkflowRuleSettings.Read(settings); + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); + var before = WorkflowRuleSettings.Read(season, settings); var mergedRules = WorkflowRuleSettings.Defaults .Select(defaultRule => { @@ -51,8 +59,8 @@ public static partial class AdminSeasonManagementEndpoints }) .ToArray(); - settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules); - var after = WorkflowRuleSettings.Read(settings); + season.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules); + var after = WorkflowRuleSettings.Read(season, settings); var changes = after .Select(rule => { @@ -72,10 +80,10 @@ public static partial class AdminSeasonManagementEndpoints adminAuditService.AddEntry( session.TwitchUserId, "workflow-rules.update", - "site-settings", - settings.Id.ToString(), - "Workflow-Regeln wurden aktualisiert.", - new { changes }, + "season", + season.Id.ToString(), + $"Workflow-Regeln fuer Season {season.Year} wurden aktualisiert.", + new { seasonId = season.Id, season.Year, changes }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); diff --git a/Backend/Endpoints/AuthDemoLoginEndpoints.cs b/Backend/Endpoints/AuthDemoLoginEndpoints.cs index 3eef4e8..f82144d 100644 --- a/Backend/Endpoints/AuthDemoLoginEndpoints.cs +++ b/Backend/Endpoints/AuthDemoLoginEndpoints.cs @@ -12,6 +12,7 @@ public static partial class AuthEndpoints { private static async Task DemoLogin( HttpContext context, + IHostEnvironment environment, AwardsDbContext db, IConfiguration configuration, DemoLoginRequest request, @@ -23,11 +24,16 @@ public static partial class AuthEndpoints var password = request.Password ?? string.Empty; var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); var databaseDemoConfigured = settings is not null - && (settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings)); + && settings.DemoLoginManagedByDatabase; string twitchUserId; string displayName; bool credentialsMatch; + var fallbackConfiguredLogin = ReadDemoLoginIdentifier(configuration); + var fallbackConfiguredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL"); + var fallbackConfiguredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD"); + var fallbackConfiguredTwitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"); + var fallbackConfiguredDisplayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"); if (databaseDemoConfigured && settings is not null) { @@ -53,6 +59,25 @@ public static partial class AuthEndpoints && DemoCredentialHasher.VerifyPassword(password, settings.DemoLoginPasswordHash, settings.DemoLoginPasswordSalt); twitchUserId = settings.DemoLoginTwitchUserId.Trim(); displayName = settings.DemoLoginDisplayName.Trim(); + + if (!credentialsMatch + && environment.IsDevelopment() + && IsDemoLoginEnabled(configuration) + && !string.IsNullOrWhiteSpace(fallbackConfiguredLogin) + && !string.IsNullOrWhiteSpace(fallbackConfiguredPassword) + && !string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId) + && !string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName)) + { + credentialsMatch = LoginMatchesIdentifier( + login, + fallbackConfiguredLogin, + fallbackConfiguredEmail, + fallbackConfiguredTwitchUserId, + fallbackConfiguredDisplayName) + && DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword); + twitchUserId = fallbackConfiguredTwitchUserId.Trim(); + displayName = fallbackConfiguredDisplayName.Trim(); + } } else { @@ -61,16 +86,10 @@ public static partial class AuthEndpoints return Results.NotFound(); } - var configuredLogin = ReadDemoLoginIdentifier(configuration); - var configuredEmail = ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL"); - var configuredPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD"); - twitchUserId = ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"); - displayName = ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"); - - if (string.IsNullOrWhiteSpace(configuredLogin) - || string.IsNullOrWhiteSpace(configuredPassword) - || string.IsNullOrWhiteSpace(twitchUserId) - || string.IsNullOrWhiteSpace(displayName)) + if (string.IsNullOrWhiteSpace(fallbackConfiguredLogin) + || string.IsNullOrWhiteSpace(fallbackConfiguredPassword) + || string.IsNullOrWhiteSpace(fallbackConfiguredTwitchUserId) + || string.IsNullOrWhiteSpace(fallbackConfiguredDisplayName)) { return Results.Json( new { message = "Demo login is not fully configured." }, @@ -79,13 +98,13 @@ public static partial class AuthEndpoints credentialsMatch = LoginMatchesIdentifier( login, - configuredLogin, - configuredEmail, - twitchUserId, - displayName) - && DemoCredentialHasher.FixedTimePlainTextEquals(password, configuredPassword); - twitchUserId = twitchUserId.Trim(); - displayName = displayName.Trim(); + fallbackConfiguredLogin, + fallbackConfiguredEmail, + fallbackConfiguredTwitchUserId, + fallbackConfiguredDisplayName) + && DemoCredentialHasher.FixedTimePlainTextEquals(password, fallbackConfiguredPassword); + twitchUserId = fallbackConfiguredTwitchUserId.Trim(); + displayName = fallbackConfiguredDisplayName.Trim(); } if (!credentialsMatch) @@ -137,7 +156,7 @@ public static partial class AuthEndpoints await db.SaveChangesAsync(context.RequestAborted); } - return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted)); + return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted)); } private static bool IsDemoLoginEnabled(IConfiguration configuration) diff --git a/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs b/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs index 4777183..65fd579 100644 --- a/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs +++ b/Backend/Endpoints/AuthDevelopmentLoginEndpoints.cs @@ -96,6 +96,6 @@ public static partial class AuthEndpoints await db.SaveChangesAsync(context.RequestAborted); } - return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted)); + return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted)); } } diff --git a/Backend/Endpoints/AuthSessionEndpoints.cs b/Backend/Endpoints/AuthSessionEndpoints.cs index a9e7e07..75b9e3f 100644 --- a/Backend/Endpoints/AuthSessionEndpoints.cs +++ b/Backend/Endpoints/AuthSessionEndpoints.cs @@ -19,7 +19,7 @@ public static partial class AuthEndpoints return Results.Unauthorized(); } - return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted)); + return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted)); } private static async Task Logout(HttpContext context, IUserSessionService userSessionService) @@ -36,6 +36,7 @@ public static partial class AuthEndpoints private static async Task ToAuthSessionDtoAsync( AwardsDbContext db, + IUserSessionService userSessionService, UserSession session, bool mustChangePassword = false, CancellationToken cancellationToken = default) @@ -43,12 +44,14 @@ public static partial class AuthEndpoints var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken); var sessionRole = teamMember?.Role ?? session.Role; var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken); + var sessionIdleTimeoutHours = await userSessionService.GetIdleTimeoutHoursAsync(cancellationToken); return new( session.SessionToken, session.TwitchUserId, teamMember?.DisplayName ?? session.DisplayName, AdminRoles.Normalize(sessionRole), permissionKeys, + sessionIdleTimeoutHours, teamMember?.MustChangePassword ?? mustChangePassword, teamMember?.Login, teamMember?.BoundTwitchUserId, diff --git a/Backend/Endpoints/AuthTeamLoginEndpoints.cs b/Backend/Endpoints/AuthTeamLoginEndpoints.cs index a5b0550..fe03b91 100644 --- a/Backend/Endpoints/AuthTeamLoginEndpoints.cs +++ b/Backend/Endpoints/AuthTeamLoginEndpoints.cs @@ -44,7 +44,7 @@ public static partial class AuthEndpoints context.RequestAborted); await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted)); + return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, member.MustChangePassword, context.RequestAborted)); } private static async Task ChangePassword( @@ -92,7 +92,7 @@ public static partial class AuthEndpoints session.Role = AdminRoles.Normalize(member.Role); await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted)); + return Results.Ok(await ToAuthSessionDtoAsync(db, userSessionService, session, false, context.RequestAborted)); } private static string BuildTeamSessionId(string login) => diff --git a/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs b/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs index fd168fc..4859f9c 100644 --- a/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs +++ b/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs @@ -235,7 +235,7 @@ public static partial class AuthEndpoints return Results.Ok(new TwitchBindingDisconnectResponse( false, false, - await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); + await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted))); } var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId); @@ -265,7 +265,7 @@ public static partial class AuthEndpoints currentSessionUsesBoundTwitch, currentSessionUsesBoundTwitch ? null - : await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); + : await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted))); } private static async Task CompleteTwitchTeamLoginAsync( diff --git a/Backend/Endpoints/PublicExtrasEndpoints.cs b/Backend/Endpoints/PublicExtrasEndpoints.cs index f94f9c7..0d80527 100644 --- a/Backend/Endpoints/PublicExtrasEndpoints.cs +++ b/Backend/Endpoints/PublicExtrasEndpoints.cs @@ -1,3 +1,6 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Backend.Common; using Backend.Contracts; using Backend.Data; @@ -8,6 +11,8 @@ namespace Backend.Endpoints; public static partial class PublicEndpoints { + private static readonly Regex PublicEmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled); + private static async Task GetSponsors(int year, AwardsDbContext db) { var season = await db.Seasons @@ -45,13 +50,19 @@ public static partial class PublicEndpoints return Results.Ok(new PublicSponsorsResponse(year, sponsors)); } + private static readonly JsonSerializerOptions ShowactJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + private static async Task CreateShowactApplication( HttpContext context, CreateShowactApplicationRequest request, AwardsDbContext db) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); - if (settings is null || !settings.ShowactApplicationsEnabled) + if (settings is null || !ShowactApplicationSchedule.IsOpenNow(settings, DateOnly.FromDateTime(DateTime.UtcNow))) { return Results.BadRequest(new { @@ -67,55 +78,137 @@ public static partial class PublicEndpoints return Results.NotFound(new { message = "Aktuell ist kein Award-Jahr aktiv." }); } - var artistName = NormalizePublicText(request.ArtistName, 120); - var contactEmail = NormalizePublicText(request.ContactEmail, 180); - var contactDiscord = NormalizePublicText(request.ContactDiscord, 120); - var performanceType = NormalizePublicText(request.PerformanceType, 80); - var description = NormalizePublicText(request.Description, 1000); - var platformUrl = NormalizePublicText(request.PlatformUrl, 500); - var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500); - - if (string.IsNullOrWhiteSpace(artistName)) + if (!IsBlankOrValidJsonObject(request.FieldResponsesJson)) { - return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." }); + return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." }); } - if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord)) + var schema = ParseShowactSchema(settings.ShowactFormSchemaJson); + var hasDynamicForm = schema.Count > 0; + + if (hasDynamicForm) { - return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." }); + Dictionary responses; + try + { + responses = string.IsNullOrWhiteSpace(request.FieldResponsesJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(request.FieldResponsesJson, ShowactJsonOptions) ?? new(); + } + catch (JsonException) + { + return Results.BadRequest(new { message = "Formulardaten konnten nicht gelesen werden." }); + } + + MergeLegacyShowactFieldsIntoResponses(schema, responses, request); + + var dynamicValidationError = ValidateShowactResponses(schema, responses); + if (dynamicValidationError is not null) + { + return Results.BadRequest(new { message = dynamicValidationError }); + } + + var artistNameField = schema.FirstOrDefault(f => f.IsArtistName); + var artistName = artistNameField is not null && responses.TryGetValue(artistNameField.Id, out var name) ? name.Trim() : "Unbekannt"; + var contactEmail = NormalizePublicText(request.ContactEmail, 180); + var contactDiscord = NormalizePublicText(request.ContactDiscord, 120); + var performanceType = NormalizePublicText(request.PerformanceType, 80); + var description = NormalizePublicText(request.Description, 1000); + var technicalNotes = NormalizePublicText(request.TechnicalNotes, 1000); + var platformUrl = NormalizePublicText(request.PlatformUrl, 500); + var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500); + var fieldResponsesJson = JsonSerializer.Serialize(responses, ShowactJsonOptions); + + var metadata = RequestMetadataReader.Read(context); + var application = new ShowactApplication + { + SeasonId = season.Id, + ArtistName = artistName[..Math.Min(artistName.Length, 120)], + ContactEmail = contactEmail, + ContactDiscord = contactDiscord, + PlatformUrl = platformUrl, + PerformanceType = performanceType, + Description = description, + TechnicalNotes = technicalNotes, + ReferenceUrl = referenceUrl, + FieldResponsesJson = fieldResponsesJson, + Status = "pending", + CreatedFromIp = metadata.ClientIp, + UserAgent = metadata.UserAgent, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.ShowactApplications.Add(application); + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, applicationId = application.Id }); } - - if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description)) + else { - return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." }); + // Legacy fixed-fields path + var artistName = NormalizePublicText(request.ArtistName, 120); + var contactEmail = NormalizePublicText(request.ContactEmail, 180); + var contactDiscord = NormalizePublicText(request.ContactDiscord, 120); + var performanceType = NormalizePublicText(request.PerformanceType, 80); + var description = NormalizePublicText(request.Description, 1000); + var platformUrl = NormalizePublicText(request.PlatformUrl, 500); + var referenceUrl = NormalizePublicText(request.ReferenceUrl, 500); + + if (string.IsNullOrWhiteSpace(artistName)) + { + return Results.BadRequest(new { message = "Kuenstlername ist erforderlich." }); + } + + if (string.IsNullOrWhiteSpace(contactEmail) && string.IsNullOrWhiteSpace(contactDiscord)) + { + return Results.BadRequest(new { message = "Bitte gib mindestens E-Mail oder Discord als Kontakt an." }); + } + + if (!IsBlankOrValidEmail(contactEmail)) + { + return Results.BadRequest(new { message = "E-Mail muss eine gueltige E-Mail-Adresse sein." }); + } + + if (string.IsNullOrWhiteSpace(performanceType) || string.IsNullOrWhiteSpace(description)) + { + return Results.BadRequest(new { message = "Art des Showacts und Beschreibung sind erforderlich." }); + } + + if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl)) + { + return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." }); + } + + var metadata = RequestMetadataReader.Read(context); + var application = new ShowactApplication + { + SeasonId = season.Id, + ArtistName = artistName, + ContactEmail = contactEmail, + ContactDiscord = contactDiscord, + PlatformUrl = platformUrl, + PerformanceType = performanceType, + Description = description, + TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000), + ReferenceUrl = referenceUrl, + Status = "pending", + CreatedFromIp = metadata.ClientIp, + UserAgent = metadata.UserAgent, + CreatedAt = DateTimeOffset.UtcNow, + }; + + db.ShowactApplications.Add(application); + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, applicationId = application.Id }); } + } - if (!IsBlankOrHttpUrl(platformUrl) || !IsBlankOrHttpUrl(referenceUrl)) - { - return Results.BadRequest(new { message = "Links muessen gueltige http(s)-URLs sein." }); - } - - var metadata = RequestMetadataReader.Read(context); - var application = new ShowactApplication - { - SeasonId = season.Id, - ArtistName = artistName, - ContactEmail = contactEmail, - ContactDiscord = contactDiscord, - PlatformUrl = platformUrl, - PerformanceType = performanceType, - Description = description, - TechnicalNotes = NormalizePublicText(request.TechnicalNotes, 1000), - ReferenceUrl = referenceUrl, - Status = "pending", - CreatedFromIp = metadata.ClientIp, - UserAgent = metadata.UserAgent, - CreatedAt = DateTimeOffset.UtcNow, - }; - - db.ShowactApplications.Add(application); - await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(new { saved = true, applicationId = application.Id }); + private sealed class ShowactFieldDefinition + { + [JsonPropertyName("id")] public string Id { get; set; } = ""; + [JsonPropertyName("type")] public string Type { get; set; } = ""; + [JsonPropertyName("label")] public string Label { get; set; } = ""; + [JsonPropertyName("required")] public bool Required { get; set; } + [JsonPropertyName("isArtistName")] public bool IsArtistName { get; set; } + [JsonPropertyName("maxLength")] public int MaxLength { get; set; } } private static string NormalizePublicText(string? value, int maxLength) @@ -128,4 +221,167 @@ public static partial class PublicEndpoints string.IsNullOrWhiteSpace(value) || (Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)); + + private static bool IsBlankOrValidEmail(string value) => + string.IsNullOrWhiteSpace(value) || PublicEmailPattern.IsMatch(value); + + private static bool IsBlankOrValidJsonObject(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static List ParseShowactSchema(string? schemaJson) + { + if (string.IsNullOrWhiteSpace(schemaJson) || schemaJson == "[]") + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(schemaJson, ShowactJsonOptions)? + .Where(field => !string.IsNullOrWhiteSpace(field.Id)) + .ToList() ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string? ValidateShowactResponses( + IReadOnlyCollection schema, + IDictionary responses) + { + foreach (var field in schema) + { + var value = responses.TryGetValue(field.Id, out var rawValue) + ? NormalizePublicText(rawValue, ResolveShowactMaxLength(field)) + : string.Empty; + responses[field.Id] = value; + + if (field.Required && string.IsNullOrWhiteSpace(value)) + { + return string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase) + ? $"Bitte bestaetige: {field.Label}" + : $"{field.Label} ist erforderlich."; + } + + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + if (string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase) && !PublicEmailPattern.IsMatch(value)) + { + return $"{field.Label} muss eine gueltige E-Mail-Adresse sein."; + } + + if (string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && !IsBlankOrHttpUrl(value)) + { + return $"{field.Label} muss ein gueltiger http(s)-Link sein."; + } + } + + return null; + } + + private static int ResolveShowactMaxLength(ShowactFieldDefinition field) + { + if (field.MaxLength > 0) + { + return Math.Min(field.MaxLength, 2000); + } + + return string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) ? 1000 : 500; + } + + private static void MergeLegacyShowactFieldsIntoResponses( + IReadOnlyCollection schema, + IDictionary responses, + CreateShowactApplicationRequest request) + { + var artistField = schema.FirstOrDefault(field => field.IsArtistName); + MergeResponseValue(artistField, request.ArtistName, responses, 120); + + var emailField = schema.FirstOrDefault(field => string.Equals(field.Type, "email", StringComparison.OrdinalIgnoreCase)); + MergeResponseValue(emailField, request.ContactEmail, responses, 180); + + var discordField = schema.FirstOrDefault(field => ContainsAny(field, "discord")); + MergeResponseValue(discordField, request.ContactDiscord, responses, 120); + + var platformField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "platform", "kanal", "profil", "channel")); + MergeResponseValue(platformField, request.PlatformUrl, responses, 500); + + var referenceField = schema.FirstOrDefault(field => string.Equals(field.Type, "url", StringComparison.OrdinalIgnoreCase) && ContainsAny(field, "referenz", "reference")); + MergeResponseValue(referenceField, request.ReferenceUrl, responses, 500); + + var performanceField = schema.FirstOrDefault(field => + ContainsAnyId(field, "performance_type", "showact_type", "show_type", "showact_roles", "roles") + || ContainsAnyLabel(field, "performance", "showact-art", "showact art", "art des showacts", "wofür möchtest", "wofuer moechtest", "bewerben")); + MergeResponseValue(performanceField, request.PerformanceType, responses, 80); + + var descriptionField = schema.FirstOrDefault(field => + string.Equals(field.Type, "textarea", StringComparison.OrdinalIgnoreCase) + && (ContainsAnyId(field, "description", "beschreibung", "show_description") + || ContainsAnyLabel(field, "beschreibung", "idee", "was moechtest", "was möchtest", "zeigen"))); + MergeResponseValue(descriptionField, request.Description, responses, 1000); + + var technicalNotesField = schema.FirstOrDefault(field => ContainsAny(field, "technical_notes", "technik", "technical", "setup", "timing")); + MergeResponseValue(technicalNotesField, request.TechnicalNotes, responses, 1000); + } + + private static void MergeResponseValue( + ShowactFieldDefinition? field, + string? requestValue, + IDictionary responses, + int maxLength) + { + if (field is null) + { + return; + } + + if (responses.TryGetValue(field.Id, out var existingValue) && !string.IsNullOrWhiteSpace(existingValue)) + { + return; + } + + var normalized = NormalizePublicText(requestValue, maxLength); + if (!string.IsNullOrWhiteSpace(normalized)) + { + responses[field.Id] = normalized; + } + } + + private static bool ContainsAny(ShowactFieldDefinition field, params string[] needles) + { + var haystack = $"{field.Id} {field.Label}".ToLowerInvariant(); + return needles.Any(haystack.Contains); + } + + private static bool ContainsAnyId(ShowactFieldDefinition field, params string[] needles) + { + var haystack = field.Id.ToLowerInvariant(); + return needles.Any(haystack.Contains); + } + + private static bool ContainsAnyLabel(ShowactFieldDefinition field, params string[] needles) + { + var haystack = field.Label.ToLowerInvariant(); + return needles.Any(haystack.Contains); + } } diff --git a/Backend/Endpoints/PublicNominationEndpoints.cs b/Backend/Endpoints/PublicNominationEndpoints.cs index ece3bc5..cbb5206 100644 --- a/Backend/Endpoints/PublicNominationEndpoints.cs +++ b/Backend/Endpoints/PublicNominationEndpoints.cs @@ -15,13 +15,14 @@ public static partial class PublicEndpoints AwardsDbContext db, IUserSessionService userSessionService, IRiskFlagService riskFlagService, - IRiskRuleService riskRuleService) + IRiskRuleService riskRuleService, + NominationEnrichmentService nominationEnrichmentService) { var submittedNominations = NormalizeSubmittedNominations(request); - if (submittedNominations.Length is 0 or > 3) + if (submittedNominations.Length == 0) { - return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." }); + return Results.BadRequest(new { message = "A nomination request must include at least one stream link." }); } if (submittedNominations.Any(item => item.Name is { Length: > 120 })) @@ -35,7 +36,7 @@ public static partial class PublicEndpoints } var distinctStreamUrls = submittedNominations - .Select(item => item.StreamUrl) + .Select(item => NormalizeNominationUrlForCompare(item.StreamUrl)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -65,16 +66,37 @@ public static partial class PublicEndpoints return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." }); } - var category = await db.Categories - .Include(item => item.Season) - .FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year); + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year); - if (category is null) + if (season is null) { - return Results.BadRequest(new { message = "The selected category does not exist for this season." }); + return Results.BadRequest(new { message = "The selected season does not exist." }); } - var nominationSeasonResolution = EnsurePublicWriteSeason(category.Season, "nomination"); + var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted); + if (string.IsNullOrWhiteSpace(categoryGroupName)) + { + return Results.BadRequest(new { message = "The selected category group does not exist for this season." }); + } + + var groupCategories = await db.Categories + .Where(item => item.SeasonId == season.Id && item.GroupName == categoryGroupName) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .ToArrayAsync(context.RequestAborted); + + if (groupCategories.Length == 0) + { + return Results.BadRequest(new { message = "The selected category group does not exist for this season." }); + } + + var maxNomineesPerUser = ResolveMaxNomineesPerUser(groupCategories); + if (submittedNominations.Length > maxNomineesPerUser) + { + return Results.BadRequest(new { message = $"Pro Kategorie sind maximal {maxNomineesPerUser} Links erlaubt." }); + } + + var nominationSeasonResolution = EnsurePublicWriteSeason(season, "nomination"); if (nominationSeasonResolution.Result is not null) { return nominationSeasonResolution.Result; @@ -89,15 +111,16 @@ public static partial class PublicEndpoints var submitterId = submitterIdResult.SubmitterId!; var requestMetadata = RequestMetadataReader.Read(context); var existingNominationCount = await db.Nominations.CountAsync(item => - item.SeasonId == category.SeasonId - && item.CategoryId == category.Id + item.SeasonId == season.Id + && item.CategoryGroupName == categoryGroupName && item.SubmittedByTwitchId == submitterId && item.Status == "pending"); var records = submittedNominations.Select(nomination => new Nomination { - SeasonId = category.SeasonId, - CategoryId = category.Id, + SeasonId = season.Id, + CategoryId = null, + CategoryGroupName = categoryGroupName, SubmittedByTwitchId = submitterId, CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name, StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl, @@ -106,6 +129,11 @@ public static partial class PublicEndpoints CreatedAt = DateTimeOffset.UtcNow, }).ToArray(); + foreach (var record in records) + { + await nominationEnrichmentService.EnrichAsync(record, groupCategories, context.RequestAborted); + } + await db.Nominations.AddRangeAsync(records); var resubmittedNominationRule = await riskRuleService.GetRuleAsync("resubmitted_nomination", context.RequestAborted); @@ -126,21 +154,21 @@ public static partial class PublicEndpoints if (existingNominationCount > 0 && resubmittedNominationRule.Enabled) { await riskFlagService.AddIfMissingAsync( - category.SeasonId, + season.Id, submitterId, "nomination", "resubmitted_nomination", resubmittedNominationRule.Severity, - "Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.", + "Ein User hat seine Nominierung in derselben Hauptkategorie erneut eingereicht.", requestMetadata, - new { categoryId = category.Id, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } }, + new { categoryGroupName, existingNominationCount, nominationIds = records.Select(item => item.Id).ToArray(), entityLinks = new[] { reviewLink } }, context.RequestAborted); } if (rapidNominationBurstRule.Enabled && recentNominationVolume >= rapidNominationBurstRule.Threshold) { await riskFlagService.AddIfMissingAsync( - category.SeasonId, + season.Id, submitterId, "nomination", "rapid_nomination_burst", @@ -152,7 +180,7 @@ public static partial class PublicEndpoints } await db.SaveChangesAsync(context.RequestAborted); - return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 }); + return Results.Ok(new { saved = submittedNominations.Length, categoryGroupName, collectedSignal = existingNominationCount > 0 }); } private readonly record struct SubmittedNomination(string? Name, string StreamUrl); @@ -191,4 +219,44 @@ public static partial class PublicEndpoints .Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl)) .ToArray(); } + + private static string NormalizeNominationUrlForCompare(string value) => + value.Trim().TrimEnd('/').ToLowerInvariant(); + + private static int ResolveMaxNomineesPerUser(IEnumerable groupCategories) + { + var configuredLimit = groupCategories + .Select(item => item.MaxNomineesPerUser) + .Where(value => value > 0) + .DefaultIfEmpty(3) + .Max(); + + return Math.Clamp(configuredLimit, 1, 10); + } + + private static async Task ResolveCategoryGroupNameAsync( + AwardsDbContext db, + int seasonId, + CreateNominationRequest request, + CancellationToken cancellationToken) + { + var categoryGroupName = request.CategoryGroupName?.Trim(); + if (!string.IsNullOrWhiteSpace(categoryGroupName)) + { + return await db.Categories + .Where(item => item.SeasonId == seasonId && item.GroupName == categoryGroupName) + .Select(item => item.GroupName) + .FirstOrDefaultAsync(cancellationToken); + } + + if (!request.CategoryId.HasValue) + { + return null; + } + + return await db.Categories + .Where(item => item.SeasonId == seasonId && item.Id == request.CategoryId.Value) + .Select(item => item.GroupName) + .FirstOrDefaultAsync(cancellationToken); + } } diff --git a/Backend/Endpoints/PublicOverviewEndpoints.cs b/Backend/Endpoints/PublicOverviewEndpoints.cs index 526de06..a17689e 100644 --- a/Backend/Endpoints/PublicOverviewEndpoints.cs +++ b/Backend/Endpoints/PublicOverviewEndpoints.cs @@ -26,12 +26,18 @@ public static partial class PublicEndpoints { return Results.Problem("Site settings are missing."); } + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var showactApplicationsOpenNow = ShowactApplicationSchedule.IsOpenNow(siteSettings, today); + + var canExposeCurrentSeasonWinners = CanExposeCurrentSeasonWinners(season.CurrentPhase); var winnerPreviewRows = await db.Results .AsNoTracking() .Include(result => result.Season) .Include(result => result.Candidate) - .Where(result => result.Season.Year < season.Year) + .Where(result => + result.Season.Year < season.Year + || canExposeCurrentSeasonWinners && result.Season.Year == season.Year) .OrderByDescending(result => result.Season.Year) .ThenBy(result => result.CategoryName) .Take(8) @@ -65,7 +71,9 @@ public static partial class PublicEndpoints var archiveYearRows = await db.Results .AsNoTracking() - .Where(result => result.Season.Year < season.Year) + .Where(result => + result.Season.Year < season.Year + || canExposeCurrentSeasonWinners && result.Season.Year == season.Year) .GroupBy(result => result.Season.Year) .Select(group => new { @@ -83,6 +91,33 @@ public static partial class PublicEndpoints var publicCategories = season.Categories .Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count)) .ToArray(); + var featuredCategories = publicCategories + .GroupBy(category => category.GroupName.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var ordered = group + .OrderBy(category => category.SortOrder) + .ThenBy(category => category.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var first = ordered[0]; + var maxNomineesPerUser = ordered + .Select(category => category.MaxNomineesPerUser) + .Where(value => value > 0) + .DefaultIfEmpty(3) + .Max(); + + return new FeaturedCategoryDto( + first.Id, + first.GroupName, + first.GroupName, + first.Description, + maxNomineesPerUser); + }) + .OrderBy(category => publicCategories + .Where(item => string.Equals(item.GroupName, category.GroupName, StringComparison.OrdinalIgnoreCase)) + .Min(item => item.SortOrder)) + .ThenBy(category => category.GroupName, StringComparer.OrdinalIgnoreCase) + .ToArray(); var response = new OverviewResponse( season.Id, season.Year, @@ -100,20 +135,15 @@ public static partial class PublicEndpoints new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)), new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)), }, - publicCategories - .Select(category => new FeaturedCategoryDto( - category.Id, - category.GroupName, - category.Name, - category.Description, - category.MaxNomineesPerUser)) - .ToArray(), + featuredCategories, winnerPreviewItems, archiveYears, new PublicSiteContentDto( siteSettings.HostDisplayName, siteSettings.HostTagline, siteSettings.NewsletterUrl, + siteSettings.ShareXUrl, + siteSettings.ShareDiscordUrl, siteSettings.PrivacyEmail, siteSettings.PrivacyPolicyContent, SeasonMappings.ReadSocialLinks(siteSettings), @@ -124,11 +154,14 @@ public static partial class PublicEndpoints string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage) ? "Clip-Einreichungen sind aktuell geschlossen." : siteSettings.ClipSubmissionDisabledMessage, - siteSettings.ShowactApplicationsEnabled, + showactApplicationsOpenNow, + siteSettings.ShowactApplicationStartsAt, + siteSettings.ShowactApplicationEndsAt, string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage) ? "Showact-Bewerbungen sind aktuell geschlossen." : siteSettings.ShowactApplicationDisabledMessage, - siteSettings.SponsorsVisible), + siteSettings.SponsorsVisible, + siteSettings.ShowactFormSchemaJson ?? "[]"), SeasonMappings.ReadFaqItems(siteSettings)); return Results.Ok(response); diff --git a/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs b/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs index 723e6fa..9fe173d 100644 --- a/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs +++ b/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs @@ -1,6 +1,7 @@ using Backend.Contracts; using Backend.Data; using Backend.Common; +using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; @@ -21,7 +22,9 @@ public static partial class PublicEndpoints } var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase); + var subcategoryTemplates = SeasonSubcategoryTemplateSettings.Read(season, season.Categories); var publicCategories = season.Categories + .Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(category, subcategoryTemplates)) .Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count)) .ToArray(); var publicCategoryIds = publicCategories.Select(category => category.Id).ToArray(); @@ -54,6 +57,8 @@ public static partial class PublicEndpoints category.GroupName, category.Description, category.MaxNomineesPerUser, + category.ViewerRangeMin, + category.ViewerRangeMax, category.Candidates .Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) .Select(candidate => diff --git a/Backend/Endpoints/PublicSiteStatusEndpoints.cs b/Backend/Endpoints/PublicSiteStatusEndpoints.cs index 1ab872e..9fdac98 100644 --- a/Backend/Endpoints/PublicSiteStatusEndpoints.cs +++ b/Backend/Endpoints/PublicSiteStatusEndpoints.cs @@ -29,7 +29,7 @@ public static partial class PublicEndpoints private static bool ResolveDemoLoginEnabled(Backend.Domain.SiteSettings settings, IConfiguration configuration) { - var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); + var usesDatabaseDemo = settings.DemoLoginManagedByDatabase; if (!usesDatabaseDemo) { return IsDemoLoginEnabled(configuration); diff --git a/Backend/Endpoints/PublicUserParticipationEndpoints.cs b/Backend/Endpoints/PublicUserParticipationEndpoints.cs index 7144838..08f8a38 100644 --- a/Backend/Endpoints/PublicUserParticipationEndpoints.cs +++ b/Backend/Endpoints/PublicUserParticipationEndpoints.cs @@ -36,6 +36,7 @@ public static partial class PublicEndpoints .Select(item => new { item.CategoryId, + item.CategoryGroupName, item.Status, Nominee = item.CandidateId != null ? item.Candidate!.DisplayName @@ -46,9 +47,10 @@ public static partial class PublicEndpoints var groupedNominations = nominations .Where(item => item.Status != "rejected" && item.Status != "superseded") .Where(item => !string.IsNullOrWhiteSpace(item.Nominee)) - .GroupBy(item => item.CategoryId) + .GroupBy(item => new { item.CategoryId, item.CategoryGroupName }) .Select(group => new UserNominationStateDto( - group.Key, + group.Key.CategoryId, + group.Key.CategoryGroupName, group.Select(item => item.Nominee!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray())) diff --git a/Backend/Extensions/ServiceCollectionExtensions.cs b/Backend/Extensions/ServiceCollectionExtensions.cs index 08877a0..3c429c3 100644 --- a/Backend/Extensions/ServiceCollectionExtensions.cs +++ b/Backend/Extensions/ServiceCollectionExtensions.cs @@ -26,6 +26,11 @@ public static class ServiceCollectionExtensions services.Configure(configuration.GetSection(TwitchAuthOptions.SectionName)); services.AddMemoryCache(); services.AddHttpClient(); + services.AddHttpClient("TwitchTracker", client => + { + client.Timeout = TimeSpan.FromSeconds(4); + client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0"); + }); var allowedOrigins = ResolveAllowedOrigins(configuration, environment); var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres"); @@ -88,6 +93,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/Backend/Migrations/20260627171320_AddShareUrls.Designer.cs b/Backend/Migrations/20260627171320_AddShareUrls.Designer.cs new file mode 100644 index 0000000..59237c0 --- /dev/null +++ b/Backend/Migrations/20260627171320_AddShareUrls.Designer.cs @@ -0,0 +1,1768 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260627171320_AddShareUrls")] + partial class AddShareUrls + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260627171320_AddShareUrls.cs b/Backend/Migrations/20260627171320_AddShareUrls.cs new file mode 100644 index 0000000..0402734 --- /dev/null +++ b/Backend/Migrations/20260627171320_AddShareUrls.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddShareUrls : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ShareDiscordUrl", + table: "SiteSettings", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ShareXUrl", + table: "SiteSettings", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "ShareDiscordUrl", "ShareXUrl" }, + values: new object[] { "", "" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShareDiscordUrl", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "ShareXUrl", + table: "SiteSettings"); + } + } +} diff --git a/Backend/Migrations/20260627174243_AddShowactDynamicForm.Designer.cs b/Backend/Migrations/20260627174243_AddShowactDynamicForm.Designer.cs new file mode 100644 index 0000000..9c1cac8 --- /dev/null +++ b/Backend/Migrations/20260627174243_AddShowactDynamicForm.Designer.cs @@ -0,0 +1,1777 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260627174243_AddShowactDynamicForm")] + partial class AddShowactDynamicForm + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260627174243_AddShowactDynamicForm.cs b/Backend/Migrations/20260627174243_AddShowactDynamicForm.cs new file mode 100644 index 0000000..554f778 --- /dev/null +++ b/Backend/Migrations/20260627174243_AddShowactDynamicForm.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddShowactDynamicForm : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ShowactFormSchemaJson", + table: "SiteSettings", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "FieldResponsesJson", + table: "ShowactApplications", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + column: "ShowactFormSchemaJson", + value: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShowactFormSchemaJson", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "FieldResponsesJson", + table: "ShowactApplications"); + } + } +} diff --git a/Backend/Migrations/20260628091734_AddCategoryViewerRanges.Designer.cs b/Backend/Migrations/20260628091734_AddCategoryViewerRanges.Designer.cs new file mode 100644 index 0000000..e91e905 --- /dev/null +++ b/Backend/Migrations/20260628091734_AddCategoryViewerRanges.Designer.cs @@ -0,0 +1,1783 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260628091734_AddCategoryViewerRanges")] + partial class AddCategoryViewerRanges + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260628091734_AddCategoryViewerRanges.cs b/Backend/Migrations/20260628091734_AddCategoryViewerRanges.cs new file mode 100644 index 0000000..bec5c66 --- /dev/null +++ b/Backend/Migrations/20260628091734_AddCategoryViewerRanges.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddCategoryViewerRanges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ViewerRangeMax", + table: "Categories", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ViewerRangeMin", + table: "Categories", + type: "integer", + nullable: true); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 6, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 7, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 8, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 9, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "Id", + keyValue: 10, + columns: new[] { "ViewerRangeMax", "ViewerRangeMin" }, + values: new object[] { null, null }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ViewerRangeMax", + table: "Categories"); + + migrationBuilder.DropColumn( + name: "ViewerRangeMin", + table: "Categories"); + } + } +} diff --git a/Backend/Migrations/20260628092717_AddSessionIdleTimeoutSettings.Designer.cs b/Backend/Migrations/20260628092717_AddSessionIdleTimeoutSettings.Designer.cs new file mode 100644 index 0000000..dc8953d --- /dev/null +++ b/Backend/Migrations/20260628092717_AddSessionIdleTimeoutSettings.Designer.cs @@ -0,0 +1,1789 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260628092717_AddSessionIdleTimeoutSettings")] + partial class AddSessionIdleTimeoutSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SessionIdleTimeoutHours = 3, + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260628092717_AddSessionIdleTimeoutSettings.cs b/Backend/Migrations/20260628092717_AddSessionIdleTimeoutSettings.cs new file mode 100644 index 0000000..333d2f3 --- /dev/null +++ b/Backend/Migrations/20260628092717_AddSessionIdleTimeoutSettings.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddSessionIdleTimeoutSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SessionIdleTimeoutHours", + table: "SiteSettings", + type: "integer", + nullable: false, + defaultValue: 3); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + column: "SessionIdleTimeoutHours", + value: 3); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SessionIdleTimeoutHours", + table: "SiteSettings"); + } + } +} diff --git a/Backend/Migrations/20260628110302_AddSeasonSubcategoryTemplates.Designer.cs b/Backend/Migrations/20260628110302_AddSeasonSubcategoryTemplates.Designer.cs new file mode 100644 index 0000000..648d75c --- /dev/null +++ b/Backend/Migrations/20260628110302_AddSeasonSubcategoryTemplates.Designer.cs @@ -0,0 +1,1799 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260628110302_AddSeasonSubcategoryTemplates")] + partial class AddSeasonSubcategoryTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SessionIdleTimeoutHours = 3, + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260628110302_AddSeasonSubcategoryTemplates.cs b/Backend/Migrations/20260628110302_AddSeasonSubcategoryTemplates.cs new file mode 100644 index 0000000..aa4c517 --- /dev/null +++ b/Backend/Migrations/20260628110302_AddSeasonSubcategoryTemplates.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddSeasonSubcategoryTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SubcategoryTemplatesJson", + table: "Seasons", + type: "text", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 1, + column: "SubcategoryTemplatesJson", + value: "[]"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 2, + column: "SubcategoryTemplatesJson", + value: "[]"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 3, + column: "SubcategoryTemplatesJson", + value: "[]"); + + migrationBuilder.UpdateData( + table: "Seasons", + keyColumn: "Id", + keyValue: 4, + column: "SubcategoryTemplatesJson", + value: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SubcategoryTemplatesJson", + table: "Seasons"); + } + } +} diff --git a/Backend/Migrations/20260628115832_AddNominationGroupTrackerIdentity.Designer.cs b/Backend/Migrations/20260628115832_AddNominationGroupTrackerIdentity.Designer.cs new file mode 100644 index 0000000..ff23927 --- /dev/null +++ b/Backend/Migrations/20260628115832_AddNominationGroupTrackerIdentity.Designer.cs @@ -0,0 +1,1936 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260628115832_AddNominationGroupTrackerIdentity")] + partial class AddNominationGroupTrackerIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.HasIndex("StreamerIdentityId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + NominationTally = 0, + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + NominationTally = 0, + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgViewers") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryGroupName = "", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi", + TrackerStatus = "pending" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryGroupName = "", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu", + TrackerStatus = "pending" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SessionIdleTimeoutHours = 3, + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260628115832_AddNominationGroupTrackerIdentity.cs b/Backend/Migrations/20260628115832_AddNominationGroupTrackerIdentity.cs new file mode 100644 index 0000000..58eec8c --- /dev/null +++ b/Backend/Migrations/20260628115832_AddNominationGroupTrackerIdentity.cs @@ -0,0 +1,388 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddNominationGroupTrackerIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Nominations_Categories_CategoryId", + table: "Nominations"); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Nominations", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AddColumn( + name: "AvgViewers", + table: "Nominations", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CategoryGroupName", + table: "Nominations", + type: "character varying(80)", + maxLength: 80, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ResolvedChannel", + table: "Nominations", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.AddColumn( + name: "ResolvedPlatform", + table: "Nominations", + type: "character varying(40)", + maxLength: 40, + nullable: true); + + migrationBuilder.AddColumn( + name: "StreamerIdentityId", + table: "Nominations", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SuggestedCategoryId", + table: "Nominations", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "TrackerCheckedAt", + table: "Nominations", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "TrackerStatus", + table: "Nominations", + type: "character varying(40)", + maxLength: 40, + nullable: false, + defaultValue: "pending"); + + migrationBuilder.AddColumn( + name: "NominationTally", + table: "Candidates", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StreamerIdentityId", + table: "Candidates", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "StreamerIdentities", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Platform = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + Login = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + NormalizedKey = table.Column(type: "character varying(180)", maxLength: 180, nullable: false), + DisplayName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + ProfileUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + LastResolvedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StreamerIdentities", x => x.Id); + }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 1, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 2, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 3, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 4, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 5, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 6, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 7, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 8, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 9, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 10, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 11, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 12, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 13, + column: "StreamerIdentityId", + value: null); + + migrationBuilder.UpdateData( + table: "Nominations", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" }, + values: new object[] { null, "", null, null, null, null, null, "pending" }); + + migrationBuilder.UpdateData( + table: "Nominations", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "AvgViewers", "CategoryGroupName", "ResolvedChannel", "ResolvedPlatform", "StreamerIdentityId", "SuggestedCategoryId", "TrackerCheckedAt", "TrackerStatus" }, + values: new object[] { null, "", null, null, null, null, null, "pending" }); + + migrationBuilder.Sql(""" + UPDATE "Nominations" n + SET "CategoryGroupName" = c."GroupName" + FROM "Categories" c + WHERE n."CategoryId" = c."Id" + AND COALESCE(n."CategoryGroupName", '') = ''; + """); + + migrationBuilder.CreateIndex( + name: "IX_Nominations_SeasonId_CategoryGroupName_Status", + table: "Nominations", + columns: new[] { "SeasonId", "CategoryGroupName", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName", + table: "Nominations", + columns: new[] { "SeasonId", "StreamerIdentityId", "CategoryGroupName" }); + + migrationBuilder.CreateIndex( + name: "IX_Nominations_StreamerIdentityId", + table: "Nominations", + column: "StreamerIdentityId"); + + migrationBuilder.CreateIndex( + name: "IX_Nominations_SuggestedCategoryId", + table: "Nominations", + column: "SuggestedCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Candidates_StreamerIdentityId", + table: "Candidates", + column: "StreamerIdentityId"); + + migrationBuilder.CreateIndex( + name: "IX_StreamerIdentities_NormalizedKey", + table: "StreamerIdentities", + column: "NormalizedKey", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Candidates_StreamerIdentities_StreamerIdentityId", + table: "Candidates", + column: "StreamerIdentityId", + principalTable: "StreamerIdentities", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Nominations_Categories_CategoryId", + table: "Nominations", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Nominations_Categories_SuggestedCategoryId", + table: "Nominations", + column: "SuggestedCategoryId", + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Nominations_StreamerIdentities_StreamerIdentityId", + table: "Nominations", + column: "StreamerIdentityId", + principalTable: "StreamerIdentities", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Candidates_StreamerIdentities_StreamerIdentityId", + table: "Candidates"); + + migrationBuilder.DropForeignKey( + name: "FK_Nominations_Categories_CategoryId", + table: "Nominations"); + + migrationBuilder.DropForeignKey( + name: "FK_Nominations_Categories_SuggestedCategoryId", + table: "Nominations"); + + migrationBuilder.DropForeignKey( + name: "FK_Nominations_StreamerIdentities_StreamerIdentityId", + table: "Nominations"); + + migrationBuilder.DropTable( + name: "StreamerIdentities"); + + migrationBuilder.DropIndex( + name: "IX_Nominations_SeasonId_CategoryGroupName_Status", + table: "Nominations"); + + migrationBuilder.DropIndex( + name: "IX_Nominations_SeasonId_StreamerIdentityId_CategoryGroupName", + table: "Nominations"); + + migrationBuilder.DropIndex( + name: "IX_Nominations_StreamerIdentityId", + table: "Nominations"); + + migrationBuilder.DropIndex( + name: "IX_Nominations_SuggestedCategoryId", + table: "Nominations"); + + migrationBuilder.DropIndex( + name: "IX_Candidates_StreamerIdentityId", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "AvgViewers", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "CategoryGroupName", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "ResolvedChannel", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "ResolvedPlatform", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "StreamerIdentityId", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "SuggestedCategoryId", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "TrackerCheckedAt", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "TrackerStatus", + table: "Nominations"); + + migrationBuilder.DropColumn( + name: "NominationTally", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "StreamerIdentityId", + table: "Candidates"); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Nominations", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_Nominations_Categories_CategoryId", + table: "Nominations", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Backend/Migrations/20260628161949_AddShowactApplicationSchedule.Designer.cs b/Backend/Migrations/20260628161949_AddShowactApplicationSchedule.Designer.cs new file mode 100644 index 0000000..1165a2b --- /dev/null +++ b/Backend/Migrations/20260628161949_AddShowactApplicationSchedule.Designer.cs @@ -0,0 +1,1942 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260628161949_AddShowactApplicationSchedule")] + partial class AddShowactApplicationSchedule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.HasIndex("StreamerIdentityId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + NominationTally = 0, + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + NominationTally = 0, + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die groesste Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgViewers") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryGroupName = "", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi", + TrackerStatus = "pending" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryGroupName = "", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu", + TrackerStatus = "pending" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationEndsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationStartsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SessionIdleTimeoutHours = 3, + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260628161949_AddShowactApplicationSchedule.cs b/Backend/Migrations/20260628161949_AddShowactApplicationSchedule.cs new file mode 100644 index 0000000..2baeac0 --- /dev/null +++ b/Backend/Migrations/20260628161949_AddShowactApplicationSchedule.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddShowactApplicationSchedule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ShowactApplicationEndsAt", + table: "SiteSettings", + type: "date", + nullable: true); + + migrationBuilder.AddColumn( + name: "ShowactApplicationStartsAt", + table: "SiteSettings", + type: "date", + nullable: true); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "ShowactApplicationEndsAt", "ShowactApplicationStartsAt" }, + values: new object[] { null, null }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShowactApplicationEndsAt", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "ShowactApplicationStartsAt", + table: "SiteSettings"); + } + } +} diff --git a/Backend/Migrations/20260628205353_AddSeasonWorkflowRulesJson.Designer.cs b/Backend/Migrations/20260628205353_AddSeasonWorkflowRulesJson.Designer.cs new file mode 100644 index 0000000..6c61020 --- /dev/null +++ b/Backend/Migrations/20260628205353_AddSeasonWorkflowRulesJson.Designer.cs @@ -0,0 +1,2010 @@ +// +using System; +using Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + [DbContext(typeof(AwardsDbContext))] + [Migration("20260628205353_AddSeasonWorkflowRulesJson")] + partial class AddSeasonWorkflowRulesJson + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backend.Domain.AdminAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("AdminTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.ToTable("AdminAuditEntries"); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CategoryName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId", "CategoryId") + .IsUnique(); + + b.ToTable("Results"); + + b.HasData( + new + { + Id = 1, + CandidateId = 8, + CategoryId = 5, + CategoryName = "VTuber des Jahres", + SeasonId = 2 + }, + new + { + Id = 2, + CandidateId = 9, + CategoryId = 6, + CategoryName = "Bestes Live Event", + SeasonId = 2 + }, + new + { + Id = 3, + CandidateId = 10, + CategoryId = 7, + CategoryName = "Clip des Jahres", + SeasonId = 2 + }, + new + { + Id = 4, + CandidateId = 11, + CategoryId = 8, + CategoryName = "VTuber des Jahres", + SeasonId = 3 + }, + new + { + Id = 5, + CandidateId = 12, + CategoryId = 9, + CategoryName = "Clip des Jahres", + SeasonId = 3 + }, + new + { + Id = 6, + CandidateId = 13, + CategoryId = 10, + CategoryName = "VTuber des Jahres", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptanceNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AcceptanceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("open"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ChannelSlug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ClipCompilationPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ClipCompilationTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClipCompilationUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClipEmbedStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("unchecked"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("SeasonId"); + + b.HasIndex("StreamerIdentityId"); + + b.ToTable("Candidates"); + + b.HasData( + new + { + Id = 1, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 2, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 3, + AcceptanceStatus = "open", + CategoryId = 1, + ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", + DisplayName = "Shiro Ch.", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 4, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 5, + AcceptanceStatus = "open", + CategoryId = 2, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura Showcase", + NominationTally = 0, + Platform = "YouTube", + SeasonId = 1 + }, + new + { + Id = 6, + AcceptanceStatus = "open", + CategoryId = 3, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 7, + AcceptanceStatus = "open", + CategoryId = 4, + ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", + DisplayName = "Moonrelay", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 1 + }, + new + { + Id = 8, + AcceptanceStatus = "open", + CategoryId = 5, + ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", + DisplayName = "Hoshimi Miyu", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 9, + AcceptanceStatus = "open", + CategoryId = 6, + ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", + DisplayName = "Kurainu 3D Live", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 10, + AcceptanceStatus = "open", + CategoryId = 7, + ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", + DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 2 + }, + new + { + Id = 11, + AcceptanceStatus = "open", + CategoryId = 8, + ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", + DisplayName = "Aoi Sakura", + NominationTally = 0, + Platform = "YouTube", + SeasonId = 3 + }, + new + { + Id = 12, + AcceptanceStatus = "open", + CategoryId = 9, + ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", + DisplayName = "Starbyte", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 3 + }, + new + { + Id = 13, + AcceptanceStatus = "open", + CategoryId = 10, + ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", + DisplayName = "Tenshi Vox", + NominationTally = 0, + Platform = "Twitch", + SeasonId = 4 + }); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MaxNomineesPerUser") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Slug") + .IsUnique(); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = 1, + Description = "Die größte Auszeichnung des Jahres.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 1, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 2, + Description = "Events, Konzerte und 3D-Shows.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 1, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 3, + Description = "Der lustigste oder emotionalste Clip des Jahres.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 1, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 4, + Description = "Die aktivste und freundlichste Community.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "Beste Community", + SeasonId = 1, + Slug = "beste-community", + SortOrder = 4 + }, + new + { + Id = 5, + Description = "Archivkategorie 2025.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 2, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 6, + Description = "Archivkategorie 2025.", + GroupName = "Performance", + MaxNomineesPerUser = 3, + Name = "Bestes Live Event", + SeasonId = 2, + Slug = "bestes-live-event", + SortOrder = 2 + }, + new + { + Id = 7, + Description = "Archivkategorie 2025.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 2, + Slug = "clip-des-jahres", + SortOrder = 3 + }, + new + { + Id = 8, + Description = "Archivkategorie 2024.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 3, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }, + new + { + Id = 9, + Description = "Archivkategorie 2024.", + GroupName = "Clips & Highlights", + MaxNomineesPerUser = 3, + Name = "Clip des Jahres", + SeasonId = 3, + Slug = "clip-des-jahres", + SortOrder = 2 + }, + new + { + Id = 10, + Description = "Archivkategorie 2023.", + GroupName = "Main Awards", + MaxNomineesPerUser = 3, + Name = "VTuber des Jahres", + SeasonId = 4, + Slug = "vtuber-des-jahres", + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ClipUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Creator") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ClipSubmissions"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgViewers") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CandidateText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowersGained") + .HasColumnType("integer"); + + b.Property("HoursStreamed") + .HasColumnType("integer"); + + b.Property("HoursWatched") + .HasColumnType("integer"); + + b.Property("PeakViewers") + .HasColumnType("integer"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StreamUrl") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.Property("TrackingFlagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TrackingReviewNote") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TrackingReviewStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("clear"); + + b.Property("TrackingReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackingReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + + b.HasIndex("SeasonId", "Status"); + + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + + b.ToTable("Nominations"); + + b.HasData( + new + { + Id = 1, + CandidateText = "Hoshimi Miyu", + CategoryGroupName = "", + CategoryId = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_hoshi", + TrackerStatus = "pending", + TrackingFlagsJson = "[]", + TrackingReviewStatus = "clear" + }, + new + { + Id = 2, + CandidateText = "Kurainu 3D Live", + CategoryGroupName = "", + CategoryId = 2, + CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SeasonId = 1, + Status = "pending", + SubmittedByTwitchId = "twitch_kurainu", + TrackerStatus = "pending", + TrackingFlagsJson = "[]", + TrackingReviewStatus = "clear" + }); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("RiskFlags"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentPhase") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("IsCommunityOnly") + .HasColumnType("boolean"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("NominationEndsAt") + .HasColumnType("date"); + + b.Property("NominationStartsAt") + .HasColumnType("date"); + + b.Property("ReviewEndsAt") + .HasColumnType("date"); + + b.Property("ReviewStartsAt") + .HasColumnType("date"); + + b.Property("ShowDate") + .HasColumnType("date"); + + b.Property("ShowStartsAt") + .HasColumnType("time without time zone"); + + b.Property("ShowStreamUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("VotingEndsAt") + .HasColumnType("date"); + + b.Property("VotingStartsAt") + .HasColumnType("date"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("Seasons"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = "Community Voting", + IsCommunityOnly = true, + IsCurrent = true, + Name = "VTuber Star Awards 2026", + NominationEndsAt = new DateOnly(2026, 5, 31), + NominationStartsAt = new DateOnly(2026, 5, 1), + ReviewEndsAt = new DateOnly(2026, 7, 10), + ReviewStartsAt = new DateOnly(2026, 7, 1), + ShowDate = new DateOnly(2026, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2026, 6, 30), + VotingStartsAt = new DateOnly(2026, 6, 1), + WorkflowRulesJson = "[]", + Year = 2026 + }, + new + { + Id = 2, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2025", + NominationEndsAt = new DateOnly(2025, 5, 31), + NominationStartsAt = new DateOnly(2025, 5, 1), + ReviewEndsAt = new DateOnly(2025, 7, 10), + ReviewStartsAt = new DateOnly(2025, 7, 1), + ShowDate = new DateOnly(2025, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2025, 6, 30), + VotingStartsAt = new DateOnly(2025, 6, 1), + WorkflowRulesJson = "[]", + Year = 2025 + }, + new + { + Id = 3, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2024", + NominationEndsAt = new DateOnly(2024, 5, 31), + NominationStartsAt = new DateOnly(2024, 5, 1), + ReviewEndsAt = new DateOnly(2024, 7, 10), + ReviewStartsAt = new DateOnly(2024, 7, 1), + ShowDate = new DateOnly(2024, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://youtube.com/c/Jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2024, 6, 30), + VotingStartsAt = new DateOnly(2024, 6, 1), + WorkflowRulesJson = "[]", + Year = 2024 + }, + new + { + Id = 4, + CurrentPhase = "Archived", + IsCommunityOnly = true, + IsCurrent = false, + Name = "VTuber Star Awards 2023", + NominationEndsAt = new DateOnly(2023, 5, 31), + NominationStartsAt = new DateOnly(2023, 5, 1), + ReviewEndsAt = new DateOnly(2023, 7, 10), + ReviewStartsAt = new DateOnly(2023, 7, 1), + ShowDate = new DateOnly(2023, 7, 20), + ShowStartsAt = new TimeOnly(20, 0, 0), + ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", + VotingEndsAt = new DateOnly(2023, 6, 30), + VotingStartsAt = new DateOnly(2023, 6, 1), + WorkflowRulesJson = "[]", + Year = 2023 + }); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ArtistName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactDiscord") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PerformanceType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("PlatformUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TechnicalNotes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "Status"); + + b.ToTable("ShowactApplications"); + }); + + modelBuilder.Entity("Backend.Domain.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClipAdminMenuVisible") + .HasColumnType("boolean"); + + b.Property("ClipReviewEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ClipSubmissionDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ClipSubmissionsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ContactContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("DemoLoginDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginEmail") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("DemoLoginEnabled") + .HasColumnType("boolean"); + + b.Property("DemoLoginManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("DemoLoginPasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DemoLoginPasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DemoLoginTwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("FaqJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HostDisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("HostTagline") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ImprintContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImprintUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("MaintenanceMessage") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("MaintenanceModeEnabled") + .HasColumnType("boolean"); + + b.Property("MaintenanceTitle") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NewsletterUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("PrivacyEmail") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("PrivacyPolicyContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrivacyPolicyUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivacyPolicyUpdatedBy") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RiskRulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactApplicationDisabledMessage") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ShowactApplicationEndsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationStartsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShowactsContent") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue(""); + + b.Property("ShowactsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("TrackingReviewNotes") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TwitchAuthManagedByDatabase") + .HasColumnType("boolean"); + + b.Property("TwitchClientId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchClientSecret") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("TwitchRedirectUri") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TwitchScope") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ViewerStatsProviderBaseUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + ClipAdminMenuVisible = true, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ClipSubmissionsEnabled = false, + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFür Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactUrl = "https://vtuber-star-awards.de/kontakt", + DemoLoginDisplayName = "Jayuhime Admin", + DemoLoginEmail = "", + DemoLoginEnabled = false, + DemoLoginManagedByDatabase = false, + DemoLoginPasswordHash = "", + DemoLoginPasswordSalt = "", + DemoLoginTwitchUserId = "jayuhime_admin", + FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", + HostDisplayName = "Jayuhime", + HostTagline = "VTuber & Award Host", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.", + ImprintUrl = "https://vtuber-star-awards.de/impressum", + MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", + MaintenanceModeEnabled = false, + MaintenanceTitle = "Sternenpause", + NewsletterUrl = "https://vtuber-star-awards.de/newsletter", + NominationLinkBlacklistJson = "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]", + PrivacyEmail = "datenschutz@vtuber-star-awards.de", + PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.", + PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + PrivacyPolicyUpdatedBy = "seed", + RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SessionIdleTimeoutHours = 3, + ShareDiscordUrl = "", + ShareXUrl = "", + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.", + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", + SponsorsContent = "Sponsoren & Partner\nHier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.", + SponsorsUrl = "https://vtuber-star-awards.de/partner", + SponsorsVisible = true, + 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.", + TrackingRulesJson = "{\"source\":{\"providerKey\":\"twitchtracker\",\"baseUrl\":\"https://twitchtracker.com/api\",\"notesSummary\":\"TwitchTracker Basic API liefert aktuell Channel-Summary-Daten fuer 30 Tage. Andere Zeitfenster bleiben konfigurierbar, werden aber als manueller Review-Fall markiert.\",\"showManualReviewNotesInReview\":true},\"importantMetrics\":[{\"key\":\"avg_viewers\",\"label\":\"Avg Viewer\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Durchschnittliche Viewer fuer den gewaehlten Zeitraum.\",\"requiredForAutoClassification\":true,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"90d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"avg_viewers\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"tracker_status\",\"label\":\"Tracker-Status\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Zeigt, ob der TwitchTracker-Lookup sauber aufgeloest werden konnte.\",\"requiredForAutoClassification\":true,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":false,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"tracker_status\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"tracker_checked_at\",\"label\":\"Letzter Tracker-Check\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Zeitpunkt der letzten automatischen Datenaufloesung.\",\"requiredForAutoClassification\":true,\"showInReview\":true,\"showInAdminSummary\":false,\"manualOverrideAllowed\":false,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"tracker_checked_at\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null}],\"optionalMetrics\":[{\"key\":\"hours_streamed\",\"label\":\"Hours Streamed\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Gesamte Streamstunden im gewaehlten Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"hours_streamed\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"hours_watched\",\"label\":\"Hours Watched\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Gesamte Watch Time fuer den gewaehlten Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"hours_watched\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"peak_viewers\",\"label\":\"Peak Viewer\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Hoechster gleichzeitiger Zuschauerwert im Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"peak_viewers\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"followers_gained\",\"label\":\"Follower Growth\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Follower-Zuwachs im gewaehlten Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"followers_gained\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"category_fit\",\"label\":\"Category Fit\",\"enabled\":false,\"sourceSupport\":\"context_only\",\"description\":\"Admin-Einschaetzung, ob die Person inhaltlich zur Unterkategorie passt.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":false,\"manualOverrideAllowed\":true,\"windowKey\":\"90d\",\"autoSupportedWindowKeys\":[],\"providerFieldKey\":null,\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"top_categories_context\",\"label\":\"Top Categories Context\",\"enabled\":false,\"sourceSupport\":\"context_only\",\"description\":\"Manueller Kontext aus zuletzt meistgestreamten Kategorien oder Games des Channels.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"90d\",\"autoSupportedWindowKeys\":[],\"providerFieldKey\":null,\"topCount\":5,\"minPrimaryCategorySharePercent\":60,\"minPrimaryCategoryHours\":20,\"maxDistinctCategoriesBeforeFlag\":6,\"ignoredCategories\":[\"Just Chatting\",\"Special Events\"],\"matchAwardCategoryAgainstTopCategories\":true,\"flagIfAwardCategoryNotInTopX\":true,\"flagIfCategorySpreadTooWide\":true,\"flagIfNoCategoryContextAvailable\":true,\"minValue\":null,\"maxValue\":null}],\"flags\":[{\"key\":\"tracker_unresolved\",\"label\":\"Tracker-Link nicht aufloesbar\",\"enabled\":true,\"severity\":\"high\",\"description\":\"Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"unsupported_platform\",\"label\":\"Plattform nicht unterstuetzt\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"no_tracker_data\",\"label\":\"Keine Tracker-Daten\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"TwitchTracker hat keinen belastbaren Summary-Wert geliefert.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"missing_required_metric\",\"label\":\"Pflichtmetrik fehlt\",\"enabled\":true,\"severity\":\"high\",\"description\":\"Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":true,\"adminNoteRequiredOnOverride\":false},{\"key\":\"manual_review_required\",\"label\":\"Manuelle Pruefung noetig\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"low_confidence_small_channel\",\"label\":\"Low Confidence Small Channel\",\"enabled\":false,\"severity\":\"low\",\"description\":\"Kleine Kanaele koennen manuell tiefer geprueft werden.\",\"autoTriggerEnabled\":false,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"insufficient_activity_context\",\"label\":\"Zu wenig Aktivitaetskontext\",\"enabled\":false,\"severity\":\"low\",\"description\":\"Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.\",\"autoTriggerEnabled\":false,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"category_fit_needs_review\",\"label\":\"Category Fit manuell pruefen\",\"enabled\":false,\"severity\":\"low\",\"description\":\"Unterkategorie muss inhaltlich manuell bestaetigt werden.\",\"autoTriggerEnabled\":false,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"unsupported_metric_window\",\"label\":\"Gewaehltes Zeitfenster nicht auto-verfuegbar\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"Die aktuelle TwitchTracker API liefert diese Metrik nicht fuer das konfigurierte Zeitfenster.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false}]}", + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "", + ViewerStatsProviderBaseUrl = "https://twitchtracker.com/api", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"},{\"key\":\"recommended_nominators_per_subcategory\",\"label\":\"Empfohlene Nominierer pro Unterkategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"warn\",\"description\":\"Zeigt in der Kategorie-\\u00DCbersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts.\"}]" + }); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebsiteUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "IsVisible", "SortOrder"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + + modelBuilder.Entity("Backend.Domain.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundTwitchDisplayName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("BoundTwitchUserId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PasswordResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordSalt") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TwitchBoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("BoundTwitchUserId") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("TeamMembers"); + }); + + modelBuilder.Entity("Backend.Domain.TeamRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Role") + .IsUnique(); + + b.ToTable("TeamRolePermissions"); + }); + + modelBuilder.Entity("Backend.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedFromIp") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TwitchUserId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("SessionToken") + .IsUnique(); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeasonId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedByTwitchId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId", "SubmittedByTwitchId") + .IsUnique(); + + b.ToTable("VoteBallots"); + + b.HasData( + new + { + Id = 1, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_1" + }, + new + { + Id = 2, + SeasonId = 1, + Status = "submitted", + SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + SubmittedByTwitchId = "twitch_vote_2" + }); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BallotId") + .HasColumnType("integer"); + + b.Property("CandidateId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BallotId"); + + b.HasIndex("CandidateId"); + + b.HasIndex("CategoryId"); + + b.ToTable("VoteEntries"); + + b.HasData( + new + { + Id = 1, + BallotId = 1, + CandidateId = 1, + CategoryId = 1 + }, + new + { + Id = 2, + BallotId = 1, + CandidateId = 4, + CategoryId = 2 + }, + new + { + Id = 3, + BallotId = 2, + CandidateId = 2, + CategoryId = 1 + }, + new + { + Id = 4, + BallotId = 2, + CandidateId = 6, + CategoryId = 3 + }); + }); + + modelBuilder.Entity("Backend.Domain.AwardResult", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Results") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Candidate", b => + { + b.HasOne("Backend.Domain.Category", "Category") + .WithMany("Candidates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany("Categories") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ClipSubmission", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + }); + + modelBuilder.Entity("Backend.Domain.Nomination", b => + { + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId"); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + + b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); + }); + + modelBuilder.Entity("Backend.Domain.RiskFlag", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.ShowactApplication", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.Sponsor", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.HasOne("Backend.Domain.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Backend.Domain.VoteEntry", b => + { + b.HasOne("Backend.Domain.VoteBallot", "Ballot") + .WithMany("Entries") + .HasForeignKey("BallotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Candidate", "Candidate") + .WithMany() + .HasForeignKey("CandidateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Backend.Domain.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ballot"); + + b.Navigation("Candidate"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Backend.Domain.Category", b => + { + b.Navigation("Candidates"); + }); + + modelBuilder.Entity("Backend.Domain.Season", b => + { + b.Navigation("Categories"); + + b.Navigation("Results"); + }); + + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + + modelBuilder.Entity("Backend.Domain.VoteBallot", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/Migrations/20260628205353_AddSeasonWorkflowRulesJson.cs b/Backend/Migrations/20260628205353_AddSeasonWorkflowRulesJson.cs new file mode 100644 index 0000000..f1ebbd8 --- /dev/null +++ b/Backend/Migrations/20260628205353_AddSeasonWorkflowRulesJson.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddSeasonWorkflowRulesJson : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WorkflowRulesJson", + table: "Seasons", + type: "text", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.Sql( + """ + UPDATE "Seasons" + SET "WorkflowRulesJson" = COALESCE(NULLIF((SELECT "WorkflowRulesJson" FROM "SiteSettings" WHERE "Id" = 1), ''), '[]') + WHERE COALESCE("WorkflowRulesJson", '') = ''; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "WorkflowRulesJson", + table: "Seasons"); + } + } +} diff --git a/Backend/Migrations/AwardsDbContextModelSnapshot.cs b/Backend/Migrations/AwardsDbContextModelSnapshot.cs index 1e382cb..d760a6c 100644 --- a/Backend/Migrations/AwardsDbContextModelSnapshot.cs +++ b/Backend/Migrations/AwardsDbContextModelSnapshot.cs @@ -212,6 +212,11 @@ namespace Backend.Migrations .HasMaxLength(120) .HasColumnType("character varying(120)"); + b.Property("NominationTally") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("Platform") .IsRequired() .HasMaxLength(40) @@ -220,12 +225,17 @@ namespace Backend.Migrations b.Property("SeasonId") .HasColumnType("integer"); + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("CategoryId"); b.HasIndex("SeasonId"); + b.HasIndex("StreamerIdentityId"); + b.ToTable("Candidates"); b.HasData( @@ -237,6 +247,7 @@ namespace Backend.Migrations ChannelSlug = "@hoshimimiyu", ClipEmbedStatus = "unchecked", DisplayName = "Hoshimi Miyu", + NominationTally = 0, Platform = "Twitch", SeasonId = 1 }, @@ -248,6 +259,7 @@ namespace Backend.Migrations ChannelSlug = "@kurainu", ClipEmbedStatus = "unchecked", DisplayName = "Kurainu", + NominationTally = 0, Platform = "Twitch", SeasonId = 1 }, @@ -259,6 +271,7 @@ namespace Backend.Migrations ChannelSlug = "@shiroch", ClipEmbedStatus = "unchecked", DisplayName = "Shiro Ch.", + NominationTally = 0, Platform = "Twitch", SeasonId = 1 }, @@ -270,6 +283,7 @@ namespace Backend.Migrations ChannelSlug = "@kurainu", ClipEmbedStatus = "unchecked", DisplayName = "Kurainu 3D Live", + NominationTally = 0, Platform = "Twitch", SeasonId = 1 }, @@ -281,6 +295,7 @@ namespace Backend.Migrations ChannelSlug = "@aoisakura", ClipEmbedStatus = "unchecked", DisplayName = "Aoi Sakura Showcase", + NominationTally = 0, Platform = "YouTube", SeasonId = 1 }, @@ -292,6 +307,7 @@ namespace Backend.Migrations ChannelSlug = "@pyonkichikingdom", ClipEmbedStatus = "unchecked", DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, Platform = "Twitch", SeasonId = 1 }, @@ -303,6 +319,7 @@ namespace Backend.Migrations ChannelSlug = "@moonrelay", ClipEmbedStatus = "unchecked", DisplayName = "Moonrelay", + NominationTally = 0, Platform = "Twitch", SeasonId = 1 }, @@ -314,6 +331,7 @@ namespace Backend.Migrations ChannelSlug = "@hoshimimiyu", ClipEmbedStatus = "unchecked", DisplayName = "Hoshimi Miyu", + NominationTally = 0, Platform = "Twitch", SeasonId = 2 }, @@ -325,6 +343,7 @@ namespace Backend.Migrations ChannelSlug = "@kurainu", ClipEmbedStatus = "unchecked", DisplayName = "Kurainu 3D Live", + NominationTally = 0, Platform = "Twitch", SeasonId = 2 }, @@ -336,6 +355,7 @@ namespace Backend.Migrations ChannelSlug = "@pyonkichikingdom", ClipEmbedStatus = "unchecked", DisplayName = "Pyonkichi Kingdom", + NominationTally = 0, Platform = "Twitch", SeasonId = 2 }, @@ -347,6 +367,7 @@ namespace Backend.Migrations ChannelSlug = "@aoisakura", ClipEmbedStatus = "unchecked", DisplayName = "Aoi Sakura", + NominationTally = 0, Platform = "YouTube", SeasonId = 3 }, @@ -358,6 +379,7 @@ namespace Backend.Migrations ChannelSlug = "@starbyte", ClipEmbedStatus = "unchecked", DisplayName = "Starbyte", + NominationTally = 0, Platform = "Twitch", SeasonId = 3 }, @@ -369,6 +391,7 @@ namespace Backend.Migrations ChannelSlug = "@tenshivox", ClipEmbedStatus = "unchecked", DisplayName = "Tenshi Vox", + NominationTally = 0, Platform = "Twitch", SeasonId = 4 }); @@ -410,6 +433,12 @@ namespace Backend.Migrations b.Property("SortOrder") .HasColumnType("integer"); + b.Property("ViewerRangeMax") + .HasColumnType("integer"); + + b.Property("ViewerRangeMin") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("SeasonId", "Slug") @@ -421,7 +450,7 @@ namespace Backend.Migrations new { Id = 1, - Description = "Die groesste Auszeichnung des Jahres.", + Description = "Die größte Auszeichnung des Jahres.", GroupName = "Main Awards", MaxNomineesPerUser = 3, Name = "VTuber des Jahres", @@ -613,6 +642,9 @@ namespace Backend.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AvgViewers") + .HasColumnType("integer"); + b.Property("CandidateId") .HasColumnType("integer"); @@ -620,12 +652,39 @@ namespace Backend.Migrations .HasMaxLength(120) .HasColumnType("character varying(120)"); - b.Property("CategoryId") + b.Property("CategoryGroupName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasDefaultValue(""); + + b.Property("CategoryId") .HasColumnType("integer"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("FollowersGained") + .HasColumnType("integer"); + + b.Property("HoursStreamed") + .HasColumnType("integer"); + + b.Property("HoursWatched") + .HasColumnType("integer"); + + b.Property("PeakViewers") + .HasColumnType("integer"); + + b.Property("ResolvedChannel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResolvedPlatform") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + b.Property("ReviewNote") .HasMaxLength(500) .HasColumnType("character varying(500)"); @@ -649,19 +708,67 @@ namespace Backend.Migrations .HasMaxLength(300) .HasColumnType("character varying(300)"); + b.Property("StreamerIdentityId") + .HasColumnType("integer"); + b.Property("SubmittedByTwitchId") .IsRequired() .HasMaxLength(120) .HasColumnType("character varying(120)"); + b.Property("SuggestedCategoryId") + .HasColumnType("integer"); + + b.Property("TrackerCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackerStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasDefaultValue("pending"); + + b.Property("TrackingFlagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + + b.Property("TrackingReviewNote") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TrackingReviewStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("clear"); + + b.Property("TrackingReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackingReviewedByTwitchId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.HasKey("Id"); b.HasIndex("CandidateId"); b.HasIndex("CategoryId"); + b.HasIndex("StreamerIdentityId"); + + b.HasIndex("SuggestedCategoryId"); + b.HasIndex("SeasonId", "Status"); + b.HasIndex("SeasonId", "CategoryGroupName", "Status"); + + b.HasIndex("SeasonId", "StreamerIdentityId", "CategoryGroupName"); + b.ToTable("Nominations"); b.HasData( @@ -669,21 +776,29 @@ namespace Backend.Migrations { Id = 1, CandidateText = "Hoshimi Miyu", + CategoryGroupName = "", CategoryId = 1, CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), SeasonId = 1, Status = "pending", - SubmittedByTwitchId = "twitch_hoshi" + SubmittedByTwitchId = "twitch_hoshi", + TrackerStatus = "pending", + TrackingFlagsJson = "[]", + TrackingReviewStatus = "clear" }, new { Id = 2, CandidateText = "Kurainu 3D Live", + CategoryGroupName = "", CategoryId = 2, CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), SeasonId = 1, Status = "pending", - SubmittedByTwitchId = "twitch_kurainu" + SubmittedByTwitchId = "twitch_kurainu", + TrackerStatus = "pending", + TrackingFlagsJson = "[]", + TrackingReviewStatus = "clear" }); }); @@ -809,12 +924,24 @@ namespace Backend.Migrations .HasMaxLength(400) .HasColumnType("character varying(400)"); + b.Property("SubcategoryTemplatesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + b.Property("VotingEndsAt") .HasColumnType("date"); b.Property("VotingStartsAt") .HasColumnType("date"); + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + b.Property("Year") .HasColumnType("integer"); @@ -840,8 +967,10 @@ namespace Backend.Migrations ShowDate = new DateOnly(2026, 7, 20), ShowStartsAt = new TimeOnly(20, 0, 0), ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", VotingEndsAt = new DateOnly(2026, 6, 30), VotingStartsAt = new DateOnly(2026, 6, 1), + WorkflowRulesJson = "[]", Year = 2026 }, new @@ -858,8 +987,10 @@ namespace Backend.Migrations ShowDate = new DateOnly(2025, 7, 20), ShowStartsAt = new TimeOnly(20, 0, 0), ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", VotingEndsAt = new DateOnly(2025, 6, 30), VotingStartsAt = new DateOnly(2025, 6, 1), + WorkflowRulesJson = "[]", Year = 2025 }, new @@ -876,8 +1007,10 @@ namespace Backend.Migrations ShowDate = new DateOnly(2024, 7, 20), ShowStartsAt = new TimeOnly(20, 0, 0), ShowStreamUrl = "https://youtube.com/c/Jayuhime", + SubcategoryTemplatesJson = "[]", VotingEndsAt = new DateOnly(2024, 6, 30), VotingStartsAt = new DateOnly(2024, 6, 1), + WorkflowRulesJson = "[]", Year = 2024 }, new @@ -894,8 +1027,10 @@ namespace Backend.Migrations ShowDate = new DateOnly(2023, 7, 20), ShowStartsAt = new TimeOnly(20, 0, 0), ShowStreamUrl = "https://twitch.tv/jayuhime", + SubcategoryTemplatesJson = "[]", VotingEndsAt = new DateOnly(2023, 6, 30), VotingStartsAt = new DateOnly(2023, 6, 1), + WorkflowRulesJson = "[]", Year = 2023 }); }); @@ -936,6 +1071,10 @@ namespace Backend.Migrations .HasMaxLength(1000) .HasColumnType("character varying(1000)"); + b.Property("FieldResponsesJson") + .IsRequired() + .HasColumnType("text"); + b.Property("PerformanceType") .IsRequired() .HasMaxLength(80) @@ -1120,16 +1259,39 @@ namespace Backend.Migrations .IsRequired() .HasColumnType("text"); + b.Property("SessionIdleTimeoutHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShareDiscordUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShareXUrl") + .IsRequired() + .HasColumnType("text"); + b.Property("ShowactApplicationDisabledMessage") .IsRequired() .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("ShowactApplicationEndsAt") + .HasColumnType("date"); + + b.Property("ShowactApplicationStartsAt") + .HasColumnType("date"); + b.Property("ShowactApplicationsEnabled") .ValueGeneratedOnAdd() .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("ShowactFormSchemaJson") + .IsRequired() + .HasColumnType("text"); + b.Property("ShowactsContent") .IsRequired() .ValueGeneratedOnAdd() @@ -1159,6 +1321,16 @@ namespace Backend.Migrations .HasColumnType("boolean") .HasDefaultValue(true); + b.Property("TrackingReviewNotes") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackingRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + b.Property("TwitchAuthManagedByDatabase") .HasColumnType("boolean"); @@ -1182,6 +1354,11 @@ namespace Backend.Migrations .HasMaxLength(300) .HasColumnType("character varying(300)"); + b.Property("ViewerStatsProviderBaseUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + b.Property("WorkflowRulesJson") .IsRequired() .ValueGeneratedOnAdd() @@ -1200,7 +1377,7 @@ namespace Backend.Migrations ClipReviewEnabled = true, ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", ClipSubmissionsEnabled = false, - ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", + ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team über die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFür Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.", ContactUrl = "https://vtuber-star-awards.de/kontakt", DemoLoginDisplayName = "Jayuhime Admin", DemoLoginEmail = "", @@ -1212,7 +1389,7 @@ namespace Backend.Migrations FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]", HostDisplayName = "Jayuhime", HostTagline = "VTuber & Award Host", - ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.", + ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze für organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsächlichen Anbieterangaben ersetzt werden.", ImprintUrl = "https://vtuber-star-awards.de/impressum", MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.", MaintenanceModeEnabled = false, @@ -1224,20 +1401,27 @@ namespace Backend.Migrations PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), PrivacyPolicyUpdatedBy = "seed", RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]", + SessionIdleTimeoutHours = 3, + ShareDiscordUrl = "", + ShareXUrl = "", ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", ShowactApplicationsEnabled = false, - ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", + ShowactFormSchemaJson = "[]", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt für die Award-Show.", ShowactsUrl = "https://vtuber-star-awards.de/showacts", SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]", - SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.", + SponsorsContent = "Sponsoren & Partner\nHier können Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den öffentlichen Kontaktflächen genannt, sobald sie final bestätigt sind.", SponsorsUrl = "https://vtuber-star-awards.de/partner", SponsorsVisible = true, + 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.", + TrackingRulesJson = "{\"source\":{\"providerKey\":\"twitchtracker\",\"baseUrl\":\"https://twitchtracker.com/api\",\"notesSummary\":\"TwitchTracker Basic API liefert aktuell Channel-Summary-Daten fuer 30 Tage. Andere Zeitfenster bleiben konfigurierbar, werden aber als manueller Review-Fall markiert.\",\"showManualReviewNotesInReview\":true},\"importantMetrics\":[{\"key\":\"avg_viewers\",\"label\":\"Avg Viewer\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Durchschnittliche Viewer fuer den gewaehlten Zeitraum.\",\"requiredForAutoClassification\":true,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"90d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"avg_viewers\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"tracker_status\",\"label\":\"Tracker-Status\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Zeigt, ob der TwitchTracker-Lookup sauber aufgeloest werden konnte.\",\"requiredForAutoClassification\":true,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":false,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"tracker_status\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"tracker_checked_at\",\"label\":\"Letzter Tracker-Check\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Zeitpunkt der letzten automatischen Datenaufloesung.\",\"requiredForAutoClassification\":true,\"showInReview\":true,\"showInAdminSummary\":false,\"manualOverrideAllowed\":false,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"tracker_checked_at\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null}],\"optionalMetrics\":[{\"key\":\"hours_streamed\",\"label\":\"Hours Streamed\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Gesamte Streamstunden im gewaehlten Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"hours_streamed\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"hours_watched\",\"label\":\"Hours Watched\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Gesamte Watch Time fuer den gewaehlten Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"hours_watched\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"peak_viewers\",\"label\":\"Peak Viewer\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Hoechster gleichzeitiger Zuschauerwert im Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"peak_viewers\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"followers_gained\",\"label\":\"Follower Growth\",\"enabled\":true,\"sourceSupport\":\"auto\",\"description\":\"Follower-Zuwachs im gewaehlten Zeitraum.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"30d\",\"autoSupportedWindowKeys\":[\"30d\"],\"providerFieldKey\":\"followers_gained\",\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"category_fit\",\"label\":\"Category Fit\",\"enabled\":false,\"sourceSupport\":\"context_only\",\"description\":\"Admin-Einschaetzung, ob die Person inhaltlich zur Unterkategorie passt.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":false,\"manualOverrideAllowed\":true,\"windowKey\":\"90d\",\"autoSupportedWindowKeys\":[],\"providerFieldKey\":null,\"topCount\":null,\"minPrimaryCategorySharePercent\":null,\"minPrimaryCategoryHours\":null,\"maxDistinctCategoriesBeforeFlag\":null,\"ignoredCategories\":[],\"matchAwardCategoryAgainstTopCategories\":false,\"flagIfAwardCategoryNotInTopX\":false,\"flagIfCategorySpreadTooWide\":false,\"flagIfNoCategoryContextAvailable\":false,\"minValue\":null,\"maxValue\":null},{\"key\":\"top_categories_context\",\"label\":\"Top Categories Context\",\"enabled\":false,\"sourceSupport\":\"context_only\",\"description\":\"Manueller Kontext aus zuletzt meistgestreamten Kategorien oder Games des Channels.\",\"requiredForAutoClassification\":false,\"showInReview\":true,\"showInAdminSummary\":true,\"manualOverrideAllowed\":true,\"windowKey\":\"90d\",\"autoSupportedWindowKeys\":[],\"providerFieldKey\":null,\"topCount\":5,\"minPrimaryCategorySharePercent\":60,\"minPrimaryCategoryHours\":20,\"maxDistinctCategoriesBeforeFlag\":6,\"ignoredCategories\":[\"Just Chatting\",\"Special Events\"],\"matchAwardCategoryAgainstTopCategories\":true,\"flagIfAwardCategoryNotInTopX\":true,\"flagIfCategorySpreadTooWide\":true,\"flagIfNoCategoryContextAvailable\":true,\"minValue\":null,\"maxValue\":null}],\"flags\":[{\"key\":\"tracker_unresolved\",\"label\":\"Tracker-Link nicht aufloesbar\",\"enabled\":true,\"severity\":\"high\",\"description\":\"Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"unsupported_platform\",\"label\":\"Plattform nicht unterstuetzt\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"no_tracker_data\",\"label\":\"Keine Tracker-Daten\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"TwitchTracker hat keinen belastbaren Summary-Wert geliefert.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"missing_required_metric\",\"label\":\"Pflichtmetrik fehlt\",\"enabled\":true,\"severity\":\"high\",\"description\":\"Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":true,\"adminNoteRequiredOnOverride\":false},{\"key\":\"manual_review_required\",\"label\":\"Manuelle Pruefung noetig\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"low_confidence_small_channel\",\"label\":\"Low Confidence Small Channel\",\"enabled\":false,\"severity\":\"low\",\"description\":\"Kleine Kanaele koennen manuell tiefer geprueft werden.\",\"autoTriggerEnabled\":false,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"insufficient_activity_context\",\"label\":\"Zu wenig Aktivitaetskontext\",\"enabled\":false,\"severity\":\"low\",\"description\":\"Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.\",\"autoTriggerEnabled\":false,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"category_fit_needs_review\",\"label\":\"Category Fit manuell pruefen\",\"enabled\":false,\"severity\":\"low\",\"description\":\"Unterkategorie muss inhaltlich manuell bestaetigt werden.\",\"autoTriggerEnabled\":false,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false},{\"key\":\"unsupported_metric_window\",\"label\":\"Gewaehltes Zeitfenster nicht auto-verfuegbar\",\"enabled\":true,\"severity\":\"medium\",\"description\":\"Die aktuelle TwitchTracker API liefert diese Metrik nicht fuer das konfigurierte Zeitfenster.\",\"autoTriggerEnabled\":true,\"requiresManualReview\":true,\"blocksApproval\":false,\"adminNoteRequiredOnOverride\":false}]}", TwitchAuthManagedByDatabase = false, TwitchClientId = "", TwitchClientSecret = "", TwitchRedirectUri = "", TwitchScope = "", - WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"}]" + ViewerStatsProviderBaseUrl = "https://twitchtracker.com/api", + WorkflowRulesJson = "[{\"key\":\"max_finalists_per_category\",\"label\":\"Finale Kandidat:innen pro Kategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"block\",\"description\":\"Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden.\"},{\"key\":\"max_candidate_appearances\",\"label\":\"Kandidaturen pro Person\",\"enabled\":true,\"limit\":2,\"mode\":\"warn\",\"description\":\"Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht.\"},{\"key\":\"max_winner_placements\",\"label\":\"Gewinnerpl\\u00E4tze pro Person\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird.\"},{\"key\":\"winner_requires_clip\",\"label\":\"Gewinner braucht Clip-Link\",\"enabled\":true,\"limit\":1,\"mode\":\"block\",\"description\":\"Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird.\"},{\"key\":\"recommended_nominators_per_subcategory\",\"label\":\"Empfohlene Nominierer pro Unterkategorie\",\"enabled\":true,\"limit\":4,\"mode\":\"warn\",\"description\":\"Zeigt in der Kategorie-\\u00DCbersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts.\"}]" }); }); @@ -1296,6 +1480,49 @@ namespace Backend.Migrations b.ToTable("Sponsors"); }); + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedKey") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ProfileUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedKey") + .IsUnique(); + + b.ToTable("StreamerIdentities"); + }); + modelBuilder.Entity("Backend.Domain.TeamMember", b => { b.Property("Id") @@ -1613,9 +1840,15 @@ namespace Backend.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Candidates") + .HasForeignKey("StreamerIdentityId"); + b.Navigation("Category"); b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); }); modelBuilder.Entity("Backend.Domain.Category", b => @@ -1648,8 +1881,7 @@ namespace Backend.Migrations b.HasOne("Backend.Domain.Category", "Category") .WithMany() .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); b.HasOne("Backend.Domain.Season", "Season") .WithMany() @@ -1657,11 +1889,24 @@ namespace Backend.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Backend.Domain.StreamerIdentity", "StreamerIdentity") + .WithMany("Nominations") + .HasForeignKey("StreamerIdentityId"); + + b.HasOne("Backend.Domain.Category", "SuggestedCategory") + .WithMany() + .HasForeignKey("SuggestedCategoryId") + .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Candidate"); b.Navigation("Category"); b.Navigation("Season"); + + b.Navigation("StreamerIdentity"); + + b.Navigation("SuggestedCategory"); }); modelBuilder.Entity("Backend.Domain.RiskFlag", b => @@ -1745,6 +1990,13 @@ namespace Backend.Migrations b.Navigation("Results"); }); + modelBuilder.Entity("Backend.Domain.StreamerIdentity", b => + { + b.Navigation("Candidates"); + + b.Navigation("Nominations"); + }); + modelBuilder.Entity("Backend.Domain.VoteBallot", b => { b.Navigation("Entries"); diff --git a/Backend/Services/IUserSessionService.cs b/Backend/Services/IUserSessionService.cs index 8019965..84acad4 100644 --- a/Backend/Services/IUserSessionService.cs +++ b/Backend/Services/IUserSessionService.cs @@ -10,5 +10,6 @@ public interface IUserSessionService Task CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default); Task CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default); Task CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default); + Task GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default); Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default); } diff --git a/Backend/Services/IViewerStatsProvider.cs b/Backend/Services/IViewerStatsProvider.cs new file mode 100644 index 0000000..16dda9a --- /dev/null +++ b/Backend/Services/IViewerStatsProvider.cs @@ -0,0 +1,14 @@ +namespace Backend.Services; + +public interface IViewerStatsProvider +{ + Task GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken); +} + +public sealed record ViewerStatsSnapshot( + int AverageViewers, + int HoursStreamed, + int HoursWatched, + int PeakViewers, + int FollowersGained, + string WindowKey); diff --git a/Backend/Services/NominationEnrichmentService.cs b/Backend/Services/NominationEnrichmentService.cs new file mode 100644 index 0000000..8b69f04 --- /dev/null +++ b/Backend/Services/NominationEnrichmentService.cs @@ -0,0 +1,173 @@ +using System.Text.RegularExpressions; +using Backend.Data; +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Services; + +public sealed partial class NominationEnrichmentService( + AwardsDbContext db, + IViewerStatsProvider viewerStatsProvider, + NominationTrackingReviewService trackingReviewService) +{ + public async Task EnrichAsync(Nomination nomination, IReadOnlyCollection groupCategories, CancellationToken cancellationToken) + { + if (!TryResolveStreamIdentity(nomination.StreamUrl, out var identity)) + { + nomination.TrackerStatus = "unresolved"; + nomination.TrackerCheckedAt = DateTimeOffset.UtcNow; + await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken); + return; + } + + var streamerIdentity = await db.StreamerIdentities + .FirstOrDefaultAsync(item => item.NormalizedKey == identity.NormalizedKey, cancellationToken); + + if (streamerIdentity is null) + { + streamerIdentity = new StreamerIdentity + { + Platform = identity.Platform, + Login = identity.Login, + NormalizedKey = identity.NormalizedKey, + DisplayName = identity.DisplayName, + ProfileUrl = identity.ProfileUrl, + }; + db.StreamerIdentities.Add(streamerIdentity); + } + else + { + streamerIdentity.Platform = identity.Platform; + streamerIdentity.Login = identity.Login; + streamerIdentity.DisplayName = string.IsNullOrWhiteSpace(streamerIdentity.DisplayName) + ? identity.DisplayName + : streamerIdentity.DisplayName; + streamerIdentity.ProfileUrl ??= identity.ProfileUrl; + } + + streamerIdentity.LastResolvedAt = DateTimeOffset.UtcNow; + nomination.StreamerIdentity = streamerIdentity; + nomination.ResolvedChannel = identity.Login; + nomination.ResolvedPlatform = identity.Platform; + nomination.TrackerCheckedAt = DateTimeOffset.UtcNow; + + if (!identity.SupportsViewerStats) + { + nomination.TrackerStatus = "unsupported_platform"; + await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken); + return; + } + + var summary = await viewerStatsProvider.GetChannelSummaryAsync(identity.Login, cancellationToken); + nomination.AvgViewers = summary?.AverageViewers; + nomination.HoursStreamed = summary?.HoursStreamed; + nomination.HoursWatched = summary?.HoursWatched; + nomination.PeakViewers = summary?.PeakViewers; + nomination.FollowersGained = summary?.FollowersGained; + nomination.SuggestedCategoryId = nomination.AvgViewers.HasValue + ? ResolveSuggestedCategoryId(groupCategories, nomination.AvgViewers.Value) + : null; + nomination.TrackerStatus = summary is not null ? "resolved" : "no_data"; + await trackingReviewService.ReevaluateAsync(nomination, true, cancellationToken); + } + + public static bool TryResolveStreamIdentity(string? streamUrl, out ResolvedStreamerIdentity identity) + { + identity = default; + if (string.IsNullOrWhiteSpace(streamUrl) || !Uri.TryCreate(streamUrl.Trim(), UriKind.Absolute, out var uri)) + { + return false; + } + + var host = uri.Host.Replace("www.", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant(); + var pathParts = uri.AbsolutePath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(part => part.TrimStart('@')) + .Where(part => !IgnoredPathParts.Contains(part, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + var login = pathParts.FirstOrDefault() ?? string.Empty; + + if (host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase)) + { + login = pathParts.FirstOrDefault() ?? uri.Host; + } + + var platform = ResolvePlatform(host); + login = SanitizeLogin(login); + if (string.IsNullOrWhiteSpace(login)) + { + return false; + } + + var profileUrl = BuildProfileUrl(platform, login, uri); + identity = new ResolvedStreamerIdentity( + platform, + login, + $"{platform.ToLowerInvariant()}:{login.ToLowerInvariant()}", + login, + profileUrl, + string.Equals(platform, "Twitch", StringComparison.OrdinalIgnoreCase)); + return true; + } + + private static int? ResolveSuggestedCategoryId(IEnumerable groupCategories, int averageViewers) + { + var orderedCategories = groupCategories + .OrderBy(item => item.SortOrder) + .ToArray(); + + var rangedCategories = orderedCategories + .Where(category => category.ViewerRangeMin.HasValue || category.ViewerRangeMax.HasValue) + .ToArray(); + + if (rangedCategories.Length > 0) + { + return rangedCategories + .FirstOrDefault(category => + (!category.ViewerRangeMin.HasValue || averageViewers >= category.ViewerRangeMin.Value) + && (!category.ViewerRangeMax.HasValue || averageViewers <= category.ViewerRangeMax.Value)) + ?.Id; + } + + return orderedCategories + .FirstOrDefault() + ?.Id; + } + + private static string ResolvePlatform(string host) + { + if (host.Contains("twitch.tv", StringComparison.OrdinalIgnoreCase)) return "Twitch"; + if (host.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) || host.Contains("youtu.be", StringComparison.OrdinalIgnoreCase)) return "YouTube"; + if (host.Contains("kick.com", StringComparison.OrdinalIgnoreCase)) return "Kick"; + + var firstPart = host.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + return string.IsNullOrWhiteSpace(firstPart) + ? "Website" + : $"{char.ToUpperInvariant(firstPart[0])}{firstPart[1..]}"; + } + + private static string BuildProfileUrl(string platform, string login, Uri originalUrl) => + platform.ToLowerInvariant() switch + { + "twitch" => $"https://twitch.tv/{login}", + "youtube" => $"https://youtube.com/{login}", + "kick" => $"https://kick.com/{login}", + _ => originalUrl.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped), + }; + + private static string SanitizeLogin(string value) => + LoginRegex().Replace(value.Trim().TrimStart('@'), string.Empty); + + private static readonly string[] IgnoredPathParts = ["c", "channel", "user", "live", "videos", "video", "clip", "clips", "directory"]; + + [GeneratedRegex("[^a-zA-Z0-9._-]", RegexOptions.Compiled)] + private static partial Regex LoginRegex(); +} + +public readonly record struct ResolvedStreamerIdentity( + string Platform, + string Login, + string NormalizedKey, + string DisplayName, + string ProfileUrl, + bool SupportsViewerStats); diff --git a/Backend/Services/NominationTrackingReviewService.cs b/Backend/Services/NominationTrackingReviewService.cs new file mode 100644 index 0000000..5a53a89 --- /dev/null +++ b/Backend/Services/NominationTrackingReviewService.cs @@ -0,0 +1,225 @@ +using Backend.Data; +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Services; + +public sealed class NominationTrackingReviewService(AwardsDbContext db) +{ + public async Task ReevaluateAsync(Nomination nomination, bool resetManualResolution, CancellationToken cancellationToken) + { + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); + + ApplyEvaluation(nomination, TrackingRulesSettings.Read(settings), resetManualResolution); + } + + public async Task ReevaluateAllAsync(bool resetManualResolution, CancellationToken cancellationToken) + { + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); + + var rules = TrackingRulesSettings.Read(settings); + await ReevaluateAllAsync(rules, resetManualResolution, cancellationToken); + } + + public async Task ReevaluateAllAsync( + TrackingRulesConfiguration rules, + bool resetManualResolution, + CancellationToken cancellationToken) + { + var nominations = await db.Nominations.ToArrayAsync(cancellationToken); + foreach (var nomination in nominations) + { + ApplyEvaluation(nomination, rules, resetManualResolution); + } + } + + public NominationTrackingEvaluation Evaluate(Nomination nomination, TrackingRulesConfiguration rules) + { + var requiredMetrics = rules.ImportantMetrics + .Where(item => item.Enabled && item.RequiredForAutoClassification) + .ToArray(); + var missingRequiredMetrics = requiredMetrics + .Where(metric => !IsMetricPresent(metric, nomination)) + .ToArray(); + var unsupportedAutomaticWindows = rules.ImportantMetrics + .Concat(rules.OptionalMetrics) + .Where(item => item.Enabled && item.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(item)) + .ToArray(); + + var triggeredFlags = new List(); + AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagTrackerUnresolved, nomination.TrackerStatus == "unresolved"); + AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedPlatform, nomination.TrackerStatus == "unsupported_platform"); + AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagNoTrackerData, nomination.TrackerStatus == "no_data"); + AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagMissingRequiredMetric, missingRequiredMetrics.Length > 0); + AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagUnsupportedMetricWindow, unsupportedAutomaticWindows.Length > 0); + + var needsManualReview = triggeredFlags.Any(flag => flag.RequiresManualReview); + AddFlagIf(triggeredFlags, rules.Flags, TrackingRulesSettings.FlagManualReviewRequired, needsManualReview); + + return new NominationTrackingEvaluation( + missingRequiredMetrics.Select(item => item.Key).ToArray(), + triggeredFlags.ToArray(), + needsManualReview || triggeredFlags.Any(flag => flag.Key == TrackingRulesSettings.FlagManualReviewRequired), + triggeredFlags.Any(flag => flag.BlocksApproval), + triggeredFlags.Any(flag => flag.AdminNoteRequiredOnOverride)); + } + + public TrackingMetricState[] BuildMetricStates(Nomination nomination, TrackingRulesConfiguration rules) => + rules.ImportantMetrics + .Concat(rules.OptionalMetrics) + .Where(item => item.Enabled && item.ShowInReview) + .Select(metric => new TrackingMetricState( + metric.Key, + metric.Label, + metric.RequiredForAutoClassification, + metric.SourceSupport, + IsMetricPresent(metric, nomination), + ResolveMetricValue(metric, nomination), + metric.Description, + metric.WindowKey, + TrackingRulesSettings.WindowLabel(metric.WindowKey), + TrackingRulesSettings.SupportsAutomaticWindow(metric))) + .ToArray(); + + private void ApplyEvaluation(Nomination nomination, TrackingRulesConfiguration rules, bool resetManualResolution) + { + var evaluation = Evaluate(nomination, rules); + nomination.TrackingFlagsJson = TrackingRulesSettings.SerializeFlagHits(evaluation.Flags); + + if (resetManualResolution || nomination.TrackingReviewStatus is not ("reviewed" or "overridden")) + { + nomination.TrackingReviewStatus = evaluation.RequiresManualReview ? "flagged" : "clear"; + if (resetManualResolution) + { + nomination.TrackingReviewNote = null; + nomination.TrackingReviewedByTwitchId = null; + nomination.TrackingReviewedAt = null; + } + } + } + + private static bool IsMetricPresent(TrackingMetricRuleSetting metric, Nomination nomination) + { + if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric)) + { + return false; + } + + return metric.Key switch + { + TrackingRulesSettings.AvgViewers => nomination.AvgViewers.HasValue, + TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(nomination.TrackerStatus), + TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt.HasValue, + TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed.HasValue, + TrackingRulesSettings.HoursWatched => nomination.HoursWatched.HasValue, + TrackingRulesSettings.PeakViewers => nomination.PeakViewers.HasValue, + TrackingRulesSettings.FollowersGained => nomination.FollowersGained.HasValue, + _ => false, + }; + } + + private static string ResolveMetricValue(TrackingMetricRuleSetting metric, Nomination nomination) + { + if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric)) + { + return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}"; + } + + return metric.Key switch + { + TrackingRulesSettings.AvgViewers => nomination.AvgViewers?.ToString() ?? "offen", + TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(nomination.TrackerStatus) ? "offen" : nomination.TrackerStatus, + TrackingRulesSettings.TrackerCheckedAt => nomination.TrackerCheckedAt?.ToString("g") ?? "offen", + TrackingRulesSettings.HoursStreamed => nomination.HoursStreamed?.ToString() ?? "offen", + TrackingRulesSettings.HoursWatched => nomination.HoursWatched?.ToString() ?? "offen", + TrackingRulesSettings.PeakViewers => nomination.PeakViewers?.ToString() ?? "offen", + TrackingRulesSettings.FollowersGained => nomination.FollowersGained?.ToString() ?? "offen", + TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check", + TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric), + _ => "manuell", + }; + } + + private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric) + { + var parts = new List(); + if (metric.TopCount.HasValue) + { + parts.Add($"Top {metric.TopCount.Value}"); + } + + if (metric.MinPrimaryCategorySharePercent.HasValue) + { + parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie"); + } + + if (metric.MinPrimaryCategoryHours.HasValue) + { + parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie"); + } + + if (metric.MaxDistinctCategoriesBeforeFlag.HasValue) + { + parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien"); + } + + if (metric.IgnoredCategories.Length > 0) + { + parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}"); + } + + return parts.Count > 0 + ? string.Join(" · ", parts) + : "Top-Kategorien manuell pruefen"; + } + + private static void AddFlagIf( + ICollection target, + IEnumerable availableFlags, + string key, + bool shouldAdd) + { + if (!shouldAdd) + { + return; + } + + var rule = availableFlags.FirstOrDefault(item => item.Key == key); + if (rule is null || !rule.Enabled || !rule.AutoTriggerEnabled) + { + return; + } + + target.Add(new TrackingFlagHit( + rule.Key, + rule.Label, + rule.Severity, + rule.Description, + rule.RequiresManualReview, + rule.BlocksApproval, + rule.AdminNoteRequiredOnOverride)); + } +} + +public sealed record NominationTrackingEvaluation( + string[] MissingRequiredMetricKeys, + TrackingFlagHit[] Flags, + bool RequiresManualReview, + bool HasBlockingFlag, + bool RequiresOverrideNote); + +public sealed record TrackingMetricState( + string Key, + string Label, + bool Required, + string SourceSupport, + bool Present, + string Value, + string Description, + string WindowKey, + string WindowLabel, + bool AutoWindowSupported); diff --git a/Backend/Services/SeasonSubcategoryTemplateSettings.cs b/Backend/Services/SeasonSubcategoryTemplateSettings.cs new file mode 100644 index 0000000..f698e11 --- /dev/null +++ b/Backend/Services/SeasonSubcategoryTemplateSettings.cs @@ -0,0 +1,206 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Backend.Contracts; +using Backend.Domain; + +namespace Backend.Services; + +public sealed record SeasonSubcategoryTemplateSetting( + string Name, + string Slug, + int SortOrder, + int? ViewerRangeMin, + int? ViewerRangeMax); + +public static class SeasonSubcategoryTemplateSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly SeasonSubcategoryTemplateSetting[] DefaultTemplates = + [ + new("Hidden Star", "hidden-star", 1, 1, 20), + new("Rising Star", "rising-star", 2, 21, 60), + new("Shining Star", "shining-star", 3, 61, null), + ]; + + public static SeasonSubcategoryTemplateSetting[] Read(Season season, IEnumerable? fallbackCategories = null) + { + var stored = OnlyViewerTemplates(Parse(season.SubcategoryTemplatesJson)); + if (stored.Length > 0) + { + return stored; + } + + if (fallbackCategories is null) + { + return []; + } + + var fallback = fallbackCategories + .GroupBy(category => new { category.Name, category.ViewerRangeMin, category.ViewerRangeMax }) + .Where(group => group.Key.ViewerRangeMin is not null || group.Key.ViewerRangeMax is not null) + .Select(group => + { + var first = group.OrderBy(item => item.SortOrder).First(); + return Normalize(new SeasonSubcategoryTemplateSetting( + first.Name, + ExtractTemplateSlug(first.Slug, first.GroupName), + first.SortOrder, + first.ViewerRangeMin, + first.ViewerRangeMax)); + }) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select((item, index) => item with { SortOrder = index + 1 }) + .ToArray(); + + return fallback.Length > 0 ? fallback : DefaultTemplates; + } + + public static string Serialize(IEnumerable templates) => + JsonSerializer.Serialize( + templates.Select(Normalize) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase), + JsonOptions); + + public static SeasonSubcategoryTemplateSetting[] Normalize(IEnumerable? templates) => + (templates ?? []) + .Select(template => Normalize(new SeasonSubcategoryTemplateSetting( + template.Name, + template.Slug, + template.SortOrder, + template.ViewerRangeMin, + template.ViewerRangeMax))) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select((item, index) => item with { SortOrder = index + 1 }) + .ToArray(); + + public static AdminSubcategoryTemplateDto[] ToDtos(IEnumerable templates) => + templates + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select(item => new AdminSubcategoryTemplateDto( + item.Name, + item.Slug, + item.SortOrder, + item.ViewerRangeMin, + item.ViewerRangeMax)) + .ToArray(); + + public static bool MatchesTemplate(Category category, IEnumerable templates) + { + var categoryTemplateSlug = ExtractTemplateSlug(category.Slug, category.GroupName); + return templates.Any(template => + string.Equals(category.Name, template.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(categoryTemplateSlug, template.Slug, StringComparison.OrdinalIgnoreCase) + || category.Slug.EndsWith($"-{template.Slug}", StringComparison.OrdinalIgnoreCase)); + } + + private static SeasonSubcategoryTemplateSetting[] Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions)? + .Select(Normalize) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select((item, index) => item with { SortOrder = index + 1 }) + .ToArray() + ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static SeasonSubcategoryTemplateSetting Normalize(SeasonSubcategoryTemplateSetting template) + { + var name = template.Name.Trim(); + var slug = Slugify(template.Slug); + if (string.IsNullOrWhiteSpace(slug)) + { + slug = Slugify(name); + } + + return template with + { + Name = name, + Slug = slug, + SortOrder = Math.Clamp(template.SortOrder, 1, 99), + ViewerRangeMin = NormalizeNullableNumber(template.ViewerRangeMin), + ViewerRangeMax = NormalizeNullableNumber(template.ViewerRangeMax), + }; + } + + private static int? NormalizeNullableNumber(int? value) => value is null ? null : Math.Clamp(value.Value, 0, 100000); + + private static SeasonSubcategoryTemplateSetting[] OnlyViewerTemplates(IEnumerable templates) + { + var items = templates + .Select(Normalize) + .Where(item => item.ViewerRangeMin is not null || item.ViewerRangeMax is not null) + .GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderBy(item => item.SortOrder).First()) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .Select((item, index) => item with { SortOrder = index + 1 }) + .ToArray(); + + return items.Length > 0 ? items : []; + } + + private static string ExtractTemplateSlug(string categorySlug, string groupName) + { + var groupSlug = Slugify(groupName); + var slug = categorySlug.Trim().ToLowerInvariant(); + var prefix = string.IsNullOrWhiteSpace(groupSlug) ? string.Empty : $"{groupSlug}-"; + if (!string.IsNullOrWhiteSpace(prefix) && slug.StartsWith(prefix, StringComparison.Ordinal)) + { + return slug[prefix.Length..]; + } + + return slug; + } + + public static string Slugify(string? value) + { + var normalized = (value ?? string.Empty) + .Trim() + .ToLowerInvariant() + .Normalize(NormalizationForm.FormD); + + var builder = new StringBuilder(normalized.Length); + var lastWasDash = false; + + foreach (var character in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark) + { + continue; + } + + if (char.IsLetterOrDigit(character)) + { + builder.Append(character); + lastWasDash = false; + continue; + } + + if (!lastWasDash && builder.Length > 0) + { + builder.Append('-'); + lastWasDash = true; + } + } + + return builder.ToString().Trim('-'); + } +} diff --git a/Backend/Services/TrackingRulesSettings.cs b/Backend/Services/TrackingRulesSettings.cs new file mode 100644 index 0000000..f9e4a89 --- /dev/null +++ b/Backend/Services/TrackingRulesSettings.cs @@ -0,0 +1,346 @@ +using System.Text.Json; +using Backend.Domain; + +namespace Backend.Services; + +public sealed record TrackingSourceSetting( + string ProviderKey, + string BaseUrl, + string NotesSummary, + bool ShowManualReviewNotesInReview); + +public sealed record TrackingMetricRuleSetting( + string Key, + string Label, + bool Enabled, + string SourceSupport, + string Description, + bool RequiredForAutoClassification, + bool ShowInReview, + bool ShowInAdminSummary, + bool ManualOverrideAllowed, + string WindowKey, + string[] AutoSupportedWindowKeys, + string? ProviderFieldKey, + int? TopCount, + int? MinPrimaryCategorySharePercent, + int? MinPrimaryCategoryHours, + int? MaxDistinctCategoriesBeforeFlag, + string[] IgnoredCategories, + bool MatchAwardCategoryAgainstTopCategories, + bool FlagIfAwardCategoryNotInTopX, + bool FlagIfCategorySpreadTooWide, + bool FlagIfNoCategoryContextAvailable, + int? MinValue, + int? MaxValue); + +public sealed record TrackingFlagRuleSetting( + string Key, + string Label, + bool Enabled, + string Severity, + string Description, + bool AutoTriggerEnabled, + bool RequiresManualReview, + bool BlocksApproval, + bool AdminNoteRequiredOnOverride); + +public sealed record TrackingRulesConfiguration( + TrackingSourceSetting Source, + TrackingMetricRuleSetting[] ImportantMetrics, + TrackingMetricRuleSetting[] OptionalMetrics, + TrackingFlagRuleSetting[] Flags); + +public sealed record TrackingFlagHit( + string Key, + string Label, + string Severity, + string Description, + bool RequiresManualReview, + bool BlocksApproval, + bool AdminNoteRequiredOnOverride); + +public static class TrackingRulesSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public const string ProviderKey = "twitchtracker"; + public const string DefaultBaseUrl = "https://twitchtracker.com/api"; + + public const string Window7d = "7d"; + public const string Window30d = "30d"; + public const string Window90d = "90d"; + public const string WindowAllTime = "all_time"; + + public const string AvgViewers = "avg_viewers"; + public const string TrackerStatus = "tracker_status"; + public const string TrackerCheckedAt = "tracker_checked_at"; + public const string HoursStreamed = "hours_streamed"; + public const string HoursWatched = "hours_watched"; + public const string PeakViewers = "peak_viewers"; + public const string FollowersGained = "followers_gained"; + public const string CategoryFit = "category_fit"; + public const string TopCategoriesContext = "top_categories_context"; + + public const string FlagTrackerUnresolved = "tracker_unresolved"; + public const string FlagUnsupportedPlatform = "unsupported_platform"; + public const string FlagNoTrackerData = "no_tracker_data"; + public const string FlagMissingRequiredMetric = "missing_required_metric"; + public const string FlagManualReviewRequired = "manual_review_required"; + public const string FlagLowConfidenceSmallChannel = "low_confidence_small_channel"; + public const string FlagInsufficientActivityContext = "insufficient_activity_context"; + public const string FlagCategoryFitNeedsReview = "category_fit_needs_review"; + public const string FlagUnsupportedMetricWindow = "unsupported_metric_window"; + + public static readonly string[] SupportedMetricWindows = [Window7d, Window30d, Window90d, WindowAllTime]; + public static readonly string[] TwitchTrackerAutoWindowSupport = [Window30d]; + + public static TrackingSourceSetting DefaultSource { get; } = + new( + ProviderKey, + DefaultBaseUrl, + "TwitchTracker Basic API liefert aktuell Channel-Summary-Daten fuer 30 Tage. Andere Zeitfenster bleiben konfigurierbar, werden aber als manueller Review-Fall markiert.", + true); + + public static TrackingMetricRuleSetting[] DefaultImportantMetrics { get; } = + [ + new(AvgViewers, "Avg Viewer", true, "auto", "Durchschnittliche Viewer fuer den gewaehlten Zeitraum.", true, true, true, true, Window90d, TwitchTrackerAutoWindowSupport, "avg_viewers", null, null, null, null, [], false, false, false, false, null, null), + new(TrackerStatus, "Tracker-Status", true, "auto", "Zeigt, ob der TwitchTracker-Lookup sauber aufgeloest werden konnte.", true, true, true, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_status", null, null, null, null, [], false, false, false, false, null, null), + new(TrackerCheckedAt, "Letzter Tracker-Check", true, "auto", "Zeitpunkt der letzten automatischen Datenaufloesung.", true, true, false, false, Window30d, TwitchTrackerAutoWindowSupport, "tracker_checked_at", null, null, null, null, [], false, false, false, false, null, null), + ]; + + public static TrackingMetricRuleSetting[] DefaultOptionalMetrics { get; } = + [ + new(HoursStreamed, "Hours Streamed", true, "auto", "Gesamte Streamstunden im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_streamed", null, null, null, null, [], false, false, false, false, null, null), + new(HoursWatched, "Hours Watched", true, "auto", "Gesamte Watch Time fuer den gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "hours_watched", null, null, null, null, [], false, false, false, false, null, null), + new(PeakViewers, "Peak Viewer", true, "auto", "Hoechster gleichzeitiger Zuschauerwert im Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "peak_viewers", null, null, null, null, [], false, false, false, false, null, null), + new(FollowersGained, "Follower Growth", true, "auto", "Follower-Zuwachs im gewaehlten Zeitraum.", false, true, true, true, Window30d, TwitchTrackerAutoWindowSupport, "followers_gained", null, null, null, null, [], false, false, false, false, null, null), + new(CategoryFit, "Category Fit", false, "context_only", "Admin-Einschaetzung, ob die Person inhaltlich zur Unterkategorie passt.", false, true, false, true, Window90d, [], null, null, null, null, null, [], false, false, false, false, null, null), + new(TopCategoriesContext, "Top Categories Context", false, "context_only", "Manueller Kontext aus zuletzt meistgestreamten Kategorien oder Games des Channels.", false, true, true, true, Window90d, [], null, 5, 60, 20, 6, ["Just Chatting", "Special Events"], true, true, true, true, null, null), + ]; + + public static TrackingFlagRuleSetting[] DefaultFlags { get; } = + [ + new(FlagTrackerUnresolved, "Tracker-Link nicht aufloesbar", true, "high", "Der Stream-Link konnte nicht sauber in einen Twitch-Channel aufgeloest werden.", true, true, false, false), + new(FlagUnsupportedPlatform, "Plattform nicht unterstuetzt", true, "medium", "Die Nominierung zeigt auf eine Plattform ohne automatische TwitchTracker-Daten.", true, true, false, false), + new(FlagNoTrackerData, "Keine Tracker-Daten", true, "medium", "TwitchTracker hat keinen belastbaren Summary-Wert geliefert.", true, true, false, false), + new(FlagMissingRequiredMetric, "Pflichtmetrik fehlt", true, "high", "Mindestens eine aktivierte Pflichtmetrik fuer die Auto-Klassifizierung fehlt.", true, true, true, false), + new(FlagManualReviewRequired, "Manuelle Pruefung noetig", true, "medium", "Die Auto-Daten reichen nicht fuer eine sichere Review-Entscheidung.", true, true, false, false), + new(FlagLowConfidenceSmallChannel, "Low Confidence Small Channel", false, "low", "Kleine Kanaele koennen manuell tiefer geprueft werden.", false, true, false, false), + new(FlagInsufficientActivityContext, "Zu wenig Aktivitaetskontext", false, "low", "Ohne zusaetzliche Kontextdaten wie Streamstunden ist die Bewertung unsicher.", false, true, false, false), + new(FlagCategoryFitNeedsReview, "Category Fit manuell pruefen", false, "low", "Unterkategorie muss inhaltlich manuell bestaetigt werden.", false, true, false, false), + new(FlagUnsupportedMetricWindow, "Gewaehltes Zeitfenster nicht auto-verfuegbar", true, "medium", "Die aktuelle TwitchTracker API liefert diese Metrik nicht fuer das konfigurierte Zeitfenster.", true, true, false, false), + ]; + + public static TrackingRulesConfiguration Read(SiteSettings? settings) + { + var parsed = Parse(settings?.TrackingRulesJson); + var source = NormalizeSource(parsed?.Source, DefaultSource); + + return new TrackingRulesConfiguration( + source, + MergeMetrics(parsed?.ImportantMetrics, DefaultImportantMetrics), + MergeMetrics(parsed?.OptionalMetrics, DefaultOptionalMetrics), + MergeFlags(parsed?.Flags, DefaultFlags)); + } + + public static string Serialize(TrackingRulesConfiguration configuration) + { + var normalized = new TrackingRulesConfiguration( + NormalizeSource(configuration.Source, DefaultSource), + MergeMetrics(configuration.ImportantMetrics, DefaultImportantMetrics), + MergeMetrics(configuration.OptionalMetrics, DefaultOptionalMetrics), + MergeFlags(configuration.Flags, DefaultFlags)); + + return JsonSerializer.Serialize(normalized, JsonOptions); + } + + public static TrackingMetricRuleSetting FindMetric(IEnumerable rules, string key) => + rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)) + ?? DefaultImportantMetrics.Concat(DefaultOptionalMetrics).First(item => item.Key == key); + + public static TrackingFlagRuleSetting FindFlag(IEnumerable flags, string key) => + flags.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)) + ?? DefaultFlags.First(item => item.Key == key); + + public static string NormalizeBaseUrl(string? rawValue) + { + var trimmed = (rawValue ?? string.Empty).Trim(); + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return DefaultBaseUrl; + } + + return uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); + } + + public static string NormalizeWindowKey(string? value, string fallback) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return SupportedMetricWindows.Contains(normalized, StringComparer.OrdinalIgnoreCase) + ? normalized + : fallback; + } + + public static string WindowLabel(string windowKey) => + NormalizeWindowKey(windowKey, Window30d) switch + { + Window7d => "7 Tage", + Window30d => "30 Tage", + Window90d => "3 Monate", + WindowAllTime => "All Time", + _ => "30 Tage", + }; + + public static bool SupportsAutomaticWindow(TrackingMetricRuleSetting metric) => + metric.ProviderFieldKey is not null + && metric.AutoSupportedWindowKeys.Any(item => string.Equals(item, metric.WindowKey, StringComparison.OrdinalIgnoreCase)); + + public static TrackingFlagHit[] ReadFlagHits(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + public static string SerializeFlagHits(IEnumerable flags) => + JsonSerializer.Serialize(flags, JsonOptions); + + private static TrackingRulesConfigurationDto? Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private static TrackingMetricRuleSetting[] MergeMetrics( + IEnumerable? storedRules, + IEnumerable defaults) => + defaults + .Select(defaultRule => + { + var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase)); + return NormalizeMetric(storedRule, defaultRule); + }) + .ToArray(); + + private static TrackingFlagRuleSetting[] MergeFlags( + IEnumerable? storedRules, + IEnumerable defaults) => + defaults + .Select(defaultRule => + { + var storedRule = storedRules?.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase)); + return NormalizeFlag(storedRule, defaultRule); + }) + .ToArray(); + + private static TrackingSourceSetting NormalizeSource(TrackingSourceSetting? stored, TrackingSourceSetting fallback) => + new( + fallback.ProviderKey, + NormalizeBaseUrl(stored?.BaseUrl ?? fallback.BaseUrl), + string.IsNullOrWhiteSpace(stored?.NotesSummary) ? fallback.NotesSummary : stored.NotesSummary.Trim(), + stored?.ShowManualReviewNotesInReview ?? fallback.ShowManualReviewNotesInReview); + + private static TrackingMetricRuleSetting NormalizeMetric(TrackingMetricRuleSetting? stored, TrackingMetricRuleSetting fallback) + { + if (stored is null) + { + return fallback; + } + + var sourceSupport = stored.SourceSupport.Trim().ToLowerInvariant() switch + { + "auto" => "auto", + "manual" => "manual", + "context_only" => "context_only", + _ => fallback.SourceSupport, + }; + + var autoSupportedWindowKeys = (stored.AutoSupportedWindowKeys ?? fallback.AutoSupportedWindowKeys) + .Select(item => NormalizeWindowKey(item, Window30d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return fallback with + { + Enabled = stored.Enabled, + SourceSupport = sourceSupport, + RequiredForAutoClassification = stored.RequiredForAutoClassification, + ShowInReview = stored.ShowInReview, + ShowInAdminSummary = stored.ShowInAdminSummary, + ManualOverrideAllowed = stored.ManualOverrideAllowed, + WindowKey = NormalizeWindowKey(stored.WindowKey, fallback.WindowKey), + AutoSupportedWindowKeys = autoSupportedWindowKeys, + ProviderFieldKey = string.IsNullOrWhiteSpace(stored.ProviderFieldKey) ? fallback.ProviderFieldKey : stored.ProviderFieldKey.Trim(), + TopCount = stored.TopCount, + MinPrimaryCategorySharePercent = stored.MinPrimaryCategorySharePercent, + MinPrimaryCategoryHours = stored.MinPrimaryCategoryHours, + MaxDistinctCategoriesBeforeFlag = stored.MaxDistinctCategoriesBeforeFlag, + IgnoredCategories = (stored.IgnoredCategories ?? fallback.IgnoredCategories) + .Select(item => item.Trim()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), + MatchAwardCategoryAgainstTopCategories = stored.MatchAwardCategoryAgainstTopCategories, + FlagIfAwardCategoryNotInTopX = stored.FlagIfAwardCategoryNotInTopX, + FlagIfCategorySpreadTooWide = stored.FlagIfCategorySpreadTooWide, + FlagIfNoCategoryContextAvailable = stored.FlagIfNoCategoryContextAvailable, + MinValue = stored.MinValue, + MaxValue = stored.MaxValue, + }; + } + + private static TrackingFlagRuleSetting NormalizeFlag(TrackingFlagRuleSetting? stored, TrackingFlagRuleSetting fallback) + { + if (stored is null) + { + return fallback; + } + + var severity = stored.Severity.Trim().ToLowerInvariant() switch + { + "high" => "high", + "medium" => "medium", + "low" => "low", + _ => fallback.Severity, + }; + + return fallback with + { + Enabled = stored.Enabled, + Severity = severity, + AutoTriggerEnabled = stored.AutoTriggerEnabled, + RequiresManualReview = stored.RequiresManualReview, + BlocksApproval = stored.BlocksApproval, + AdminNoteRequiredOnOverride = stored.AdminNoteRequiredOnOverride, + }; + } + + private sealed record TrackingRulesConfigurationDto( + TrackingSourceSetting? Source, + TrackingMetricRuleSetting[]? ImportantMetrics, + TrackingMetricRuleSetting[]? OptionalMetrics, + TrackingFlagRuleSetting[]? Flags); +} diff --git a/Backend/Services/TwitchTrackerViewerStatsProvider.cs b/Backend/Services/TwitchTrackerViewerStatsProvider.cs new file mode 100644 index 0000000..cfde12c --- /dev/null +++ b/Backend/Services/TwitchTrackerViewerStatsProvider.cs @@ -0,0 +1,85 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Services; + +public sealed class TwitchTrackerViewerStatsProvider( + IHttpClientFactory httpClientFactory, + AwardsDbContext db, + ILogger logger) + : IViewerStatsProvider +{ + public async Task GetChannelSummaryAsync(string twitchLogin, CancellationToken cancellationToken) + { + var login = twitchLogin.Trim().TrimStart('@').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(login)) + { + return null; + } + + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(3)); + + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, timeout.Token); + + var baseUrl = TrackingRulesSettings.NormalizeBaseUrl(settings?.ViewerStatsProviderBaseUrl); + var client = httpClientFactory.CreateClient("TwitchTracker"); + var response = await client.GetFromJsonAsync( + $"{baseUrl}/channels/summary/{Uri.EscapeDataString(login)}", + timeout.Token); + + if (response is null) + { + return null; + } + + return new ViewerStatsSnapshot( + response.AverageViewers, + Math.Max(0, response.MinutesStreamed / 60), + response.HoursWatched, + response.PeakViewers, + response.FollowersGained, + TrackingRulesSettings.Window30d); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogInformation("TwitchTracker API lookup timed out for {TwitchLogin}.", login); + return null; + } + catch (Exception ex) + { + logger.LogInformation(ex, "TwitchTracker API lookup failed for {TwitchLogin}.", login); + return null; + } + } + + private sealed class TwitchTrackerChannelSummaryResponse + { + [JsonPropertyName("rank")] + public int Rank { get; init; } + + [JsonPropertyName("minutes_streamed")] + public int MinutesStreamed { get; init; } + + [JsonPropertyName("avg_viewers")] + public int AverageViewers { get; init; } + + [JsonPropertyName("max_viewers")] + public int PeakViewers { get; init; } + + [JsonPropertyName("hours_watched")] + public int HoursWatched { get; init; } + + [JsonPropertyName("followers")] + public int FollowersGained { get; init; } + + [JsonPropertyName("followers_total")] + public int FollowersTotal { get; init; } + } +} diff --git a/Backend/Services/UserSessionService.cs b/Backend/Services/UserSessionService.cs index 67a0740..8ad85a5 100644 --- a/Backend/Services/UserSessionService.cs +++ b/Backend/Services/UserSessionService.cs @@ -1,15 +1,18 @@ using Backend.Common; using Backend.Contracts; +using Backend.Data; using Backend.Domain; using Backend.Repositories; using Backend.Security; +using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; namespace Backend.Services; -public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService +public sealed class UserSessionService(IUserSessionRepository userSessionRepository, AwardsDbContext db) : IUserSessionService { - private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12); + public const int MinimumIdleTimeoutHours = 3; + public const int DefaultIdleTimeoutHours = 3; private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30); public async Task ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default) @@ -27,7 +30,8 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit } var now = DateTimeOffset.UtcNow; - if (IsExpired(session, now)) + var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken); + if (IsExpired(session, now, idleSessionLifetime)) { session.IsActive = false; await userSessionRepository.SaveChangesAsync(cancellationToken); @@ -40,6 +44,17 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit return session; } + public async Task GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default) + { + var configuredHours = await db.SiteSettings + .AsNoTracking() + .Where(item => item.Id == 1) + .Select(item => (int?)item.SessionIdleTimeoutHours) + .FirstOrDefaultAsync(cancellationToken); + + return NormalizeIdleTimeoutHours(configuredHours); + } + public Task CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default) { return CreateSessionAsync( @@ -94,9 +109,18 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit : null; } - private static bool IsExpired(UserSession session, DateTimeOffset now) => + private async Task ResolveIdleSessionLifetimeAsync(CancellationToken cancellationToken) + { + var idleTimeoutHours = await GetIdleTimeoutHoursAsync(cancellationToken); + return TimeSpan.FromHours(idleTimeoutHours); + } + + public static int NormalizeIdleTimeoutHours(int? configuredHours) => + Math.Max(MinimumIdleTimeoutHours, configuredHours ?? DefaultIdleTimeoutHours); + + private static bool IsExpired(UserSession session, DateTimeOffset now, TimeSpan idleSessionLifetime) => session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime) - || session.LastSeenAt <= now.Subtract(IdleSessionLifetime); + || session.LastSeenAt <= now.Subtract(idleSessionLifetime); private async Task PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken) { diff --git a/Backend/Services/WorkflowRuleSettings.cs b/Backend/Services/WorkflowRuleSettings.cs index fb1bc44..299bed9 100644 --- a/Backend/Services/WorkflowRuleSettings.cs +++ b/Backend/Services/WorkflowRuleSettings.cs @@ -19,6 +19,7 @@ public static class WorkflowRuleSettings public const string MaxCandidateAppearances = "max_candidate_appearances"; public const string MaxWinnerPlacements = "max_winner_placements"; public const string WinnerRequiresClip = "winner_requires_clip"; + public const string RecommendedNominatorsPerSubcategory = "recommended_nominators_per_subcategory"; public static WorkflowRuleSetting[] Defaults { get; } = [ @@ -26,11 +27,28 @@ public static class WorkflowRuleSettings new(MaxCandidateAppearances, "Kandidaturen pro Person", true, 2, "warn", "Warnt oder blockiert, wenn dieselbe Person in zu vielen Kategorien final auftaucht."), new(MaxWinnerPlacements, "Gewinnerplätze pro Person", true, 1, "block", "Verhindert oder warnt, wenn dieselbe Person mehrfach als Gewinner:in gesetzt wird."), new(WinnerRequiresClip, "Gewinner braucht Clip-Link", true, 1, "block", "Verhindert oder warnt, wenn ein Gewinner ohne gepflegten YouTube-/Twitch-Clip gesetzt wird."), + new(RecommendedNominatorsPerSubcategory, "Empfohlene Nominierer pro Unterkategorie", true, 4, "warn", "Zeigt in der Kategorie-Übersicht an, ab wann eine Unterkategorie nominierungsseitig gut getragen ist. Diese Regel blockiert nichts."), ]; public static WorkflowRuleSetting[] Read(SiteSettings? settings) { var storedRules = Parse(settings?.WorkflowRulesJson); + return Read(storedRules); + } + + public static WorkflowRuleSetting[] Read(Season? season, SiteSettings? settings) + { + var seasonRules = Parse(season?.WorkflowRulesJson); + if (seasonRules.Length > 0) + { + return Read(seasonRules); + } + + return Read(settings); + } + + private static WorkflowRuleSetting[] Read(WorkflowRuleSetting[] storedRules) + { return Defaults .Select(defaultRule => { @@ -98,6 +116,11 @@ public static class WorkflowRuleSettings _ => fallback.Mode, }; + if (string.Equals(fallback.Key, RecommendedNominatorsPerSubcategory, StringComparison.OrdinalIgnoreCase)) + { + mode = "warn"; + } + return rule with { Key = fallback.Key, diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 0e790fc..2677843 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -3,6 +3,8 @@ import { computed, reactive, ref } from 'vue' import { RouterLink, useRoute, useRouter } from 'vue-router' import AppShellAccountModals from './AppShellAccountModals.vue' +import AdminToastViewport from './admin/AdminToastViewport.vue' +import { useAdminApiDisconnectToast } from '../composables/useAdminApiDisconnectToast' import { useBodyScrollLock } from '../composables/useBodyScrollLock' import { privacyContentToHtml } from '../lib/privacyContent' import { useAuthStore } from '../stores/auth' @@ -35,6 +37,7 @@ const navItems = [ ] const visibleNavItems = computed(() => navItems) +const isAdminRoute = computed(() => route.path.startsWith('/admin')) const privacyContent = computed( () => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent, ) @@ -140,6 +143,7 @@ const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;' const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;' +useAdminApiDisconnectToast(isAdminRoute) + diff --git a/frontend/src/components/admin/AdminCandidateEditorModal.vue b/frontend/src/components/admin/AdminCandidateEditorModal.vue index 669cbc9..0db89bb 100644 --- a/frontend/src/components/admin/AdminCandidateEditorModal.vue +++ b/frontend/src/components/admin/AdminCandidateEditorModal.vue @@ -31,6 +31,41 @@ +
+
+

Regel-Kontext

+

Diese Werte helfen bei mehrfachen Nominierungen und Gewinnergrenzen.

+
+
+
+

Kandidaturen

+ {{ identitySummary.appearances }} +
+
+

Angenommen

+ {{ identitySummary.acceptedAppearances }} +
+
+

Gewinner

+ {{ identitySummary.winnerPlacements }} +
+
+

Viewer-Nominierungen

+ {{ identitySummary.nominationTally }} +
+
+
+

+ {{ notice.mode === 'block' ? 'Blockiert' : 'Warnung' }}: {{ notice.message }} +

+
+
+

Annahmestatus

@@ -128,6 +163,13 @@ defineProps<{ acceptanceStatusOptions: Array<{ label: string; value: string; description: string }> clipEmbedStatusOptions: Array<{ label: string; value: string }> selectedPlatformValue: string + identitySummary: { + appearances: number + acceptedAppearances: number + winnerPlacements: number + nominationTally: number + } | null + ruleNotices: Array<{ mode: 'warn' | 'block'; message: string }> canSave: boolean saving: boolean }>() diff --git a/frontend/src/components/admin/AdminCandidatesTable.vue b/frontend/src/components/admin/AdminCandidatesTable.vue index 6a7440c..cdff0a2 100644 --- a/frontend/src/components/admin/AdminCandidatesTable.vue +++ b/frontend/src/components/admin/AdminCandidatesTable.vue @@ -22,6 +22,13 @@

{{ candidate.displayName }}

{{ candidate.channelSlug }}

+

+ {{ candidate.nominationTally }} Viewer-Nominierungen + +

Mögliches Duplikat in dieser Kategorie

@@ -122,6 +129,7 @@ const props = defineProps<{ rangeEnd: number categoryLabelMap: Record duplicateCandidateKeys: Map + candidateIdentitySummaries: Record candidateWorkflowNotices: Record> acceptanceStatusOptions: Array<{ label: string; value: string; description: string }> }>() @@ -138,6 +146,15 @@ function isDuplicate(candidate: AdminCandidateItem) { || (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1 } +function identityKey(candidate: AdminCandidateItem) { + if (typeof candidate.streamerIdentityId === 'number') { + return `identity:${candidate.streamerIdentityId}` + } + + const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase() + return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}` +} + function acceptanceLabel(value: string) { return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen' } diff --git a/frontend/src/components/admin/AdminCategoryGroupModal.vue b/frontend/src/components/admin/AdminCategoryGroupModal.vue new file mode 100644 index 0000000..585f09e --- /dev/null +++ b/frontend/src/components/admin/AdminCategoryGroupModal.vue @@ -0,0 +1,109 @@ + diff --git a/frontend/src/components/admin/AdminNominationReviewModal.vue b/frontend/src/components/admin/AdminNominationReviewModal.vue index e7b8b12..e9cacf4 100644 --- a/frontend/src/components/admin/AdminNominationReviewModal.vue +++ b/frontend/src/components/admin/AdminNominationReviewModal.vue @@ -1,52 +1,51 @@ + +