From 18b61bed52cd4d32d796e0acbad6b130267d4c8c Mon Sep 17 00:00:00 2001 From: AzuTear Date: Sat, 27 Jun 2026 18:39:35 +0200 Subject: [PATCH] Improve admin candidate modal UX and add clip menu visibility toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Widen AdminCandidateEditorModal to size lg for better readability - Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link" - Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport - Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled Co-Authored-By: Claude Sonnet 4.6 --- Backend/Common/SeasonMappings.cs | 1 + Backend/Contracts/AdminModerationContracts.cs | 20 + Backend/Contracts/AdminSeasonContracts.cs | 16 +- .../Contracts/AdminSiteSettingsContracts.cs | 22 + Backend/Contracts/ExtrasContracts.cs | 51 + Backend/Contracts/PublicOverviewContracts.cs | 15 +- .../PublicSeasonCategoryContracts.cs | 4 +- .../Contracts/PublicWinnerArchiveContracts.cs | 6 +- Backend/Data/AwardsDbContext.cs | 46 + Backend/Data/OperationalTablesBootstrapper.cs | 116 ++ Backend/Data/SeedData.cs | 10 + Backend/Data/SeedSiteSettingsBootstrapper.cs | 56 + Backend/Domain/Candidate.cs | 6 + Backend/Domain/ShowactApplication.cs | 23 + Backend/Domain/SiteSettings.cs | 11 + Backend/Domain/Sponsor.cs | 17 + Backend/Endpoints/AdminEndpoints.cs | 1 + Backend/Endpoints/AdminExtrasEndpoints.cs | 321 +++ Backend/Endpoints/AdminModerationEndpoints.cs | 12 + .../AdminNominationLinkBlacklistEndpoints.cs | 123 ++ .../AdminSeasonCandidateEndpoints.cs | 105 +- .../Endpoints/AdminSeasonDetailEndpoints.cs | 8 +- .../AdminSeasonManagementEndpoints.cs | 8 + .../Endpoints/AdminSeasonManagementSupport.cs | 132 ++ .../Endpoints/AdminSeasonResultsEndpoints.cs | 34 + .../Endpoints/AdminSiteSettingsEndpoints.cs | 119 +- .../Endpoints/AdminWorkflowRuleEndpoints.cs | 87 + Backend/Endpoints/PublicClipEndpoints.cs | 16 + Backend/Endpoints/PublicEndpoints.cs | 9 + Backend/Endpoints/PublicExtrasEndpoints.cs | 131 ++ .../Endpoints/PublicNominationEndpoints.cs | 11 + Backend/Endpoints/PublicOverviewEndpoints.cs | 21 +- .../PublicSeasonCategoryReadEndpoints.cs | 27 +- .../Endpoints/PublicWinnerArchiveEndpoints.cs | 10 +- ..._AddCandidatePreparationFields.Designer.cs | 1527 ++++++++++++++ ...627114301_AddCandidatePreparationFields.cs | 177 ++ ...627125128_AddWorkflowRulesJson.Designer.cs | 1534 ++++++++++++++ .../20260627125128_AddWorkflowRulesJson.cs | 35 + ...AddOptionalClipFeatureSettings.Designer.cs | 1552 +++++++++++++++ ...27131746_AddOptionalClipFeatureSettings.cs | 55 + ...729_AddNominationLinkBlacklist.Designer.cs | 1559 +++++++++++++++ ...260627135729_AddNominationLinkBlacklist.cs | 36 + ...163650_AddClipAdminMenuVisible.Designer.cs | 1758 +++++++++++++++++ .../20260627163650_AddClipAdminMenuVisible.cs | 180 ++ .../AwardsDbContextModelSnapshot.cs | 289 ++- .../NominationLinkBlacklistSettings.cs | 121 ++ Backend/Services/WorkflowRuleSettings.cs | 111 ++ docs/workflow-feedback-plan.md | 255 +++ frontend/src/components/AppShell.vue | 3 + .../admin/AdminCandidateEditorModal.vue | 80 +- .../admin/AdminCandidatesFiltersBar.vue | 10 +- .../components/admin/AdminCandidatesTable.vue | 61 +- .../AdminContentLandingExtrasSection.vue | 393 ++++ .../admin/AdminContentLinksSection.vue | 20 +- .../AdminNominationLinkBlacklistModal.vue | 160 ++ .../admin/AdminNominationReviewModal.vue | 4 + .../admin/AdminOptionalFeaturesCard.vue | 78 + .../admin/AdminOptionalFeaturesModal.vue | 131 ++ .../admin/AdminReviewDecisionPanel.vue | 34 +- .../admin/AdminWorkflowRulesCard.vue | 126 ++ .../src/components/admin/adminContentTypes.ts | 2 + .../admin/useAdminCandidateManager.ts | 202 +- .../components/admin/useAdminClipManager.ts | 22 + .../admin/useAdminContentManager.ts | 6 + .../admin/useAdminOptionalFeatures.ts | 123 ++ .../admin/useAdminReviewsManager.ts | 21 + .../admin/useAdminSettingsOverview.ts | 17 +- .../admin/useAdminWinnersManager.ts | 62 + .../components/admin/useAdminWorkflowRules.ts | 97 + .../src/components/cinematicStarLoader.css | 6 +- .../home/HomeAccountAndPrivacyModals.vue | 4 +- .../src/components/home/HomeArchiveModal.vue | 68 +- .../components/home/HomeCategoriesSection.vue | 2 +- .../home/HomeCategoryProgressRail.vue | 37 + .../src/components/home/HomeExtrasSection.vue | 136 ++ .../components/home/HomeHeroStreamBand.vue | 8 +- .../components/home/HomeInteractionModal.vue | 371 +++- .../components/home/HomeLandingExperience.vue | 22 +- .../src/components/home/HomeLandingModals.vue | 7 +- .../home/HomeMissingVotesConfirm.vue | 37 + .../home/HomeNominationReviewPane.vue | 46 + .../home/HomeNominationWizardPane.vue | 59 + .../home/HomeParticipationSection.vue | 10 +- .../src/components/home/HomeStarField.vue | 72 + .../home/HomeStickyCountdownPill.vue | 37 + .../home/HomeSupportFooterSection.vue | 8 +- .../components/home/HomeTimelineSection.vue | 4 +- .../components/home/HomeVotingPickerPane.vue | 205 +- .../home/HomeWinnerShowcaseSection.vue | 54 +- .../src/components/home/HomeWizardFooter.vue | 36 + .../components/home/homeLandingExperience.css | 1320 ++++++++++++- .../src/components/home/homeLandingTypes.ts | 6 +- .../src/components/home/homeModalTypes.ts | 7 + .../home/useHomeArchivePresentation.ts | 39 +- .../home/useHomeLandingPresentation.ts | 20 +- .../components/home/useHomeLandingState.ts | 14 +- .../home/useHomeLandingViewEffects.ts | 77 +- .../home/useHomeParticipationActions.ts | 10 + .../home/useHomeParticipationPresentation.ts | 10 +- .../useHomeParticipationSessionActions.ts | 6 + .../home/useHomeParticipationState.ts | 15 +- .../home/useHomeParticipationSubmitActions.ts | 69 +- .../home/useHomePhasePresentation.ts | 8 +- frontend/src/components/ui/NativeSelect.vue | 63 +- frontend/src/lib/api/adminApi.ts | 41 + frontend/src/lib/api/publicApi.ts | 6 + frontend/src/stores/awards.ts | 121 +- frontend/src/stores/awards/defaults.ts | 56 +- frontend/src/types/awards/admin.ts | 68 + frontend/src/types/awards/payloads.ts | 65 + frontend/src/types/awards/public.ts | 37 + .../src/views/admin/AdminCandidatesView.vue | 48 + frontend/src/views/admin/AdminClipsView.vue | 30 +- frontend/src/views/admin/AdminContentView.vue | 28 +- frontend/src/views/admin/AdminLayoutView.vue | 17 +- .../src/views/admin/AdminNominationsView.vue | 29 +- frontend/src/views/admin/AdminReviewsView.vue | 4 + .../src/views/admin/AdminSettingsView.vue | 85 +- frontend/src/views/admin/AdminWinnersView.vue | 41 +- 119 files changed, 15638 insertions(+), 367 deletions(-) create mode 100644 Backend/Contracts/ExtrasContracts.cs create mode 100644 Backend/Domain/ShowactApplication.cs create mode 100644 Backend/Domain/Sponsor.cs create mode 100644 Backend/Endpoints/AdminExtrasEndpoints.cs create mode 100644 Backend/Endpoints/AdminNominationLinkBlacklistEndpoints.cs create mode 100644 Backend/Endpoints/AdminWorkflowRuleEndpoints.cs create mode 100644 Backend/Endpoints/PublicExtrasEndpoints.cs create mode 100644 Backend/Migrations/20260627114301_AddCandidatePreparationFields.Designer.cs create mode 100644 Backend/Migrations/20260627114301_AddCandidatePreparationFields.cs create mode 100644 Backend/Migrations/20260627125128_AddWorkflowRulesJson.Designer.cs create mode 100644 Backend/Migrations/20260627125128_AddWorkflowRulesJson.cs create mode 100644 Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.Designer.cs create mode 100644 Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.cs create mode 100644 Backend/Migrations/20260627135729_AddNominationLinkBlacklist.Designer.cs create mode 100644 Backend/Migrations/20260627135729_AddNominationLinkBlacklist.cs create mode 100644 Backend/Migrations/20260627163650_AddClipAdminMenuVisible.Designer.cs create mode 100644 Backend/Migrations/20260627163650_AddClipAdminMenuVisible.cs create mode 100644 Backend/Services/NominationLinkBlacklistSettings.cs create mode 100644 Backend/Services/WorkflowRuleSettings.cs create mode 100644 docs/workflow-feedback-plan.md create mode 100644 frontend/src/components/admin/AdminContentLandingExtrasSection.vue create mode 100644 frontend/src/components/admin/AdminNominationLinkBlacklistModal.vue create mode 100644 frontend/src/components/admin/AdminOptionalFeaturesCard.vue create mode 100644 frontend/src/components/admin/AdminOptionalFeaturesModal.vue create mode 100644 frontend/src/components/admin/AdminWorkflowRulesCard.vue create mode 100644 frontend/src/components/admin/useAdminOptionalFeatures.ts create mode 100644 frontend/src/components/admin/useAdminWorkflowRules.ts create mode 100644 frontend/src/components/home/HomeCategoryProgressRail.vue create mode 100644 frontend/src/components/home/HomeExtrasSection.vue create mode 100644 frontend/src/components/home/HomeMissingVotesConfirm.vue create mode 100644 frontend/src/components/home/HomeNominationReviewPane.vue create mode 100644 frontend/src/components/home/HomeNominationWizardPane.vue create mode 100644 frontend/src/components/home/HomeStarField.vue create mode 100644 frontend/src/components/home/HomeStickyCountdownPill.vue create mode 100644 frontend/src/components/home/HomeWizardFooter.vue diff --git a/Backend/Common/SeasonMappings.cs b/Backend/Common/SeasonMappings.cs index 7d0e463..e5a4d42 100644 --- a/Backend/Common/SeasonMappings.cs +++ b/Backend/Common/SeasonMappings.cs @@ -184,5 +184,6 @@ public static class SeasonMappings new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent), new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent), new FooterLinkDto("sponsors", "Sponsoren & Partner", settings.SponsorsUrl, settings.SponsorsContent), + new FooterLinkDto("showacts", "Showacts", settings.ShowactsUrl, settings.ShowactsContent), ]; } diff --git a/Backend/Contracts/AdminModerationContracts.cs b/Backend/Contracts/AdminModerationContracts.cs index 9d0953a..b8b0b50 100644 --- a/Backend/Contracts/AdminModerationContracts.cs +++ b/Backend/Contracts/AdminModerationContracts.cs @@ -91,6 +91,14 @@ public sealed record ApproveNominationRequest( public sealed record RejectNominationRequest(string? ReviewNote); +public sealed record AdminNominationLinkBlacklistEntryDto(string Url); + +public sealed record AdminNominationLinkBlacklistResponse(AdminNominationLinkBlacklistEntryDto[] Entries); + +public sealed record UpdateNominationLinkBlacklistRequest(string[] Urls); + +public sealed record AddNominationLinkBlacklistEntryRequest(string Url); + public sealed record UpdateClipStatusRequest( string Status, string? ReviewNote); @@ -116,3 +124,15 @@ public sealed record AdminRiskRuleDto( public sealed record AdminRiskRulesResponse(AdminRiskRuleDto[] Rules); public sealed record UpdateRiskRulesRequest(AdminRiskRuleDto[] Rules); + +public sealed record AdminWorkflowRuleDto( + string Key, + string Label, + bool Enabled, + int Limit, + string Mode, + string Description); + +public sealed record AdminWorkflowRulesResponse(AdminWorkflowRuleDto[] Rules); + +public sealed record UpdateWorkflowRulesRequest(AdminWorkflowRuleDto[] Rules); diff --git a/Backend/Contracts/AdminSeasonContracts.cs b/Backend/Contracts/AdminSeasonContracts.cs index fb6e06a..9a2fdae 100644 --- a/Backend/Contracts/AdminSeasonContracts.cs +++ b/Backend/Contracts/AdminSeasonContracts.cs @@ -23,7 +23,13 @@ public sealed record AdminCandidateItemDto( int CategoryId, string DisplayName, string ChannelSlug, - string Platform); + string Platform, + string AcceptanceStatus, + string? AcceptanceNote, + string? ClipCompilationUrl, + string? ClipCompilationTitle, + string? ClipCompilationPlatform, + string ClipEmbedStatus); public sealed record AdminAwardResultItemDto( int Id, @@ -102,7 +108,13 @@ public sealed record UpsertCandidateRequest( int CategoryId, string DisplayName, string ChannelSlug, - string Platform); + string Platform, + string? AcceptanceStatus = null, + string? AcceptanceNote = null, + string? ClipCompilationUrl = null, + string? ClipCompilationTitle = null, + string? ClipCompilationPlatform = null, + string? ClipEmbedStatus = null); public sealed record SetAwardResultRequest( int CategoryId, diff --git a/Backend/Contracts/AdminSiteSettingsContracts.cs b/Backend/Contracts/AdminSiteSettingsContracts.cs index 8ce83dd..bd943f1 100644 --- a/Backend/Contracts/AdminSiteSettingsContracts.cs +++ b/Backend/Contracts/AdminSiteSettingsContracts.cs @@ -14,6 +14,8 @@ public sealed record AdminSiteSettingsResponse( string ContactContent, string SponsorsUrl, string SponsorsContent, + string ShowactsUrl, + string ShowactsContent, IEnumerable SocialLinks, IEnumerable Faq); @@ -29,6 +31,8 @@ public sealed record UpdateSiteSettingsRequest( string ContactContent, string SponsorsUrl, string SponsorsContent, + string ShowactsUrl, + string ShowactsContent, PublicSocialLinkDto[] SocialLinks, FaqItemDto[] Faq); @@ -49,6 +53,24 @@ public sealed record AdminOperationalSettingsResponse( string MaintenanceTitle, string MaintenanceMessage); +public sealed record AdminOptionalFeatureSettingsResponse( + bool ClipSubmissionsEnabled, + bool ClipReviewEnabled, + bool ClipAdminMenuVisible, + string ClipSubmissionDisabledMessage, + bool ShowactApplicationsEnabled, + string ShowactApplicationDisabledMessage, + bool SponsorsVisible); + +public sealed record UpdateOptionalFeatureSettingsRequest( + bool ClipSubmissionsEnabled, + bool ClipReviewEnabled, + bool ClipAdminMenuVisible, + string ClipSubmissionDisabledMessage, + bool ShowactApplicationsEnabled, + string ShowactApplicationDisabledMessage, + bool SponsorsVisible); + public sealed record UpdateOperationalSettingsRequest( bool DemoLoginEnabled, string DemoLoginEmail, diff --git a/Backend/Contracts/ExtrasContracts.cs b/Backend/Contracts/ExtrasContracts.cs new file mode 100644 index 0000000..3289db8 --- /dev/null +++ b/Backend/Contracts/ExtrasContracts.cs @@ -0,0 +1,51 @@ +namespace Backend.Contracts; + +public sealed record SponsorDto( + int Id, + int SeasonId, + string Name, + string WebsiteUrl, + string LogoUrl, + string Description, + string Tier, + int SortOrder, + bool IsVisible); + +public sealed record PublicSponsorsResponse(int Year, SponsorDto[] Items); + +public sealed record UpsertSponsorRequest( + string Name, + string WebsiteUrl, + string LogoUrl, + string Description, + string Tier, + int SortOrder, + bool IsVisible); + +public sealed record ShowactApplicationDto( + int Id, + int SeasonId, + string ArtistName, + string ContactEmail, + string ContactDiscord, + string PlatformUrl, + string PerformanceType, + string Description, + string TechnicalNotes, + string ReferenceUrl, + string Status, + string? ReviewNote, + DateTimeOffset CreatedAt, + DateTimeOffset? ReviewedAt); + +public sealed record CreateShowactApplicationRequest( + string ArtistName, + string ContactEmail, + string ContactDiscord, + string PlatformUrl, + string PerformanceType, + string Description, + string TechnicalNotes, + string ReferenceUrl); + +public sealed record UpdateShowactStatusRequest(string Status, string? ReviewNote); diff --git a/Backend/Contracts/PublicOverviewContracts.cs b/Backend/Contracts/PublicOverviewContracts.cs index 21f8fce..c3072f1 100644 --- a/Backend/Contracts/PublicOverviewContracts.cs +++ b/Backend/Contracts/PublicOverviewContracts.cs @@ -20,7 +20,11 @@ public sealed record WinnerPreviewDto( string WinnerName, string WinnerSlug, string WinnerPlatform, - string WinnerUrl); + string WinnerUrl, + string? ClipUrl, + string? ClipTitle, + string? ClipPlatform, + string? ClipEmbedStatus); public sealed record ArchiveYearDto( int Year, @@ -57,6 +61,14 @@ public sealed record PublicSiteStatusResponse( string MaintenanceTitle, string MaintenanceMessage); +public sealed record PublicFeatureFlagsDto( + bool ClipSubmissionsEnabled, + bool ClipReviewEnabled, + string ClipSubmissionDisabledMessage, + bool ShowactApplicationsEnabled, + string ShowactApplicationDisabledMessage, + bool SponsorsVisible); + public sealed record OverviewResponse( int SeasonId, int Year, @@ -72,4 +84,5 @@ public sealed record OverviewResponse( IEnumerable WinnersPreview, IEnumerable ArchiveYears, PublicSiteContentDto SiteContent, + PublicFeatureFlagsDto FeatureFlags, IEnumerable Faq); diff --git a/Backend/Contracts/PublicSeasonCategoryContracts.cs b/Backend/Contracts/PublicSeasonCategoryContracts.cs index c9df6ea..bd920ff 100644 --- a/Backend/Contracts/PublicSeasonCategoryContracts.cs +++ b/Backend/Contracts/PublicSeasonCategoryContracts.cs @@ -4,10 +4,12 @@ public sealed record CandidateSummaryDto( int Id, string DisplayName, string ChannelSlug, + string ChannelUrl, string Platform, string? ClipUrl, string? ClipTitle, - string? ClipPlatform); + string? ClipPlatform, + string? ClipEmbedStatus); public sealed record PublicCategoryDetailDto( int Id, diff --git a/Backend/Contracts/PublicWinnerArchiveContracts.cs b/Backend/Contracts/PublicWinnerArchiveContracts.cs index 372676b..9f121a1 100644 --- a/Backend/Contracts/PublicWinnerArchiveContracts.cs +++ b/Backend/Contracts/PublicWinnerArchiveContracts.cs @@ -5,7 +5,11 @@ public sealed record WinnerArchiveItemDto( string WinnerName, string WinnerSlug, string WinnerPlatform, - string WinnerUrl); + string WinnerUrl, + string? ClipUrl, + string? ClipTitle, + string? ClipPlatform, + string? ClipEmbedStatus); public sealed record WinnerArchiveResponse( int Year, diff --git a/Backend/Data/AwardsDbContext.cs b/Backend/Data/AwardsDbContext.cs index c4d6293..c851075 100644 --- a/Backend/Data/AwardsDbContext.cs +++ b/Backend/Data/AwardsDbContext.cs @@ -16,6 +16,8 @@ public sealed class AwardsDbContext(DbContextOptions options) : public DbSet RiskFlags => Set(); public DbSet AdminAuditEntries => Set(); public DbSet ClipSubmissions => Set(); + public DbSet ShowactApplications => Set(); + public DbSet Sponsors => Set(); public DbSet SiteSettings => Set(); public DbSet TeamMembers => Set(); public DbSet TeamRolePermissions => Set(); @@ -40,6 +42,8 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.ImprintUrl).HasMaxLength(400); entity.Property(item => item.ContactUrl).HasMaxLength(400); entity.Property(item => item.SponsorsUrl).HasMaxLength(400); + entity.Property(item => item.ShowactsUrl).HasMaxLength(400); + entity.Property(item => item.ShowactsContent).HasDefaultValue(string.Empty); entity.Property(item => item.DemoLoginEmail).HasMaxLength(180); entity.Property(item => item.DemoLoginPasswordHash).HasMaxLength(120); entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80); @@ -51,6 +55,14 @@ public sealed class AwardsDbContext(DbContextOptions options) : entity.Property(item => item.TwitchScope).HasMaxLength(300); entity.Property(item => item.MaintenanceTitle).HasMaxLength(120); entity.Property(item => item.MaintenanceMessage).HasMaxLength(600); + entity.Property(item => item.WorkflowRulesJson).HasDefaultValue("[]"); + 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.ShowactApplicationDisabledMessage).HasMaxLength(240); + entity.Property(item => item.SponsorsVisible).HasDefaultValue(true); }); modelBuilder.Entity(entity => @@ -88,6 +100,12 @@ public sealed class AwardsDbContext(DbContextOptions options) : 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.AcceptanceStatus).HasMaxLength(30).HasDefaultValue("open"); + entity.Property(item => item.AcceptanceNote).HasMaxLength(500); + entity.Property(item => item.ClipCompilationUrl).HasMaxLength(500); + entity.Property(item => item.ClipCompilationTitle).HasMaxLength(200); + entity.Property(item => item.ClipCompilationPlatform).HasMaxLength(40); + entity.Property(item => item.ClipEmbedStatus).HasMaxLength(30).HasDefaultValue("unchecked"); }); modelBuilder.Entity(entity => @@ -169,6 +187,34 @@ public sealed class AwardsDbContext(DbContextOptions options) : .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity(entity => + { + entity.Property(item => item.ArtistName).HasMaxLength(120); + entity.Property(item => item.ContactEmail).HasMaxLength(180); + entity.Property(item => item.ContactDiscord).HasMaxLength(120); + entity.Property(item => item.PlatformUrl).HasMaxLength(500); + entity.Property(item => item.PerformanceType).HasMaxLength(80); + entity.Property(item => item.Description).HasMaxLength(1000); + entity.Property(item => item.TechnicalNotes).HasMaxLength(1000); + entity.Property(item => item.ReferenceUrl).HasMaxLength(500); + entity.Property(item => item.Status).HasMaxLength(20); + entity.Property(item => item.ReviewNote).HasMaxLength(500); + entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120); + entity.Property(item => item.CreatedFromIp).HasMaxLength(80); + entity.Property(item => item.UserAgent).HasMaxLength(400); + entity.HasIndex(item => new { item.SeasonId, item.Status }); + }); + + modelBuilder.Entity(entity => + { + entity.Property(item => item.Name).HasMaxLength(120); + entity.Property(item => item.WebsiteUrl).HasMaxLength(500); + entity.Property(item => item.LogoUrl).HasMaxLength(500); + entity.Property(item => item.Description).HasMaxLength(500); + entity.Property(item => item.Tier).HasMaxLength(80); + entity.HasIndex(item => new { item.SeasonId, item.IsVisible, item.SortOrder }); + }); + SeedData.Apply(modelBuilder); } } diff --git a/Backend/Data/OperationalTablesBootstrapper.cs b/Backend/Data/OperationalTablesBootstrapper.cs index f32dea6..f0eac28 100644 --- a/Backend/Data/OperationalTablesBootstrapper.cs +++ b/Backend/Data/OperationalTablesBootstrapper.cs @@ -16,6 +16,30 @@ public static class OperationalTablesBootstrapper ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]'; + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]'; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "NominationLinkBlacklistJson" text NOT NULL DEFAULT '[]'; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.'; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ShowactApplicationsEnabled" boolean NOT NULL DEFAULT false; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "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 "SiteSettings" ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false; @@ -40,6 +64,12 @@ public static class OperationalTablesBootstrapper ALTER TABLE "SiteSettings" ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT ''; + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ShowactsUrl" character varying(400) NOT NULL DEFAULT ''; + + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ShowactsContent" text NOT NULL DEFAULT ''; + CREATE TABLE IF NOT EXISTS "RiskFlags" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "SeasonId" integer NULL, @@ -121,6 +151,74 @@ public static class OperationalTablesBootstrapper CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_CandidateId" ON "ClipSubmissions" ("CandidateId"); + CREATE TABLE IF NOT EXISTS "ShowactApplications" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "SeasonId" integer NOT NULL, + "ArtistName" character varying(120) NOT NULL, + "ContactEmail" character varying(180) NOT NULL, + "ContactDiscord" character varying(120) NOT NULL, + "PlatformUrl" character varying(500) NOT NULL, + "PerformanceType" character varying(80) NOT NULL, + "Description" character varying(1000) NOT NULL, + "TechnicalNotes" character varying(1000) NOT NULL, + "ReferenceUrl" character varying(500) NOT NULL, + "Status" character varying(20) NOT NULL, + "ReviewNote" character varying(500) NULL, + "ReviewedByTwitchId" character varying(120) NULL, + "CreatedFromIp" character varying(80) NOT NULL, + "UserAgent" character varying(400) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "ReviewedAt" timestamp with time zone NULL + ); + + CREATE INDEX IF NOT EXISTS "IX_ShowactApplications_SeasonId_Status" + ON "ShowactApplications" ("SeasonId", "Status"); + + CREATE TABLE IF NOT EXISTS "Sponsors" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "SeasonId" integer NOT NULL, + "Name" character varying(120) NOT NULL, + "WebsiteUrl" character varying(500) NOT NULL, + "LogoUrl" character varying(500) NOT NULL, + "Description" character varying(500) NOT NULL, + "Tier" character varying(80) NOT NULL, + "SortOrder" integer NOT NULL, + "IsVisible" boolean NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + "UpdatedAt" timestamp with time zone NULL + ); + + CREATE INDEX IF NOT EXISTS "IX_Sponsors_SeasonId_IsVisible_SortOrder" + ON "Sponsors" ("SeasonId", "IsVisible", "SortOrder"); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_ShowactApplications_Seasons_SeasonId' + ) THEN + ALTER TABLE "ShowactApplications" + ADD CONSTRAINT "FK_ShowactApplications_Seasons_SeasonId" + FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") + ON DELETE CASCADE; + END IF; + END $$; + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'FK_Sponsors_Seasons_SeasonId' + ) THEN + ALTER TABLE "Sponsors" + ADD CONSTRAINT "FK_Sponsors_Seasons_SeasonId" + FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") + ON DELETE CASCADE; + END IF; + END $$; + DO $$ BEGIN IF NOT EXISTS ( @@ -138,6 +236,24 @@ public static class OperationalTablesBootstrapper ALTER TABLE "Nominations" ADD COLUMN IF NOT EXISTS "StreamUrl" character varying(300) NULL; + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "AcceptanceStatus" character varying(30) NOT NULL DEFAULT 'open'; + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "AcceptanceNote" character varying(500) NULL; + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "ClipCompilationUrl" character varying(500) NULL; + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "ClipCompilationTitle" character varying(200) NULL; + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "ClipCompilationPlatform" character varying(40) NULL; + + ALTER TABLE "Candidates" + ADD COLUMN IF NOT EXISTS "ClipEmbedStatus" character varying(30) NOT NULL DEFAULT 'unchecked'; + ALTER TABLE "Nominations" ADD COLUMN IF NOT EXISTS "Status" character varying(20) NOT NULL DEFAULT 'pending'; diff --git a/Backend/Data/SeedData.cs b/Backend/Data/SeedData.cs index c2c347e..fe87a06 100644 --- a/Backend/Data/SeedData.cs +++ b/Backend/Data/SeedData.cs @@ -48,7 +48,17 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus ContactContent = SeedCatalog.DefaultContactContent, SponsorsUrl = "https://vtuber-star-awards.de/partner", SponsorsContent = SeedCatalog.DefaultSponsorsContent, + ShowactsUrl = "https://vtuber-star-awards.de/showacts", + ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults), + WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults), + NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults), + ClipSubmissionsEnabled = false, + ClipReviewEnabled = true, + ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.", + ShowactApplicationsEnabled = false, + ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen.", + SponsorsVisible = true, SocialLinksJson = JsonSerializer.Serialize(new[] { new { label = "Twitch", platform = "twitch", url = "https://twitch.tv/jayuhime", icon = "twitch", showOnHost = true, showOnCommunity = true }, diff --git a/Backend/Data/SeedSiteSettingsBootstrapper.cs b/Backend/Data/SeedSiteSettingsBootstrapper.cs index c3f08c3..e4b0e36 100644 --- a/Backend/Data/SeedSiteSettingsBootstrapper.cs +++ b/Backend/Data/SeedSiteSettingsBootstrapper.cs @@ -41,6 +41,26 @@ public static partial class SeedDataBootstrapper settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults); } + if (!HasValidWorkflowRules(settings.WorkflowRulesJson)) + { + settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(WorkflowRuleSettings.Defaults); + } + + if (!HasValidNominationLinkBlacklist(settings.NominationLinkBlacklistJson)) + { + settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(NominationLinkBlacklistSettings.Defaults); + } + + if (string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage)) + { + settings.ClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen."; + } + + if (string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)) + { + settings.ShowactApplicationDisabledMessage = "Showact-Bewerbungen sind aktuell geschlossen."; + } + if (string.IsNullOrWhiteSpace(settings.ImprintContent)) { settings.ImprintContent = SeedCatalog.DefaultImprintContent; @@ -55,6 +75,16 @@ public static partial class SeedDataBootstrapper { settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent; } + + if (string.IsNullOrWhiteSpace(settings.ShowactsUrl)) + { + settings.ShowactsUrl = "https://vtuber-star-awards.de/showacts"; + } + + if (string.IsNullOrWhiteSpace(settings.ShowactsContent)) + { + settings.ShowactsContent = "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show."; + } } private static bool HasValidSiteArray(string? json, params string[] requiredKeys) @@ -87,4 +117,30 @@ public static partial class SeedDataBootstrapper private static bool HasValidRiskRules(string? json) => RiskRuleSettings.Read(new Backend.Domain.SiteSettings { RiskRulesJson = json ?? string.Empty }).Length == RiskRuleSettings.Defaults.Length; + + private static bool HasValidWorkflowRules(string? json) => + WorkflowRuleSettings.Read(new Backend.Domain.SiteSettings { WorkflowRulesJson = json ?? string.Empty }).Length == WorkflowRuleSettings.Defaults.Length; + + private static bool HasValidNominationLinkBlacklist(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(json); + return document.RootElement.ValueKind == JsonValueKind.Array + && document.RootElement.EnumerateArray().Any(item => + item.ValueKind == JsonValueKind.Object + && item.TryGetProperty("Url", out var url) + && url.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(url.GetString())); + } + catch (JsonException) + { + return false; + } + } } diff --git a/Backend/Domain/Candidate.cs b/Backend/Domain/Candidate.cs index 5aa4009..6ce2f09 100644 --- a/Backend/Domain/Candidate.cs +++ b/Backend/Domain/Candidate.cs @@ -10,4 +10,10 @@ public sealed class Candidate public string DisplayName { get; set; } = string.Empty; public string ChannelSlug { get; set; } = string.Empty; public string Platform { get; set; } = "Twitch"; + public string AcceptanceStatus { get; set; } = "open"; + public string? AcceptanceNote { get; set; } + public string? ClipCompilationUrl { get; set; } + public string? ClipCompilationTitle { get; set; } + public string? ClipCompilationPlatform { get; set; } + public string ClipEmbedStatus { get; set; } = "unchecked"; } diff --git a/Backend/Domain/ShowactApplication.cs b/Backend/Domain/ShowactApplication.cs new file mode 100644 index 0000000..821323c --- /dev/null +++ b/Backend/Domain/ShowactApplication.cs @@ -0,0 +1,23 @@ +namespace Backend.Domain; + +public sealed class ShowactApplication +{ + public int Id { get; set; } + public int SeasonId { get; set; } + public Season Season { get; set; } = null!; + public string ArtistName { get; set; } = string.Empty; + public string ContactEmail { get; set; } = string.Empty; + public string ContactDiscord { get; set; } = string.Empty; + public string PlatformUrl { get; set; } = string.Empty; + public string PerformanceType { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string TechnicalNotes { get; set; } = string.Empty; + public string ReferenceUrl { get; set; } = string.Empty; + public string Status { get; set; } = "pending"; + public string? ReviewNote { get; set; } + public string? ReviewedByTwitchId { get; set; } + public string CreatedFromIp { get; set; } = string.Empty; + public string UserAgent { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ReviewedAt { get; set; } +} diff --git a/Backend/Domain/SiteSettings.cs b/Backend/Domain/SiteSettings.cs index d915bbe..72ac1a3 100644 --- a/Backend/Domain/SiteSettings.cs +++ b/Backend/Domain/SiteSettings.cs @@ -16,9 +16,20 @@ public sealed class SiteSettings public string ContactContent { get; set; } = string.Empty; public string SponsorsUrl { get; set; } = string.Empty; public string SponsorsContent { get; set; } = string.Empty; + public string ShowactsUrl { get; set; } = string.Empty; + public string ShowactsContent { get; set; } = string.Empty; public string SocialLinksJson { get; set; } = "[]"; public string FaqJson { get; set; } = "[]"; public string RiskRulesJson { get; set; } = "[]"; + public string WorkflowRulesJson { get; set; } = "[]"; + 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 string ShowactApplicationDisabledMessage { get; set; } = "Showact-Bewerbungen sind aktuell geschlossen."; + public bool SponsorsVisible { get; set; } = true; public bool DemoLoginManagedByDatabase { get; set; } public bool DemoLoginEnabled { get; set; } public string DemoLoginEmail { get; set; } = string.Empty; diff --git a/Backend/Domain/Sponsor.cs b/Backend/Domain/Sponsor.cs new file mode 100644 index 0000000..259b4c0 --- /dev/null +++ b/Backend/Domain/Sponsor.cs @@ -0,0 +1,17 @@ +namespace Backend.Domain; + +public sealed class Sponsor +{ + public int Id { get; set; } + public int SeasonId { get; set; } + public Season Season { get; set; } = null!; + public string Name { get; set; } = string.Empty; + public string WebsiteUrl { get; set; } = string.Empty; + public string LogoUrl { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Tier { get; set; } = "Partner"; + public int SortOrder { get; set; } + public bool IsVisible { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? UpdatedAt { get; set; } +} diff --git a/Backend/Endpoints/AdminEndpoints.cs b/Backend/Endpoints/AdminEndpoints.cs index e24151a..cb18b22 100644 --- a/Backend/Endpoints/AdminEndpoints.cs +++ b/Backend/Endpoints/AdminEndpoints.cs @@ -14,6 +14,7 @@ public static class AdminEndpoints group.MapAdminDashboardEndpoints(); group.MapAdminSeasonManagementEndpoints(); group.MapAdminModerationEndpoints(); + group.MapAdminExtrasEndpoints(); group.MapAdminTeamEndpoints(); return app; diff --git a/Backend/Endpoints/AdminExtrasEndpoints.cs b/Backend/Endpoints/AdminExtrasEndpoints.cs new file mode 100644 index 0000000..727138e --- /dev/null +++ b/Backend/Endpoints/AdminExtrasEndpoints.cs @@ -0,0 +1,321 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static class AdminExtrasEndpoints +{ + private static readonly string[] ContentPermissions = ["content", "settings"]; + private static readonly HashSet AllowedShowactStatuses = new(StringComparer.OrdinalIgnoreCase) + { + "pending", + "shortlisted", + "accepted", + "rejected", + }; + + public static RouteGroupBuilder MapAdminExtrasEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/seasons/{seasonId:int}/showacts", GetShowactApplications) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions)) + .WithName("GetAdminShowactApplications") + .WithOpenApi(); + + group.MapPost("/showacts/{applicationId:int}/status", UpdateShowactStatus) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) + .WithName("UpdateAdminShowactStatus") + .WithOpenApi(); + + group.MapDelete("/showacts/{applicationId:int}", DeleteShowactApplication) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) + .WithName("DeleteAdminShowactApplication") + .WithOpenApi(); + + group.MapGet("/seasons/{seasonId:int}/sponsors", GetSponsors) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, ContentPermissions)) + .WithName("GetAdminSponsors") + .WithOpenApi(); + + group.MapPost("/seasons/{seasonId:int}/sponsors", CreateSponsor) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) + .WithName("CreateAdminSponsor") + .WithOpenApi(); + + group.MapPut("/sponsors/{sponsorId:int}", UpdateSponsor) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) + .WithName("UpdateAdminSponsor") + .WithOpenApi(); + + group.MapDelete("/sponsors/{sponsorId:int}", DeleteSponsor) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, "content")) + .WithName("DeleteAdminSponsor") + .WithOpenApi(); + + return group; + } + + private static async Task GetShowactApplications(int seasonId, AwardsDbContext db) + { + var applications = await db.ShowactApplications + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.Status == "pending" ? 0 : 1) + .ThenByDescending(item => item.CreatedAt) + .Select(item => ToDto(item)) + .ToArrayAsync(); + + return Results.Ok(applications); + } + + private static async Task UpdateShowactStatus( + HttpContext context, + int applicationId, + UpdateShowactStatusRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId); + if (application is null) + { + return Results.NotFound(); + } + + var status = NormalizeStatus(request.Status); + if (!AllowedShowactStatuses.Contains(status)) + { + return Results.BadRequest(new { message = "Status muss pending, shortlisted, accepted oder rejected sein." }); + } + + var session = AdminEndpointConventions.CurrentSession(context); + var before = new { application.Status, application.ReviewNote }; + application.Status = status; + application.ReviewNote = NormalizeText(request.ReviewNote, 500); + application.ReviewedByTwitchId = session.TwitchUserId; + application.ReviewedAt = DateTimeOffset.UtcNow; + + adminAuditService.AddEntry( + session.TwitchUserId, + "showact.status", + "showact-application", + application.Id.ToString(), + $"Showact-Bewerbung von {application.ArtistName} wurde auf {status} gesetzt.", + new { before, after = new { application.Status, application.ReviewNote } }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, application = ToDto(application) }); + } + + private static async Task DeleteShowactApplication( + HttpContext context, + int applicationId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var application = await db.ShowactApplications.FirstOrDefaultAsync(item => item.Id == applicationId); + if (application is null) + { + return Results.NotFound(); + } + + var session = AdminEndpointConventions.CurrentSession(context); + db.ShowactApplications.Remove(application); + adminAuditService.AddEntry( + session.TwitchUserId, + "showact.delete", + "showact-application", + application.Id.ToString(), + $"Showact-Bewerbung von {application.ArtistName} wurde geloescht.", + new { application.ArtistName, application.ContactEmail, application.Status }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, applicationId }); + } + + private static async Task GetSponsors(int seasonId, AwardsDbContext db) + { + var sponsors = await db.Sponsors + .AsNoTracking() + .Where(item => item.SeasonId == seasonId) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Select(item => ToDto(item)) + .ToArrayAsync(); + + return Results.Ok(sponsors); + } + + private static async Task CreateSponsor( + HttpContext context, + int seasonId, + UpsertSponsorRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + if (!await db.Seasons.AnyAsync(item => item.Id == seasonId, context.RequestAborted)) + { + return Results.NotFound(); + } + + var sponsor = new Sponsor { SeasonId = seasonId, CreatedAt = DateTimeOffset.UtcNow }; + var validation = ApplySponsorRequest(sponsor, request); + if (validation is not null) + { + return validation; + } + + db.Sponsors.Add(sponsor); + var session = AdminEndpointConventions.CurrentSession(context); + adminAuditService.AddEntry( + session.TwitchUserId, + "sponsor.create", + "sponsor", + "new", + $"Sponsor {sponsor.Name} wurde angelegt.", + ToDto(sponsor), + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) }); + } + + private static async Task UpdateSponsor( + HttpContext context, + int sponsorId, + UpsertSponsorRequest request, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted); + if (sponsor is null) + { + return Results.NotFound(); + } + + var before = ToDto(sponsor); + var validation = ApplySponsorRequest(sponsor, request); + if (validation is not null) + { + return validation; + } + + sponsor.UpdatedAt = DateTimeOffset.UtcNow; + var session = AdminEndpointConventions.CurrentSession(context); + adminAuditService.AddEntry( + session.TwitchUserId, + "sponsor.update", + "sponsor", + sponsor.Id.ToString(), + $"Sponsor {sponsor.Name} wurde aktualisiert.", + new { before, after = ToDto(sponsor) }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { saved = true, sponsor = ToDto(sponsor) }); + } + + private static async Task DeleteSponsor( + HttpContext context, + int sponsorId, + AwardsDbContext db, + IAdminAuditService adminAuditService) + { + var sponsor = await db.Sponsors.FirstOrDefaultAsync(item => item.Id == sponsorId, context.RequestAborted); + if (sponsor is null) + { + return Results.NotFound(); + } + + var session = AdminEndpointConventions.CurrentSession(context); + db.Sponsors.Remove(sponsor); + adminAuditService.AddEntry( + session.TwitchUserId, + "sponsor.delete", + "sponsor", + sponsor.Id.ToString(), + $"Sponsor {sponsor.Name} wurde geloescht.", + ToDto(sponsor), + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new { deleted = true, sponsorId }); + } + + private static IResult? ApplySponsorRequest(Sponsor sponsor, UpsertSponsorRequest request) + { + var name = NormalizeText(request.Name, 120); + if (string.IsNullOrWhiteSpace(name)) + { + return Results.BadRequest(new { message = "Sponsor-Name ist erforderlich." }); + } + + var websiteUrl = NormalizeText(request.WebsiteUrl, 500); + var logoUrl = NormalizeText(request.LogoUrl, 500); + if (!IsBlankOrHttpUrl(websiteUrl) || !IsBlankOrHttpUrl(logoUrl)) + { + return Results.BadRequest(new { message = "Sponsor-Links muessen gueltige http(s)-URLs sein." }); + } + + sponsor.Name = name; + sponsor.WebsiteUrl = websiteUrl; + sponsor.LogoUrl = logoUrl; + sponsor.Description = NormalizeText(request.Description, 500); + sponsor.Tier = NormalizeText(request.Tier, 80); + if (string.IsNullOrWhiteSpace(sponsor.Tier)) + { + sponsor.Tier = "Partner"; + } + + sponsor.SortOrder = Math.Clamp(request.SortOrder, 0, 9999); + sponsor.IsVisible = request.IsVisible; + return null; + } + + private static SponsorDto ToDto(Sponsor sponsor) => + new( + sponsor.Id, + sponsor.SeasonId, + sponsor.Name, + sponsor.WebsiteUrl, + sponsor.LogoUrl, + sponsor.Description, + sponsor.Tier, + sponsor.SortOrder, + sponsor.IsVisible); + + private static ShowactApplicationDto ToDto(ShowactApplication application) => + new( + application.Id, + application.SeasonId, + application.ArtistName, + application.ContactEmail, + application.ContactDiscord, + application.PlatformUrl, + application.PerformanceType, + application.Description, + application.TechnicalNotes, + application.ReferenceUrl, + application.Status, + application.ReviewNote, + application.CreatedAt, + application.ReviewedAt); + + private static string NormalizeStatus(string? value) => + string.IsNullOrWhiteSpace(value) ? "pending" : value.Trim().ToLowerInvariant(); + + private static string NormalizeText(string? value, int maxLength) + { + var trimmed = (value ?? string.Empty).Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private static bool IsBlankOrHttpUrl(string value) => + string.IsNullOrWhiteSpace(value) + || (Uri.TryCreate(value, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)); +} diff --git a/Backend/Endpoints/AdminModerationEndpoints.cs b/Backend/Endpoints/AdminModerationEndpoints.cs index 37fd6a7..39dd636 100644 --- a/Backend/Endpoints/AdminModerationEndpoints.cs +++ b/Backend/Endpoints/AdminModerationEndpoints.cs @@ -20,6 +20,18 @@ public static partial class AdminModerationEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) .WithName("RejectAdminNomination") .WithOpenApi(); + group.MapGet("/nominations/link-blacklist", GetNominationLinkBlacklist) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) + .WithName("GetAdminNominationLinkBlacklist") + .WithOpenApi(); + group.MapPut("/nominations/link-blacklist", UpdateNominationLinkBlacklist) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) + .WithName("UpdateAdminNominationLinkBlacklist") + .WithOpenApi(); + group.MapPost("/nominations/link-blacklist", AddNominationLinkBlacklistEntry) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations)) + .WithName("AddAdminNominationLinkBlacklistEntry") + .WithOpenApi(); group.MapGet("/risk-flags", GetRiskFlags) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk)) .WithName("GetAdminRiskFlags") diff --git a/Backend/Endpoints/AdminNominationLinkBlacklistEndpoints.cs b/Backend/Endpoints/AdminNominationLinkBlacklistEndpoints.cs new file mode 100644 index 0000000..251cd3e --- /dev/null +++ b/Backend/Endpoints/AdminNominationLinkBlacklistEndpoints.cs @@ -0,0 +1,123 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminModerationEndpoints +{ + private static async Task GetNominationLinkBlacklist(AwardsDbContext db) + { + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + return Results.Ok(ToNominationLinkBlacklistResponse(settings)); + } + + private static async Task UpdateNominationLinkBlacklist( + HttpContext context, + UpdateNominationLinkBlacklistRequest 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 normalizedEntries = NormalizeBlacklistEntries(request.Urls, out var invalidUrl); + if (invalidUrl is not null) + { + return Results.BadRequest(new { message = $"Blacklist-Link ist keine gueltige http(s)-URL: {invalidUrl}" }); + } + + settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(normalizedEntries); + adminAuditService.AddEntry( + session.TwitchUserId, + "nomination-link-blacklist.update", + "site-settings", + settings.Id.ToString(), + "Nominierungs-Link-Blacklist wurde aktualisiert.", + new { count = normalizedEntries.Length }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(ToNominationLinkBlacklistResponse(settings)); + } + + private static async Task AddNominationLinkBlacklistEntry( + HttpContext context, + AddNominationLinkBlacklistEntryRequest 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(); + } + + if (!NominationLinkBlacklistSettings.TryNormalizeUrl(request.Url, out var normalizedUrl)) + { + return Results.BadRequest(new { message = "Blacklist-Link ist keine gueltige http(s)-URL." }); + } + + var entries = NominationLinkBlacklistSettings.Read(settings).ToList(); + if (!NominationLinkBlacklistSettings.IsBlocked(normalizedUrl, entries)) + { + entries.Add(new NominationLinkBlacklistEntry(normalizedUrl)); + settings.NominationLinkBlacklistJson = NominationLinkBlacklistSettings.Serialize(entries); + + adminAuditService.AddEntry( + session.TwitchUserId, + "nomination-link-blacklist.add", + "site-settings", + settings.Id.ToString(), + "Link wurde zur Nominierungs-Blacklist hinzugefuegt.", + new { url = normalizedUrl }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + } + + return Results.Ok(ToNominationLinkBlacklistResponse(settings)); + } + + private static AdminNominationLinkBlacklistResponse ToNominationLinkBlacklistResponse(Backend.Domain.SiteSettings settings) => + new(NominationLinkBlacklistSettings.Read(settings) + .Select(entry => new AdminNominationLinkBlacklistEntryDto(entry.Url)) + .ToArray()); + + private static NominationLinkBlacklistEntry[] NormalizeBlacklistEntries(string[]? urls, out string? invalidUrl) + { + invalidUrl = null; + var entries = new List(); + foreach (var rawUrl in urls ?? []) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + continue; + } + + if (!NominationLinkBlacklistSettings.TryNormalizeUrl(rawUrl, out var normalizedUrl)) + { + invalidUrl = rawUrl; + return []; + } + + entries.Add(new NominationLinkBlacklistEntry(normalizedUrl)); + } + + return entries + .DistinctBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} diff --git a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs index dc3c3d6..f41b31b 100644 --- a/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonCandidateEndpoints.cs @@ -9,6 +9,9 @@ namespace Backend.Endpoints; public static partial class AdminSeasonManagementEndpoints { + private static readonly string[] CandidateAcceptanceStatuses = ["open", "contacted", "accepted", "declined"]; + private static readonly string[] CandidateClipEmbedStatuses = ["unchecked", "embeddable", "link_only", "blocked"]; + private static async Task CreateCandidate( HttpContext context, int seasonId, @@ -31,6 +34,20 @@ public static partial class AdminSeasonManagementEndpoints var normalizedDisplayName = request.DisplayName.Trim(); var normalizedChannelSlug = request.ChannelSlug.Trim(); + var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses); + var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote); + var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl); + var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle); + var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform); + var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses); + + if (normalizedClipUrl is null) + { + normalizedClipTitle = null; + normalizedClipPlatform = null; + normalizedClipEmbedStatus = "unchecked"; + } + if (await db.Candidates.AnyAsync(item => item.SeasonId == seasonId && item.CategoryId == request.CategoryId @@ -40,6 +57,20 @@ public static partial class AdminSeasonManagementEndpoints return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." }); } + var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync( + db, + seasonId, + request.CategoryId, + null, + normalizedDisplayName, + normalizedChannelSlug, + normalizedAcceptanceStatus, + context.RequestAborted); + if (workflowRuleBlock is not null) + { + return workflowRuleBlock; + } + var candidate = new Candidate { SeasonId = seasonId, @@ -47,6 +78,12 @@ public static partial class AdminSeasonManagementEndpoints DisplayName = normalizedDisplayName, ChannelSlug = normalizedChannelSlug, Platform = request.Platform.Trim(), + AcceptanceStatus = normalizedAcceptanceStatus, + AcceptanceNote = normalizedAcceptanceNote, + ClipCompilationUrl = normalizedClipUrl, + ClipCompilationTitle = normalizedClipTitle, + ClipCompilationPlatform = normalizedClipPlatform, + ClipEmbedStatus = normalizedClipEmbedStatus, }; db.Candidates.Add(candidate); @@ -56,7 +93,7 @@ public static partial class AdminSeasonManagementEndpoints "candidate", request.DisplayName.Trim(), $"Kandidat {request.DisplayName.Trim()} wurde angelegt.", - new { seasonId, request.CategoryId, request.Platform }, + new { seasonId, request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); @@ -92,6 +129,20 @@ public static partial class AdminSeasonManagementEndpoints var normalizedDisplayName = request.DisplayName.Trim(); var normalizedChannelSlug = request.ChannelSlug.Trim(); + var normalizedAcceptanceStatus = NormalizeCandidateChoice(request.AcceptanceStatus, "open", CandidateAcceptanceStatuses); + var normalizedAcceptanceNote = NormalizeOptionalCandidateText(request.AcceptanceNote); + var normalizedClipUrl = NormalizeOptionalCandidateUrl(request.ClipCompilationUrl); + var normalizedClipTitle = NormalizeOptionalCandidateText(request.ClipCompilationTitle); + var normalizedClipPlatform = NormalizeOptionalCandidateText(request.ClipCompilationPlatform); + var normalizedClipEmbedStatus = NormalizeCandidateChoice(request.ClipEmbedStatus, "unchecked", CandidateClipEmbedStatuses); + + if (normalizedClipUrl is null) + { + normalizedClipTitle = null; + normalizedClipPlatform = null; + normalizedClipEmbedStatus = "unchecked"; + } + if (await db.Candidates.AnyAsync(item => item.SeasonId == candidate.SeasonId && item.CategoryId == request.CategoryId @@ -102,10 +153,30 @@ public static partial class AdminSeasonManagementEndpoints return Results.BadRequest(new { message = "A candidate with the same display name or channel slug already exists in this category." }); } + var workflowRuleBlock = await BuildCandidateWorkflowRuleBlockAsync( + db, + candidate.SeasonId, + request.CategoryId, + candidateId, + normalizedDisplayName, + normalizedChannelSlug, + normalizedAcceptanceStatus, + context.RequestAborted); + if (workflowRuleBlock is not null) + { + return workflowRuleBlock; + } + candidate.CategoryId = request.CategoryId; candidate.DisplayName = normalizedDisplayName; candidate.ChannelSlug = normalizedChannelSlug; candidate.Platform = request.Platform.Trim(); + candidate.AcceptanceStatus = normalizedAcceptanceStatus; + candidate.AcceptanceNote = normalizedAcceptanceNote; + candidate.ClipCompilationUrl = normalizedClipUrl; + candidate.ClipCompilationTitle = normalizedClipTitle; + candidate.ClipCompilationPlatform = normalizedClipPlatform; + candidate.ClipEmbedStatus = normalizedClipEmbedStatus; adminAuditService.AddEntry( session.TwitchUserId, @@ -113,7 +184,7 @@ public static partial class AdminSeasonManagementEndpoints "candidate", candidate.Id.ToString(), $"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.", - new { request.CategoryId, request.Platform }, + new { request.CategoryId, request.Platform, acceptanceStatus = normalizedAcceptanceStatus, hasCompilation = normalizedClipUrl is not null }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); @@ -146,4 +217,34 @@ public static partial class AdminSeasonManagementEndpoints await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { deleted = true, candidateId }); } + + private static string NormalizeCandidateChoice(string? value, string fallback, IReadOnlyCollection allowedValues) + { + var normalized = value?.Trim().ToLowerInvariant(); + return !string.IsNullOrWhiteSpace(normalized) && allowedValues.Contains(normalized) + ? normalized + : fallback; + } + + private static string? NormalizeOptionalCandidateText(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + private static string? NormalizeOptionalCandidateUrl(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) + { + throw new BadHttpRequestException("Compilation-Link muss eine gültige http(s)-URL sein."); + } + + return uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped); + } } diff --git a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs index 834964e..cfd1c4b 100644 --- a/Backend/Endpoints/AdminSeasonDetailEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonDetailEndpoints.cs @@ -26,7 +26,13 @@ public static partial class AdminSeasonManagementEndpoints item.CategoryId, item.DisplayName, item.ChannelSlug, - item.Platform)) + item.Platform, + item.AcceptanceStatus, + item.AcceptanceNote, + item.ClipCompilationUrl, + item.ClipCompilationTitle, + item.ClipCompilationPlatform, + item.ClipEmbedStatus)) .ToArrayAsync(); var candidateCounts = candidates diff --git a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs index a8cd7c1..983fc73 100644 --- a/Backend/Endpoints/AdminSeasonManagementEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonManagementEndpoints.cs @@ -56,6 +56,14 @@ public static partial class AdminSeasonManagementEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners)) .WithName("DeleteAdminResult") .WithOpenApi(); + group.MapGet("/workflow-rules", GetWorkflowRules) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings)) + .WithName("GetAdminWorkflowRules") + .WithOpenApi(); + group.MapPut("/workflow-rules", UpdateWorkflowRules) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Settings)) + .WithName("UpdateAdminWorkflowRules") + .WithOpenApi(); return group; } } diff --git a/Backend/Endpoints/AdminSeasonManagementSupport.cs b/Backend/Endpoints/AdminSeasonManagementSupport.cs index 7515ff7..a22bfd1 100644 --- a/Backend/Endpoints/AdminSeasonManagementSupport.cs +++ b/Backend/Endpoints/AdminSeasonManagementSupport.cs @@ -1,6 +1,8 @@ using Backend.Common; using Backend.Contracts; using Backend.Data; +using Backend.Domain; +using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; @@ -14,6 +16,17 @@ public static partial class AdminSeasonManagementEndpoints private const int MaxCandidateDisplayNameLength = 120; private const int MaxCandidateChannelSlugLength = 120; private const int MaxCandidatePlatformLength = 60; + private const int MaxCandidateAcceptanceNoteLength = 500; + private const int MaxCandidateClipUrlLength = 500; + private const int MaxCandidateClipTitleLength = 200; + private const int MaxCandidateClipPlatformLength = 40; + + private sealed record CandidateRuleSnapshot( + int Id, + int CategoryId, + string DisplayName, + string ChannelSlug, + string AcceptanceStatus); private static IResult? ValidateSeasonRequest(CreateSeasonRequest request) { @@ -132,6 +145,80 @@ public static partial class AdminSeasonManagementEndpoints }); } + private static async Task LoadWorkflowRulesAsync(AwardsDbContext db, CancellationToken cancellationToken) + { + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, cancellationToken); + + return WorkflowRuleSettings.Read(settings); + } + + private static IResult CreateWorkflowRuleError(string message) => + Results.BadRequest(new { message = $"Workflow-Regel blockiert: {message}" }); + + private static async Task BuildCandidateWorkflowRuleBlockAsync( + AwardsDbContext db, + int seasonId, + int categoryId, + int? existingCandidateId, + string displayName, + string channelSlug, + string acceptanceStatus, + CancellationToken cancellationToken) + { + if (string.Equals(acceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var rules = await LoadWorkflowRulesAsync(db, cancellationToken); + 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 CandidateRuleSnapshot( + item.Id, + item.CategoryId, + item.DisplayName, + item.ChannelSlug, + item.AcceptanceStatus)) + .ToArrayAsync(cancellationToken); + + if (WorkflowRuleSettings.ShouldBlock(finalistsRule)) + { + var categoryCount = existingCandidates.Count(item => item.CategoryId == categoryId); + if (categoryCount >= finalistsRule.Limit) + { + return CreateWorkflowRuleError( + $"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 => + string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), identityKey, StringComparison.Ordinal)); + if (appearanceCount >= appearancesRule.Limit) + { + return CreateWorkflowRuleError( + $"Diese Person ist bereits {appearanceCount}x als Kandidat:in eingetragen. Limit: {appearancesRule.Limit}."); + } + } + + return null; + } + private static string[] BuildNewSeasonReadinessIssues( string currentPhase, bool isCurrent, @@ -311,6 +398,51 @@ public static partial class AdminSeasonManagementEndpoints return Results.BadRequest(new { message = $"Platform is required and must stay below {MaxCandidatePlatformLength} characters." }); } + if (!IsAllowedCandidateChoice(request.AcceptanceStatus, CandidateAcceptanceStatuses)) + { + return Results.BadRequest(new { message = "Acceptance status must be open, contacted, accepted, or declined." }); + } + + if (!IsAllowedCandidateChoice(request.ClipEmbedStatus, CandidateClipEmbedStatuses)) + { + return Results.BadRequest(new { message = "Clip embed status must be unchecked, embeddable, link_only, or blocked." }); + } + + if (request.AcceptanceNote?.Trim().Length > MaxCandidateAcceptanceNoteLength) + { + return Results.BadRequest(new { message = $"Acceptance note must stay below {MaxCandidateAcceptanceNoteLength} characters." }); + } + + var clipUrl = request.ClipCompilationUrl?.Trim(); + if (!string.IsNullOrWhiteSpace(clipUrl)) + { + if (clipUrl.Length > MaxCandidateClipUrlLength) + { + return Results.BadRequest(new { message = $"Compilation link must stay below {MaxCandidateClipUrlLength} characters." }); + } + + if (!Uri.TryCreate(clipUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) + { + return Results.BadRequest(new { message = "Compilation link must be a valid http(s) URL." }); + } + } + + if (request.ClipCompilationTitle?.Trim().Length > MaxCandidateClipTitleLength) + { + return Results.BadRequest(new { message = $"Compilation title must stay below {MaxCandidateClipTitleLength} characters." }); + } + + if (request.ClipCompilationPlatform?.Trim().Length > MaxCandidateClipPlatformLength) + { + return Results.BadRequest(new { message = $"Compilation platform must stay below {MaxCandidateClipPlatformLength} characters." }); + } + return null; } + + private static bool IsAllowedCandidateChoice(string? value, IReadOnlyCollection allowedValues) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) || allowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase); + } } diff --git a/Backend/Endpoints/AdminSeasonResultsEndpoints.cs b/Backend/Endpoints/AdminSeasonResultsEndpoints.cs index 4f886b3..a02b1a0 100644 --- a/Backend/Endpoints/AdminSeasonResultsEndpoints.cs +++ b/Backend/Endpoints/AdminSeasonResultsEndpoints.cs @@ -36,6 +36,40 @@ 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 winnerRequiresClipRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.WinnerRequiresClip); + if (WorkflowRuleSettings.ShouldBlock(winnerRequiresClipRule) + && string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl)) + { + return CreateWorkflowRuleError( + "Dieser Kandidat hat noch keinen gepflegten Clip-Link. Bitte zuerst die Clip-Compilation am Kandidaten hinterlegen oder die Workflow-Regel umstellen."); + } + + var winnerPlacementsRule = WorkflowRuleSettings.Find(workflowRules, WorkflowRuleSettings.MaxWinnerPlacements); + if (WorkflowRuleSettings.ShouldBlock(winnerPlacementsRule)) + { + var candidateIdentityKey = WorkflowRuleSettings.CandidateIdentityKey(candidate); + var existingWinnerIdentities = await db.Results + .AsNoTracking() + .Include(item => item.Candidate) + .Where(item => item.SeasonId == seasonId && item.CategoryId != request.CategoryId) + .Select(item => new + { + item.CategoryId, + item.Candidate.DisplayName, + item.Candidate.ChannelSlug, + }) + .ToArrayAsync(context.RequestAborted); + var existingWinnerCount = existingWinnerIdentities.Count(item => + string.Equals(WorkflowRuleSettings.CandidateIdentityKey(item.DisplayName, item.ChannelSlug), candidateIdentityKey, StringComparison.Ordinal)); + + if (existingWinnerCount >= winnerPlacementsRule.Limit) + { + return CreateWorkflowRuleError( + $"Diese Person hat bereits {existingWinnerCount} Gewinnerplatz(e). Limit: {winnerPlacementsRule.Limit}."); + } + } + var existingResult = await db.Results.FirstOrDefaultAsync(item => item.SeasonId == seasonId && item.CategoryId == request.CategoryId); diff --git a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs index 1db9dee..18270fb 100644 --- a/Backend/Endpoints/AdminSiteSettingsEndpoints.cs +++ b/Backend/Endpoints/AdminSiteSettingsEndpoints.cs @@ -13,6 +13,7 @@ public static class AdminSiteSettingsEndpoints { private const string FallbackMaintenanceTitle = "Sternenpause"; private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; + private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen."; public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group) { @@ -32,6 +33,14 @@ public static class AdminSiteSettingsEndpoints .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminOperationalSettings") .WithOpenApi(); + group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) + .WithName("GetAdminOptionalFeatureSettings") + .WithOpenApi(); + group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings) + .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) + .WithName("UpdateAdminOptionalFeatureSettings") + .WithOpenApi(); return group; } @@ -57,6 +66,8 @@ public static class AdminSiteSettingsEndpoints settings.ContactContent, settings.SponsorsUrl, settings.SponsorsContent, + settings.ShowactsUrl, + settings.ShowactsContent, SeasonMappings.ReadSocialLinks(settings), SeasonMappings.ReadFaqItems(settings))); } @@ -100,6 +111,8 @@ public static class AdminSiteSettingsEndpoints settings.ContactContent = request.ContactContent.Trim(); settings.SponsorsUrl = normalizedUrls.SponsorsUrl; settings.SponsorsContent = request.SponsorsContent.Trim(); + settings.ShowactsUrl = normalizedUrls.ShowactsUrl; + settings.ShowactsContent = request.ShowactsContent.Trim(); settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks); settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); @@ -133,7 +146,8 @@ public static class AdminSiteSettingsEndpoints if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var 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)) + || !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage) + || !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } @@ -144,6 +158,7 @@ public static class AdminSiteSettingsEndpoints ImprintUrl = imprintUrl, ContactUrl = contactUrl, SponsorsUrl = sponsorsUrl, + ShowactsUrl = showactsUrl, }; var normalizedSocialLinks = new List(); @@ -187,6 +202,108 @@ public static class AdminSiteSettingsEndpoints public string ImprintUrl { get; set; } = string.Empty; public string ContactUrl { get; set; } = string.Empty; public string SponsorsUrl { get; set; } = string.Empty; + public string ShowactsUrl { get; set; } = string.Empty; + } + + private static async Task GetOptionalFeatureSettings(AwardsDbContext db) + { + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + return Results.Ok(ToOptionalFeatureSettingsResponse(settings)); + } + + private static async Task UpdateOptionalFeatureSettings( + HttpContext context, + UpdateOptionalFeatureSettingsRequest 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 before = ToOptionalFeatureSettingsResponse(settings); + var disabledMessage = NormalizeOptionalFeatureText( + request.ClipSubmissionDisabledMessage, + FallbackClipSubmissionDisabledMessage, + 240); + var showactDisabledMessage = NormalizeOptionalFeatureText( + request.ShowactApplicationDisabledMessage, + "Showact-Bewerbungen sind aktuell geschlossen.", + 240); + + settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled; + settings.ClipReviewEnabled = request.ClipReviewEnabled; + settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible; + settings.ClipSubmissionDisabledMessage = disabledMessage; + settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled; + settings.ShowactApplicationDisabledMessage = showactDisabledMessage; + settings.SponsorsVisible = request.SponsorsVisible; + + var after = ToOptionalFeatureSettingsResponse(settings); + adminAuditService.AddEntry( + session.TwitchUserId, + "optional-features.update", + "site-settings", + settings.Id.ToString(), + "Optionale Workflow-Features wurden aktualisiert.", + new + { + before, + after, + changes = BuildOptionalFeatureChanges(before, after), + }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(after); + } + + private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) => + new( + settings.ClipSubmissionsEnabled, + settings.ClipReviewEnabled, + settings.ClipAdminMenuVisible, + string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage) + ? FallbackClipSubmissionDisabledMessage + : settings.ClipSubmissionDisabledMessage, + settings.ShowactApplicationsEnabled, + string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage) + ? "Showact-Bewerbungen sind aktuell geschlossen." + : settings.ShowactApplicationDisabledMessage, + settings.SponsorsVisible); + + private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength) + { + var trimmed = (value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return fallback; + } + + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private static object[] BuildOptionalFeatureChanges( + AdminOptionalFeatureSettingsResponse before, + AdminOptionalFeatureSettingsResponse after) + { + var changes = new List(); + AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled); + AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled); + 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, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage); + AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible); + return changes.ToArray(); } private static async Task GetOperationalSettings(AwardsDbContext db, IConfiguration configuration) diff --git a/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs b/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs new file mode 100644 index 0000000..9a4c86f --- /dev/null +++ b/Backend/Endpoints/AdminWorkflowRuleEndpoints.cs @@ -0,0 +1,87 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Services; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class AdminSeasonManagementEndpoints +{ + private static async Task GetWorkflowRules(AwardsDbContext db) + { + var settings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null) + { + return Results.NotFound(); + } + + return Results.Ok(new AdminWorkflowRulesResponse(WorkflowRuleSettings.Read(settings).Select(ToWorkflowRuleDto).ToArray())); + } + + private static async Task UpdateWorkflowRules( + HttpContext context, + 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) + { + return Results.NotFound(); + } + + var before = WorkflowRuleSettings.Read(settings); + var mergedRules = WorkflowRuleSettings.Defaults + .Select(defaultRule => + { + var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key); + return requestRule is null + ? defaultRule + : new WorkflowRuleSetting( + defaultRule.Key, + defaultRule.Label, + requestRule.Enabled, + requestRule.Limit, + requestRule.Mode, + defaultRule.Description); + }) + .ToArray(); + + settings.WorkflowRulesJson = WorkflowRuleSettings.Serialize(mergedRules); + var after = WorkflowRuleSettings.Read(settings); + var changes = after + .Select(rule => + { + var previous = before.First(item => item.Key == rule.Key); + return new + { + field = rule.Key, + label = rule.Label, + from = $"{previous.Enabled}/{previous.Limit}/{previous.Mode}", + to = $"{rule.Enabled}/{rule.Limit}/{rule.Mode}", + sensitive = false, + }; + }) + .Where(change => change.from != change.to) + .ToArray(); + + adminAuditService.AddEntry( + session.TwitchUserId, + "workflow-rules.update", + "site-settings", + settings.Id.ToString(), + "Workflow-Regeln wurden aktualisiert.", + new { changes }, + RequestMetadataReader.Read(context)); + + await db.SaveChangesAsync(context.RequestAborted); + return Results.Ok(new AdminWorkflowRulesResponse(after.Select(ToWorkflowRuleDto).ToArray())); + } + + private static AdminWorkflowRuleDto ToWorkflowRuleDto(WorkflowRuleSetting rule) => + new(rule.Key, rule.Label, rule.Enabled, rule.Limit, rule.Mode, rule.Description); +} diff --git a/Backend/Endpoints/PublicClipEndpoints.cs b/Backend/Endpoints/PublicClipEndpoints.cs index 34a9e86..eb96113 100644 --- a/Backend/Endpoints/PublicClipEndpoints.cs +++ b/Backend/Endpoints/PublicClipEndpoints.cs @@ -28,6 +28,22 @@ public static partial class PublicEndpoints return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." }); } + var siteSettings = await db.SiteSettings + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == 1, context.RequestAborted); + if (siteSettings is null) + { + return Results.Problem("Site settings are missing."); + } + + if (!siteSettings.ClipSubmissionsEnabled) + { + var message = string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage) + ? "Clip-Einreichungen sind aktuell geschlossen." + : siteSettings.ClipSubmissionDisabledMessage; + return Results.BadRequest(new { message }); + } + var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year); var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination"); if (clipSeasonResolution.Result is not null) diff --git a/Backend/Endpoints/PublicEndpoints.cs b/Backend/Endpoints/PublicEndpoints.cs index a0284ad..01c0375 100644 --- a/Backend/Endpoints/PublicEndpoints.cs +++ b/Backend/Endpoints/PublicEndpoints.cs @@ -22,6 +22,10 @@ public static partial class PublicEndpoints .WithName("GetWinnerArchive") .WithOpenApi(); + group.MapGet("/seasons/{year:int}/sponsors", GetSponsors) + .WithName("GetPublicSponsors") + .WithOpenApi(); + group.MapGet("/seasons/{year:int}/me", GetUserParticipation) .WithName("GetUserParticipation") .WithOpenApi(); @@ -41,6 +45,11 @@ public static partial class PublicEndpoints .WithName("CreateClip") .WithOpenApi(); + group.MapPost("/showacts", CreateShowactApplication) + .RequireRateLimiting(Backend.Common.ApplicationDefaults.PublicWriteRateLimitPolicy) + .WithName("CreateShowactApplication") + .WithOpenApi(); + return app; } } diff --git a/Backend/Endpoints/PublicExtrasEndpoints.cs b/Backend/Endpoints/PublicExtrasEndpoints.cs new file mode 100644 index 0000000..f94f9c7 --- /dev/null +++ b/Backend/Endpoints/PublicExtrasEndpoints.cs @@ -0,0 +1,131 @@ +using Backend.Common; +using Backend.Contracts; +using Backend.Data; +using Backend.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Endpoints; + +public static partial class PublicEndpoints +{ + private static async Task GetSponsors(int year, AwardsDbContext db) + { + var season = await db.Seasons + .AsNoTracking() + .FirstOrDefaultAsync(item => item.Year == year); + + if (season is null) + { + return Results.NotFound(); + } + + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + if (settings is null || !settings.SponsorsVisible) + { + return Results.Ok(new PublicSponsorsResponse(year, [])); + } + + var sponsors = await db.Sponsors + .AsNoTracking() + .Where(item => item.SeasonId == season.Id && item.IsVisible) + .OrderBy(item => item.SortOrder) + .ThenBy(item => item.Name) + .Select(item => new SponsorDto( + item.Id, + item.SeasonId, + item.Name, + item.WebsiteUrl, + item.LogoUrl, + item.Description, + item.Tier, + item.SortOrder, + item.IsVisible)) + .ToArrayAsync(); + + return Results.Ok(new PublicSponsorsResponse(year, sponsors)); + } + + 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) + { + return Results.BadRequest(new + { + message = string.IsNullOrWhiteSpace(settings?.ShowactApplicationDisabledMessage) + ? "Showact-Bewerbungen sind aktuell geschlossen." + : settings.ShowactApplicationDisabledMessage, + }); + } + + var season = await db.Seasons.FirstOrDefaultAsync(item => item.IsCurrent, context.RequestAborted); + if (season is null) + { + 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)) + { + 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 (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 }); + } + + private static string NormalizePublicText(string? value, int maxLength) + { + var trimmed = (value ?? string.Empty).Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private static bool IsBlankOrHttpUrl(string value) => + string.IsNullOrWhiteSpace(value) + || (Uri.TryCreate(value, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)); +} diff --git a/Backend/Endpoints/PublicNominationEndpoints.cs b/Backend/Endpoints/PublicNominationEndpoints.cs index 87c92b5..ece3bc5 100644 --- a/Backend/Endpoints/PublicNominationEndpoints.cs +++ b/Backend/Endpoints/PublicNominationEndpoints.cs @@ -54,6 +54,17 @@ public static partial class PublicEndpoints return Results.BadRequest(new { message = "A valid http(s) stream link is required." }); } + var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); + var linkBlacklist = NominationLinkBlacklistSettings.Read(settings); + var blacklistedStreamUrl = submittedNominations + .Select(item => item.StreamUrl) + .FirstOrDefault(item => NominationLinkBlacklistSettings.IsBlocked(item, linkBlacklist)); + + if (blacklistedStreamUrl is not null) + { + 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); diff --git a/Backend/Endpoints/PublicOverviewEndpoints.cs b/Backend/Endpoints/PublicOverviewEndpoints.cs index bdcb9b6..526de06 100644 --- a/Backend/Endpoints/PublicOverviewEndpoints.cs +++ b/Backend/Endpoints/PublicOverviewEndpoints.cs @@ -42,6 +42,10 @@ public static partial class PublicEndpoints WinnerName = result.Candidate.DisplayName, WinnerSlug = result.Candidate.ChannelSlug, WinnerPlatform = result.Candidate.Platform, + ClipUrl = result.Candidate.ClipCompilationUrl, + ClipTitle = result.Candidate.ClipCompilationTitle, + ClipPlatform = result.Candidate.ClipCompilationPlatform, + ClipEmbedStatus = result.Candidate.ClipEmbedStatus, }) .ToArrayAsync(); @@ -52,7 +56,11 @@ public static partial class PublicEndpoints result.WinnerName, result.WinnerSlug, result.WinnerPlatform, - SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug))) + SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug), + result.ClipUrl, + result.ClipTitle, + result.ClipPlatform, + result.ClipEmbedStatus)) .ToArray(); var archiveYearRows = await db.Results @@ -110,6 +118,17 @@ public static partial class PublicEndpoints siteSettings.PrivacyPolicyContent, SeasonMappings.ReadSocialLinks(siteSettings), SeasonMappings.BuildFooterLinks(siteSettings)), + new PublicFeatureFlagsDto( + siteSettings.ClipSubmissionsEnabled, + siteSettings.ClipReviewEnabled, + string.IsNullOrWhiteSpace(siteSettings.ClipSubmissionDisabledMessage) + ? "Clip-Einreichungen sind aktuell geschlossen." + : siteSettings.ClipSubmissionDisabledMessage, + siteSettings.ShowactApplicationsEnabled, + string.IsNullOrWhiteSpace(siteSettings.ShowactApplicationDisabledMessage) + ? "Showact-Bewerbungen sind aktuell geschlossen." + : siteSettings.ShowactApplicationDisabledMessage, + siteSettings.SponsorsVisible), SeasonMappings.ReadFaqItems(siteSettings)); return Results.Ok(response); diff --git a/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs b/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs index a6c1e4d..723e6fa 100644 --- a/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs +++ b/Backend/Endpoints/PublicSeasonCategoryReadEndpoints.cs @@ -54,17 +54,36 @@ public static partial class PublicEndpoints category.GroupName, category.Description, category.MaxNomineesPerUser, - category.Candidates.Select(candidate => + category.Candidates + .Where(candidate => !string.Equals(candidate.AcceptanceStatus, "declined", StringComparison.OrdinalIgnoreCase)) + .Select(candidate => { var clip = ResolveCandidateClip(candidate, clipsByCandidateId, clipsByCreatorKey); + var candidateClipUrl = string.IsNullOrWhiteSpace(candidate.ClipCompilationUrl) + || string.Equals(candidate.ClipEmbedStatus, "blocked", StringComparison.OrdinalIgnoreCase) + ? null + : candidate.ClipCompilationUrl.Trim(); + var clipUrl = candidateClipUrl ?? clip?.ClipUrl; + var clipTitle = candidateClipUrl is not null + ? string.IsNullOrWhiteSpace(candidate.ClipCompilationTitle) ? "Highlight-Clip ansehen" : candidate.ClipCompilationTitle.Trim() + : clip?.Title; + var clipPlatform = candidateClipUrl is not null + ? string.IsNullOrWhiteSpace(candidate.ClipCompilationPlatform) ? candidate.Platform : candidate.ClipCompilationPlatform.Trim() + : clip?.Platform; + var clipEmbedStatus = candidateClipUrl is not null + ? candidate.ClipEmbedStatus + : null; + return new CandidateSummaryDto( candidate.Id, candidate.DisplayName, candidate.ChannelSlug, + SeasonMappings.BuildProfileUrl(candidate.Platform, candidate.ChannelSlug), candidate.Platform, - clip?.ClipUrl, - clip?.Title, - clip?.Platform); + clipUrl, + clipTitle, + clipPlatform, + clipEmbedStatus); }).ToArray())) .ToArray())); } diff --git a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs index fee13bf..a488083 100644 --- a/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs +++ b/Backend/Endpoints/PublicWinnerArchiveEndpoints.cs @@ -35,6 +35,10 @@ public static partial class PublicEndpoints WinnerName = result.Candidate.DisplayName, WinnerSlug = result.Candidate.ChannelSlug, WinnerPlatform = result.Candidate.Platform, + ClipUrl = result.Candidate.ClipCompilationUrl, + ClipTitle = result.Candidate.ClipCompilationTitle, + ClipPlatform = result.Candidate.ClipCompilationPlatform, + ClipEmbedStatus = result.Candidate.ClipEmbedStatus, }) .ToArrayAsync(); @@ -44,7 +48,11 @@ public static partial class PublicEndpoints result.WinnerName, result.WinnerSlug, result.WinnerPlatform, - SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug))) + SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug), + result.ClipUrl, + result.ClipTitle, + result.ClipPlatform, + result.ClipEmbedStatus)) .ToArray(); return Results.Ok(new WinnerArchiveResponse(year, items)); diff --git a/Backend/Migrations/20260627114301_AddCandidatePreparationFields.Designer.cs b/Backend/Migrations/20260627114301_AddCandidatePreparationFields.Designer.cs new file mode 100644 index 0000000..1a2531f --- /dev/null +++ b/Backend/Migrations/20260627114301_AddCandidatePreparationFields.Designer.cs @@ -0,0 +1,1527 @@ +// +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("20260627114301_AddCandidatePreparationFields")] + partial class AddCandidatePreparationFields + { + /// + 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.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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.HasKey("Id"); + + b.ToTable("SiteSettings"); + + b.HasData( + new + { + Id = 1, + 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", + 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.\"}]", + 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", + TwitchAuthManagedByDatabase = false, + TwitchClientId = "", + TwitchClientSecret = "", + TwitchRedirectUri = "", + TwitchScope = "" + }); + }); + + 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.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/20260627114301_AddCandidatePreparationFields.cs b/Backend/Migrations/20260627114301_AddCandidatePreparationFields.cs new file mode 100644 index 0000000..178b2b4 --- /dev/null +++ b/Backend/Migrations/20260627114301_AddCandidatePreparationFields.cs @@ -0,0 +1,177 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddCandidatePreparationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AcceptanceNote", + table: "Candidates", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "AcceptanceStatus", + table: "Candidates", + type: "character varying(30)", + maxLength: 30, + nullable: false, + defaultValue: "open"); + + migrationBuilder.AddColumn( + name: "ClipCompilationPlatform", + table: "Candidates", + type: "character varying(40)", + maxLength: 40, + nullable: true); + + migrationBuilder.AddColumn( + name: "ClipCompilationTitle", + table: "Candidates", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "ClipCompilationUrl", + table: "Candidates", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ClipEmbedStatus", + table: "Candidates", + type: "character varying(30)", + maxLength: 30, + nullable: false, + defaultValue: "unchecked"); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 4, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 5, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 6, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 7, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 8, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 9, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 10, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 11, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 12, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + + migrationBuilder.UpdateData( + table: "Candidates", + keyColumn: "Id", + keyValue: 13, + columns: new[] { "AcceptanceNote", "AcceptanceStatus", "ClipCompilationPlatform", "ClipCompilationTitle", "ClipCompilationUrl", "ClipEmbedStatus" }, + values: new object[] { null, "open", null, null, null, "unchecked" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AcceptanceNote", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "AcceptanceStatus", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "ClipCompilationPlatform", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "ClipCompilationTitle", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "ClipCompilationUrl", + table: "Candidates"); + + migrationBuilder.DropColumn( + name: "ClipEmbedStatus", + table: "Candidates"); + } + } +} diff --git a/Backend/Migrations/20260627125128_AddWorkflowRulesJson.Designer.cs b/Backend/Migrations/20260627125128_AddWorkflowRulesJson.Designer.cs new file mode 100644 index 0000000..f537bb7 --- /dev/null +++ b/Backend/Migrations/20260627125128_AddWorkflowRulesJson.Designer.cs @@ -0,0 +1,1534 @@ +// +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("20260627125128_AddWorkflowRulesJson")] + partial class AddWorkflowRulesJson + { + /// + 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.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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, + 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", + 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.\"}]", + 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", + 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.\"}]" + }); + }); + + 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.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/20260627125128_AddWorkflowRulesJson.cs b/Backend/Migrations/20260627125128_AddWorkflowRulesJson.cs new file mode 100644 index 0000000..d638ed6 --- /dev/null +++ b/Backend/Migrations/20260627125128_AddWorkflowRulesJson.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddWorkflowRulesJson : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "WorkflowRulesJson" text NOT NULL DEFAULT '[]'; + """); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + column: "WorkflowRulesJson", + value: "[{\"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.\"}]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + DROP COLUMN IF EXISTS "WorkflowRulesJson"; + """); + } + } +} diff --git a/Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.Designer.cs b/Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.Designer.cs new file mode 100644 index 0000000..0d188de --- /dev/null +++ b/Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.Designer.cs @@ -0,0 +1,1552 @@ +// +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("20260627131746_AddOptionalClipFeatureSettings")] + partial class AddOptionalClipFeatureSettings + { + /// + 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.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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, + 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", + 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.\"}]", + 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", + 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.\"}]" + }); + }); + + 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.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/20260627131746_AddOptionalClipFeatureSettings.cs b/Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.cs new file mode 100644 index 0000000..cfd72a2 --- /dev/null +++ b/Backend/Migrations/20260627131746_AddOptionalClipFeatureSettings.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddOptionalClipFeatureSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ClipReviewEnabled" boolean NOT NULL DEFAULT true; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ClipSubmissionDisabledMessage" character varying(240) NOT NULL DEFAULT 'Clip-Einreichungen sind aktuell geschlossen.'; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + ADD COLUMN IF NOT EXISTS "ClipSubmissionsEnabled" boolean NOT NULL DEFAULT false; + """); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "ClipReviewEnabled", "ClipSubmissionDisabledMessage" }, + values: new object[] { true, "Clip-Einreichungen sind aktuell geschlossen." }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + DROP COLUMN IF EXISTS "ClipReviewEnabled"; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + DROP COLUMN IF EXISTS "ClipSubmissionDisabledMessage"; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "SiteSettings" + DROP COLUMN IF EXISTS "ClipSubmissionsEnabled"; + """); + } + } +} diff --git a/Backend/Migrations/20260627135729_AddNominationLinkBlacklist.Designer.cs b/Backend/Migrations/20260627135729_AddNominationLinkBlacklist.Designer.cs new file mode 100644 index 0000000..49cb260 --- /dev/null +++ b/Backend/Migrations/20260627135729_AddNominationLinkBlacklist.Designer.cs @@ -0,0 +1,1559 @@ +// +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("20260627135729_AddNominationLinkBlacklist")] + partial class AddNominationLinkBlacklist + { + /// + 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.SiteSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("SocialLinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("SponsorsUrl") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + 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, + 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.\"}]", + 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", + 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.\"}]" + }); + }); + + 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.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/20260627135729_AddNominationLinkBlacklist.cs b/Backend/Migrations/20260627135729_AddNominationLinkBlacklist.cs new file mode 100644 index 0000000..f67889f --- /dev/null +++ b/Backend/Migrations/20260627135729_AddNominationLinkBlacklist.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddNominationLinkBlacklist : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NominationLinkBlacklistJson", + table: "SiteSettings", + type: "text", + nullable: false, + defaultValue: "[]"); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + column: "NominationLinkBlacklistJson", + value: "[{\"Url\":\"https://kick.com/\"},{\"Url\":\"https://www.twitch.tv/\"},{\"Url\":\"https://www.youtube.com/\"}]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NominationLinkBlacklistJson", + table: "SiteSettings"); + } + } +} diff --git a/Backend/Migrations/20260627163650_AddClipAdminMenuVisible.Designer.cs b/Backend/Migrations/20260627163650_AddClipAdminMenuVisible.Designer.cs new file mode 100644 index 0000000..b31a5cd --- /dev/null +++ b/Backend/Migrations/20260627163650_AddClipAdminMenuVisible.Designer.cs @@ -0,0 +1,1758 @@ +// +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("20260627163650_AddClipAdminMenuVisible")] + partial class AddClipAdminMenuVisible + { + /// + 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("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.\"}]", + 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/20260627163650_AddClipAdminMenuVisible.cs b/Backend/Migrations/20260627163650_AddClipAdminMenuVisible.cs new file mode 100644 index 0000000..a3d84d8 --- /dev/null +++ b/Backend/Migrations/20260627163650_AddClipAdminMenuVisible.cs @@ -0,0 +1,180 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backend.Migrations +{ + /// + public partial class AddClipAdminMenuVisible : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClipAdminMenuVisible", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ShowactApplicationDisabledMessage", + table: "SiteSettings", + type: "character varying(240)", + maxLength: 240, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ShowactApplicationsEnabled", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ShowactsContent", + table: "SiteSettings", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ShowactsUrl", + table: "SiteSettings", + type: "character varying(400)", + maxLength: 400, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SponsorsVisible", + table: "SiteSettings", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "ShowactApplications", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SeasonId = table.Column(type: "integer", nullable: false), + ArtistName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + ContactEmail = table.Column(type: "character varying(180)", maxLength: 180, nullable: false), + ContactDiscord = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + PlatformUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + PerformanceType = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + TechnicalNotes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + ReferenceUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ReviewNote = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + ReviewedByTwitchId = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + CreatedFromIp = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + UserAgent = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ReviewedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ShowactApplications", x => x.Id); + table.ForeignKey( + name: "FK_ShowactApplications_Seasons_SeasonId", + column: x => x.SeasonId, + principalTable: "Seasons", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Sponsors", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SeasonId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + WebsiteUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + LogoUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Tier = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + IsVisible = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Sponsors", x => x.Id); + table.ForeignKey( + name: "FK_Sponsors_Seasons_SeasonId", + column: x => x.SeasonId, + principalTable: "Seasons", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "ClipAdminMenuVisible", "ShowactApplicationDisabledMessage", "ShowactsContent", "ShowactsUrl", "SponsorsVisible", "WorkflowRulesJson" }, + values: new object[] { true, "Showact-Bewerbungen sind aktuell geschlossen.", "Informationen zu Showact-Bewerbungen, Ablauf und Kontakt fuer die Award-Show.", "https://vtuber-star-awards.de/showacts", true, "[{\"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.\"}]" }); + + migrationBuilder.CreateIndex( + name: "IX_ShowactApplications_SeasonId_Status", + table: "ShowactApplications", + columns: new[] { "SeasonId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Sponsors_SeasonId_IsVisible_SortOrder", + table: "Sponsors", + columns: new[] { "SeasonId", "IsVisible", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ShowactApplications"); + + migrationBuilder.DropTable( + name: "Sponsors"); + + migrationBuilder.DropColumn( + name: "ClipAdminMenuVisible", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "ShowactApplicationDisabledMessage", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "ShowactApplicationsEnabled", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "ShowactsContent", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "ShowactsUrl", + table: "SiteSettings"); + + migrationBuilder.DropColumn( + name: "SponsorsVisible", + table: "SiteSettings"); + + migrationBuilder.UpdateData( + table: "SiteSettings", + keyColumn: "Id", + keyValue: 1, + column: "WorkflowRulesJson", + value: "[{\"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.\"}]"); + } + } +} diff --git a/Backend/Migrations/AwardsDbContextModelSnapshot.cs b/Backend/Migrations/AwardsDbContextModelSnapshot.cs index 8a35478..1e382cb 100644 --- a/Backend/Migrations/AwardsDbContextModelSnapshot.cs +++ b/Backend/Migrations/AwardsDbContextModelSnapshot.cs @@ -169,6 +169,17 @@ namespace Backend.Migrations 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"); @@ -177,6 +188,25 @@ namespace Backend.Migrations .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) @@ -202,8 +232,10 @@ namespace Backend.Migrations new { Id = 1, + AcceptanceStatus = "open", CategoryId = 1, ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", DisplayName = "Hoshimi Miyu", Platform = "Twitch", SeasonId = 1 @@ -211,8 +243,10 @@ namespace Backend.Migrations new { Id = 2, + AcceptanceStatus = "open", CategoryId = 1, ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", DisplayName = "Kurainu", Platform = "Twitch", SeasonId = 1 @@ -220,8 +254,10 @@ namespace Backend.Migrations new { Id = 3, + AcceptanceStatus = "open", CategoryId = 1, ChannelSlug = "@shiroch", + ClipEmbedStatus = "unchecked", DisplayName = "Shiro Ch.", Platform = "Twitch", SeasonId = 1 @@ -229,8 +265,10 @@ namespace Backend.Migrations new { Id = 4, + AcceptanceStatus = "open", CategoryId = 2, ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", DisplayName = "Kurainu 3D Live", Platform = "Twitch", SeasonId = 1 @@ -238,8 +276,10 @@ namespace Backend.Migrations new { Id = 5, + AcceptanceStatus = "open", CategoryId = 2, ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", DisplayName = "Aoi Sakura Showcase", Platform = "YouTube", SeasonId = 1 @@ -247,8 +287,10 @@ namespace Backend.Migrations new { Id = 6, + AcceptanceStatus = "open", CategoryId = 3, ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", DisplayName = "Pyonkichi Kingdom", Platform = "Twitch", SeasonId = 1 @@ -256,8 +298,10 @@ namespace Backend.Migrations new { Id = 7, + AcceptanceStatus = "open", CategoryId = 4, ChannelSlug = "@moonrelay", + ClipEmbedStatus = "unchecked", DisplayName = "Moonrelay", Platform = "Twitch", SeasonId = 1 @@ -265,8 +309,10 @@ namespace Backend.Migrations new { Id = 8, + AcceptanceStatus = "open", CategoryId = 5, ChannelSlug = "@hoshimimiyu", + ClipEmbedStatus = "unchecked", DisplayName = "Hoshimi Miyu", Platform = "Twitch", SeasonId = 2 @@ -274,8 +320,10 @@ namespace Backend.Migrations new { Id = 9, + AcceptanceStatus = "open", CategoryId = 6, ChannelSlug = "@kurainu", + ClipEmbedStatus = "unchecked", DisplayName = "Kurainu 3D Live", Platform = "Twitch", SeasonId = 2 @@ -283,8 +331,10 @@ namespace Backend.Migrations new { Id = 10, + AcceptanceStatus = "open", CategoryId = 7, ChannelSlug = "@pyonkichikingdom", + ClipEmbedStatus = "unchecked", DisplayName = "Pyonkichi Kingdom", Platform = "Twitch", SeasonId = 2 @@ -292,8 +342,10 @@ namespace Backend.Migrations new { Id = 11, + AcceptanceStatus = "open", CategoryId = 8, ChannelSlug = "@aoisakura", + ClipEmbedStatus = "unchecked", DisplayName = "Aoi Sakura", Platform = "YouTube", SeasonId = 3 @@ -301,8 +353,10 @@ namespace Backend.Migrations new { Id = 12, + AcceptanceStatus = "open", CategoryId = 9, ChannelSlug = "@starbyte", + ClipEmbedStatus = "unchecked", DisplayName = "Starbyte", Platform = "Twitch", SeasonId = 3 @@ -310,8 +364,10 @@ namespace Backend.Migrations new { Id = 13, + AcceptanceStatus = "open", CategoryId = 10, ChannelSlug = "@tenshivox", + ClipEmbedStatus = "unchecked", DisplayName = "Tenshi Vox", Platform = "Twitch", SeasonId = 4 @@ -844,6 +900,93 @@ namespace Backend.Migrations }); }); + 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") @@ -852,6 +995,24 @@ namespace Backend.Migrations 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"); @@ -933,6 +1094,12 @@ namespace Backend.Migrations .HasMaxLength(400) .HasColumnType("character varying(400)"); + b.Property("NominationLinkBlacklistJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + b.Property("PrivacyEmail") .IsRequired() .HasMaxLength(160) @@ -953,6 +1120,27 @@ namespace Backend.Migrations .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"); @@ -966,6 +1154,11 @@ namespace Backend.Migrations .HasMaxLength(400) .HasColumnType("character varying(400)"); + b.Property("SponsorsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + b.Property("TwitchAuthManagedByDatabase") .HasColumnType("boolean"); @@ -989,6 +1182,12 @@ namespace Backend.Migrations .HasMaxLength(300) .HasColumnType("character varying(300)"); + b.Property("WorkflowRulesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("[]"); + b.HasKey("Id"); b.ToTable("SiteSettings"); @@ -997,6 +1196,10 @@ namespace Backend.Migrations 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", @@ -1015,22 +1218,84 @@ namespace Backend.Migrations 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.\"}]", + 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 = "" + 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") @@ -1408,6 +1673,28 @@ namespace Backend.Migrations 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") diff --git a/Backend/Services/NominationLinkBlacklistSettings.cs b/Backend/Services/NominationLinkBlacklistSettings.cs new file mode 100644 index 0000000..f6cac48 --- /dev/null +++ b/Backend/Services/NominationLinkBlacklistSettings.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Backend.Domain; + +namespace Backend.Services; + +public sealed record NominationLinkBlacklistEntry(string Url); + +public static class NominationLinkBlacklistSettings +{ + public static readonly NominationLinkBlacklistEntry[] Defaults = + [ + new("https://www.twitch.tv/"), + new("https://kick.com/"), + new("https://www.youtube.com/"), + ]; + + public static NominationLinkBlacklistEntry[] Read(SiteSettings? settings) + { + var entries = Parse(settings?.NominationLinkBlacklistJson); + return entries.Length > 0 ? entries : Defaults; + } + + public static string Serialize(IEnumerable entries) + { + var normalizedEntries = entries + .Select(entry => NormalizeEntry(entry.Url)) + .Where(entry => entry is not null) + .Select(entry => new NominationLinkBlacklistEntry(entry!)) + .DistinctBy(entry => BuildComparisonKey(entry.Url), StringComparer.OrdinalIgnoreCase) + .OrderBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return JsonSerializer.Serialize(normalizedEntries); + } + + public static bool TryNormalizeUrl(string? rawUrl, out string normalizedUrl) + { + normalizedUrl = NormalizeEntry(rawUrl) ?? string.Empty; + return !string.IsNullOrWhiteSpace(normalizedUrl); + } + + public static bool IsBlocked(string rawUrl, IEnumerable entries) + { + var submittedKey = BuildComparisonKey(rawUrl); + return !string.IsNullOrWhiteSpace(submittedKey) + && entries.Any(entry => string.Equals(BuildComparisonKey(entry.Url), submittedKey, StringComparison.OrdinalIgnoreCase)); + } + + private static NominationLinkBlacklistEntry[] Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + var entries = JsonSerializer.Deserialize(json); + return entries?.Where(entry => !string.IsNullOrWhiteSpace(entry.Url)).ToArray() ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string? NormalizeEntry(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return null; + } + + var candidate = rawUrl.Trim(); + if (!candidate.Contains("://", StringComparison.Ordinal)) + { + candidate = $"https://{candidate}"; + } + + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) + { + return null; + } + + if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host)) + { + return null; + } + + var builder = new UriBuilder(uri) + { + Scheme = Uri.UriSchemeHttps, + Host = uri.Host.ToLowerInvariant(), + Port = -1, + Query = string.Empty, + Fragment = string.Empty, + }; + + var normalized = builder.Uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped); + return normalized.EndsWith('/') ? normalized : $"{normalized}/"; + } + + private static string BuildComparisonKey(string? rawUrl) + { + if (NormalizeEntry(rawUrl) is not { } normalized) + { + return string.Empty; + } + + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return string.Empty; + } + + var host = uri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) + ? uri.Host[4..] + : uri.Host; + var path = uri.AbsolutePath.TrimEnd('/'); + return $"{host.ToLowerInvariant()}{path.ToLowerInvariant()}"; + } +} diff --git a/Backend/Services/WorkflowRuleSettings.cs b/Backend/Services/WorkflowRuleSettings.cs new file mode 100644 index 0000000..fb1bc44 --- /dev/null +++ b/Backend/Services/WorkflowRuleSettings.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using Backend.Domain; + +namespace Backend.Services; + +public sealed record WorkflowRuleSetting( + string Key, + string Label, + bool Enabled, + int Limit, + string Mode, + string Description); + +public static class WorkflowRuleSettings +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public const string MaxFinalistsPerCategory = "max_finalists_per_category"; + public const string MaxCandidateAppearances = "max_candidate_appearances"; + public const string MaxWinnerPlacements = "max_winner_placements"; + public const string WinnerRequiresClip = "winner_requires_clip"; + + public static WorkflowRuleSetting[] Defaults { get; } = + [ + new(MaxFinalistsPerCategory, "Finale Kandidat:innen pro Kategorie", true, 4, "block", "Begrenzt, wie viele finale Kandidat:innen in einer Kategorie vorbereitet werden."), + 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."), + ]; + + public static WorkflowRuleSetting[] Read(SiteSettings? settings) + { + var storedRules = Parse(settings?.WorkflowRulesJson); + return Defaults + .Select(defaultRule => + { + var storedRule = storedRules.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase)); + return storedRule is null ? defaultRule : Normalize(storedRule, defaultRule); + }) + .ToArray(); + } + + public static string Serialize(IEnumerable rules) => + JsonSerializer.Serialize(rules.Select(rule => Normalize(rule, Defaults.FirstOrDefault(item => item.Key == rule.Key) ?? rule)), JsonOptions); + + public static WorkflowRuleSetting Find(IEnumerable rules, string key) => + rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)) + ?? Defaults.First(item => item.Key == key); + + public static bool ShouldBlock(WorkflowRuleSetting rule) => + rule.Enabled && string.Equals(rule.Mode, "block", StringComparison.OrdinalIgnoreCase); + + public static string CandidateIdentityKey(Candidate candidate) + { + var channel = candidate.ChannelSlug.Trim().TrimStart('@').ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(channel)) + { + return $"slug:{channel}"; + } + + return $"name:{candidate.DisplayName.Trim().ToLowerInvariant()}"; + } + + public static string CandidateIdentityKey(string displayName, string channelSlug) + { + var channel = channelSlug.Trim().TrimStart('@').ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(channel)) + { + return $"slug:{channel}"; + } + + return $"name:{displayName.Trim().ToLowerInvariant()}"; + } + + private static WorkflowRuleSetting[] Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static WorkflowRuleSetting Normalize(WorkflowRuleSetting rule, WorkflowRuleSetting fallback) + { + var mode = rule.Mode.Trim().ToLowerInvariant() switch + { + "block" => "block", + "warn" => "warn", + _ => fallback.Mode, + }; + + return rule with + { + Key = fallback.Key, + Label = string.IsNullOrWhiteSpace(rule.Label) ? fallback.Label : rule.Label.Trim(), + Enabled = rule.Enabled, + Limit = Math.Clamp(rule.Limit, 1, 50), + Mode = mode, + Description = string.IsNullOrWhiteSpace(rule.Description) ? fallback.Description : rule.Description.Trim(), + }; + } +} diff --git a/docs/workflow-feedback-plan.md b/docs/workflow-feedback-plan.md new file mode 100644 index 0000000..ca74b85 --- /dev/null +++ b/docs/workflow-feedback-plan.md @@ -0,0 +1,255 @@ +# Workflow-Feedback: Planung und Ausarbeitung + +Stand: 2026-06-27 +Quelle: `VTuber Star Awards Workflow.docx` inklusive Kommentaren, abgeglichen mit dem aktuellen VTubeAwards-Repo. + +## Executive Summary + +Die erste grosse Aenderung sollte den Core Workflow stabilisieren: Nominierung, Vorbereitung, Voting und Review/Auswertung. Das Feedback beschreibt weniger ein komplett neues Produktmodell als eine klarere Fuehrung durch bereits vorhandene Konzepte. + +Die wichtigsten Produktentscheidungen: + +- Kategorien mit Viewer-Groessen werden als einzelne Kategorien pro Unterkategorie abgebildet, gruppiert ueber `GroupName`. +- Viewer duerfen pro Kategorie bis zu drei Stream- oder Kanal-Links nominieren. +- Eine Nominierung muss nicht fuer alle Kategorien abgegeben werden. +- Clip-Compilations werden nicht als Videodateien in der App gespeichert. +- Voting und Gewinnerbereiche nutzen externe YouTube-/Twitch-Links oder Embeds. +- Die Vorbereitung nach der Nominierung bleibt ein Admin-/Teamprozess mit manuellem Kontakt und Annahmestatus. +- Showacts und Sponsoren werden als admin-verwaltbare Folgefeatures geplant, aber nicht in Phase 1 umgesetzt. + +## Feedback-Analyse + +### Kategorien und Unterkategorien + +Das Dokument beschreibt pro Award-Kategorie drei Unterkategorien nach Viewer-Groesse: + +- Hidden Star: 1 bis 20 Viewer +- Rising Star: 21 bis 60 Viewer +- Shining Star: 61+ Viewer + +Im aktuellen Datenmodell passt das am besten zu einzelnen `Category`-Datensaetzen pro Unterkategorie. Der uebergeordnete Award-Bereich, zum Beispiel `Gamer`, bleibt `GroupName`; die konkrete Unterkategorie wird `Name`, zum Beispiel `Hidden Star der Gamer`. + +Damit entsteht kein paralleles Kategorienmodell. Admin-, Public- und Voting-Flows koennen weiter mit `CategoryId` arbeiten. + +### Nominierung + +Das Feedback wuenscht pro Kategorie bis zu drei Nominierungen als Stream-/Kanal-Links. Namen sind nicht zwingend noetig, weil die Admins aus dem Link den finalen Kandidaten erstellen oder zuordnen koennen. + +Geplanter Zielzustand: + +- Pro Kategorie koennen ein bis drei Links eingereicht werden. +- Doppelte Links innerhalb derselben Kategorie werden blockiert. +- Leere Kategorien duerfen uebersprungen werden. +- Die Nominierungsoberflaeche wird wie ein Wizard aufgebaut: Kategorien links, Inhalt rechts, klare Weiter-Navigation. +- Clip-Einreichung wird aus dem Nominierungsformular entfernt oder deutlich getrennt, weil laut Feedback Clips in der Nominierungsphase eher Probleme verursachen. + +Backendseitig existiert bereits eine passende Grundlage: `CreateNominationRequest` unterstuetzt mehrere `Nominations`, und die API verhindert doppelte Links innerhalb eines Requests. Die groesste Arbeit liegt daher im Public UI und in der Kommunikation der Regeln. + +### Vorbereitung + +Nach der Nominierungsphase prueft das Team die Nominierungen, zaehlt aus und kontaktiert VTuber, ob sie die Nominierung annehmen. Erst danach werden Clip-Compilations relevant. + +Geplanter Zielzustand: + +- Admins sehen pro Review-Fall genug Signal, um Kandidaten zuzuordnen. +- Final ausgewaehlte Nominierte bekommen einen Annahmestatus. +- Admins koennen pro Kandidat eine externe Clip-Compilation-URL pflegen. +- Es wird keine Upload-Infrastruktur fuer Videodateien gebaut. +- Ungelistete YouTube-Videos oder Twitch-Clips koennen verlinkt oder eingebettet werden. + +Das haelt die Verantwortung fuer Hosting, Speicher, Copyright und Transcoding ausserhalb der App. + +### Voting + +Das Feedback zum Voting betrifft vor allem Bedienung und Sicherheit vor unvollstaendigen Abgaben. + +Geplanter Zielzustand: + +- Das Voting bleibt ein gefuehrter Picker mit Kategorienavigation. +- Es gibt einen klaren Weiter-Button unten rechts. +- Das Modal behaelt eine stabile Groesse, damit beim Kategorienwechsel nichts springt. +- Vor dem Absenden wird angezeigt, in welchen Kategorien noch keine Stimme gesetzt wurde. +- Nutzer koennen Votes bearbeiten, solange die Votingphase aktiv ist. + +Backendseitig ist das Bearbeiten bereits angelegt: ein bestehendes Ballot wird beim erneuten Speichern ersetzt. Das sollte im UI bewusst als Feature kommuniziert werden. + +### Review und Auswertung + +Das Dokument nennt zwei interne Regeln: + +- Eine Person kann maximal zwei Mal nominiert werden. +- Eine Person kann maximal ein Mal gewinnen. + +Diese Regeln sollten nicht still im Public UI verschwinden, sondern als Admin-Guard und Review-Hilfe geplant werden. + +Geplanter Zielzustand: + +- Admins koennen final maximal vier Nominierte pro Unterkategorie festlegen. +- Admins sehen Warnungen, wenn eine Person zu oft nominiert oder als Gewinner markiert wird. +- Die App blockiert riskante finale Veroeffentlichungen oder verlangt eine bewusste Admin-Bestaetigung. +- Gewinner werden pro Unterkategorie bestimmt; bei Gleichstand oder Sonderfaellen entscheidet das Team manuell. + +### Website Extras + +Showacts und Sponsoren sind sinnvoll, aber nicht Teil der ersten Core-Workflow-Aenderung. + +Folgeplanung: + +- Showact-Bewerbungen werden als eigenes Website-Formular geplant, mit Admin-Liste zur Sichtung. +- Sponsoren werden admin-verwaltbar, inklusive Logo, Link, Sichtbarkeit, Sortierung und optionaler Tier-Stufe. +- Sponsorendarstellung kann als Landingpage-Banner, Karussell oder dedizierter Abschnitt umgesetzt werden. + +## Priorisierte Umsetzung + +### Phase 1: Public Nominierungs- und Voting-UX + +Ziel: Der Public Flow entspricht dem Feedback, ohne zuerst das Datenmodell stark umzubauen. + +Umsetzung: + +- Nominierungsmodal zu einem Wizard umbauen. +- Pro Kategorie bis zu drei Link-Felder anbieten. +- Kategorien als linke Navigation anzeigen. +- Kategorien ohne Eingaben erlauben. +- Doppelte Links clientseitig validieren und Backend-Fehler sauber anzeigen. +- Clip-Einreichung aus dem Nominierungsflow entfernen oder als separaten, weniger prominenten Flow belassen. +- Voting-Wizard um Weiter-Button und fehlende-Stimmen-Hinweis erweitern. +- Vote-Bearbeitung sichtbar kommunizieren, wenn bereits gespeicherte Stimmen geladen wurden. + +Akzeptanz: + +- Eine Kategorie kann mit einem, zwei oder drei Links eingereicht werden. +- Doppelte Links in derselben Kategorie werden blockiert. +- Ein leerer Kategorienblock verhindert nicht das Absenden anderer Kategorien. +- Voting kann gespeichert und in derselben Phase erneut geaendert werden. + +### Phase 2: Admin-Vorbereitung und Clip-Compilation-Links + +Ziel: Das Team kann aus Review-Signalen finale Nominierte vorbereiten und externe Compilations pflegen. + +Umsetzung: + +- Admin-Review um einen klaren Schritt "finale Nominierte auswaehlen" erweitern. +- Annahmestatus je finalem Nominee planen: offen, angefragt, angenommen, abgesagt. +- Externe Clip-Compilation-URL, Titel und Plattform je Kandidat oder Kandidaten-Kategorie-Zuordnung pflegen. +- Keine Videodateien speichern. +- Optional Embed-Vorschau fuer YouTube/Twitch anzeigen, wenn technisch sicher moeglich. + +Akzeptanz: + +- Admins koennen sehen, welche Kandidaten fuer eine Unterkategorie final vorbereitet sind. +- Externe Clip-Links erscheinen im Voting. +- Fehlende Clips blockieren die App nicht, werden aber sichtbar markiert. + +### Phase 3: Review, Gewinner und Archiv + +Ziel: Auswertung und Gewinnerdarstellung folgen den internen Regeln. + +Umsetzung: + +- Gewinner pro Unterkategorie verwalten. +- Guard fuer "eine Person gewinnt maximal ein Mal" einplanen. +- Konfigurierbare Regel "Gewinner braucht Clip-Link" einplanen; Standard blockiert Gewinner ohne gepflegte YouTube-/Twitch-Compilation. +- Guard oder Warnung fuer "eine Person maximal zwei Mal nominiert" einplanen. +- Gewinnerbilder im Archiv groesser und ruhiger darstellen. +- Clip-Compilation im Archiv anzeigen; Standard ist externer Link oder Embed, kein Upload. +- Countdown auf der Landingpage visuell groesser und prominenter gestalten. + +Akzeptanz: + +- Admins koennen Gewinner nicht versehentlich doppelt vergeben, ohne Warnung oder bewusste Bestaetigung. +- Admins koennen steuern, ob Gewinner ohne Clip-Link blockiert oder nur gewarnt werden. +- Archiv zeigt vorhandene Gewinner-Clips eingebettet an und bleibt bei fehlenden Clips stabil. +- Countdown ist auf Desktop und Mobile gut lesbar. + +### Phase 4: Showacts und Sponsoren + +Ziel: Website Extras werden admin-verwaltbar statt ueber externe Workarounds gepflegt. + +Umsetzung: + +- Showact-Bewerbungsformular als Public Feature umsetzen; Aktivierung laeuft ueber optionale Workflow-Einstellungen. +- Admin-Ansicht fuer Showact-Bewerbungen umsetzen, inklusive Status `pending`, `shortlisted`, `accepted`, `rejected` und Review-Notiz. +- Sponsorendatenmodell umsetzen: Name, Logo, URL, Tier, Sortierung, Sichtbarkeit, Saison. +- Sponsorverwaltung im Admin-Panel umsetzen und Sponsorendarstellung auf der Landingpage an `SponsorsVisible` koppeln. +- Public-Showact-Submit blockiert sauber, wenn Bewerbungen geschlossen sind, und nutzt den konfigurierbaren Admin-Hinweis. + +Akzeptanz: + +- Showact-Bewerbungen koennen ohne Google Form gesammelt werden. +- Sponsoren koennen ohne Codeaenderung pro Saison gepflegt und sortiert werden. +- Toggles fuer Showact-Bewerbungen und Sponsoren-Sichtbarkeit liegen im eigenen Modal fuer optionale Workflows. + +## Interface- und Datenentscheidungen + +### Beibehalten + +- `CategoryId` bleibt die zentrale Einheit fuer Nominierung und Voting. +- `GroupName` bleibt die Gruppierung fuer uebergeordnete Award-Bereiche. +- Die Nominierungs-API bleibt grundsaetzlich erhalten. +- Die Voting-API bleibt grundsaetzlich erhalten. +- Wiederholtes Vote-Speichern bleibt erlaubt und wird als Bearbeiten behandelt. + +### Erweitern + +- Kandidaten oder Kandidaten-Kategorie-Zuordnungen brauchen externe Clip-Compilation-Metadaten, falls die vorhandene Clip-Zuordnung nicht ausreicht: + - URL + - Titel + - Plattform + - optionaler Embed-Status +- Admin-Review braucht Statusinformationen fuer final ausgewaehlte Nominierte. +- Gewinnerverwaltung braucht Guards fuer interne Regeln, inklusive "maximal ein Gewinnerplatz" und "Clip-Link fuer Gewinner erforderlich". + +### Nicht bauen + +- Kein eigener Video-Upload. +- Kein Speichern von Videodateien. +- Kein paralleles Unterkategorienmodell neben `Category`. +- Kein verpflichtendes Komplett-Ausfuellen aller Kategorien. + +## Offene Produktfragen + +Diese Fragen muessen nicht vor Phase 1 beantwortet werden, sollten aber vor Phase 2 oder 3 geklaert sein: + +- Wird der Annahmestatus pro Kandidat global oder pro Kategorie gepflegt? +- Soll eine abgesagte Person automatisch durch die naechste Review-Auswahl ersetzt werden koennen? +- Soll die "maximal zwei Nominierungen"-Regel hart blockieren oder nur warnen? +- Soll die "maximal ein Gewinner"-Regel hart blockieren oder Admin-Override erlauben? +- Werden Sponsor-Tiers oeffentlich benannt oder nur intern zur Sortierung genutzt? +- Welche Pflichtfelder braucht das Showact-Formular final? + +## Test Plan + +### Build und statische Checks + +- `npm run build` in `/Users/azu/Desktop/VTubeAwards/frontend` +- `dotnet build Backend/Backend.csproj` in `/Users/azu/Desktop/VTubeAwards` + +### Browserpruefung lokal + +- Nominierung mit einem Link in einer Kategorie. +- Nominierung mit zwei Links in einer Kategorie. +- Nominierung mit drei Links in einer Kategorie. +- Doppelte Links in derselben Kategorie. +- Leere Kategorien ueberspringen. +- Voting-Kategorie wechseln. +- Weiter-Button im Voting verwenden. +- Fehlende-Stimmen-Hinweis vor dem Absenden pruefen. +- Vote speichern, erneut oeffnen, aendern und erneut speichern. +- Mobile Layout bei 360px, 390px und 768px ohne horizontales Overflow pruefen. + +### API-Smoke-Checks + +- `GET /api/public/overview` +- `GET /api/public/seasons/{year}/categories` +- `GET /api/public/seasons/{year}/me` +- `POST /api/public/nominations` mit authentifizierter Session. +- `POST /api/public/votes` mit authentifizierter Session. + +## Umsetzungshinweise + +- Phase 1 sollte moeglichst frontendlastig bleiben und bestehende Backend-Faehigkeiten nutzen. +- Wenn Datenmodell-Migrationen noetig werden, sollten sie erst mit Phase 2 eingefuehrt werden. +- Alle Public-UI-Aenderungen muessen gegen echte Backenddaten laufen, nicht gegen Demo-State. +- Fuer echte UI-Aenderungen reicht ein Build nicht aus; der Flow muss im Browser geprueft werden. +- Kleine Vue-Komponenten bevorzugen, besonders beim Umbau des Nominierungs- und Votingmodals. diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 5f2ba65..0e790fc 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue' import { RouterLink, useRoute, useRouter } from 'vue-router' import AppShellAccountModals from './AppShellAccountModals.vue' +import { useBodyScrollLock } from '../composables/useBodyScrollLock' import { privacyContentToHtml } from '../lib/privacyContent' import { useAuthStore } from '../stores/auth' import { useAwardsStore } from '../stores/awards' @@ -13,6 +14,8 @@ const router = useRouter() const authStore = useAuthStore() const awardsStore = useAwardsStore() +useBodyScrollLock(() => awardsStore.loading) + const loginForm = reactive({ twitchUserId: 'jayuhime_viewer', displayName: 'Jayuhime', diff --git a/frontend/src/components/admin/AdminCandidateEditorModal.vue b/frontend/src/components/admin/AdminCandidateEditorModal.vue index 1ed5de9..669cbc9 100644 --- a/frontend/src/components/admin/AdminCandidateEditorModal.vue +++ b/frontend/src/components/admin/AdminCandidateEditorModal.vue @@ -1,5 +1,5 @@ @@ -223,7 +414,7 @@ function notifyClipCategoryChange(value: number) {
✦ Nominierungsphase
-

Clip einreichen

+

Clip einreichen

Die besten Clips werden in der Award-Show präsentiert.

diff --git a/frontend/src/components/home/HomeLandingExperience.vue b/frontend/src/components/home/HomeLandingExperience.vue index 4b856da..062b828 100644 --- a/frontend/src/components/home/HomeLandingExperience.vue +++ b/frontend/src/components/home/HomeLandingExperience.vue @@ -3,8 +3,10 @@ import { useRouter } from 'vue-router' import CinematicStarLoader from '../CinematicStarLoader.vue' import HomeCategoriesSection from './HomeCategoriesSection.vue' import HomeHeroShell from './HomeHeroShell.vue' +import HomeExtrasSection from './HomeExtrasSection.vue' import HomeLandingModals from './HomeLandingModals.vue' import HomeParticipationSection from './HomeParticipationSection.vue' +import HomeStickyCountdownPill from './HomeStickyCountdownPill.vue' import HomeSupportFooterSection from './HomeSupportFooterSection.vue' import HomeTimelineSection from './HomeTimelineSection.vue' import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue' @@ -50,6 +52,7 @@ const { voteCount, totalCats, canSubmitVote, + savedVoteActive, activeCatName, phaseCardTitle, phaseCardDescription, @@ -89,6 +92,7 @@ const { archiveYears, selectedArchive, winnerShowcase, + activeCat, submitted, submitting, formError, @@ -143,13 +147,13 @@ const { } = useHomeLandingState() const previewPhaseButtons = [ - { key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' }, + { key: 'nomination', label: 'Nominierung', hint: 'Links einreichen' }, { key: 'voting', label: 'Voting', hint: 'Community stimmt ab' }, { key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' }, { key: 'show', label: 'Show', hint: 'Live-Finale' }, ] as const -const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({ +const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmit } = useHomeLandingViewEffects({ router, store, authStore, @@ -165,7 +169,6 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi preparationPhase, completedPhase, initializeHomeInteractions, - submitNomination, submitClip, }) @@ -186,6 +189,13 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
+ +
@@ -282,6 +292,8 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi :on-section-action="onSectionAction" /> + + Promise | void - submitNomination: () => Promise | void + submitNomination: (context: HomeNominationSubmitContext) => Promise | void submitting: boolean onClipCatChange: (event: Event) => void catOptions: HomeSelectionOption[] @@ -100,11 +103,13 @@ defineProps<{ :picker-title="pickerTitle" :picker-subtitle="pickerSubtitle" :cat-list="catList" + :active-cat-index="activeCatIndex" :active-cat-name="activeCatName" :noms="noms" :vote-count="voteCount" :total-cats="totalCats" :can-submit-vote="canSubmitVote" + :saved-vote-active="savedVoteActive" :submit-vote="submitVote" :submit-nomination="submitNomination" :submitting="submitting" diff --git a/frontend/src/components/home/HomeMissingVotesConfirm.vue b/frontend/src/components/home/HomeMissingVotesConfirm.vue new file mode 100644 index 0000000..3263bc8 --- /dev/null +++ b/frontend/src/components/home/HomeMissingVotesConfirm.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/home/HomeNominationReviewPane.vue b/frontend/src/components/home/HomeNominationReviewPane.vue new file mode 100644 index 0000000..1f79a85 --- /dev/null +++ b/frontend/src/components/home/HomeNominationReviewPane.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/components/home/HomeNominationWizardPane.vue b/frontend/src/components/home/HomeNominationWizardPane.vue new file mode 100644 index 0000000..aaf33bd --- /dev/null +++ b/frontend/src/components/home/HomeNominationWizardPane.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/home/HomeParticipationSection.vue b/frontend/src/components/home/HomeParticipationSection.vue index 36084c4..d8395ba 100644 --- a/frontend/src/components/home/HomeParticipationSection.vue +++ b/frontend/src/components/home/HomeParticipationSection.vue @@ -1,4 +1,6 @@ + + diff --git a/frontend/src/components/home/HomeStickyCountdownPill.vue b/frontend/src/components/home/HomeStickyCountdownPill.vue new file mode 100644 index 0000000..c2a6dd0 --- /dev/null +++ b/frontend/src/components/home/HomeStickyCountdownPill.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/home/HomeSupportFooterSection.vue b/frontend/src/components/home/HomeSupportFooterSection.vue index b6f7972..c938a07 100644 --- a/frontend/src/components/home/HomeSupportFooterSection.vue +++ b/frontend/src/components/home/HomeSupportFooterSection.vue @@ -78,7 +78,7 @@ function isSafePublicUrl(value: string | null | undefined) {
-

COMMUNITY & UPDATES

+

COMMUNITY & UPDATES

Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!

-

TEILE DIE AWARDS

+

TEILE DIE AWARDS

Supporte deine Favoriten und teile die Awards mit deinen Freunden!

Auf X teilen @@ -132,7 +132,7 @@ function isSafePublicUrl(value: string | null | undefined) {
✧ Häufige Fragen
-

FAQ

+

FAQ

Alles, was du über Nominierung, Voting und die Show wissen musst.

@@ -184,7 +184,7 @@ function isSafePublicUrl(value: string | null | undefined) {
Footer Seite
-

{{ activeFooterLink.label }}

+

{{ activeFooterLink.label }}

diff --git a/frontend/src/components/home/HomeTimelineSection.vue b/frontend/src/components/home/HomeTimelineSection.vue index b989e1c..425e0a5 100644 --- a/frontend/src/components/home/HomeTimelineSection.vue +++ b/frontend/src/components/home/HomeTimelineSection.vue @@ -2,7 +2,7 @@
✦ Der Ablauf
-

In vier Schritten auf die Bühne

+

In vier Schritten auf die Bühne

Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur grossen Show.

@@ -23,7 +23,7 @@

Nominierung

{{ formatTimelineRange('nomination') }}

Die Community reicht ihre Favoriten ein — pro Kategorie bis zu drei Nominierungen.

- +
diff --git a/frontend/src/components/home/HomeVotingPickerPane.vue b/frontend/src/components/home/HomeVotingPickerPane.vue index 5e92db5..630d6bc 100644 --- a/frontend/src/components/home/HomeVotingPickerPane.vue +++ b/frontend/src/components/home/HomeVotingPickerPane.vue @@ -1,16 +1,115 @@ diff --git a/frontend/src/components/home/HomeWinnerShowcaseSection.vue b/frontend/src/components/home/HomeWinnerShowcaseSection.vue index 7cf0b8c..db2bf37 100644 --- a/frontend/src/components/home/HomeWinnerShowcaseSection.vue +++ b/frontend/src/components/home/HomeWinnerShowcaseSection.vue @@ -1,26 +1,20 @@